diff --git a/cmd/cli/kubectl-kyverno/commands/apply/command.go b/cmd/cli/kubectl-kyverno/commands/apply/command.go index 009a374fb2..78580b0be3 100644 --- a/cmd/cli/kubectl-kyverno/commands/apply/command.go +++ b/cmd/cli/kubectl-kyverno/commands/apply/command.go @@ -35,6 +35,7 @@ import ( "github.com/kyverno/kyverno/pkg/clients/dclient" "github.com/kyverno/kyverno/pkg/config" engineapi "github.com/kyverno/kyverno/pkg/engine/api" + gctxstore "github.com/kyverno/kyverno/pkg/globalcontext/store" "github.com/kyverno/kyverno/pkg/imageverification/imagedataloader" gitutils "github.com/kyverno/kyverno/pkg/utils/git" policyvalidation "github.com/kyverno/kyverno/pkg/validation/policy" @@ -348,11 +349,13 @@ func (c *ApplyCommandConfig) applyValidatingPolicies( } eng := engine.NewEngine(provider, namespaceProvider, matching.NewMatcher()) // TODO: mock when no cluster provided + gctxStore := gctxstore.New() var contextProvider celpolicy.Context if dclient != nil { contextProvider, err = celpolicy.NewContextProvider( dclient, []imagedataloader.Option{imagedataloader.WithLocalCredentials(c.RegistryAccess)}, + gctxStore, ) if err != nil { return nil, err diff --git a/cmd/kyverno/main.go b/cmd/kyverno/main.go index f2c2f0a0c3..992b1ecccc 100644 --- a/cmd/kyverno/main.go +++ b/cmd/kyverno/main.go @@ -593,9 +593,11 @@ func main() { backgroundServiceAccountName, reportsServiceAccountName, ) + contextProvider, err := celpolicy.NewContextProvider( setup.KyvernoDynamicClient, nil, + gcstore, // []imagedataloader.Option{imagedataloader.WithLocalCredentials(c.RegistryAccess)}, ) if err != nil { diff --git a/pkg/cel/libs/context/impl.go b/pkg/cel/libs/context/impl.go index 3ca5a405c0..fb8a8e77ec 100644 --- a/pkg/cel/libs/context/impl.go +++ b/pkg/cel/libs/context/impl.go @@ -27,13 +27,18 @@ func (c *impl) get_configmap_string_string(args ...ref.Val) ref.Val { } } -func (c *impl) get_globalreference_string(ctx ref.Val, name ref.Val) ref.Val { - if self, err := utils.ConvertToNative[Context](ctx); err != nil { +func (c *impl) get_globalreference_string(args ...ref.Val) ref.Val { + if len(args) != 3 { + return types.NewErr("expected 3 arguments, got %d", len(args)) + } + if self, err := utils.ConvertToNative[Context](args[0]); err != nil { return types.WrapErr(err) - } else if name, err := utils.ConvertToNative[string](name); err != nil { + } else if name, err := utils.ConvertToNative[string](args[1]); err != nil { + return types.WrapErr(err) + } else if projection, err := utils.ConvertToNative[string](args[2]); err != nil { return types.WrapErr(err) } else { - globalRef, err := self.GetGlobalReference(name) + globalRef, err := self.GetGlobalReference(name, projection) if err != nil { // Errors are not expected here since Parse is a more lenient parser than ParseRequestURI. return types.NewErr("failed to get global reference: %v", err) diff --git a/pkg/cel/libs/context/impl_test.go b/pkg/cel/libs/context/impl_test.go index bfc40e635b..0beb063193 100644 --- a/pkg/cel/libs/context/impl_test.go +++ b/pkg/cel/libs/context/impl_test.go @@ -2,10 +2,12 @@ package context import ( "context" + "errors" "strings" "testing" "github.com/google/cel-go/cel" + "github.com/kyverno/kyverno/pkg/globalcontext/store" "github.com/kyverno/kyverno/pkg/imageverification/imagedataloader" "github.com/stretchr/testify/assert" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" @@ -13,7 +15,7 @@ import ( type ctx struct { GetConfigMapFunc func(string, string) (unstructured.Unstructured, error) - GetGlobalReferenceFunc func(string) (any, error) + GetGlobalReferenceFunc func(string, string) (any, error) GetImageDataFunc func(string) (*imagedataloader.ImageData, error) ListResourcesFunc func(string, string, string) (*unstructured.UnstructuredList, error) GetResourcesFunc func(string, string, string, string) (*unstructured.Unstructured, error) @@ -23,8 +25,8 @@ func (mock *ctx) GetConfigMap(ns string, n string) (unstructured.Unstructured, e return mock.GetConfigMapFunc(ns, n) } -func (mock *ctx) GetGlobalReference(n string) (any, error) { - return mock.GetGlobalReferenceFunc(n) +func (mock *ctx) GetGlobalReference(n, p string) (any, error) { + return mock.GetGlobalReferenceFunc(n, p) } func (mock *ctx) GetImageData(n string) (*imagedataloader.ImageData, error) { @@ -71,6 +73,33 @@ func Test_impl_get_configmap_string_string(t *testing.T) { assert.True(t, called) } +type mockGctxStore struct { + data map[string]store.Entry +} + +func (m *mockGctxStore) Get(name string) (store.Entry, bool) { + entry, ok := m.data[name] + return entry, ok +} + +func (m *mockGctxStore) Set(name string, data store.Entry) { + if m.data == nil { + m.data = make(map[string]store.Entry) + } + m.data[name] = data +} + +type mockEntry struct { + data any + err error +} + +func (m *mockEntry) Get(_ string) (any, error) { + return m.data, m.err +} + +func (m *mockEntry) Stop() {} + func Test_impl_get_globalreference_string(t *testing.T) { opts := Lib() base, err := cel.NewEnv(opts) @@ -82,28 +111,83 @@ func Test_impl_get_globalreference_string(t *testing.T) { env, err := base.Extend(options...) assert.NoError(t, err) assert.NotNil(t, env) - ast, issues := env.Compile(`context.GetGlobalReference("foo")`) + ast, issues := env.Compile(`context.GetGlobalReference("foo", "bar")`) assert.Nil(t, issues) assert.NotNil(t, ast) prog, err := env.Program(ast) assert.NoError(t, err) assert.NotNil(t, prog) - called := false - data := map[string]any{ - "context": Context{&ctx{ - GetGlobalReferenceFunc: func(string) (any, error) { - type foo struct { - s string - } - called = true - return foo{"bar"}, nil + + tests := []struct { + name string + gctxStoreData map[string]store.Entry + expectedValue any + expectedError string + }{ + { + name: "global context entry not found", + gctxStoreData: map[string]store.Entry{}, + expectedError: "global context entry not found", + }, + { + name: "global context entry returns error", + gctxStoreData: map[string]store.Entry{ + "foo": &mockEntry{err: errors.New("get entry error")}, }, - }}, + expectedError: "get entry error", + }, + { + name: "global context entry returns string", + gctxStoreData: map[string]store.Entry{ + "foo": &mockEntry{data: "stringValue"}, + }, + expectedValue: "stringValue", + }, + { + name: "global context entry returns map", + gctxStoreData: map[string]store.Entry{ + "foo": &mockEntry{data: map[string]interface{}{"key": "value"}}, + }, + expectedValue: map[string]interface{}{"key": "value"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockStore := &mockGctxStore{data: tt.gctxStoreData} + data := map[string]any{ + "context": Context{&ctx{ + GetGlobalReferenceFunc: func(name string, path string) (any, error) { + ent, ok := mockStore.Get(name) + if !ok { + return nil, errors.New("global context entry not found") + } + return ent.Get(path) + }, + }}, + } + out, _, err := prog.Eval(data) + + if tt.expectedError != "" { + assert.Error(t, err) + assert.Contains(t, err.Error(), tt.expectedError) + } else { + assert.NoError(t, err) + if tt.expectedValue == nil { + assert.Nil(t, out.Value()) + } else { + assert.NotNil(t, out) + if expectedUnstructured, ok := tt.expectedValue.(unstructured.Unstructured); ok { + actualUnstructured, ok := out.Value().(unstructured.Unstructured) + assert.True(t, ok, "Expected unstructured.Unstructured, got %T", out.Value()) + assert.Equal(t, expectedUnstructured, actualUnstructured) + } else { + assert.Equal(t, tt.expectedValue, out.Value()) + } + } + } + }) } - out, _, err := prog.Eval(data) - assert.NoError(t, err) - assert.NotNil(t, out) - assert.True(t, called) } func Test_impl_get_imagedata_string(t *testing.T) { diff --git a/pkg/cel/libs/context/lib.go b/pkg/cel/libs/context/lib.go index 537fa5172d..39b8c12c60 100644 --- a/pkg/cel/libs/context/lib.go +++ b/pkg/cel/libs/context/lib.go @@ -52,7 +52,7 @@ func (c *lib) extendEnv(env *cel.Env) (*cel.Env, error) { }, "GetGlobalReference": { // TODO: should not use DynType in return - cel.MemberOverload("get_globalreference_string", []*cel.Type{ContextType, types.StringType}, types.DynType, cel.BinaryBinding(impl.get_globalreference_string)), + cel.MemberOverload("get_globalreference_string", []*cel.Type{ContextType, types.StringType, types.StringType}, types.DynType, cel.FunctionBinding(impl.get_globalreference_string)), }, "GetImageData": { // TODO: should not use DynType in return diff --git a/pkg/cel/libs/context/types.go b/pkg/cel/libs/context/types.go index 7fec7ba3a0..233ed81ea2 100644 --- a/pkg/cel/libs/context/types.go +++ b/pkg/cel/libs/context/types.go @@ -15,7 +15,7 @@ var ( type ContextInterface interface { GetConfigMap(string, string) (unstructured.Unstructured, error) - GetGlobalReference(string) (any, error) + GetGlobalReference(string, string) (any, error) GetImageData(string) (*imagedataloader.ImageData, error) ListResource(apiVersion, resource, namespace string) (*unstructured.UnstructuredList, error) GetResource(apiVersion, resource, namespace, name string) (*unstructured.Unstructured, error) diff --git a/pkg/cel/policy/context.go b/pkg/cel/policy/context.go index 65271acfd9..5998d624ae 100644 --- a/pkg/cel/policy/context.go +++ b/pkg/cel/policy/context.go @@ -2,14 +2,18 @@ package policy import ( "context" + "encoding/json" + "errors" contextlib "github.com/kyverno/kyverno/pkg/cel/libs/context" "github.com/kyverno/kyverno/pkg/clients/dclient" "github.com/kyverno/kyverno/pkg/config" + gctxstore "github.com/kyverno/kyverno/pkg/globalcontext/store" "github.com/kyverno/kyverno/pkg/imageverification/imagedataloader" kubeutils "github.com/kyverno/kyverno/pkg/utils/kube" 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/client-go/dynamic" "k8s.io/client-go/kubernetes" @@ -21,9 +25,14 @@ type contextProvider struct { client kubernetes.Interface dclient dynamic.Interface imagedata imagedataloader.Fetcher + gctxStore gctxstore.Store } -func NewContextProvider(client dclient.Interface, imageOpts []imagedataloader.Option) (Context, error) { +func NewContextProvider( + client dclient.Interface, + imageOpts []imagedataloader.Option, + gctxStore gctxstore.Store, +) (Context, error) { idl, err := imagedataloader.New(client.GetKubeClient().CoreV1().Secrets(config.KyvernoNamespace()), imageOpts...) if err != nil { return nil, err @@ -32,6 +41,7 @@ func NewContextProvider(client dclient.Interface, imageOpts []imagedataloader.Op client: client.GetKubeClient(), dclient: client.GetDynamicInterface(), imagedata: idl, + gctxStore: gctxStore, }, nil } @@ -47,8 +57,38 @@ func (cp *contextProvider) GetConfigMap(namespace string, name string) (unstruct return *out, nil } -func (cp *contextProvider) GetGlobalReference(string) (any, error) { - return nil, nil +func (cp *contextProvider) GetGlobalReference(name, projection string) (any, error) { + ent, ok := cp.gctxStore.Get(name) + if !ok { + return nil, errors.New("global context entry not found") + } + data, err := ent.Get(projection) + if err != nil { + return nil, err + } + + if isLikelyKubernetesObject(data) { + out, err := kubeutils.ObjToUnstructured(data) + if err != nil { + return nil, err + } + if out != nil { + return *out, nil + } else { + return nil, errors.New("failed to convert to Unstructured") + } + } else { + raw, err := json.Marshal(data) + if err != nil { + return nil, err + } + apiData := map[string]interface{}{} + err = json.Unmarshal(raw, &apiData) + if err != nil { + return nil, err + } + return data, nil + } } func (cp *contextProvider) GetImageData(image string) (*imagedataloader.ImageData, error) { @@ -56,6 +96,24 @@ func (cp *contextProvider) GetImageData(image string) (*imagedataloader.ImageDat return cp.imagedata.FetchImageData(context.TODO(), image) } +func isLikelyKubernetesObject(data any) bool { + if data == nil { + return false + } + + if m, ok := data.(map[string]interface{}); ok { + _, hasAPIVersion := m["apiVersion"] + _, hasKind := m["kind"] + return hasAPIVersion && hasKind + } + + if _, ok := data.(runtime.Object); ok { + return true + } + + return false +} + func (cp *contextProvider) ListResource(apiVersion, resource, namespace string) (*unstructured.UnstructuredList, error) { groupVersion, err := schema.ParseGroupVersion(apiVersion) if err != nil { diff --git a/pkg/controllers/report/utils/scanner.go b/pkg/controllers/report/utils/scanner.go index d203f1f68d..4b7f57194a 100644 --- a/pkg/controllers/report/utils/scanner.go +++ b/pkg/controllers/report/utils/scanner.go @@ -16,6 +16,7 @@ import ( "github.com/kyverno/kyverno/pkg/engine" engineapi "github.com/kyverno/kyverno/pkg/engine/api" "github.com/kyverno/kyverno/pkg/engine/jmespath" + gctxstore "github.com/kyverno/kyverno/pkg/globalcontext/store" reportutils "github.com/kyverno/kyverno/pkg/utils/report" "go.uber.org/multierr" admissionv1 "k8s.io/api/admission/v1" @@ -154,12 +155,14 @@ func (s *scanner) ScanResource( func(name string) *corev1.Namespace { return ns }, matching.NewMatcher(), ) + gctxStore := gctxstore.New() // create context provider context, err := celpolicy.NewContextProvider( s.client, nil, // TODO // []imagedataloader.Option{imagedataloader.WithLocalCredentials(c.RegistryAccess)}, + gctxStore, ) if err != nil { logger.Error(err, "failed to create cel context provider") diff --git a/test/conformance/chainsaw/validating-policies/context/globalreference/README.md b/test/conformance/chainsaw/validating-policies/context/globalreference/README.md new file mode 100644 index 0000000000..e4830f2597 --- /dev/null +++ b/test/conformance/chainsaw/validating-policies/context/globalreference/README.md @@ -0,0 +1,11 @@ +## Description + +This test verifies that Global Context Entries are evaluated correctly. + +## Expected Behavior + +`new-deployment` should be created. + +## Reference Issues + + diff --git a/test/conformance/chainsaw/validating-policies/context/globalreference/chainsaw-test.yaml b/test/conformance/chainsaw/validating-policies/context/globalreference/chainsaw-test.yaml new file mode 100755 index 0000000000..de0210c12e --- /dev/null +++ b/test/conformance/chainsaw/validating-policies/context/globalreference/chainsaw-test.yaml @@ -0,0 +1,26 @@ +apiVersion: chainsaw.kyverno.io/v1alpha1 +kind: Test +metadata: + name: apicall-correct +spec: + steps: + - try: + - apply: + file: namespace.yaml + - apply: + file: main-deployment.yaml + - apply: + file: gctxentry.yaml + - sleep: + duration: 3s + - name: create policy + try: + - create: + file: policy.yaml + - sleep: + duration: 3s + - try: + - apply: + file: new-deployment.yaml + - assert: + file: new-deployment-exists.yaml diff --git a/test/conformance/chainsaw/validating-policies/context/globalreference/gctxentry.yaml b/test/conformance/chainsaw/validating-policies/context/globalreference/gctxentry.yaml new file mode 100755 index 0000000000..4cd652dfbf --- /dev/null +++ b/test/conformance/chainsaw/validating-policies/context/globalreference/gctxentry.yaml @@ -0,0 +1,8 @@ +apiVersion: kyverno.io/v2alpha1 +kind: GlobalContextEntry +metadata: + name: gctxentry-apicall-correct +spec: + apiCall: + urlPath: "/apis/apps/v1/namespaces/test-globalcontext-apicall-correct/deployments" + refreshInterval: 1h diff --git a/test/conformance/chainsaw/validating-policies/context/globalreference/main-deployment.yaml b/test/conformance/chainsaw/validating-policies/context/globalreference/main-deployment.yaml new file mode 100755 index 0000000000..de449d2cd9 --- /dev/null +++ b/test/conformance/chainsaw/validating-policies/context/globalreference/main-deployment.yaml @@ -0,0 +1,28 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: main-deployment + namespace: test-globalcontext-apicall-correct + labels: + app: main-deployment +spec: + replicas: 1 + selector: + matchLabels: + app: main-deployment + template: + metadata: + labels: + app: main-deployment + spec: + containers: + - name: pause + image: registry.k8s.io/pause:latest + resources: + requests: + cpu: 10m + memory: 10Mi + limits: + cpu: 10m + memory: 10Mi + terminationGracePeriodSeconds: 0 diff --git a/test/conformance/chainsaw/validating-policies/context/globalreference/namespace.yaml b/test/conformance/chainsaw/validating-policies/context/globalreference/namespace.yaml new file mode 100755 index 0000000000..80f2b8e573 --- /dev/null +++ b/test/conformance/chainsaw/validating-policies/context/globalreference/namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: test-globalcontext-apicall-correct diff --git a/test/conformance/chainsaw/validating-policies/context/globalreference/new-deployment-exists.yaml b/test/conformance/chainsaw/validating-policies/context/globalreference/new-deployment-exists.yaml new file mode 100755 index 0000000000..24d08fe849 --- /dev/null +++ b/test/conformance/chainsaw/validating-policies/context/globalreference/new-deployment-exists.yaml @@ -0,0 +1,7 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: new-deployment + namespace: test-globalcontext-apicall-correct + labels: + app: new-deployment diff --git a/test/conformance/chainsaw/validating-policies/context/globalreference/new-deployment.yaml b/test/conformance/chainsaw/validating-policies/context/globalreference/new-deployment.yaml new file mode 100755 index 0000000000..72d3039d3a --- /dev/null +++ b/test/conformance/chainsaw/validating-policies/context/globalreference/new-deployment.yaml @@ -0,0 +1,28 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: new-deployment + namespace: test-globalcontext-apicall-correct + labels: + app: new-deployment +spec: + replicas: 1 + selector: + matchLabels: + app: new-deployment + template: + metadata: + labels: + app: new-deployment + spec: + containers: + - name: pause + image: registry.k8s.io/pause:latest + resources: + requests: + cpu: 10m + memory: 10Mi + limits: + cpu: 10m + memory: 10Mi + terminationGracePeriodSeconds: 0 diff --git a/test/conformance/chainsaw/validating-policies/context/globalreference/policy.yaml b/test/conformance/chainsaw/validating-policies/context/globalreference/policy.yaml new file mode 100755 index 0000000000..62c580ec84 --- /dev/null +++ b/test/conformance/chainsaw/validating-policies/context/globalreference/policy.yaml @@ -0,0 +1,20 @@ +apiVersion: policies.kyverno.io/v1alpha1 +kind: ValidatingPolicy +metadata: + name: cpol-apicall-correct +spec: + matchConstraints: + resourceRules: + - apiGroups: [] + apiVersions: [v1] + operations: [CREATE, UPDATE] + resources: [pods] + variables: + - name: dcount + expression: >- + context.GetGlobalReference("gctxentry-apicall-correct", "") + validations: + - expression: >- + variables.dcount != 0 + message: >- + main-deployment should exist