From da1fbd9475ff6b6ed6af26adcb62b293a0ab514e Mon Sep 17 00:00:00 2001 From: Frank Jogeleit Date: Thu, 6 Mar 2025 16:13:13 +0100 Subject: [PATCH] Cel HTTP Lib (#12241) * Implement HTTP CEL lib for external API calls Signed-off-by: Frank Jogeleit * fix lint errors Signed-off-by: Frank Jogeleit --------- Signed-off-by: Frank Jogeleit --- pkg/cel/libs/http/http.go | 111 +++++++++++++++++ pkg/cel/libs/http/impl.go | 84 +++++++++++++ pkg/cel/libs/http/impl_test.go | 215 +++++++++++++++++++++++++++++++++ pkg/cel/libs/http/lib.go | 69 +++++++++++ pkg/cel/policy/compiler.go | 5 +- 5 files changed, 483 insertions(+), 1 deletion(-) create mode 100644 pkg/cel/libs/http/http.go create mode 100644 pkg/cel/libs/http/impl.go create mode 100644 pkg/cel/libs/http/impl_test.go create mode 100644 pkg/cel/libs/http/lib.go diff --git a/pkg/cel/libs/http/http.go b/pkg/cel/libs/http/http.go new file mode 100644 index 0000000000..6708e5bc12 --- /dev/null +++ b/pkg/cel/libs/http/http.go @@ -0,0 +1,111 @@ +package http + +import ( + "bytes" + "context" + "crypto/tls" + "crypto/x509" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/kyverno/kyverno/pkg/tracing" + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" +) + +type HttpInterface interface { + Get(url string, headers map[string]string) (map[string]any, error) + Post(url string, data map[string]any, headers map[string]string) (map[string]any, error) + Client(caBundle string) (HttpInterface, error) +} + +type ClientInterface interface { + Do(*http.Request) (*http.Response, error) +} + +type HTTP struct { + HttpInterface +} + +type HttpProvider struct { + client ClientInterface +} + +func (r *HttpProvider) Get(url string, headers map[string]string) (map[string]any, error) { + req, err := http.NewRequestWithContext(context.TODO(), "GET", url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %v", err) + } + for h, v := range headers { + req.Header.Add(h, v) + } + return r.executeRequest(r.client, req) +} + +func (r *HttpProvider) Post(url string, data map[string]any, headers map[string]string) (map[string]any, error) { + body, err := buildRequestData(data) + if err != nil { + return nil, fmt.Errorf("failed to encode request data: %v", err) + } + + req, err := http.NewRequestWithContext(context.TODO(), "POST", url, body) + if err != nil { + return nil, fmt.Errorf("failed to create request: %v", err) + } + for h, v := range headers { + req.Header.Add(h, v) + } + return r.executeRequest(r.client, req) +} + +func (r *HttpProvider) executeRequest(client ClientInterface, req *http.Request) (map[string]any, error) { + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("HTTP %s", resp.Status) + } + + body := make(map[string]any) + + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + return nil, fmt.Errorf("Unable to decode JSON body %v", err) + } + + return body, nil +} + +func (r *HttpProvider) Client(caBundle string) (HttpInterface, error) { + if caBundle == "" { + return r, nil + } + + caCertPool := x509.NewCertPool() + if ok := caCertPool.AppendCertsFromPEM([]byte(caBundle)); !ok { + return nil, fmt.Errorf("failed to parse PEM CA bundle for APICall") + } + transport := &http.Transport{ + TLSClientConfig: &tls.Config{ + RootCAs: caCertPool, + MinVersion: tls.VersionTLS12, + }, + } + return &HttpProvider{ + client: &http.Client{ + Transport: tracing.Transport(transport, otelhttp.WithFilter(tracing.RequestFilterIsInSpan)), + }, + }, nil +} + +func buildRequestData(data map[string]any) (io.Reader, error) { + buffer := new(bytes.Buffer) + if err := json.NewEncoder(buffer).Encode(data); err != nil { + return nil, fmt.Errorf("failed to encode HTTP POST data %v: %w", data, err) + } + + return buffer, nil +} diff --git a/pkg/cel/libs/http/impl.go b/pkg/cel/libs/http/impl.go new file mode 100644 index 0000000000..9062c90606 --- /dev/null +++ b/pkg/cel/libs/http/impl.go @@ -0,0 +1,84 @@ +package http + +import ( + "fmt" + + "github.com/google/cel-go/common/types" + "github.com/google/cel-go/common/types/ref" + "github.com/kyverno/kyverno/pkg/cel/utils" +) + +type impl struct { + types.Adapter +} + +func (c *impl) get_request_with_client_string(args ...ref.Val) ref.Val { + if request, err := utils.ConvertToNative[HTTP](args[0]); err != nil { + return types.WrapErr(err) + } else if url, err := utils.ConvertToNative[string](args[1]); err != nil { + return types.WrapErr(err) + } else if header, err := utils.ConvertToNative[map[string]string](args[2]); err != nil { + return types.WrapErr(err) + } else { + data, err := request.Get(url, header) + if err != nil { + return types.NewErr("request failed: %v", err) + } + + return c.NativeToValue(data) + } +} + +func (c *impl) get_request_string(request, url ref.Val) ref.Val { + return c.get_request_with_client_string(request, url, c.NativeToValue(make(map[string]string, 0))) +} + +func (c *impl) get_request_with_headers_string(args ...ref.Val) ref.Val { + return c.get_request_with_client_string(args...) +} + +func (c *impl) post_request_string_with_client(args ...ref.Val) ref.Val { + if request, err := utils.ConvertToNative[HTTP](args[0]); err != nil { + return types.WrapErr(err) + } else if url, err := utils.ConvertToNative[string](args[1]); err != nil { + return types.WrapErr(err) + } else if data, err := utils.ConvertToNative[map[string]any](args[2]); err != nil { + return types.WrapErr(err) + } else if header, err := utils.ConvertToNative[map[string]string](args[3]); err != nil { + return types.WrapErr(err) + } else { + data, err := request.Post(url, data, header) + if err != nil { + return types.NewErr("request failed: %v", err) + } + + return c.NativeToValue(data) + } +} + +func (c *impl) http_client_string(request, caBundle ref.Val) ref.Val { + fmt.Println("http_client_string") + if request, err := utils.ConvertToNative[HTTP](request); err != nil { + fmt.Println("conv request") + return types.WrapErr(err) + } else if caBundle, err := utils.ConvertToNative[string](caBundle); err != nil { + fmt.Println("conv ca bundle") + return types.WrapErr(err) + } else { + fmt.Println("call client") + caRequest, err := request.Client(caBundle) + if err != nil { + return types.NewErr("request failed: %v", err) + } + + return c.NativeToValue(caRequest) + } +} + +func (c *impl) post_request_string(args ...ref.Val) ref.Val { + return c.post_request_string_with_client(args[0], args[1], args[2], c.NativeToValue(make(map[string]string, 0))) +} + +func (c *impl) post_request_with_headers_string(args ...ref.Val) ref.Val { + return c.post_request_string_with_client(args...) +} diff --git a/pkg/cel/libs/http/impl_test.go b/pkg/cel/libs/http/impl_test.go new file mode 100644 index 0000000000..cf48037f70 --- /dev/null +++ b/pkg/cel/libs/http/impl_test.go @@ -0,0 +1,215 @@ +package http + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "testing" + + "github.com/google/cel-go/cel" + "github.com/google/cel-go/common/types" + "github.com/stretchr/testify/assert" +) + +var pemExample = `-----BEGIN CERTIFICATE----- +MIICMzCCAZygAwIBAgIJALiPnVsvq8dsMA0GCSqGSIb3DQEBBQUAMFMxCzAJBgNV +BAYTAlVTMQwwCgYDVQQIEwNmb28xDDAKBgNVBAcTA2ZvbzEMMAoGA1UEChMDZm9v +MQwwCgYDVQQLEwNmb28xDDAKBgNVBAMTA2ZvbzAeFw0xMzAzMTkxNTQwMTlaFw0x +ODAzMTgxNTQwMTlaMFMxCzAJBgNVBAYTAlVTMQwwCgYDVQQIEwNmb28xDDAKBgNV +BAcTA2ZvbzEMMAoGA1UEChMDZm9vMQwwCgYDVQQLEwNmb28xDDAKBgNVBAMTA2Zv +bzCBnzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEAzdGfxi9CNbMf1UUcvDQh7MYB +OveIHyc0E0KIbhjK5FkCBU4CiZrbfHagaW7ZEcN0tt3EvpbOMxxc/ZQU2WN/s/wP +xph0pSfsfFsTKM4RhTWD2v4fgk+xZiKd1p0+L4hTtpwnEw0uXRVd0ki6muwV5y/P ++5FHUeldq+pgTcgzuK8CAwEAAaMPMA0wCwYDVR0PBAQDAgLkMA0GCSqGSIb3DQEB +BQUAA4GBAJiDAAtY0mQQeuxWdzLRzXmjvdSuL9GoyT3BF/jSnpxz5/58dba8pWen +v3pj4P3w5DoOso0rzkZy2jEsEitlVM2mLSbQpMM+MUVQCQoiG6W9xuCFuxSrwPIS +pAqEAuV4DNoxQKKWmhVv+J0ptMWD25Pnpxeq5sXzghfJnslJlQND +-----END CERTIFICATE-----` + +type testClient struct { + doFunc func(req *http.Request) (*http.Response, error) +} + +func (t testClient) Do(req *http.Request) (*http.Response, error) { + return t.doFunc(req) +} + +func Test_impl_get_request(t *testing.T) { + opts := Lib() + base, err := cel.NewEnv(opts) + assert.NoError(t, err) + assert.NotNil(t, base) + options := []cel.EnvOption{ + cel.Variable("http", HTTPType), + } + env, err := base.Extend(options...) + assert.NoError(t, err) + assert.NotNil(t, env) + ast, issues := env.Compile(`http.Get("http://localhost:8080")`) + fmt.Println(issues.String()) + assert.Nil(t, issues) + assert.NotNil(t, ast) + prog, err := env.Program(ast) + assert.NoError(t, err) + assert.NotNil(t, prog) + out, _, err := prog.Eval(map[string]any{ + "http": HTTP{&HttpProvider{ + client: testClient{ + doFunc: func(req *http.Request) (*http.Response, error) { + assert.Equal(t, req.URL.String(), "http://localhost:8080") + assert.Equal(t, req.Method, "GET") + + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"body": "ok"}`))}, nil + }, + }, + }}, + }) + assert.NoError(t, err) + body := out.Value().(map[string]any) + assert.Equal(t, body["body"], "ok") +} + +func Test_impl_get_request_with_headers(t *testing.T) { + opts := Lib() + base, err := cel.NewEnv(opts) + assert.NoError(t, err) + assert.NotNil(t, base) + options := []cel.EnvOption{ + cel.Variable("http", HTTPType), + } + env, err := base.Extend(options...) + assert.NoError(t, err) + assert.NotNil(t, env) + ast, issues := env.Compile(`http.Get("http://localhost:8080", {"Authorization": "Bearer token"})`) + fmt.Println(issues.String()) + assert.Nil(t, issues) + assert.NotNil(t, ast) + prog, err := env.Program(ast) + assert.NoError(t, err) + assert.NotNil(t, prog) + out, _, err := prog.Eval(map[string]any{ + "http": HTTP{&HttpProvider{ + client: testClient{ + doFunc: func(req *http.Request) (*http.Response, error) { + assert.Equal(t, req.URL.String(), "http://localhost:8080") + assert.Equal(t, req.Method, "GET") + assert.Equal(t, req.Header.Get("Authorization"), "Bearer token") + + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"body": "ok"}`))}, nil + }, + }, + }}, + }) + assert.NoError(t, err) + body := out.Value().(map[string]any) + assert.Equal(t, body["body"], "ok") +} + +func Test_impl_post_request(t *testing.T) { + opts := Lib() + base, err := cel.NewEnv(opts) + assert.NoError(t, err) + assert.NotNil(t, base) + options := []cel.EnvOption{ + cel.Variable("http", HTTPType), + } + env, err := base.Extend(options...) + assert.NoError(t, err) + assert.NotNil(t, env) + ast, issues := env.Compile(`http.Post("http://localhost:8080", {"key": "value"})`) + fmt.Println(issues.String()) + assert.Nil(t, issues) + assert.NotNil(t, ast) + prog, err := env.Program(ast) + assert.NoError(t, err) + assert.NotNil(t, prog) + out, _, err := prog.Eval(map[string]any{ + "http": HTTP{&HttpProvider{ + client: testClient{ + doFunc: func(req *http.Request) (*http.Response, error) { + assert.Equal(t, req.URL.String(), "http://localhost:8080") + assert.Equal(t, req.Method, "POST") + + data := make(map[string]string, 0) + json.NewDecoder(req.Body).Decode(&data) + assert.Equal(t, data["key"], "value") + + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"body": "ok"}`))}, nil + }, + }, + }}, + }) + assert.NoError(t, err) + body := out.Value().(map[string]any) + assert.Equal(t, body["body"], "ok") +} + +func Test_impl_post_request_with_headers(t *testing.T) { + opts := Lib() + base, err := cel.NewEnv(opts) + assert.NoError(t, err) + assert.NotNil(t, base) + options := []cel.EnvOption{ + cel.Variable("http", HTTPType), + } + env, err := base.Extend(options...) + assert.NoError(t, err) + assert.NotNil(t, env) + ast, issues := env.Compile(`http.Post("http://localhost:8080", {"key": "value"}, {"Authorization": "Bearer token"})`) + fmt.Println(issues.String()) + assert.Nil(t, issues) + assert.NotNil(t, ast) + prog, err := env.Program(ast) + assert.NoError(t, err) + assert.NotNil(t, prog) + out, _, err := prog.Eval(map[string]any{ + "http": HTTP{&HttpProvider{ + client: testClient{ + doFunc: func(req *http.Request) (*http.Response, error) { + assert.Equal(t, req.URL.String(), "http://localhost:8080") + assert.Equal(t, req.Method, "POST") + assert.Equal(t, req.Header.Get("Authorization"), "Bearer token") + + data := make(map[string]string, 0) + json.NewDecoder(req.Body).Decode(&data) + assert.Equal(t, data["key"], "value") + + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"body": "ok"}`))}, nil + }, + }, + }}, + }) + assert.NoError(t, err) + body := out.Value().(map[string]any) + assert.Equal(t, body["body"], "ok") +} + +func Test_impl_http_client_string(t *testing.T) { + opts := Lib() + base, err := cel.NewEnv(opts) + assert.NoError(t, err) + assert.NotNil(t, base) + options := []cel.EnvOption{ + cel.Variable("pem", types.StringType), + cel.Variable("http", HTTPType), + } + env, err := base.Extend(options...) + assert.NoError(t, err) + assert.NotNil(t, env) + ast, issues := env.Compile(`http.Client(pem)`) + fmt.Println(issues.String()) + assert.Nil(t, issues) + assert.NotNil(t, ast) + prog, err := env.Program(ast) + assert.NoError(t, err) + assert.NotNil(t, prog) + out, _, err := prog.Eval(map[string]any{ + "pem": pemExample, + "http": HTTP{&HttpProvider{}}, + }) + assert.NoError(t, err) + reqProvider := out.Value().(*HttpProvider) + assert.NotNil(t, reqProvider) +} diff --git a/pkg/cel/libs/http/lib.go b/pkg/cel/libs/http/lib.go new file mode 100644 index 0000000000..29f6fe646b --- /dev/null +++ b/pkg/cel/libs/http/lib.go @@ -0,0 +1,69 @@ +package http + +import ( + "net/http" + "reflect" + + "github.com/google/cel-go/cel" + "github.com/google/cel-go/common/types" + "github.com/google/cel-go/ext" + apiservercel "k8s.io/apiserver/pkg/cel" +) + +const libraryName = "kyverno.http" + +var HTTPType = types.DynType + +type lib struct{} + +func Lib() cel.EnvOption { + // create the cel lib env option + return cel.Lib(&lib{}) +} + +func Types() []*apiservercel.DeclType { + return []*apiservercel.DeclType{} +} + +func (*lib) LibraryName() string { + return libraryName +} + +func (c *lib) CompileOptions() []cel.EnvOption { + return []cel.EnvOption{ + ext.NativeTypes(reflect.TypeFor[http.Request]()), + c.extendEnv, + } +} + +func (*lib) ProgramOptions() []cel.ProgramOption { + return []cel.ProgramOption{} +} + +func (c *lib) extendEnv(env *cel.Env) (*cel.Env, error) { + // create implementation, recording the envoy types aware adapter + impl := impl{ + Adapter: env.CELTypeAdapter(), + } + // build our function overloads + libraryDecls := map[string][]cel.FunctionOpt{ + "Get": { + cel.MemberOverload("get_request_string", []*cel.Type{HTTPType, types.StringType}, types.NewMapType(types.StringType, types.AnyType), cel.BinaryBinding(impl.get_request_string)), + cel.MemberOverload("get_request_with_headers_string", []*cel.Type{HTTPType, types.StringType, types.NewMapType(types.StringType, types.StringType)}, types.NewMapType(types.StringType, types.AnyType), cel.FunctionBinding(impl.get_request_with_headers_string)), + }, + "Post": { + cel.MemberOverload("post_request_string", []*cel.Type{HTTPType, types.StringType, types.NewMapType(types.StringType, types.AnyType)}, types.NewMapType(types.StringType, types.AnyType), cel.FunctionBinding(impl.post_request_string)), + cel.MemberOverload("post_request__with_headers_string", []*cel.Type{HTTPType, types.StringType, types.NewMapType(types.StringType, types.AnyType), types.NewMapType(types.StringType, types.StringType)}, types.NewMapType(types.StringType, types.AnyType), cel.FunctionBinding(impl.post_request_with_headers_string)), + }, + "Client": { + cel.MemberOverload("http_client_string", []*cel.Type{HTTPType, types.StringType}, HTTPType, cel.BinaryBinding(impl.http_client_string)), + }, + } + // create env options corresponding to our function overloads + options := []cel.EnvOption{} + for name, overloads := range libraryDecls { + options = append(options, cel.Function(name, overloads...)) + } + // extend environment with our function overloads + return env.Extend(options...) +} diff --git a/pkg/cel/policy/compiler.go b/pkg/cel/policy/compiler.go index 5f3d4a5b4f..e447acb8bb 100644 --- a/pkg/cel/policy/compiler.go +++ b/pkg/cel/policy/compiler.go @@ -9,6 +9,7 @@ import ( engine "github.com/kyverno/kyverno/pkg/cel" vpolautogen "github.com/kyverno/kyverno/pkg/cel/autogen" "github.com/kyverno/kyverno/pkg/cel/libs/context" + "github.com/kyverno/kyverno/pkg/cel/libs/http" admissionregistrationv1 "k8s.io/api/admissionregistration/v1" "k8s.io/apimachinery/pkg/util/validation/field" apiservercel "k8s.io/apiserver/pkg/cel" @@ -16,6 +17,7 @@ import ( const ( ContextKey = "context" + HttpKey = "http" NamespaceObjectKey = "namespaceObject" ObjectKey = "object" OldObjectKey = "oldObject" @@ -108,6 +110,7 @@ func (c *compiler) compileForKubernetes(policy *policiesv1alpha1.ValidatingPolic declTypes = append(declTypes, context.Types()...) options := []cel.EnvOption{ cel.Variable(ContextKey, context.ContextType), + cel.Variable(HttpKey, http.HTTPType), cel.Variable(NamespaceObjectKey, NamespaceType.CelType()), cel.Variable(ObjectKey, cel.DynType), cel.Variable(OldObjectKey, cel.DynType), @@ -125,7 +128,7 @@ func (c *compiler) compileForKubernetes(policy *policiesv1alpha1.ValidatingPolic panic(err) } options = append(options, declOptions...) - options = append(options, context.Lib()) + options = append(options, context.Lib(), http.Lib()) // TODO: params, authorizer, authorizer.requestResource ? env, err := base.Extend(options...) if err != nil {