diff --git a/.vscode/launch.json b/.vscode/launch.json index a053f46bd0..d03e43f821 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -90,7 +90,7 @@ "cwd": "${workspaceFolder}", "args": [ "test", - "./test/cli/", + "test/cli" ], } ] diff --git a/pkg/engine/context/context.go b/pkg/engine/context/context.go index 53230059a7..5088ef275a 100644 --- a/pkg/engine/context/context.go +++ b/pkg/engine/context/context.go @@ -2,23 +2,28 @@ package context import ( "encoding/csv" - "encoding/json" "fmt" + "regexp" "strings" - "sync" - jsonpatch "github.com/evanphx/json-patch/v5" + jsoniter "github.com/json-iterator/go" kyvernov1 "github.com/kyverno/kyverno/api/kyverno/v1" kyvernov1beta1 "github.com/kyverno/kyverno/api/kyverno/v1beta1" "github.com/kyverno/kyverno/pkg/config" "github.com/kyverno/kyverno/pkg/engine/jmespath" + "github.com/kyverno/kyverno/pkg/engine/jsonutils" "github.com/kyverno/kyverno/pkg/logging" apiutils "github.com/kyverno/kyverno/pkg/utils/api" admissionv1 "k8s.io/api/admission/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" ) -var logger = logging.WithName("context") +var ( + logger = logging.WithName("context") + json = jsoniter.ConfigCompatibleWithStandardLibrary + ReservedKeys = regexp.MustCompile(`request|serviceAccountName|serviceAccountNamespace|element|elementIndex|@|images|image|([a-z_0-9]+\()[^{}]`) +) // EvalInterface is used to query and inspect context data // TODO: move to contextapi to prevent circular dependencies @@ -26,6 +31,9 @@ type EvalInterface interface { // Query accepts a JMESPath expression and returns matching data Query(query string) (interface{}, error) + // Operation returns the admission operation i.e. "request.operation" + QueryOperation() string + // HasChanged accepts a JMESPath expression and compares matching data in the // request.object and request.oldObject context fields. If the data has changed // it return `true`. If the data has not changed it returns false. If either @@ -100,50 +108,70 @@ type Interface interface { EvalInterface - // AddJSON merges the json with context - addJSON(dataRaw []byte) error + // AddJSON merges the json map with context + addJSON(dataMap map[string]interface{}) error } // Context stores the data resources as JSON type context struct { jp jmespath.Interface - mutex sync.RWMutex - jsonRaw []byte - jsonRawCheckpoints [][]byte + jsonRaw map[string]interface{} + jsonRawCheckpoints []map[string]interface{} images map[string]map[string]apiutils.ImageInfo + operation kyvernov1.AdmissionOperation deferred DeferredLoaders } // NewContext returns a new context func NewContext(jp jmespath.Interface) Interface { - return NewContextFromRaw(jp, []byte(`{}`)) + return NewContextFromRaw(jp, map[string]interface{}{}) } // NewContextFromRaw returns a new context initialized with raw data -func NewContextFromRaw(jp jmespath.Interface, raw []byte) Interface { +func NewContextFromRaw(jp jmespath.Interface, raw map[string]interface{}) Interface { return &context{ jp: jp, jsonRaw: raw, - jsonRawCheckpoints: make([][]byte, 0), + jsonRawCheckpoints: make([]map[string]interface{}, 0), deferred: NewDeferredLoaders(), } } // addJSON merges json data -func (ctx *context) addJSON(dataRaw []byte) error { - ctx.mutex.Lock() - defer ctx.mutex.Unlock() - json, err := jsonpatch.MergeMergePatches(ctx.jsonRaw, dataRaw) - if err != nil { - return fmt.Errorf("failed to merge JSON data: %w", err) - } - ctx.jsonRaw = json +func (ctx *context) addJSON(dataMap map[string]interface{}) error { + mergeMaps(dataMap, ctx.jsonRaw) return nil } +func (ctx *context) QueryOperation() string { + if ctx.operation != "" { + return string(ctx.operation) + } + + if requestMap, val := ctx.jsonRaw["request"].(map[string]interface{}); val { + if op, val := requestMap["operation"].(string); val { + return op + } + } + + return "" +} + // AddRequest adds an admission request to context func (ctx *context) AddRequest(request admissionv1.AdmissionRequest) error { - return addToContext(ctx, request, "request") + // an AdmissionRequest needs to be marshaled / unmarshaled as + // JSON to properly convert types of runtime.RawExtension + mapObj, err := jsonutils.DocumentToUntyped(request) + if err != nil { + return err + } + + if err := addToContext(ctx, mapObj, "request"); err != nil { + return err + } + + ctx.operation = kyvernov1.AdmissionOperation(request.Operation) + return nil } func (ctx *context) AddVariable(key string, value interface{}) error { @@ -181,31 +209,39 @@ func (ctx *context) ReplaceContextEntry(name string, dataRaw []byte) error { // AddResource data at path: request.object func (ctx *context) AddResource(data map[string]interface{}) error { + clearLeafValue(ctx.jsonRaw, "request", "object") return addToContext(ctx, data, "request", "object") } // AddOldResource data at path: request.oldObject func (ctx *context) AddOldResource(data map[string]interface{}) error { + clearLeafValue(ctx.jsonRaw, "request", "oldObject") return addToContext(ctx, data, "request", "oldObject") } // AddTargetResource adds data at path: target func (ctx *context) SetTargetResource(data map[string]interface{}) error { - if err := addToContext(ctx, nil, "target"); err != nil { - logger.Error(err, "unable to replace target resource") - return err - } + clearLeafValue(ctx.jsonRaw, "target") return addToContext(ctx, data, "target") } // AddOperation data at path: request.operation func (ctx *context) AddOperation(data string) error { - return addToContext(ctx, data, "request", "operation") + if err := addToContext(ctx, data, "request", "operation"); err != nil { + return err + } + + ctx.operation = kyvernov1.AdmissionOperation(data) + return nil } // AddUserInfo adds userInfo at path request.userInfo func (ctx *context) AddUserInfo(userRequestInfo kyvernov1beta1.RequestInfo) error { - return addToContext(ctx, userRequestInfo, "request") + if data, err := toUnstructured(&userRequestInfo); err == nil { + return addToContext(ctx, data, "request") + } else { + return err + } } // AddServiceAccount removes prefix 'system:serviceaccount:' and namespace, then loads only SA name and SA namespace @@ -225,33 +261,14 @@ func (ctx *context) AddServiceAccount(userName string) error { saName = groups[1] saNamespace = groups[0] } - saNameObj := struct { - SA string `json:"serviceAccountName"` - }{ - SA: saName, + data := map[string]interface{}{ + "serviceAccountName": saName, + "serviceAccountNamespace": saNamespace, } - saNameRaw, err := json.Marshal(saNameObj) - if err != nil { - logger.Error(err, "failed to marshal the SA") - return err - } - if err := ctx.addJSON(saNameRaw); err != nil { + if err := ctx.addJSON(data); err != nil { return err } - saNsObj := struct { - SA string `json:"serviceAccountNamespace"` - }{ - SA: saNamespace, - } - saNsRaw, err := json.Marshal(saNsObj) - if err != nil { - logger.Error(err, "failed to marshal the SA namespace") - return err - } - if err := ctx.addJSON(saNsRaw); err != nil { - return err - } logger.V(4).Info("Adding service account", "service account name", saName, "service account namespace", saNamespace) return nil } @@ -267,8 +284,8 @@ func (ctx *context) AddElement(data interface{}, index, nesting int) error { data = map[string]interface{}{ "element": data, nestedElement: data, - "elementIndex": index, - nestedElementIndex: index, + "elementIndex": int64(index), + nestedElementIndex: int64(index), } return addToContext(ctx, data) } @@ -291,13 +308,45 @@ func (ctx *context) AddImageInfos(resource *unstructured.Unstructured, cfg confi if err != nil { return err } + + return ctx.addImageInfos(images) +} + +func (ctx *context) addImageInfos(images map[string]map[string]apiutils.ImageInfo) error { if len(images) == 0 { return nil } ctx.images = images + utm, err := convertImagesToUnstructured(images) + if err != nil { + return err + } - logging.V(4).Info("updated image info", "images", images) - return addToContext(ctx, images, "images") + logging.V(4).Info("updated image info", "images", utm) + return addToContext(ctx, utm, "images") +} + +func convertImagesToUnstructured(images map[string]map[string]apiutils.ImageInfo) (map[string]interface{}, error) { + results := map[string]interface{}{} + for containerType, v := range images { + imgMap := map[string]interface{}{} + for containerName := range v { + imageInfo := v[containerName] + img, err := toUnstructured(&imageInfo.ImageInfo) + if err != nil { + return nil, err + } + + var pointer interface{} = imageInfo.Pointer + img["jsonPointer"] = pointer + + imgMap[containerName] = img + } + + results[containerType] = imgMap + } + + return results, nil } func (ctx *context) GenerateCustomImageInfo(resource *unstructured.Unstructured, imageExtractorConfigs kyvernov1.ImageExtractorConfigs, cfg config.Configuration) (map[string]map[string]apiutils.ImageInfo, error) { @@ -306,12 +355,11 @@ func (ctx *context) GenerateCustomImageInfo(resource *unstructured.Unstructured, return nil, fmt.Errorf("failed to extract images: %w", err) } - if len(images) == 0 { - logger.V(4).Info("no images found", "extractor", imageExtractorConfigs) - return nil, nil + if err := ctx.addImageInfos(images); err != nil { + return nil, fmt.Errorf("failed to add images to context: %w", err) } - return images, addToContext(ctx, images, "images") + return images, nil } func (ctx *context) ImageInfo() map[string]map[string]apiutils.ImageInfo { @@ -321,13 +369,23 @@ func (ctx *context) ImageInfo() map[string]map[string]apiutils.ImageInfo { // Checkpoint creates a copy of the current internal state and // pushes it into a stack of stored states. func (ctx *context) Checkpoint() { - ctx.mutex.Lock() - defer ctx.mutex.Unlock() - jsonRawCheckpoint := make([]byte, len(ctx.jsonRaw)) - copy(jsonRawCheckpoint, ctx.jsonRaw) + jsonRawCheckpoint := ctx.copyContext(ctx.jsonRaw) ctx.jsonRawCheckpoints = append(ctx.jsonRawCheckpoints, jsonRawCheckpoint) } +func (ctx *context) copyContext(in map[string]interface{}) map[string]interface{} { + out := make(map[string]interface{}, len(in)) + for k, v := range in { + if ReservedKeys.MatchString(k) { + out[k] = v + } else { + out[k] = runtime.DeepCopyJSONValue(v) + } + } + + return out +} + // Restore sets the internal state to the last checkpoint, and removes the checkpoint. func (ctx *context) Restore() { ctx.reset(true) @@ -344,20 +402,19 @@ func (ctx *context) reset(restore bool) { } } -func (ctx *context) resetCheckpoint(removeCheckpoint bool) bool { - ctx.mutex.Lock() - defer ctx.mutex.Unlock() - +func (ctx *context) resetCheckpoint(restore bool) bool { if len(ctx.jsonRawCheckpoints) == 0 { return false } n := len(ctx.jsonRawCheckpoints) - 1 jsonRawCheckpoint := ctx.jsonRawCheckpoints[n] - ctx.jsonRaw = make([]byte, len(jsonRawCheckpoint)) - copy(ctx.jsonRaw, jsonRawCheckpoint) - if removeCheckpoint { + + if restore { ctx.jsonRawCheckpoints = ctx.jsonRawCheckpoints[:n] + ctx.jsonRaw = jsonRawCheckpoint + } else { + ctx.jsonRaw = ctx.copyContext(jsonRawCheckpoint) } return true diff --git a/pkg/engine/context/deferred_test.go b/pkg/engine/context/deferred_test.go index e6fa3c818f..67e8f234d7 100644 --- a/pkg/engine/context/deferred_test.go +++ b/pkg/engine/context/deferred_test.go @@ -95,8 +95,8 @@ func TestDeferredLoaderMismatch(t *testing.T) { func newContext() *context { return &context{ jp: jp, - jsonRaw: []byte(`{}`), - jsonRawCheckpoints: make([][]byte, 0), + jsonRaw: make(map[string]interface{}), + jsonRawCheckpoints: make([]map[string]interface{}, 0), deferred: NewDeferredLoaders(), } } @@ -290,7 +290,7 @@ func TestDeferredCheckpointRestore(t *testing.T) { func TestDeferredForloop(t *testing.T) { ctx := newContext() - addDeferred(ctx, "value", -1) + addDeferred(ctx, "value", float64(-1)) ctx.Checkpoint() for i := 0; i < 5; i++ { @@ -299,7 +299,7 @@ func TestDeferredForloop(t *testing.T) { assert.Equal(t, float64(i-1), val) ctx.Reset() - mock, _ := addDeferred(ctx, "value", i) + mock, _ := addDeferred(ctx, "value", float64(i)) val, err = ctx.Query("value") assert.NilError(t, err) assert.Equal(t, float64(i), val) diff --git a/pkg/engine/context/evaluate.go b/pkg/engine/context/evaluate.go index 1624ea1b59..a8a7009678 100644 --- a/pkg/engine/context/evaluate.go +++ b/pkg/engine/context/evaluate.go @@ -1,7 +1,6 @@ package context import ( - "encoding/json" "fmt" "strings" @@ -25,13 +24,7 @@ func (ctx *context) Query(query string) (interface{}, error) { return nil, fmt.Errorf("incorrect query %s: %v", query, err) } // search - ctx.mutex.RLock() - defer ctx.mutex.RUnlock() - var data interface{} - if err := json.Unmarshal(ctx.jsonRaw, &data); err != nil { - return nil, fmt.Errorf("failed to unmarshal context: %w", err) - } - result, err := queryPath.Search(data) + result, err := queryPath.Search(ctx.jsonRaw) if err != nil { return nil, fmt.Errorf("JMESPath query failed: %w", err) } diff --git a/pkg/engine/context/evaluate_test.go b/pkg/engine/context/evaluate_test.go index c41c57e90d..6612d5e6bb 100644 --- a/pkg/engine/context/evaluate_test.go +++ b/pkg/engine/context/evaluate_test.go @@ -3,6 +3,7 @@ package context import ( "testing" + kyvernov1 "github.com/kyverno/kyverno/api/kyverno/v1" "github.com/stretchr/testify/assert" admissionv1 "k8s.io/api/admission/v1" ) @@ -65,3 +66,23 @@ func createTestContext(obj, oldObj string) Interface { ctx.AddRequest(request) return ctx } + +func TestQueryOperation(t *testing.T) { + ctx := createTestContext(`{"a": {"b": 1, "c": 2}, "d": 3}`, `{"a": {"b": 2, "c": 2}, "d": 4}`) + assert.Equal(t, ctx.QueryOperation(), "UPDATE") + request := admissionv1.AdmissionRequest{ + Operation: admissionv1.Delete, + } + + err := ctx.AddRequest(request) + assert.Nil(t, err) + assert.Equal(t, ctx.QueryOperation(), "DELETE") + + err = ctx.AddOperation(string(kyvernov1.Connect)) + assert.Nil(t, err) + assert.Equal(t, ctx.QueryOperation(), "CONNECT") + + err = ctx.AddRequest(admissionv1.AdmissionRequest{}) + assert.Nil(t, err) + assert.Equal(t, ctx.QueryOperation(), "") +} diff --git a/pkg/engine/context/loaders/variable.go b/pkg/engine/context/loaders/variable.go index 355fdaa689..c4cc69c678 100644 --- a/pkg/engine/context/loaders/variable.go +++ b/pkg/engine/context/loaders/variable.go @@ -8,6 +8,7 @@ import ( kyvernov1 "github.com/kyverno/kyverno/api/kyverno/v1" enginecontext "github.com/kyverno/kyverno/pkg/engine/context" "github.com/kyverno/kyverno/pkg/engine/jmespath" + "github.com/kyverno/kyverno/pkg/engine/jsonutils" "github.com/kyverno/kyverno/pkg/engine/variables" ) @@ -58,7 +59,7 @@ func (vl *variableLoader) loadVariable() (err error) { var defaultValue interface{} = nil if entry.Variable.Default != nil { - value, err := variables.DocumentToUntyped(entry.Variable.Default) + value, err := jsonutils.DocumentToUntyped(entry.Variable.Default) if err != nil { return fmt.Errorf("invalid default for variable %s", entry.Name) } @@ -71,7 +72,7 @@ func (vl *variableLoader) loadVariable() (err error) { var output interface{} = defaultValue if entry.Variable.Value != nil { - value, _ := variables.DocumentToUntyped(entry.Variable.Value) + value, _ := jsonutils.DocumentToUntyped(entry.Variable.Value) variable, err := variables.SubstituteAll(logger, ctx, value) if err != nil { return fmt.Errorf("failed to substitute variables in context entry %s %s: %v", entry.Name, entry.Variable.Value, err) diff --git a/pkg/engine/context/mock_context.go b/pkg/engine/context/mock_context.go index 07f359f2fc..f38554bc34 100644 --- a/pkg/engine/context/mock_context.go +++ b/pkg/engine/context/mock_context.go @@ -64,6 +64,16 @@ func (ctx *MockContext) Query(query string) (interface{}, error) { } } +func (ctx *MockContext) QueryOperation() string { + if op, err := ctx.Query("request.operation"); err != nil { + if op != nil { + return op.(string) + } + } + + return "" +} + func (ctx *MockContext) isVariableDefined(variable string) bool { for _, pattern := range ctx.getVariables() { if wildcard.Match(pattern, variable) { diff --git a/pkg/engine/context/utils.go b/pkg/engine/context/utils.go index a07d9c07f0..d13d83a868 100644 --- a/pkg/engine/context/utils.go +++ b/pkg/engine/context/utils.go @@ -1,16 +1,14 @@ package context import ( - "encoding/json" + "reflect" + + "k8s.io/apimachinery/pkg/runtime" ) // AddJSONObject merges json data -func AddJSONObject(ctx Interface, data interface{}) error { - jsonBytes, err := json.Marshal(data) - if err != nil { - return err - } - return ctx.addJSON(jsonBytes) +func AddJSONObject(ctx Interface, data map[string]interface{}) error { + return ctx.addJSON(data) } func AddResource(ctx Interface, dataRaw []byte) error { @@ -32,19 +30,83 @@ func AddOldResource(ctx Interface, dataRaw []byte) error { } func addToContext(ctx *context, data interface{}, tags ...string) error { - dataRaw, err := json.Marshal(push(data, tags...)) - if err != nil { - logger.Error(err, "failed to marshal the resource") + if v, err := convertStructs(data); err != nil { return err + } else { + dataRaw := push(v, tags...) + return ctx.addJSON(dataRaw) } - return ctx.addJSON(dataRaw) } -func push(data interface{}, tags ...string) interface{} { +func clearLeafValue(data map[string]interface{}, tags ...string) bool { + if len(tags) == 0 { + return false + } + + for i := 0; i < len(tags); i++ { + k := tags[i] + if i == len(tags)-1 { + delete(data, k) + return true + } + + if nextMap, ok := data[k].(map[string]interface{}); ok { + data = nextMap + } else { + return false + } + } + + return false +} + +// convertStructs converts structs, and pointers-to-structs, to map[string]interface{} +func convertStructs(value interface{}) (interface{}, error) { + if value != nil { + v := reflect.ValueOf(value) + if v.Kind() == reflect.Struct { + return toUnstructured(value) + } + + if v.Kind() == reflect.Ptr { + ptrVal := v.Elem() + if ptrVal.Kind() == reflect.Struct { + return toUnstructured(value) + } + } + } + + return value, nil +} + +func push(data interface{}, tags ...string) map[string]interface{} { for i := len(tags) - 1; i >= 0; i-- { data = map[string]interface{}{ tags[i]: data, } } - return data + + return data.(map[string]interface{}) +} + +// mergeMaps merges srcMap entries into destMap +func mergeMaps(srcMap, destMap map[string]interface{}) { + for k, v := range srcMap { + if nextSrcMap, ok := v.(map[string]interface{}); ok { + if nextDestMap, ok := destMap[k].(map[string]interface{}); ok { + mergeMaps(nextSrcMap, nextDestMap) + } else { + destMap[k] = nextSrcMap + } + } else { + destMap[k] = v + } + } +} + +// toUnstructured converts a struct with JSON tags to a map[string]interface{} +func toUnstructured(typedStruct interface{}) (map[string]interface{}, error) { + converter := runtime.DefaultUnstructuredConverter + u, err := converter.ToUnstructured(typedStruct) + return u, err } diff --git a/pkg/engine/context/utils_test.go b/pkg/engine/context/utils_test.go new file mode 100644 index 0000000000..11f758e905 --- /dev/null +++ b/pkg/engine/context/utils_test.go @@ -0,0 +1,144 @@ +package context + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestMergeMaps(t *testing.T) { + map1 := map[string]interface{}{ + "strVal": "bar1", + "strVal2": "bar2", + "intVal": 2, + "arrayVal": []string{"1", "2", "3"}, + "mapVal": map[string]interface{}{ + "foo": "bar", + }, + "mapVal2": map[string]interface{}{ + "foo2": map[string]interface{}{ + "foo3": 3, + }, + }, + } + + map2 := map[string]interface{}{ + "strVal": "bar2", + "intVal": 3, + "intVal2": 3, + "arrayVal": []string{"1", "2", "3", "4"}, + "mapVal": map[string]interface{}{ + "foo1": "bar1", + "foo2": "bar2", + }, + } + + mergeMaps(map1, map2) + + assert.Equal(t, "bar1", map2["strVal"]) + assert.Equal(t, "bar2", map2["strVal2"]) + assert.Equal(t, 2, map2["intVal"]) + assert.Equal(t, 3, map2["intVal2"]) + assert.Equal(t, []string{"1", "2", "3"}, map2["arrayVal"]) + assert.Equal(t, map[string]interface{}{"foo": "bar", "foo1": "bar1", "foo2": "bar2"}, map2["mapVal"]) + assert.Equal(t, map1["mapVal2"], map2["mapVal2"]) + + requestObj := map[string]interface{}{ + "request": map[string]interface{}{ + "object": map[string]interface{}{ + "foo": "bar", + }, + }, + } + + ctxMap := map[string]interface{}{} + mergeMaps(requestObj, ctxMap) + + r := ctxMap["request"].(map[string]interface{}) + o := r["object"].(map[string]interface{}) + assert.Equal(t, o["foo"], "bar") + + requestObj2 := map[string]interface{}{ + "request": map[string]interface{}{ + "object": map[string]interface{}{ + "foo": "bar2", + "foo2": "bar2", + }, + }, + } + + mergeMaps(requestObj2, ctxMap) + r2 := ctxMap["request"].(map[string]interface{}) + o2 := r2["object"].(map[string]interface{}) + assert.Equal(t, "bar2", o2["foo"]) + assert.Equal(t, "bar2", o2["foo2"]) + + request3 := map[string]interface{}{ + "request": map[string]interface{}{ + "userInfo": "user1", + }, + } + + mergeMaps(request3, ctxMap) + r3 := ctxMap["request"].(map[string]interface{}) + o3 := r3["object"].(map[string]interface{}) + assert.NotNil(t, o3) + assert.Equal(t, "bar2", o2["foo"]) + assert.Equal(t, "bar2", o2["foo2"]) + assert.Equal(t, "user1", r3["userInfo"]) +} + +func TestStructToUntypedMap(t *testing.T) { + type SampleStuct struct { + Name string `json:"name"` + ID int32 `json:"identifier"` + } + + sample := &SampleStuct{ + Name: "user1", + ID: 12345, + } + + result, err := toUnstructured(sample) + assert.Nil(t, err) + + assert.Equal(t, "user1", result["name"]) + assert.Equal(t, int64(12345), result["identifier"]) +} + +func TestClearLeaf(t *testing.T) { + request := map[string]interface{}{ + "request": map[string]interface{}{ + "object": map[string]interface{}{ + "key1": "val1", + "key2": "val2", + }, + }, + } + + result := clearLeafValue(request, "request", "object", "key1") + assert.True(t, result) + + r := request["request"].(map[string]interface{}) + o := r["object"].(map[string]interface{}) + _, exists := o["key1"] + assert.Equal(t, false, exists) + + _, exists = o["key2"] + assert.Equal(t, true, exists) + + result = clearLeafValue(request, "request", "object", "key3") + assert.True(t, result) + + _, exists = o["key3"] + assert.Equal(t, false, exists) + + result = clearLeafValue(request, "request", "object-bad", "key3") + assert.Equal(t, false, result) + + result = clearLeafValue(request, "request", "object") + assert.True(t, result) + + _, exists = r["object"] + assert.Equal(t, false, exists) +} diff --git a/pkg/engine/handlers/mutation/common.go b/pkg/engine/handlers/mutation/common.go index e41b3bbd69..ad102ee229 100644 --- a/pkg/engine/handlers/mutation/common.go +++ b/pkg/engine/handlers/mutation/common.go @@ -66,6 +66,7 @@ func (f *forEachMutator) mutateElements(ctx context.Context, foreach kyvernov1.F defer f.policyContext.JSONContext().Restore() patchedResource := f.resource + reverse := false // if it's a patch strategic merge, reverse by default if foreach.RawPatchStrategicMerge != nil { diff --git a/pkg/engine/jsonutils/convert.go b/pkg/engine/jsonutils/convert.go new file mode 100644 index 0000000000..6b38dfc776 --- /dev/null +++ b/pkg/engine/jsonutils/convert.go @@ -0,0 +1,22 @@ +package jsonutils + +import jsoniter "github.com/json-iterator/go" + +var json = jsoniter.ConfigCompatibleWithStandardLibrary + +// DocumentToUntyped converts a typed object to JSON data +// i.e. string, []interface{}, map[string]interface{} +func DocumentToUntyped(doc interface{}) (interface{}, error) { + jsonDoc, err := json.Marshal(doc) + if err != nil { + return nil, err + } + + var untyped interface{} + err = json.Unmarshal(jsonDoc, &untyped) + if err != nil { + return nil, err + } + + return untyped, nil +} diff --git a/pkg/engine/jsonutils/traverse_test.go b/pkg/engine/jsonutils/traverse_test.go index e32c13a169..b01defe54a 100644 --- a/pkg/engine/jsonutils/traverse_test.go +++ b/pkg/engine/jsonutils/traverse_test.go @@ -1,7 +1,6 @@ package jsonutils import ( - "encoding/json" "testing" "gotest.tools/assert" diff --git a/pkg/engine/mutate/mutation.go b/pkg/engine/mutate/mutation.go index eea23bd5f8..c75af0ebcf 100644 --- a/pkg/engine/mutate/mutation.go +++ b/pkg/engine/mutate/mutation.go @@ -47,7 +47,9 @@ func Mutate(rule *kyvernov1.Rule, ctx context.Interface, resource unstructured.U if patcher == nil { return NewErrorResponse("empty mutate rule", nil) } - resourceBytes, err := resource.MarshalJSON() + + patchedResource := resource.DeepCopy() + resourceBytes, err := patchedResource.MarshalJSON() if err != nil { return NewErrorResponse("failed to marshal resource", err) } @@ -58,19 +60,15 @@ func Mutate(rule *kyvernov1.Rule, ctx context.Interface, resource unstructured.U if strings.TrimSpace(string(resourceBytes)) == strings.TrimSpace(string(patchedBytes)) { return NewResponse(engineapi.RuleStatusSkip, resource, "no patches applied") } - if err := resource.UnmarshalJSON(patchedBytes); err != nil { + if err := patchedResource.UnmarshalJSON(patchedBytes); err != nil { return NewErrorResponse("failed to unmarshal patched resource", err) } if rule.IsMutateExisting() { - if err := ctx.SetTargetResource(resource.Object); err != nil { - return NewErrorResponse("failed to update patched resource in the JSON context", err) - } - } else { - if err := ctx.AddResource(resource.Object); err != nil { - return NewErrorResponse("failed to update patched resource in the JSON context", err) + if err := ctx.SetTargetResource(patchedResource.Object); err != nil { + return NewErrorResponse("failed to update patched target resource in the JSON context", err) } } - return NewResponse(engineapi.RuleStatusPass, resource, "resource patched") + return NewResponse(engineapi.RuleStatusPass, *patchedResource, "resource patched") } func ForEach(name string, foreach kyvernov1.ForEachMutation, policyContext engineapi.PolicyContext, resource unstructured.Unstructured, element interface{}, logger logr.Logger) *Response { @@ -83,7 +81,9 @@ func ForEach(name string, foreach kyvernov1.ForEachMutation, policyContext engin if patcher == nil { return NewErrorResponse("empty mutate rule", nil) } - resourceBytes, err := resource.MarshalJSON() + + patchedResource := resource.DeepCopy() + resourceBytes, err := patchedResource.MarshalJSON() if err != nil { return NewErrorResponse("failed to marshal resource", err) } @@ -94,13 +94,11 @@ func ForEach(name string, foreach kyvernov1.ForEachMutation, policyContext engin if strings.TrimSpace(string(resourceBytes)) == strings.TrimSpace(string(patchedBytes)) { return NewResponse(engineapi.RuleStatusSkip, resource, "no patches applied") } - if err := resource.UnmarshalJSON(patchedBytes); err != nil { + if err := patchedResource.UnmarshalJSON(patchedBytes); err != nil { return NewErrorResponse("failed to unmarshal patched resource", err) - } else if err := ctx.AddResource(resource.Object); err != nil { - return NewErrorResponse("failed to update patched resource in the JSON context", err) - } else { - return NewResponse(engineapi.RuleStatusPass, resource, "resource patched") } + + return NewResponse(engineapi.RuleStatusPass, *patchedResource, "resource patched") } func substituteAllInForEach(fe kyvernov1.ForEachMutation, ctx context.Interface, logger logr.Logger) (*kyvernov1.ForEachMutation, error) { diff --git a/pkg/engine/policycontext/policy_context.go b/pkg/engine/policycontext/policy_context.go index 9282f9d673..72c4bb4be1 100644 --- a/pkg/engine/policycontext/policy_context.go +++ b/pkg/engine/policycontext/policy_context.go @@ -193,6 +193,7 @@ func NewPolicyContext( configuration config.Configuration, ) (*PolicyContext, error) { enginectx := enginectx.NewContext(jp) + if operation != kyvernov1.Delete { if err := enginectx.AddResource(resource.Object); err != nil { return nil, err diff --git a/pkg/engine/utils/foreach.go b/pkg/engine/utils/foreach.go index 1c88048f6a..c0e4960780 100644 --- a/pkg/engine/utils/foreach.go +++ b/pkg/engine/utils/foreach.go @@ -5,7 +5,7 @@ import ( engineapi "github.com/kyverno/kyverno/pkg/engine/api" enginecontext "github.com/kyverno/kyverno/pkg/engine/context" - "github.com/kyverno/kyverno/pkg/engine/variables" + "github.com/kyverno/kyverno/pkg/engine/jsonutils" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" ) @@ -31,7 +31,7 @@ func InvertedElement(elements []interface{}) { } func AddElementToContext(ctx engineapi.PolicyContext, element interface{}, index, nesting int, elementScope *bool) error { - data, err := variables.DocumentToUntyped(element) + data, err := jsonutils.DocumentToUntyped(element) if err != nil { return err } diff --git a/pkg/engine/utils/utils.go b/pkg/engine/utils/utils.go index 1bab7ee793..3e0e1e03fe 100644 --- a/pkg/engine/utils/utils.go +++ b/pkg/engine/utils/utils.go @@ -14,8 +14,16 @@ import ( ) func IsDeleteRequest(ctx engineapi.PolicyContext) bool { + if ctx == nil { + return false + } + + if op := ctx.Operation(); string(op) != "" { + return op == kyvernov1.Delete + } + + // if the NewResource is empty, the request is a DELETE newResource := ctx.NewResource() - // if the OldResource is not empty, and the NewResource is empty, the request is a DELETE return IsEmptyUnstructured(&newResource) } diff --git a/pkg/engine/validation_test.go b/pkg/engine/validation_test.go index 5289646ca1..cbc7a5ab03 100644 --- a/pkg/engine/validation_test.go +++ b/pkg/engine/validation_test.go @@ -6,7 +6,6 @@ import ( "strings" "testing" - kyverno "github.com/kyverno/kyverno/api/kyverno/v1" kyvernov1 "github.com/kyverno/kyverno/api/kyverno/v1" kyvernov1beta1 "github.com/kyverno/kyverno/api/kyverno/v1beta1" "github.com/kyverno/kyverno/pkg/config" @@ -53,7 +52,7 @@ func testValidate( func newPolicyContext( t *testing.T, resource unstructured.Unstructured, - operation kyverno.AdmissionOperation, + operation kyvernov1.AdmissionOperation, admissionInfo *kyvernov1beta1.RequestInfo, ) *PolicyContext { t.Helper() @@ -2145,7 +2144,7 @@ func executeTest(t *testing.T, test testCase) { t.Fatal(err) } - pc := newPolicyContext(t, newR, kyverno.AdmissionOperation(request.Operation), &userInfo). + pc := newPolicyContext(t, newR, kyvernov1.AdmissionOperation(request.Operation), &userInfo). WithPolicy(&policy). WithOldResource(oldR) diff --git a/pkg/engine/variables/variables_test.go b/pkg/engine/variables/variables_test.go index a59295c3d0..80bcfa78ad 100644 --- a/pkg/engine/variables/variables_test.go +++ b/pkg/engine/variables/variables_test.go @@ -1,7 +1,6 @@ package variables import ( - "encoding/json" "reflect" "testing" diff --git a/pkg/engine/variables/vars.go b/pkg/engine/variables/vars.go index bfe33c24f0..6f2fa11f19 100644 --- a/pkg/engine/variables/vars.go +++ b/pkg/engine/variables/vars.go @@ -1,13 +1,13 @@ package variables import ( - "encoding/json" "errors" "fmt" "path" "strings" "github.com/go-logr/logr" + jsoniter "github.com/json-iterator/go" gojmespath "github.com/kyverno/go-jmespath" kyvernov1 "github.com/kyverno/kyverno/api/kyverno/v1" "github.com/kyverno/kyverno/pkg/engine/anchor" @@ -19,6 +19,8 @@ import ( "github.com/kyverno/kyverno/pkg/utils/jsonpointer" ) +var json = jsoniter.ConfigCompatibleWithStandardLibrary + // ReplaceAllVars replaces all variables with the value defined in the replacement function // This is used to avoid validation errors func ReplaceAllVars(src string, repl func(string) string) string { @@ -58,7 +60,7 @@ func SubstituteAll(log logr.Logger, ctx context.EvalInterface, document interfac } func SubstituteAllInPreconditions(log logr.Logger, ctx context.EvalInterface, document interface{}) (interface{}, error) { - untypedDoc, err := DocumentToUntyped(document) + untypedDoc, err := jsonUtils.DocumentToUntyped(document) if err != nil { return nil, err } @@ -66,7 +68,7 @@ func SubstituteAllInPreconditions(log logr.Logger, ctx context.EvalInterface, do } func SubstituteAllInType[T any](log logr.Logger, ctx context.EvalInterface, t *T) (*T, error) { - untyped, err := DocumentToUntyped(t) + untyped, err := jsonUtils.DocumentToUntyped(t) if err != nil { return nil, err } @@ -99,23 +101,6 @@ func SubstituteAllInRule(log logr.Logger, ctx context.EvalInterface, rule kyvern return *result, nil } -// DocumentToUntyped converts a typed object to JSON data i.e. -// string, []interface{}, map[string]interface{} -func DocumentToUntyped(doc interface{}) (interface{}, error) { - jsonDoc, err := json.Marshal(doc) - if err != nil { - return nil, err - } - - var untyped interface{} - err = json.Unmarshal(jsonDoc, &untyped) - if err != nil { - return nil, err - } - - return untyped, nil -} - func untypedToTyped[T any](untyped interface{}) (*T, error) { jsonRule, err := json.Marshal(untyped) if err != nil { @@ -184,7 +169,7 @@ func substituteAll(log logr.Logger, ctx context.EvalInterface, document interfac func SubstituteAllForceMutate(log logr.Logger, ctx context.Interface, typedRule kyvernov1.Rule) (_ kyvernov1.Rule, err error) { var rule interface{} - rule, err = DocumentToUntyped(typedRule) + rule, err = jsonUtils.DocumentToUntyped(typedRule) if err != nil { return kyvernov1.Rule{}, err } @@ -407,10 +392,11 @@ func isDeleteRequest(ctx context.EvalInterface) bool { if ctx == nil { return false } - operation, err := ctx.Query("request.operation") - if err == nil && operation == "DELETE" { - return true + + if op := ctx.QueryOperation(); op != "" { + return op == "DELETE" } + return false } diff --git a/pkg/engine/variables/vars_test.go b/pkg/engine/variables/vars_test.go index 63bf8b2fa1..cc441f96de 100644 --- a/pkg/engine/variables/vars_test.go +++ b/pkg/engine/variables/vars_test.go @@ -1,8 +1,6 @@ package variables import ( - "bytes" - "encoding/json" "fmt" "strings" "testing" @@ -208,11 +206,6 @@ func Test_subVars_with_JMESPath_At(t *testing.T) { }`) var err error - - expected := new(bytes.Buffer) - err = json.Compact(expected, expectedRaw) - assert.NilError(t, err) - var pattern, resource interface{} err = json.Unmarshal(patternMap, &pattern) assert.NilError(t, err) @@ -227,7 +220,7 @@ func Test_subVars_with_JMESPath_At(t *testing.T) { assert.NilError(t, err) out, err := json.Marshal(output) assert.NilError(t, err) - assert.Equal(t, string(out), expected.String()) + assert.Equal(t, string(out), compact(t, expectedRaw)) } func Test_subVars_withRegexMatch(t *testing.T) { @@ -268,10 +261,6 @@ func Test_subVars_withRegexMatch(t *testing.T) { var err error - expected := new(bytes.Buffer) - err = json.Compact(expected, expectedRaw) - assert.NilError(t, err) - var pattern, resource interface{} err = json.Unmarshal(patternMap, &pattern) assert.NilError(t, err) @@ -286,7 +275,7 @@ func Test_subVars_withRegexMatch(t *testing.T) { assert.NilError(t, err) out, err := json.Marshal(output) assert.NilError(t, err) - assert.Equal(t, string(out), expected.String()) + assert.Equal(t, string(out), compact(t, expectedRaw)) } func Test_subVars_withMerge(t *testing.T) { @@ -298,10 +287,6 @@ func Test_subVars_withMerge(t *testing.T) { var err error - expected := new(bytes.Buffer) - err = json.Compact(expected, expectedRaw) - assert.NilError(t, err) - var pattern, resource interface{} err = json.Unmarshal(patternMap, &pattern) assert.NilError(t, err) @@ -316,7 +301,17 @@ func Test_subVars_withMerge(t *testing.T) { assert.NilError(t, err) out, err := json.Marshal(output) assert.NilError(t, err) - assert.Equal(t, string(out), expected.String()) + assert.Equal(t, string(out), compact(t, expectedRaw)) +} + +func compact(t *testing.T, in []byte) string { + var tmp map[string]interface{} + err := json.Unmarshal(in, &tmp) + assert.NilError(t, err) + + out, err := json.Marshal(tmp) + assert.NilError(t, err) + return string(out) } func Test_subVars_withRegexReplaceAll(t *testing.T) { @@ -393,10 +388,12 @@ func Test_ReplacingPathWhenDeleting(t *testing.T) { var pattern interface{} var err error err = json.Unmarshal(patternRaw, &pattern) - if err != nil { - t.Error(err) - } - ctx := context.NewContextFromRaw(jp, resourceRaw) + assert.NilError(t, err) + + ctxMap, err := unmarshalToMap(resourceRaw) + assert.NilError(t, err) + + ctx := context.NewContextFromRaw(jp, ctxMap) assert.NilError(t, err) pattern, err = SubstituteAll(logr.Discard(), ctx, pattern) @@ -405,6 +402,15 @@ func Test_ReplacingPathWhenDeleting(t *testing.T) { assert.Equal(t, fmt.Sprintf("%v", pattern), "bar") } +func unmarshalToMap(jsonBytes []byte) (map[string]interface{}, error) { + var data map[string]interface{} + if err := json.Unmarshal(jsonBytes, &data); err != nil { + return nil, err + } + + return data, nil +} + func Test_ReplacingNestedVariableWhenDeleting(t *testing.T) { patternRaw := []byte(`"{{request.object.metadata.annotations.{{request.object.metadata.annotations.targetnew}}}}"`) @@ -428,12 +434,12 @@ func Test_ReplacingNestedVariableWhenDeleting(t *testing.T) { var pattern interface{} var err error err = json.Unmarshal(patternRaw, &pattern) - if err != nil { - t.Error(err) - } - ctx := context.NewContextFromRaw(jp, resourceRaw) assert.NilError(t, err) + ctxMap, err := unmarshalToMap(resourceRaw) + assert.NilError(t, err) + + ctx := context.NewContextFromRaw(jp, ctxMap) pattern, err = SubstituteAll(logr.Discard(), ctx, pattern) assert.NilError(t, err) @@ -633,7 +639,10 @@ func Test_variableSubstitution_array(t *testing.T) { err := json.Unmarshal(ruleRaw, &rule) assert.NilError(t, err) - ctx := context.NewContextFromRaw(jp, configmapRaw) + ctxMap, err := unmarshalToMap(configmapRaw) + assert.NilError(t, err) + + ctx := context.NewContextFromRaw(jp, ctxMap) context.AddResource(ctx, resourceRaw) vars, err := SubstituteAllInRule(logr.Discard(), ctx, rule) @@ -1173,7 +1182,11 @@ func Test_ReplacingEscpNestedVariableWhenDeleting(t *testing.T) { if err != nil { t.Error(err) } - ctx := context.NewContextFromRaw(jp, resourceRaw) + + ctxMap, err := unmarshalToMap(resourceRaw) + assert.NilError(t, err) + + ctx := context.NewContextFromRaw(jp, ctxMap) assert.NilError(t, err) pattern, err = SubstituteAll(logr.Discard(), ctx, pattern) diff --git a/pkg/validation/policy/validate.go b/pkg/validation/policy/validate.go index c80a0b17f3..d78806894f 100644 --- a/pkg/validation/policy/validate.go +++ b/pkg/validation/policy/validate.go @@ -34,7 +34,7 @@ import ( ) var ( - allowedVariables = regexp.MustCompile(`request\.|serviceAccountName|serviceAccountNamespace|element|elementIndex|@|images|images\.|image\.|([a-z_0-9]+\()[^{}]`) + allowedVariables = enginecontext.ReservedKeys allowedVariablesBackground = regexp.MustCompile(`request\.|element|elementIndex|@|images|images\.|image\.|([a-z_0-9]+\()[^{}]`) allowedVariablesInTarget = regexp.MustCompile(`request\.|serviceAccountName|serviceAccountNamespace|element|elementIndex|@|images|images\.|image\.|target\.|([a-z_0-9]+\()[^{}]`) allowedVariablesBackgroundInTarget = regexp.MustCompile(`request\.|element|elementIndex|@|images|images\.|image\.|target\.|([a-z_0-9]+\()[^{}]`) diff --git a/test/cli/test/images/verify-signature/policies.yaml b/test/cli/test/images/verify-signature/policies.yaml index ac076e50af..66cbc630fb 100644 --- a/test/cli/test/images/verify-signature/policies.yaml +++ b/test/cli/test/images/verify-signature/policies.yaml @@ -80,5 +80,5 @@ spec: mutateDigest: false required: true useCache: true - verifyDigest: true + verifyDigest: false validationFailureAction: Enforce