diff --git a/.crds/envoy.kyverno.io_authorizationpolicies.yaml b/.crds/envoy.kyverno.io_authorizationpolicies.yaml index 85fad11c..039d437c 100644 --- a/.crds/envoy.kyverno.io_authorizationpolicies.yaml +++ b/.crds/envoy.kyverno.io_authorizationpolicies.yaml @@ -40,9 +40,34 @@ spec: description: AuthorizationPolicySpec defines the spec of an authorization policy properties: - authorizations: - description: Authorizations contain CEL expressions which is used - to apply the authorization. + allow: + description: Allow contain CEL expressions which is used to allow + a request. + items: + description: Authorization defines an authorization policy rule + properties: + match: + description: Match represents the match condition which will + be evaluated by CEL. Must evaluate to bool. + type: string + response: + description: |- + Response represents the response expression which will be evaluated by CEL. + ref: https://github.com/google/cel-spec + CEL expressions have access to CEL variables as well as some other useful variables: + + - 'object' - The object from the incoming request. (https://www.envoyproxy.io/docs/envoy/latest/api-v3/service/auth/v3/external_auth.proto#service-auth-v3-checkrequest) + + CEL expressions are expected to return an envoy CheckResponse (https://www.envoyproxy.io/docs/envoy/latest/api-v3/service/auth/v3/external_auth.proto#service-auth-v3-checkresponse). + type: string + required: + - response + type: object + type: array + x-kubernetes-list-type: atomic + deny: + description: Deny contain CEL expressions which is used to deny a + request. items: description: Authorization defines an authorization policy rule properties: diff --git a/.manifests/policies/demo-policy.example.com.yaml b/.manifests/policies/demo-policy.example.com.yaml index 323bd814..cfb809e2 100644 --- a/.manifests/policies/demo-policy.example.com.yaml +++ b/.manifests/policies/demo-policy.example.com.yaml @@ -1,4 +1,4 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/kyverno/kyverno-envoy-plugin/main/.schemas/json/authorizationpolicy-envoy-v1alpha1.json +# yaml-language-server: $schema=../../.schemas/json/authorizationpolicy-envoy-v1alpha1.json apiVersion: envoy.kyverno.io/v1alpha1 kind: AuthorizationPolicy metadata: @@ -11,17 +11,26 @@ spec: expression: object.attributes.request.http.headers[?"x-force-unauthenticated"].orValue("") in ["enabled", "true"] - name: metadata expression: '{"my-new-metadata": "my-new-value"}' - authorizations: + deny: # if force_unauthenticated -> 401 - - match: variables.force_unauthenticated + - match: > + variables.force_unauthenticated response: > envoy .Denied(401) .WithBody("Authentication Failed") .Response() - # if force_authorized -> 200 - - match: variables.force_authorized + # if force_unauthenticated -> 403 + - match: > + !variables.force_authorized response: > + envoy + .Denied(403) + .WithBody("Unauthorized Request") + .Response() + allow: + # else -> 200 + - response: > envoy .Allowed() .WithHeader("x-validated-by", "my-security-checkpoint") @@ -29,10 +38,3 @@ spec: .WithResponseHeader("x-add-custom-response-header", "added") .Response() .WithMetadata(variables.metadata) - # else -> 403 - - match: 'true' - response: > - envoy - .Denied(403) - .WithBody("Unauthorized Request") - .Response() diff --git a/.schemas/json/authorizationpolicy-envoy-v1alpha1.json b/.schemas/json/authorizationpolicy-envoy-v1alpha1.json index 9c4234c1..bfaa8885 100644 --- a/.schemas/json/authorizationpolicy-envoy-v1alpha1.json +++ b/.schemas/json/authorizationpolicy-envoy-v1alpha1.json @@ -327,8 +327,40 @@ "null" ], "properties": { - "authorizations": { - "description": "Authorizations contain CEL expressions which is used to apply the authorization.", + "allow": { + "description": "Allow contain CEL expressions which is used to allow a request.", + "type": [ + "array", + "null" + ], + "items": { + "description": "Authorization defines an authorization policy rule", + "type": [ + "object", + "null" + ], + "required": [ + "response" + ], + "properties": { + "match": { + "description": "Match represents the match condition which will be evaluated by CEL. Must evaluate to bool.", + "type": [ + "string", + "null" + ] + }, + "response": { + "description": "Response represents the response expression which will be evaluated by CEL.\nref: https://github.com/google/cel-spec\nCEL expressions have access to CEL variables as well as some other useful variables:\n\n- 'object' - The object from the incoming request. (https://www.envoyproxy.io/docs/envoy/latest/api-v3/service/auth/v3/external_auth.proto#service-auth-v3-checkrequest)\n\nCEL expressions are expected to return an envoy CheckResponse (https://www.envoyproxy.io/docs/envoy/latest/api-v3/service/auth/v3/external_auth.proto#service-auth-v3-checkresponse).", + "type": "string" + } + }, + "additionalProperties": false + }, + "x-kubernetes-list-type": "atomic" + }, + "deny": { + "description": "Deny contain CEL expressions which is used to deny a request.", "type": [ "array", "null" diff --git a/apis/v1alpha1/types.go b/apis/v1alpha1/types.go index cccf11b8..629737bd 100644 --- a/apis/v1alpha1/types.go +++ b/apis/v1alpha1/types.go @@ -61,10 +61,15 @@ type AuthorizationPolicySpec struct { // +optional Variables []admissionregistrationv1.Variable `json:"variables,omitempty" patchStrategy:"merge" patchMergeKey:"name"` - // Authorizations contain CEL expressions which is used to apply the authorization. + // Deny contain CEL expressions which is used to deny a request. // +listType=atomic // +optional - Authorizations []Authorization `json:"authorizations,omitempty"` + Deny []Authorization `json:"deny,omitempty"` + + // Allow contain CEL expressions which is used to allow a request. + // +listType=atomic + // +optional + Allow []Authorization `json:"allow,omitempty"` } func (s *AuthorizationPolicySpec) GetFailurePolicy() admissionregistrationv1.FailurePolicyType { diff --git a/apis/v1alpha1/zz_generated.deepcopy.go b/apis/v1alpha1/zz_generated.deepcopy.go index 553da5a6..0fef65f2 100644 --- a/apis/v1alpha1/zz_generated.deepcopy.go +++ b/apis/v1alpha1/zz_generated.deepcopy.go @@ -100,8 +100,13 @@ func (in *AuthorizationPolicySpec) DeepCopyInto(out *AuthorizationPolicySpec) { *out = make([]v1.Variable, len(*in)) copy(*out, *in) } - if in.Authorizations != nil { - in, out := &in.Authorizations, &out.Authorizations + if in.Deny != nil { + in, out := &in.Deny, &out.Deny + *out = make([]Authorization, len(*in)) + copy(*out, *in) + } + if in.Allow != nil { + in, out := &in.Allow, &out.Allow *out = make([]Authorization, len(*in)) copy(*out, *in) } diff --git a/charts/kyverno-authz-server/templates/crds.yaml b/charts/kyverno-authz-server/templates/crds.yaml index 3ce45bd0..666450ae 100644 --- a/charts/kyverno-authz-server/templates/crds.yaml +++ b/charts/kyverno-authz-server/templates/crds.yaml @@ -49,9 +49,34 @@ spec: description: AuthorizationPolicySpec defines the spec of an authorization policy properties: - authorizations: - description: Authorizations contain CEL expressions which is used - to apply the authorization. + allow: + description: Allow contain CEL expressions which is used to allow + a request. + items: + description: Authorization defines an authorization policy rule + properties: + match: + description: Match represents the match condition which will + be evaluated by CEL. Must evaluate to bool. + type: string + response: + description: |- + Response represents the response expression which will be evaluated by CEL. + ref: https://github.com/google/cel-spec + CEL expressions have access to CEL variables as well as some other useful variables: + + - 'object' - The object from the incoming request. (https://www.envoyproxy.io/docs/envoy/latest/api-v3/service/auth/v3/external_auth.proto#service-auth-v3-checkrequest) + + CEL expressions are expected to return an envoy CheckResponse (https://www.envoyproxy.io/docs/envoy/latest/api-v3/service/auth/v3/external_auth.proto#service-auth-v3-checkresponse). + type: string + required: + - response + type: object + type: array + x-kubernetes-list-type: atomic + deny: + description: Deny contain CEL expressions which is used to deny a + request. items: description: Authorization defines an authorization policy rule properties: diff --git a/pkg/authz/cel/libs/envoy/impl.go b/pkg/authz/cel/libs/envoy/impl.go index 12885213..874b2b2b 100644 --- a/pkg/authz/cel/libs/envoy/impl.go +++ b/pkg/authz/cel/libs/envoy/impl.go @@ -193,40 +193,41 @@ func (c *impl) header_keep_empty_value_bool(header ref.Val, flag ref.Val) ref.Va } } -func (c *impl) response_code(code ref.Val) ref.Val { - if code, err := utils.ConvertToNative[codes.Code](code); err != nil { +func (c *impl) response_ok(ok ref.Val) ref.Val { + if ok, err := utils.ConvertToNative[*authv3.OkHttpResponse](ok); err != nil { return types.WrapErr(err) } else { - return c.NativeToValue(&authv3.CheckResponse{ - Status: &status.Status{Code: int32(code)}, + return c.NativeToValue(&OkResponse{ + Status: &status.Status{Code: int32(codes.OK)}, + OkHttpResponse: ok, }) } } -func (c *impl) response_ok(ok ref.Val) ref.Val { - if ok, err := utils.ConvertToNative[*authv3.OkHttpResponse](ok); err != nil { +func (c *impl) response_denied(denied ref.Val) ref.Val { + if denied, err := utils.ConvertToNative[*authv3.DeniedHttpResponse](denied); err != nil { return types.WrapErr(err) } else { - return c.NativeToValue(&authv3.CheckResponse{ - Status: &status.Status{Code: int32(codes.OK)}, - HttpResponse: &authv3.CheckResponse_OkResponse{OkResponse: ok}, + return c.NativeToValue(&DeniedResponse{ + Status: &status.Status{Code: int32(codes.PermissionDenied)}, + DeniedHttpResponse: denied, }) } } -func (c *impl) response_denied(denied ref.Val) ref.Val { - if denied, err := utils.ConvertToNative[*authv3.DeniedHttpResponse](denied); err != nil { +func (c *impl) response_ok_with_message(response ref.Val, message ref.Val) ref.Val { + if response, err := utils.ConvertToNative[*OkResponse](response); err != nil { + return types.WrapErr(err) + } else if message, err := utils.ConvertToNative[string](message); err != nil { return types.WrapErr(err) } else { - return c.NativeToValue(&authv3.CheckResponse{ - Status: &status.Status{Code: int32(codes.PermissionDenied)}, - HttpResponse: &authv3.CheckResponse_DeniedResponse{DeniedResponse: denied}, - }) + response.Status.Message = message + return c.NativeToValue(response) } } -func (c *impl) response_with_message(response ref.Val, message ref.Val) ref.Val { - if response, err := utils.ConvertToNative[*authv3.CheckResponse](response); err != nil { +func (c *impl) response_denied_with_message(response ref.Val, message ref.Val) ref.Val { + if response, err := utils.ConvertToNative[*DeniedResponse](response); err != nil { return types.WrapErr(err) } else if message, err := utils.ConvertToNative[string](message); err != nil { return types.WrapErr(err) @@ -236,8 +237,19 @@ func (c *impl) response_with_message(response ref.Val, message ref.Val) ref.Val } } -func (c *impl) response_with_metadata(response ref.Val, metadata ref.Val) ref.Val { - if response, err := utils.ConvertToNative[*authv3.CheckResponse](response); err != nil { +func (c *impl) response_ok_with_metadata(response ref.Val, metadata ref.Val) ref.Val { + if response, err := utils.ConvertToNative[*OkResponse](response); err != nil { + return types.WrapErr(err) + } else if metadata, err := utils.ConvertToNative[*structpb.Struct](metadata); err != nil { + return types.WrapErr(err) + } else { + response.DynamicMetadata = metadata + return c.NativeToValue(response) + } +} + +func (c *impl) response_denied_with_metadata(response ref.Val, metadata ref.Val) ref.Val { + if response, err := utils.ConvertToNative[*DeniedResponse](response); err != nil { return types.WrapErr(err) } else if metadata, err := utils.ConvertToNative[*structpb.Struct](metadata); err != nil { return types.WrapErr(err) diff --git a/pkg/authz/cel/libs/envoy/lib.go b/pkg/authz/cel/libs/envoy/lib.go index 5ee6773e..5e15fa0c 100644 --- a/pkg/authz/cel/libs/envoy/lib.go +++ b/pkg/authz/cel/libs/envoy/lib.go @@ -1,21 +1,28 @@ package envoy import ( + "reflect" + authv3 "github.com/envoyproxy/go-control-plane/envoy/service/auth/v3" "github.com/google/cel-go/cel" "github.com/google/cel-go/common/types" "github.com/google/cel-go/common/types/ref" + "github.com/google/cel-go/ext" + status "google.golang.org/genproto/googleapis/rpc/status" + "google.golang.org/protobuf/types/known/structpb" ) -// envoy types var ( + // envoy auth types CheckRequest = types.NewObjectType("envoy.service.auth.v3.CheckRequest") - CheckResponse = types.NewObjectType("envoy.service.auth.v3.CheckResponse") - OkHttpResponse = types.NewObjectType("envoy.service.auth.v3.OkHttpResponse") DeniedHttpResponse = types.NewObjectType("envoy.service.auth.v3.DeniedHttpResponse") - Metadata = types.NewObjectType("google.protobuf.Struct") HeaderValueOption = types.NewObjectType("envoy.config.core.v3.HeaderValueOption") + Metadata = types.NewObjectType("google.protobuf.Struct") + OkHttpResponse = types.NewObjectType("envoy.service.auth.v3.OkHttpResponse") QueryParameter = types.NewObjectType("envoy.config.core.v3.QueryParameter") + // lib types + DeniedResponseType = types.NewObjectType("envoy.DeniedResponse") + OkResponseType = types.NewObjectType("envoy.OkResponse") ) type lib struct{} @@ -28,7 +35,14 @@ func Lib() cel.EnvOption { func (c *lib) CompileOptions() []cel.EnvOption { return []cel.EnvOption{ // register envoy protobuf messages - cel.Types((*authv3.CheckRequest)(nil), (*authv3.CheckResponse)(nil)), + cel.Types( + (*authv3.CheckRequest)(nil), + (*authv3.DeniedHttpResponse)(nil), + (*authv3.OkHttpResponse)(nil), + (*status.Status)(nil), + (*structpb.Struct)(nil), + ), + ext.NativeTypes(ext.ParseStructTags(true), reflect.TypeFor[DeniedResponse](), reflect.TypeFor[OkResponse]()), // extend environment with function overloads c.extendEnv, } @@ -51,11 +65,6 @@ func (*lib) extendEnv(env *cel.Env) (*cel.Env, error) { "envoy.Denied": { cel.Overload("denied", []*cel.Type{types.IntType}, DeniedHttpResponse, cel.UnaryBinding(impl.denied)), }, - "envoy.Response": { - cel.Overload("response_code", []*cel.Type{types.IntType}, CheckResponse, cel.UnaryBinding(impl.response_code)), - cel.Overload("response_ok", []*cel.Type{OkHttpResponse}, CheckResponse, cel.UnaryBinding(impl.response_ok)), - cel.Overload("response_denied", []*cel.Type{DeniedHttpResponse}, CheckResponse, cel.UnaryBinding(impl.response_denied)), - }, "envoy.Header": { cel.Overload("header_key_value", []*cel.Type{types.StringType, types.StringType}, HeaderValueOption, cel.BinaryBinding(impl.header_key_value)), }, @@ -90,14 +99,16 @@ func (*lib) extendEnv(env *cel.Env) (*cel.Env, error) { cel.MemberOverload("header_keep_empty_value_bool", []*cel.Type{HeaderValueOption, types.BoolType}, HeaderValueOption, cel.BinaryBinding(impl.header_keep_empty_value_bool)), }, "Response": { - cel.MemberOverload("ok_response", []*cel.Type{OkHttpResponse}, CheckResponse, cel.UnaryBinding(impl.response_ok)), - cel.MemberOverload("denied_response", []*cel.Type{DeniedHttpResponse}, CheckResponse, cel.UnaryBinding(impl.response_denied)), + cel.MemberOverload("ok_response", []*cel.Type{OkHttpResponse}, OkResponseType, cel.UnaryBinding(impl.response_ok)), + cel.MemberOverload("denied_response", []*cel.Type{DeniedHttpResponse}, DeniedResponseType, cel.UnaryBinding(impl.response_denied)), }, "WithMessage": { - cel.MemberOverload("response_with_message", []*cel.Type{CheckResponse, types.StringType}, CheckResponse, cel.BinaryBinding(impl.response_with_message)), + cel.MemberOverload("response_ok_with_message", []*cel.Type{OkResponseType, types.StringType}, OkResponseType, cel.BinaryBinding(impl.response_ok_with_message)), + cel.MemberOverload("response_denied_with_message", []*cel.Type{DeniedResponseType, types.StringType}, DeniedResponseType, cel.BinaryBinding(impl.response_denied_with_message)), }, "WithMetadata": { - cel.MemberOverload("response_with_metadata", []*cel.Type{CheckResponse, Metadata}, CheckResponse, cel.BinaryBinding(impl.response_with_metadata)), + cel.MemberOverload("response_ok_with_metadata", []*cel.Type{OkResponseType, Metadata}, OkResponseType, cel.BinaryBinding(impl.response_ok_with_metadata)), + cel.MemberOverload("response_denied_with_metadata", []*cel.Type{DeniedResponseType, Metadata}, DeniedResponseType, cel.BinaryBinding(impl.response_denied_with_metadata)), }, } // create env options corresponding to our function overloads diff --git a/pkg/authz/cel/libs/envoy/lib_test.go b/pkg/authz/cel/libs/envoy/lib_test.go index 68792a07..40a1f9f3 100644 --- a/pkg/authz/cel/libs/envoy/lib_test.go +++ b/pkg/authz/cel/libs/envoy/lib_test.go @@ -4,34 +4,111 @@ import ( "reflect" "testing" + corev3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" authv3 "github.com/envoyproxy/go-control-plane/envoy/service/auth/v3" "github.com/google/cel-go/cel" "github.com/google/cel-go/interpreter" "github.com/kyverno/kyverno-envoy-plugin/pkg/authz/cel/libs/envoy" "github.com/stretchr/testify/assert" + status "google.golang.org/genproto/googleapis/rpc/status" + "google.golang.org/protobuf/types/known/structpb" ) -func TestNewEnv(t *testing.T) { - source := ` -envoy - .Denied(401) - .WithBody("Authentication Failed") - .WithHeader(envoy.Header("foo", "bar").KeepEmptyValue()) - .Response() - .WithMetadata({"my-new-metadata": "my-new-value"}) - .WithMessage("hello") -` - env, err := cel.NewEnv(envoy.Lib()) - assert.NoError(t, err) - ast, issues := env.Compile(source) - assert.Nil(t, issues) - prog, err := env.Program(ast) - assert.NoError(t, err) - assert.NotNil(t, prog) - out, _, err := prog.Eval(interpreter.EmptyActivation()) - assert.NoError(t, err) - assert.NotNil(t, out) - a, err := out.ConvertToNative(reflect.TypeFor[*authv3.CheckResponse]()) - assert.NoError(t, err) - assert.NotNil(t, a) +func TestOkResponse(t *testing.T) { + tests := []struct { + name string + source string + want envoy.OkResponse + }{{ + name: "fluent", + source: ` + envoy + .Allowed() + .WithHeader(envoy.Header("foo", "bar").KeepEmptyValue()) + .Response() + .WithMetadata({"my-new-metadata": "my-new-value"}) + `, + want: envoy.OkResponse{ + Status: &status.Status{ + Code: 0, + }, + OkHttpResponse: &authv3.OkHttpResponse{ + Headers: []*corev3.HeaderValueOption{{ + Header: &corev3.HeaderValue{ + Key: "foo", + Value: "bar", + }, + KeepEmptyValue: true, + }}, + }, + DynamicMetadata: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "my-new-metadata": structpb.NewStringValue("my-new-value"), + }, + }, + }, + }, { + name: "empty", + want: envoy.OkResponse{}, + source: ` + envoy.OkResponse{} + `, + }, { + name: "with status", + want: envoy.OkResponse{ + Status: &status.Status{ + Code: 0, + }, + }, + source: ` + envoy.OkResponse{ + status: google.rpc.Status{ + code: 0 + } + } + `, + }, { + name: "with metadata", + want: envoy.OkResponse{ + DynamicMetadata: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "foo": structpb.NewStringValue("bar"), + }, + }, + }, + source: ` + envoy.OkResponse{ + dynamic_metadata: { + "foo": "bar" + } + } + `, + }, { + name: "with response", + want: envoy.OkResponse{ + OkHttpResponse: &authv3.OkHttpResponse{}, + }, + source: ` + envoy.OkResponse{ + http_response: envoy.service.auth.v3.OkHttpResponse{} + } + `, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + env, err := cel.NewEnv(envoy.Lib()) + assert.NoError(t, err) + ast, issues := env.Compile(tt.source) + assert.Nil(t, issues) + prog, err := env.Program(ast) + assert.NoError(t, err) + assert.NotNil(t, prog) + out, _, err := prog.Eval(interpreter.EmptyActivation()) + assert.NoError(t, err) + assert.NotNil(t, out) + got, err := out.ConvertToNative(reflect.TypeFor[envoy.OkResponse]()) + assert.NoError(t, err) + assert.EqualExportedValues(t, tt.want, got) + }) + } } diff --git a/pkg/authz/cel/libs/envoy/response.go b/pkg/authz/cel/libs/envoy/response.go new file mode 100644 index 00000000..d0cf81ca --- /dev/null +++ b/pkg/authz/cel/libs/envoy/response.go @@ -0,0 +1,53 @@ +package envoy + +import ( + authv3 "github.com/envoyproxy/go-control-plane/envoy/service/auth/v3" + status "google.golang.org/genproto/googleapis/rpc/status" + "google.golang.org/protobuf/types/known/structpb" +) + +type OkResponse struct { + // Status “OK“ allows the request. Any other status indicates the request should be denied, and + // for HTTP filter, if not overridden by :ref:`denied HTTP response status ` + // Envoy sends “403 Forbidden“ HTTP status code by default. + Status *status.Status `cel:"status"` + // An message that contains HTTP response attributes. This message is + // used when the authorization service needs to send custom responses to the + // downstream client or, to modify/add request headers being dispatched to the upstream. + // + // Types that are assignable to HttpResponse: + // + // *CheckResponse_DeniedResponse + // *CheckResponse_OkResponse + OkHttpResponse *authv3.OkHttpResponse `cel:"http_response"` + // Optional response metadata that will be emitted as dynamic metadata to be consumed by the next + // filter. This metadata lives in a namespace specified by the canonical name of extension filter + // that requires it: + // + // - :ref:`envoy.filters.http.ext_authz ` for HTTP filter. + // - :ref:`envoy.filters.network.ext_authz ` for network filter. + DynamicMetadata *structpb.Struct `cel:"dynamic_metadata"` +} + +type DeniedResponse struct { + // Status “OK“ allows the request. Any other status indicates the request should be denied, and + // for HTTP filter, if not overridden by :ref:`denied HTTP response status ` + // Envoy sends “403 Forbidden“ HTTP status code by default. + Status *status.Status `cel:"status"` + // An message that contains HTTP response attributes. This message is + // used when the authorization service needs to send custom responses to the + // downstream client or, to modify/add request headers being dispatched to the upstream. + // + // Types that are assignable to HttpResponse: + // + // *CheckResponse_DeniedResponse + // *CheckResponse_OkResponse + DeniedHttpResponse *authv3.DeniedHttpResponse `cel:"http_response"` + // Optional response metadata that will be emitted as dynamic metadata to be consumed by the next + // filter. This metadata lives in a namespace specified by the canonical name of extension filter + // that requires it: + // + // - :ref:`envoy.filters.http.ext_authz ` for HTTP filter. + // - :ref:`envoy.filters.network.ext_authz ` for network filter. + DynamicMetadata *structpb.Struct `cel:"dynamic_metadata"` +} diff --git a/pkg/authz/service.go b/pkg/authz/service.go index ee3241e5..94138e3d 100644 --- a/pkg/authz/service.go +++ b/pkg/authz/service.go @@ -29,19 +29,42 @@ func (s *service) check(ctx context.Context, r *authv3.CheckRequest) (*authv3.Ch if err != nil { return nil, err } + // TODO: eliminate allocations + allow := make([]policy.AllowFunc, 0, len(policies)) + deny := make([]policy.DenyFunc, 0, len(policies)) // iterate over policies for _, policy := range policies { - // execute policy - response, err := policy(r) + // collect allow/deny + a, d := policy.For(r) + allow = append(allow, a) + deny = append(deny, d) + } + // check deny first + for _, deny := range deny { + // execute rule + response, err := deny() + // return error if any + if err != nil { + return nil, err + } + // if the reponse returned by the rule evaluation was not nil, return + if response != nil { + return response, nil + } + } + // check allow + for _, allow := range allow { + // execute rule + response, err := allow() // return error if any if err != nil { return nil, err } - // if the reponse returned by the policy evaluation was not nil, return + // if the reponse returned by the rule evaluation was not nil, return if response != nil { return response, nil } } // we didn't have a response - return nil, nil + return &authv3.CheckResponse{}, nil } diff --git a/pkg/policy/compiler.go b/pkg/policy/compiler.go index fd603d08..ede71d0b 100644 --- a/pkg/policy/compiler.go +++ b/pkg/policy/compiler.go @@ -1,13 +1,16 @@ package policy import ( + "fmt" + "sync" + authv3 "github.com/envoyproxy/go-control-plane/envoy/service/auth/v3" "github.com/google/cel-go/cel" "github.com/google/cel-go/common/types" "github.com/google/cel-go/common/types/ref" "github.com/kyverno/kyverno-envoy-plugin/apis/v1alpha1" engine "github.com/kyverno/kyverno-envoy-plugin/pkg/authz/cel" - "github.com/kyverno/kyverno-envoy-plugin/pkg/authz/cel/libs/envoy" + envoy "github.com/kyverno/kyverno-envoy-plugin/pkg/authz/cel/libs/envoy" "github.com/kyverno/kyverno-envoy-plugin/pkg/authz/cel/utils" admissionregistrationv1 "k8s.io/api/admissionregistration/v1" "k8s.io/apimachinery/pkg/util/validation/field" @@ -19,10 +22,187 @@ const ( ObjectKey = "object" ) -type PolicyFunc func(*authv3.CheckRequest) (*authv3.CheckResponse, error) +type ( + AllowFunc func() (*authv3.CheckResponse, error) + DenyFunc func() (*authv3.CheckResponse, error) +) + +type CompiledPolicy interface { + For(r *authv3.CheckRequest) (AllowFunc, DenyFunc) +} + +type authorizationProgram struct { + match cel.Program + response cel.Program +} + +type compiledPolicy struct { + failurePolicy admissionregistrationv1.FailurePolicyType + matchConditions []cel.Program + variables map[string]cel.Program + allow []authorizationProgram + deny []authorizationProgram +} + +func (p compiledPolicy) For(r *authv3.CheckRequest) (AllowFunc, DenyFunc) { + match := sync.OnceValues(func() (bool, error) { + data := map[string]any{ + ObjectKey: r, + } + for _, matchCondition := range p.matchConditions { + // evaluate the condition + out, _, err := matchCondition.Eval(data) + // check error + if err != nil { + return false, err + } + // try to convert to a bool + result, err := utils.ConvertToNative[bool](out) + // check error + if err != nil { + return false, err + } + // if condition is false, skip + if !result { + return false, nil + } + } + return true, nil + }) + variables := sync.OnceValue(func() map[string]any { + vars := lazy.NewMapValue(engine.VariablesType) + data := map[string]any{ + ObjectKey: r, + VariablesKey: vars, + } + for name, variable := range p.variables { + vars.Append(name, func(*lazy.MapValue) ref.Val { + out, _, err := variable.Eval(data) + if out != nil { + return out + } + if err != nil { + return types.WrapErr(err) + } + return nil + }) + } + return data + }) + allow := func() (*authv3.CheckResponse, error) { + if match, err := match(); err != nil { + return nil, err + } else if !match { + return nil, nil + } + data := variables() + for _, rule := range p.allow { + matched, err := matchRule(rule, data) + // check error + if err != nil { + return nil, err + } + // if condition is false, continue + if !matched { + continue + } + // evaluate the rule + response, err := evaluateRule[envoy.OkResponse](rule, data) + // check error + if err != nil { + return nil, err + } + // no error and evaluation result is not nil, return + return &authv3.CheckResponse{ + Status: response.Status, + HttpResponse: &authv3.CheckResponse_OkResponse{ + OkResponse: response.OkHttpResponse, + }, + DynamicMetadata: response.DynamicMetadata, + }, nil + } + return nil, nil + } + deny := func() (*authv3.CheckResponse, error) { + if match, err := match(); err != nil { + return nil, err + } else if !match { + return nil, nil + } + data := variables() + for _, rule := range p.deny { + matched, err := matchRule(rule, data) + // check error + if err != nil { + return nil, err + } + // if condition is false, continue + if !matched { + continue + } + // evaluate the rule + response, err := evaluateRule[envoy.DeniedResponse](rule, data) + // check error + if err != nil { + return nil, err + } + // no error and evaluation result is not nil, return + return &authv3.CheckResponse{ + Status: response.Status, + HttpResponse: &authv3.CheckResponse_DeniedResponse{ + DeniedResponse: response.DeniedHttpResponse, + }, + DynamicMetadata: response.DynamicMetadata, + }, nil + } + return nil, nil + } + failurePolicy := func(inner func() (*authv3.CheckResponse, error)) func() (*authv3.CheckResponse, error) { + return func() (*authv3.CheckResponse, error) { + response, err := inner() + if err != nil && p.failurePolicy == admissionregistrationv1.Fail { + return nil, err + } + return response, nil + } + } + return failurePolicy(allow), failurePolicy(deny) +} + +func matchRule(rule authorizationProgram, data map[string]any) (bool, error) { + // if no match clause, consider a match + if rule.match == nil { + return true, nil + } + // evaluate rule match condition + out, _, err := rule.match.Eval(data) + if err != nil { + return false, err + } + // try to convert to a match result + matched, err := utils.ConvertToNative[bool](out) + if err != nil { + return false, err + } + return matched, err +} + +func evaluateRule[T any](rule authorizationProgram, data map[string]any) (*T, error) { + out, _, err := rule.response.Eval(data) + // check error + if err != nil { + return nil, err + } + response, err := utils.ConvertToNative[T](out) + // check error + if err != nil { + return nil, err + } + return &response, nil +} type Compiler interface { - Compile(*v1alpha1.AuthorizationPolicy) (PolicyFunc, field.ErrorList) + Compile(*v1alpha1.AuthorizationPolicy) (CompiledPolicy, field.ErrorList) } func NewCompiler() Compiler { @@ -31,12 +211,7 @@ func NewCompiler() Compiler { type compiler struct{} -func (c *compiler) Compile(policy *v1alpha1.AuthorizationPolicy) (PolicyFunc, field.ErrorList) { - type authorizationPrograms struct { - Match cel.Program - Response cel.Program - } - +func (c *compiler) Compile(policy *v1alpha1.AuthorizationPolicy) (CompiledPolicy, field.ErrorList) { var allErrs field.ErrorList base, err := engine.NewEnv() if err != nil { @@ -88,132 +263,72 @@ func (c *compiler) Compile(policy *v1alpha1.AuthorizationPolicy) (PolicyFunc, fi variables[variable.Name] = prog } } - var authorizations []authorizationPrograms + var denies []authorizationProgram { - path := path.Child("authorizations") - for i, rule := range policy.Spec.Authorizations { + path := path.Child("deny") + for i, rule := range policy.Spec.Deny { path := path.Index(i) - program := authorizationPrograms{} - - if rule.Match != "" { - path := path.Child("match") - ast, issues := env.Compile(rule.Match) - if err := issues.Err(); err != nil { - return nil, append(allErrs, field.Invalid(path, rule.Match, err.Error())) - } - if !ast.OutputType().IsExactType(types.BoolType) { - return nil, append(allErrs, field.Invalid(path, rule.Match, "rule match output is expected to be of type bool")) - } - prog, err := env.Program(ast) - if err != nil { - return nil, append(allErrs, field.Invalid(path, rule.Match, err.Error())) - } - program.Match = prog + program, errs := compileAuthorization(path, rule, env, envoy.DeniedResponseType) + if errs != nil { + return nil, append(allErrs, errs...) } - - { - path := path.Child("response") - ast, issues := env.Compile(rule.Response) - if err := issues.Err(); err != nil { - return nil, append(allErrs, field.Invalid(path, rule.Response, err.Error())) - } - if !ast.OutputType().IsExactType(envoy.CheckResponse) { - return nil, append(allErrs, field.Invalid(path, rule.Response, "rule response output is expected to be of type envoy.service.auth.v3.CheckResponse")) - } - prog, err := env.Program(ast) - if err != nil { - return nil, append(allErrs, field.Invalid(path, rule.Response, err.Error())) - } - program.Response = prog - } - - authorizations = append(authorizations, program) + denies = append(denies, program) } } - eval := func(r *authv3.CheckRequest) (*authv3.CheckResponse, error) { - vars := lazy.NewMapValue(engine.VariablesType) - data := map[string]any{ - ObjectKey: r, - VariablesKey: vars, - } - for _, matchCondition := range matchConditions { - // evaluate the condition - out, _, err := matchCondition.Eval(data) - // check error - if err != nil { - return nil, err - } - // try to convert to a bool - result, err := utils.ConvertToNative[bool](out) - // check error - if err != nil { - return nil, err - } - // if condition is false, skip - if !result { - return nil, nil + var allows []authorizationProgram + { + path := path.Child("allow") + for i, rule := range policy.Spec.Allow { + path := path.Index(i) + program, errs := compileAuthorization(path, rule, env, envoy.OkResponseType) + if errs != nil { + return nil, append(allErrs, errs...) } + allows = append(allows, program) } - for name, variable := range variables { - vars.Append(name, func(*lazy.MapValue) ref.Val { - out, _, err := variable.Eval(data) - if out != nil { - return out - } - if err != nil { - return types.WrapErr(err) - } - return nil - }) - } - for _, rule := range authorizations { - if rule.Match != nil { - // evaluate rule match condition - out, _, err := rule.Match.Eval(data) - if err != nil { - return nil, err - } - // try to convert to a match result - matched, err := utils.ConvertToNative[bool](out) - if err != nil { - return nil, err - } - // if condition is false, continue - if !matched { - continue - } - } + } + return compiledPolicy{ + failurePolicy: policy.Spec.GetFailurePolicy(), + matchConditions: matchConditions, + variables: variables, + allow: allows, + deny: denies, + }, nil +} - // evaluate the rule - out, _, err := rule.Response.Eval(data) - // check error - if err != nil { - return nil, err - } - // evaluation result is nil, continue - if _, ok := out.(types.Null); ok { - continue - } - // try to convert to a check response - response, err := utils.ConvertToNative[*authv3.CheckResponse](out) - // check error - if err != nil { - return nil, err - } - // evaluation result is nil, continue - if response == nil { - continue - } - // no error and evaluation result is not nil, return - return response, nil +func compileAuthorization(path *field.Path, rule v1alpha1.Authorization, env *cel.Env, output *types.Type) (authorizationProgram, field.ErrorList) { + var allErrs field.ErrorList + program := authorizationProgram{} + if rule.Match != "" { + path := path.Child("match") + ast, issues := env.Compile(rule.Match) + if err := issues.Err(); err != nil { + return authorizationProgram{}, append(allErrs, field.Invalid(path, rule.Match, err.Error())) } - return nil, nil + if !ast.OutputType().IsExactType(types.BoolType) { + return authorizationProgram{}, append(allErrs, field.Invalid(path, rule.Match, "rule match output is expected to be of type bool")) + } + prog, err := env.Program(ast) + if err != nil { + return authorizationProgram{}, append(allErrs, field.Invalid(path, rule.Match, err.Error())) + } + program.match = prog } - return func(r *authv3.CheckRequest) (*authv3.CheckResponse, error) { - response, err := eval(r) - if err != nil && policy.Spec.GetFailurePolicy() == admissionregistrationv1.Fail { - return nil, err + { + path := path.Child("response") + ast, issues := env.Compile(rule.Response) + if err := issues.Err(); err != nil { + return authorizationProgram{}, append(allErrs, field.Invalid(path, rule.Response, err.Error())) } - return response, nil - }, nil + if !ast.OutputType().IsExactType(output) { + msg := fmt.Sprintf("rule response output is expected to be of type %s", output.TypeName()) + return authorizationProgram{}, append(allErrs, field.Invalid(path, rule.Response, msg)) + } + prog, err := env.Program(ast) + if err != nil { + return authorizationProgram{}, append(allErrs, field.Invalid(path, rule.Response, err.Error())) + } + program.response = prog + } + return program, nil } diff --git a/pkg/policy/compiler_test.go b/pkg/policy/compiler_test.go index 043d0e8f..a565872d 100644 --- a/pkg/policy/compiler_test.go +++ b/pkg/policy/compiler_test.go @@ -28,17 +28,19 @@ var pol = &v1alpha1.AuthorizationPolicy{ Expression: `{"my-new-metadata": "my-new-value"}`, }, }, - Authorizations: []v1alpha1.Authorization{ + Deny: []v1alpha1.Authorization{ { Match: "variables.force_unauthenticated", Response: `envoy.Denied(401).WithBody("Authentication Failed").Response()`, }, { - Match: "variables.force_authorized", - Response: `envoy.Allowed().WithHeader("x-validated-by", "my-security-checkpoint").WithoutHeader("x-force-authorized").WithResponseHeader("x-add-custom-response-header", "added").Response().WithMetadata(variables.metadata)`, + Match: "!variables.force_authorized", + Response: `envoy.Denied(403).WithBody("Unauthorized Request").Response()`, }, + }, + Allow: []v1alpha1.Authorization{ { - Response: `envoy.Denied(403).WithBody("Unauthorized Request").Response()`, + Response: `envoy.Allowed().WithHeader("x-validated-by", "my-security-checkpoint").WithoutHeader("x-force-authorized").WithResponseHeader("x-add-custom-response-header", "added").Response().WithMetadata(variables.metadata)`, }, }, }, @@ -47,7 +49,7 @@ var pol = &v1alpha1.AuthorizationPolicy{ func TestCompiler(t *testing.T) { compiler := policy.NewCompiler() - function, errList := compiler.Compile(pol) + compiled, errList := compiler.Compile(pol) assert.NoError(t, errList.ToAggregate()) type testCase struct { @@ -108,8 +110,13 @@ func TestCompiler(t *testing.T) { } for _, test := range tests { - resp, err := function(test.request) + allow, deny := compiled.For(test.request) + resp, err := deny() assert.NoError(t, err) + if resp == nil { + resp, err = allow() + assert.NoError(t, err) + } assert.NotNil(t, resp) ok := assert.IsType(t, test.responseType, resp.HttpResponse) diff --git a/pkg/policy/provider.go b/pkg/policy/provider.go index 3b7ae500..d9f2e32c 100644 --- a/pkg/policy/provider.go +++ b/pkg/policy/provider.go @@ -15,7 +15,7 @@ import ( ) type Provider interface { - CompiledPolicies(context.Context) ([]PolicyFunc, error) + CompiledPolicies(context.Context) ([]CompiledPolicy, error) } func NewKubeProvider(mgr ctrl.Manager, compiler Compiler) (Provider, error) { @@ -30,8 +30,8 @@ type policyReconciler struct { client client.Client compiler Compiler lock *sync.Mutex - policies map[string]PolicyFunc - sortPolicies func() []PolicyFunc + policies map[string]CompiledPolicy + sortPolicies func() []CompiledPolicy } func newPolicyReconciler(client client.Client, compiler Compiler) *policyReconciler { @@ -39,8 +39,8 @@ func newPolicyReconciler(client client.Client, compiler Compiler) *policyReconci client: client, compiler: compiler, lock: &sync.Mutex{}, - policies: map[string]PolicyFunc{}, - sortPolicies: func() []PolicyFunc { + policies: map[string]CompiledPolicy{}, + sortPolicies: func() []CompiledPolicy { return nil }, } @@ -63,7 +63,7 @@ func (r *policyReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctr var policy v1alpha1.AuthorizationPolicy // Reset the sorted func on every reconcile so the policies get resorted in next call resetSortPolicies := func() { - r.sortPolicies = sync.OnceValue(func() []PolicyFunc { + r.sortPolicies = sync.OnceValue(func() []CompiledPolicy { r.lock.Lock() defer r.lock.Unlock() return mapToSortedSlice(r.policies) @@ -93,6 +93,6 @@ func (r *policyReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctr return ctrl.Result{}, nil } -func (r *policyReconciler) CompiledPolicies(ctx context.Context) ([]PolicyFunc, error) { +func (r *policyReconciler) CompiledPolicies(ctx context.Context) ([]CompiledPolicy, error) { return slices.Clone(r.sortPolicies()), nil } diff --git a/tests/e2e/validation-webhook/authorizations/match-conditions/compilation-failure/chainsaw-test.yaml b/tests/e2e/validation-webhook/allow/match/compilation-failure/chainsaw-test.yaml similarity index 68% rename from tests/e2e/validation-webhook/authorizations/match-conditions/compilation-failure/chainsaw-test.yaml rename to tests/e2e/validation-webhook/allow/match/compilation-failure/chainsaw-test.yaml index 952c6744..8cbefce4 100644 --- a/tests/e2e/validation-webhook/authorizations/match-conditions/compilation-failure/chainsaw-test.yaml +++ b/tests/e2e/validation-webhook/allow/match/compilation-failure/chainsaw-test.yaml @@ -10,6 +10,6 @@ spec: expect: - check: ($error): |- - admission webhook "kyverno-authz-server-validation.kyverno.svc" denied the request: AuthorizationPolicy.envoy.kyverno.io "compilation-failure" is invalid: spec.authorizations[0].match: Invalid value: "'flop' + 2\n": ERROR: :1:8: found no matching overload for '_+_' applied to '(string, int)' + admission webhook "kyverno-authz-server-validation.kyverno.svc" denied the request: AuthorizationPolicy.envoy.kyverno.io "compilation-failure" is invalid: spec.allow[0].match: Invalid value: "'flop' + 2\n": ERROR: :1:8: found no matching overload for '_+_' applied to '(string, int)' | 'flop' + 2 | .......^ diff --git a/tests/e2e/validation-webhook/allow/match/compilation-failure/policy.yaml b/tests/e2e/validation-webhook/allow/match/compilation-failure/policy.yaml new file mode 100644 index 00000000..7894eaea --- /dev/null +++ b/tests/e2e/validation-webhook/allow/match/compilation-failure/policy.yaml @@ -0,0 +1,13 @@ +# yaml-language-server: $schema=../../../../../../.schemas/json/authorizationpolicy-envoy-v1alpha1.json +apiVersion: envoy.kyverno.io/v1alpha1 +kind: AuthorizationPolicy +metadata: + name: compilation-failure +spec: + allow: + - match: > + 'flop' + 2 + response: > + envoy + .Allowed() + .Response() diff --git a/tests/e2e/validation-webhook/authorizations/match-conditions/invalid-output-type/chainsaw-test.yaml b/tests/e2e/validation-webhook/allow/match/invalid-output-type/chainsaw-test.yaml similarity index 70% rename from tests/e2e/validation-webhook/authorizations/match-conditions/invalid-output-type/chainsaw-test.yaml rename to tests/e2e/validation-webhook/allow/match/invalid-output-type/chainsaw-test.yaml index a6b90579..cc5d369a 100644 --- a/tests/e2e/validation-webhook/authorizations/match-conditions/invalid-output-type/chainsaw-test.yaml +++ b/tests/e2e/validation-webhook/allow/match/invalid-output-type/chainsaw-test.yaml @@ -10,4 +10,4 @@ spec: expect: - check: ($error): |- - admission webhook "kyverno-authz-server-validation.kyverno.svc" denied the request: AuthorizationPolicy.envoy.kyverno.io "invalid-output-type" is invalid: spec.authorizations[0].match: Invalid value: "'flop'\n": rule match output is expected to be of type bool + admission webhook "kyverno-authz-server-validation.kyverno.svc" denied the request: AuthorizationPolicy.envoy.kyverno.io "invalid-output-type" is invalid: spec.allow[0].match: Invalid value: "'flop'\n": rule match output is expected to be of type bool diff --git a/tests/e2e/validation-webhook/allow/match/invalid-output-type/policy.yaml b/tests/e2e/validation-webhook/allow/match/invalid-output-type/policy.yaml new file mode 100644 index 00000000..c54c0d8e --- /dev/null +++ b/tests/e2e/validation-webhook/allow/match/invalid-output-type/policy.yaml @@ -0,0 +1,13 @@ +# yaml-language-server: $schema=../../../../../../.schemas/json/authorizationpolicy-envoy-v1alpha1.json +apiVersion: envoy.kyverno.io/v1alpha1 +kind: AuthorizationPolicy +metadata: + name: invalid-output-type +spec: + allow: + - match: > + 'flop' + response: > + envoy + .Allowed() + .Response() diff --git a/tests/e2e/validation-webhook/authorizations/match-conditions/valid/chainsaw-test.yaml b/tests/e2e/validation-webhook/allow/match/valid/chainsaw-test.yaml similarity index 100% rename from tests/e2e/validation-webhook/authorizations/match-conditions/valid/chainsaw-test.yaml rename to tests/e2e/validation-webhook/allow/match/valid/chainsaw-test.yaml diff --git a/tests/e2e/validation-webhook/allow/match/valid/policy.yaml b/tests/e2e/validation-webhook/allow/match/valid/policy.yaml new file mode 100644 index 00000000..68d03320 --- /dev/null +++ b/tests/e2e/validation-webhook/allow/match/valid/policy.yaml @@ -0,0 +1,13 @@ +# yaml-language-server: $schema=../../../../../../.schemas/json/authorizationpolicy-envoy-v1alpha1.json +apiVersion: envoy.kyverno.io/v1alpha1 +kind: AuthorizationPolicy +metadata: + name: valid +spec: + allow: + - match: > + false + response: > + envoy + .Allowed() + .Response() diff --git a/tests/e2e/validation-webhook/authorizations/compilation-failure/chainsaw-test.yaml b/tests/e2e/validation-webhook/allow/response/compilation-failure/chainsaw-test.yaml similarity index 64% rename from tests/e2e/validation-webhook/authorizations/compilation-failure/chainsaw-test.yaml rename to tests/e2e/validation-webhook/allow/response/compilation-failure/chainsaw-test.yaml index 405af84c..e3fff4cc 100644 --- a/tests/e2e/validation-webhook/authorizations/compilation-failure/chainsaw-test.yaml +++ b/tests/e2e/validation-webhook/allow/response/compilation-failure/chainsaw-test.yaml @@ -10,6 +10,6 @@ spec: expect: - check: ($error): |- - admission webhook "kyverno-authz-server-validation.kyverno.svc" denied the request: AuthorizationPolicy.envoy.kyverno.io "compilation-failure" is invalid: spec.authorizations[0].response: Invalid value: "envoy.Allowed() + 1\n": ERROR: :1:17: found no matching overload for '_+_' applied to '(envoy.service.auth.v3.OkHttpResponse, int)' + admission webhook "kyverno-authz-server-validation.kyverno.svc" denied the request: AuthorizationPolicy.envoy.kyverno.io "compilation-failure" is invalid: spec.allow[0].response: Invalid value: "envoy.Allowed() + 1\n": ERROR: :1:17: found no matching overload for '_+_' applied to '(envoy.service.auth.v3.OkHttpResponse, int)' | envoy.Allowed() + 1 | ................^ diff --git a/tests/e2e/validation-webhook/allow/response/compilation-failure/policy.yaml b/tests/e2e/validation-webhook/allow/response/compilation-failure/policy.yaml new file mode 100644 index 00000000..6115708a --- /dev/null +++ b/tests/e2e/validation-webhook/allow/response/compilation-failure/policy.yaml @@ -0,0 +1,9 @@ +# yaml-language-server: $schema=../../../../../../.schemas/json/authorizationpolicy-envoy-v1alpha1.json +apiVersion: envoy.kyverno.io/v1alpha1 +kind: AuthorizationPolicy +metadata: + name: compilation-failure +spec: + allow: + - response: > + envoy.Allowed() + 1 diff --git a/tests/e2e/validation-webhook/authorizations/invalid-output-type/chainsaw-test.yaml b/tests/e2e/validation-webhook/allow/response/invalid-output-type/chainsaw-test.yaml similarity index 65% rename from tests/e2e/validation-webhook/authorizations/invalid-output-type/chainsaw-test.yaml rename to tests/e2e/validation-webhook/allow/response/invalid-output-type/chainsaw-test.yaml index 5f3501d4..76f780ab 100644 --- a/tests/e2e/validation-webhook/authorizations/invalid-output-type/chainsaw-test.yaml +++ b/tests/e2e/validation-webhook/allow/response/invalid-output-type/chainsaw-test.yaml @@ -10,4 +10,4 @@ spec: expect: - check: ($error): |- - admission webhook "kyverno-authz-server-validation.kyverno.svc" denied the request: AuthorizationPolicy.envoy.kyverno.io "invalid-output-type" is invalid: spec.authorizations[0].response: Invalid value: "'bye'\n": rule response output is expected to be of type envoy.service.auth.v3.CheckResponse + admission webhook "kyverno-authz-server-validation.kyverno.svc" denied the request: AuthorizationPolicy.envoy.kyverno.io "invalid-output-type" is invalid: spec.allow[0].response: Invalid value: "'bye'\n": rule response output is expected to be of type envoy.OkResponse diff --git a/tests/e2e/validation-webhook/allow/response/invalid-output-type/policy.yaml b/tests/e2e/validation-webhook/allow/response/invalid-output-type/policy.yaml new file mode 100644 index 00000000..7bc4fe29 --- /dev/null +++ b/tests/e2e/validation-webhook/allow/response/invalid-output-type/policy.yaml @@ -0,0 +1,9 @@ +# yaml-language-server: $schema=../../../../../../.schemas/json/authorizationpolicy-envoy-v1alpha1.json +apiVersion: envoy.kyverno.io/v1alpha1 +kind: AuthorizationPolicy +metadata: + name: invalid-output-type +spec: + allow: + - response: > + 'bye' diff --git a/tests/e2e/validation-webhook/authorizations/valid/chainsaw-test.yaml b/tests/e2e/validation-webhook/allow/response/valid/chainsaw-test.yaml similarity index 100% rename from tests/e2e/validation-webhook/authorizations/valid/chainsaw-test.yaml rename to tests/e2e/validation-webhook/allow/response/valid/chainsaw-test.yaml diff --git a/tests/e2e/validation-webhook/authorizations/valid/policy.yaml b/tests/e2e/validation-webhook/allow/response/valid/policy.yaml similarity index 77% rename from tests/e2e/validation-webhook/authorizations/valid/policy.yaml rename to tests/e2e/validation-webhook/allow/response/valid/policy.yaml index 895ce3b7..9ea4b339 100644 --- a/tests/e2e/validation-webhook/authorizations/valid/policy.yaml +++ b/tests/e2e/validation-webhook/allow/response/valid/policy.yaml @@ -1,4 +1,4 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/kyverno/kyverno-envoy-plugin/main/.schemas/json/authorizationpolicy-envoy-v1alpha1.json +# yaml-language-server: $schema=../../../../../../.schemas/json/authorizationpolicy-envoy-v1alpha1.json apiVersion: envoy.kyverno.io/v1alpha1 kind: AuthorizationPolicy metadata: @@ -11,17 +11,26 @@ spec: expression: object.attributes.request.http.headers[?"x-force-unauthenticated"].orValue("") in ["enabled", "true"] - name: metadata expression: '{"my-new-metadata": "my-new-value"}' - authorizations: + deny: # if force_unauthenticated -> 401 - - match: variables.force_unauthenticated + - match: > + variables.force_unauthenticated response: > envoy .Denied(401) .WithBody("Authentication Failed") .Response() - # if force_authorized -> 200 - - match: variables.force_authorized + # if force_unauthenticated -> 403 + - match: > + !variables.force_authorized response: > + envoy + .Denied(403) + .WithBody("Unauthorized Request") + .Response() + allow: + # else -> 200 + - response: > envoy .Allowed() .WithHeader("x-validated-by", "my-security-checkpoint") @@ -29,9 +38,3 @@ spec: .WithResponseHeader("x-add-custom-response-header", "added") .Response() .WithMetadata(variables.metadata) - # else -> 403 - - response: > - envoy - .Denied(403) - .WithBody("Unauthorized Request") - .Response() diff --git a/tests/e2e/validation-webhook/authorizations/compilation-failure/policy.yaml b/tests/e2e/validation-webhook/authorizations/compilation-failure/policy.yaml deleted file mode 100644 index e056c550..00000000 --- a/tests/e2e/validation-webhook/authorizations/compilation-failure/policy.yaml +++ /dev/null @@ -1,9 +0,0 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/kyverno/kyverno-envoy-plugin/main/.schemas/json/authorizationpolicy-envoy-v1alpha1.json -apiVersion: envoy.kyverno.io/v1alpha1 -kind: AuthorizationPolicy -metadata: - name: compilation-failure -spec: - authorizations: - - response: > - envoy.Allowed() + 1 diff --git a/tests/e2e/validation-webhook/authorizations/invalid-output-type/policy.yaml b/tests/e2e/validation-webhook/authorizations/invalid-output-type/policy.yaml deleted file mode 100644 index 2fd56b94..00000000 --- a/tests/e2e/validation-webhook/authorizations/invalid-output-type/policy.yaml +++ /dev/null @@ -1,9 +0,0 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/kyverno/kyverno-envoy-plugin/main/.schemas/json/authorizationpolicy-envoy-v1alpha1.json -apiVersion: envoy.kyverno.io/v1alpha1 -kind: AuthorizationPolicy -metadata: - name: invalid-output-type -spec: - authorizations: - - response: > - 'bye' diff --git a/tests/e2e/validation-webhook/deny/match/compilation-failure/chainsaw-test.yaml b/tests/e2e/validation-webhook/deny/match/compilation-failure/chainsaw-test.yaml new file mode 100644 index 00000000..e6616b02 --- /dev/null +++ b/tests/e2e/validation-webhook/deny/match/compilation-failure/chainsaw-test.yaml @@ -0,0 +1,15 @@ +apiVersion: chainsaw.kyverno.io/v1alpha1 +kind: Test +metadata: + name: compilation-failure +spec: + steps: + - try: + - create: + file: ./policy.yaml + expect: + - check: + ($error): |- + admission webhook "kyverno-authz-server-validation.kyverno.svc" denied the request: AuthorizationPolicy.envoy.kyverno.io "compilation-failure" is invalid: spec.deny[0].match: Invalid value: "'flop' + 2\n": ERROR: :1:8: found no matching overload for '_+_' applied to '(string, int)' + | 'flop' + 2 + | .......^ diff --git a/tests/e2e/validation-webhook/authorizations/match-conditions/compilation-failure/policy.yaml b/tests/e2e/validation-webhook/deny/match/compilation-failure/policy.yaml similarity index 67% rename from tests/e2e/validation-webhook/authorizations/match-conditions/compilation-failure/policy.yaml rename to tests/e2e/validation-webhook/deny/match/compilation-failure/policy.yaml index 47530515..f5383812 100644 --- a/tests/e2e/validation-webhook/authorizations/match-conditions/compilation-failure/policy.yaml +++ b/tests/e2e/validation-webhook/deny/match/compilation-failure/policy.yaml @@ -1,10 +1,10 @@ -# yaml-language-server: $schema=../../../../../.schemas/json/authorizationpolicy-envoy-v1alpha1.json +# yaml-language-server: $schema=../../../../../../.schemas/json/authorizationpolicy-envoy-v1alpha1.json apiVersion: envoy.kyverno.io/v1alpha1 kind: AuthorizationPolicy metadata: name: compilation-failure spec: - authorizations: + deny: - match: > 'flop' + 2 response: > diff --git a/tests/e2e/validation-webhook/deny/match/invalid-output-type/chainsaw-test.yaml b/tests/e2e/validation-webhook/deny/match/invalid-output-type/chainsaw-test.yaml new file mode 100644 index 00000000..b2a7170a --- /dev/null +++ b/tests/e2e/validation-webhook/deny/match/invalid-output-type/chainsaw-test.yaml @@ -0,0 +1,13 @@ +apiVersion: chainsaw.kyverno.io/v1alpha1 +kind: Test +metadata: + name: invalid-output-type +spec: + steps: + - try: + - create: + file: ./policy.yaml + expect: + - check: + ($error): |- + admission webhook "kyverno-authz-server-validation.kyverno.svc" denied the request: AuthorizationPolicy.envoy.kyverno.io "invalid-output-type" is invalid: spec.deny[0].match: Invalid value: "'flop'\n": rule match output is expected to be of type bool diff --git a/tests/e2e/validation-webhook/authorizations/match-conditions/invalid-output-type/policy.yaml b/tests/e2e/validation-webhook/deny/match/invalid-output-type/policy.yaml similarity index 67% rename from tests/e2e/validation-webhook/authorizations/match-conditions/invalid-output-type/policy.yaml rename to tests/e2e/validation-webhook/deny/match/invalid-output-type/policy.yaml index c6e250fb..c4820ced 100644 --- a/tests/e2e/validation-webhook/authorizations/match-conditions/invalid-output-type/policy.yaml +++ b/tests/e2e/validation-webhook/deny/match/invalid-output-type/policy.yaml @@ -1,10 +1,10 @@ -# yaml-language-server: $schema=../../../../../.schemas/json/authorizationpolicy-envoy-v1alpha1.json +# yaml-language-server: $schema=../../../../../../.schemas/json/authorizationpolicy-envoy-v1alpha1.json apiVersion: envoy.kyverno.io/v1alpha1 kind: AuthorizationPolicy metadata: name: invalid-output-type spec: - authorizations: + deny: - match: > 'flop' response: > diff --git a/tests/e2e/validation-webhook/deny/match/valid/chainsaw-test.yaml b/tests/e2e/validation-webhook/deny/match/valid/chainsaw-test.yaml new file mode 100644 index 00000000..df1e970c --- /dev/null +++ b/tests/e2e/validation-webhook/deny/match/valid/chainsaw-test.yaml @@ -0,0 +1,9 @@ +apiVersion: chainsaw.kyverno.io/v1alpha1 +kind: Test +metadata: + name: valid +spec: + steps: + - try: + - create: + file: ./policy.yaml diff --git a/tests/e2e/validation-webhook/authorizations/match-conditions/valid/policy.yaml b/tests/e2e/validation-webhook/deny/match/valid/policy.yaml similarity index 55% rename from tests/e2e/validation-webhook/authorizations/match-conditions/valid/policy.yaml rename to tests/e2e/validation-webhook/deny/match/valid/policy.yaml index eb38ef69..7d9baceb 100644 --- a/tests/e2e/validation-webhook/authorizations/match-conditions/valid/policy.yaml +++ b/tests/e2e/validation-webhook/deny/match/valid/policy.yaml @@ -1,10 +1,10 @@ -# yaml-language-server: $schema=../../../../../.schemas/json/authorizationpolicy-envoy-v1alpha1.json +# yaml-language-server: $schema=../../../../../../.schemas/json/authorizationpolicy-envoy-v1alpha1.json apiVersion: envoy.kyverno.io/v1alpha1 kind: AuthorizationPolicy metadata: name: valid spec: - authorizations: + deny: - match: > false response: > @@ -12,7 +12,3 @@ spec: .Denied(403) .WithBody("Unauthorized Request") .Response() - - response: > - envoy - .Allowed() - .Response() diff --git a/tests/e2e/validation-webhook/deny/response/compilation-failure/chainsaw-test.yaml b/tests/e2e/validation-webhook/deny/response/compilation-failure/chainsaw-test.yaml new file mode 100644 index 00000000..37b6f2ff --- /dev/null +++ b/tests/e2e/validation-webhook/deny/response/compilation-failure/chainsaw-test.yaml @@ -0,0 +1,15 @@ +apiVersion: chainsaw.kyverno.io/v1alpha1 +kind: Test +metadata: + name: compilation-failure +spec: + steps: + - try: + - create: + file: ./policy.yaml + expect: + - check: + ($error): |- + admission webhook "kyverno-authz-server-validation.kyverno.svc" denied the request: AuthorizationPolicy.envoy.kyverno.io "compilation-failure" is invalid: spec.deny[0].response: Invalid value: "envoy.Allowed() + 1\n": ERROR: :1:17: found no matching overload for '_+_' applied to '(envoy.service.auth.v3.OkHttpResponse, int)' + | envoy.Allowed() + 1 + | ................^ diff --git a/tests/e2e/validation-webhook/deny/response/compilation-failure/policy.yaml b/tests/e2e/validation-webhook/deny/response/compilation-failure/policy.yaml new file mode 100644 index 00000000..d0c97b56 --- /dev/null +++ b/tests/e2e/validation-webhook/deny/response/compilation-failure/policy.yaml @@ -0,0 +1,9 @@ +# yaml-language-server: $schema=../../../../../../.schemas/json/authorizationpolicy-envoy-v1alpha1.json +apiVersion: envoy.kyverno.io/v1alpha1 +kind: AuthorizationPolicy +metadata: + name: compilation-failure +spec: + deny: + - response: > + envoy.Allowed() + 1 diff --git a/tests/e2e/validation-webhook/deny/response/invalid-output-type/chainsaw-test.yaml b/tests/e2e/validation-webhook/deny/response/invalid-output-type/chainsaw-test.yaml new file mode 100644 index 00000000..d3844ca5 --- /dev/null +++ b/tests/e2e/validation-webhook/deny/response/invalid-output-type/chainsaw-test.yaml @@ -0,0 +1,13 @@ +apiVersion: chainsaw.kyverno.io/v1alpha1 +kind: Test +metadata: + name: invalid-output-type +spec: + steps: + - try: + - create: + file: ./policy.yaml + expect: + - check: + ($error): |- + admission webhook "kyverno-authz-server-validation.kyverno.svc" denied the request: AuthorizationPolicy.envoy.kyverno.io "invalid-output-type" is invalid: spec.deny[0].response: Invalid value: "'bye'\n": rule response output is expected to be of type envoy.DeniedResponse diff --git a/tests/e2e/validation-webhook/deny/response/invalid-output-type/policy.yaml b/tests/e2e/validation-webhook/deny/response/invalid-output-type/policy.yaml new file mode 100644 index 00000000..a4a7af3a --- /dev/null +++ b/tests/e2e/validation-webhook/deny/response/invalid-output-type/policy.yaml @@ -0,0 +1,9 @@ +# yaml-language-server: $schema=../../../../../../.schemas/json/authorizationpolicy-envoy-v1alpha1.json +apiVersion: envoy.kyverno.io/v1alpha1 +kind: AuthorizationPolicy +metadata: + name: invalid-output-type +spec: + deny: + - response: > + 'bye' diff --git a/tests/e2e/validation-webhook/deny/response/valid/chainsaw-test.yaml b/tests/e2e/validation-webhook/deny/response/valid/chainsaw-test.yaml new file mode 100644 index 00000000..df1e970c --- /dev/null +++ b/tests/e2e/validation-webhook/deny/response/valid/chainsaw-test.yaml @@ -0,0 +1,9 @@ +apiVersion: chainsaw.kyverno.io/v1alpha1 +kind: Test +metadata: + name: valid +spec: + steps: + - try: + - create: + file: ./policy.yaml diff --git a/tests/e2e/validation-webhook/deny/response/valid/policy.yaml b/tests/e2e/validation-webhook/deny/response/valid/policy.yaml new file mode 100644 index 00000000..9ea4b339 --- /dev/null +++ b/tests/e2e/validation-webhook/deny/response/valid/policy.yaml @@ -0,0 +1,40 @@ +# yaml-language-server: $schema=../../../../../../.schemas/json/authorizationpolicy-envoy-v1alpha1.json +apiVersion: envoy.kyverno.io/v1alpha1 +kind: AuthorizationPolicy +metadata: + name: valid +spec: + variables: + - name: force_authorized + expression: object.attributes.request.http.headers[?"x-force-authorized"].orValue("") in ["enabled", "true"] + - name: force_unauthenticated + expression: object.attributes.request.http.headers[?"x-force-unauthenticated"].orValue("") in ["enabled", "true"] + - name: metadata + expression: '{"my-new-metadata": "my-new-value"}' + deny: + # if force_unauthenticated -> 401 + - match: > + variables.force_unauthenticated + response: > + envoy + .Denied(401) + .WithBody("Authentication Failed") + .Response() + # if force_unauthenticated -> 403 + - match: > + !variables.force_authorized + response: > + envoy + .Denied(403) + .WithBody("Unauthorized Request") + .Response() + allow: + # else -> 200 + - response: > + envoy + .Allowed() + .WithHeader("x-validated-by", "my-security-checkpoint") + .WithoutHeader("x-force-authorized") + .WithResponseHeader("x-add-custom-response-header", "added") + .Response() + .WithMetadata(variables.metadata) diff --git a/tests/e2e/validation-webhook/match-conditions/compilation-failure/policy.yaml b/tests/e2e/validation-webhook/match-conditions/compilation-failure/policy.yaml index 2013fafb..6087ccd9 100644 --- a/tests/e2e/validation-webhook/match-conditions/compilation-failure/policy.yaml +++ b/tests/e2e/validation-webhook/match-conditions/compilation-failure/policy.yaml @@ -15,17 +15,26 @@ spec: expression: object.attributes.request.http.headers[?"x-force-unauthenticated"].orValue("") in ["enabled", "true"] - name: metadata expression: '{"my-new-metadata": "my-new-value"}' - authorizations: + deny: # if force_unauthenticated -> 401 - - match: variables.force_unauthenticated + - match: > + variables.force_unauthenticated response: > envoy .Denied(401) .WithBody("Authentication Failed") .Response() - # if force_authorized -> 200 - - match: variables.force_authorized + # if force_unauthenticated -> 403 + - match: > + !variables.force_authorized response: > + envoy + .Denied(403) + .WithBody("Unauthorized Request") + .Response() + allow: + # else -> 200 + - response: > envoy .Allowed() .WithHeader("x-validated-by", "my-security-checkpoint") @@ -33,9 +42,3 @@ spec: .WithResponseHeader("x-add-custom-response-header", "added") .Response() .WithMetadata(variables.metadata) - # else -> 403 - - response: > - envoy - .Denied(403) - .WithBody("Unauthorized Request") - .Response() diff --git a/tests/e2e/validation-webhook/match-conditions/invalid-output-type/policy.yaml b/tests/e2e/validation-webhook/match-conditions/invalid-output-type/policy.yaml index 11205b0b..e5b21a4c 100644 --- a/tests/e2e/validation-webhook/match-conditions/invalid-output-type/policy.yaml +++ b/tests/e2e/validation-webhook/match-conditions/invalid-output-type/policy.yaml @@ -15,17 +15,26 @@ spec: expression: object.attributes.request.http.headers[?"x-force-unauthenticated"].orValue("") in ["enabled", "true"] - name: metadata expression: '{"my-new-metadata": "my-new-value"}' - authorizations: + deny: # if force_unauthenticated -> 401 - - match: variables.force_unauthenticated + - match: > + variables.force_unauthenticated response: > envoy .Denied(401) .WithBody("Authentication Failed") .Response() - # if force_authorized -> 200 - - match: variables.force_authorized + # if force_unauthenticated -> 403 + - match: > + !variables.force_authorized response: > + envoy + .Denied(403) + .WithBody("Unauthorized Request") + .Response() + allow: + # else -> 200 + - response: > envoy .Allowed() .WithHeader("x-validated-by", "my-security-checkpoint") @@ -33,9 +42,3 @@ spec: .WithResponseHeader("x-add-custom-response-header", "added") .Response() .WithMetadata(variables.metadata) - # else -> 403 - - response: > - envoy - .Denied(403) - .WithBody("Unauthorized Request") - .Response() diff --git a/tests/e2e/validation-webhook/match-conditions/valid/policy.yaml b/tests/e2e/validation-webhook/match-conditions/valid/policy.yaml index dbbb95a3..8420df17 100644 --- a/tests/e2e/validation-webhook/match-conditions/valid/policy.yaml +++ b/tests/e2e/validation-webhook/match-conditions/valid/policy.yaml @@ -15,17 +15,26 @@ spec: expression: object.attributes.request.http.headers[?"x-force-unauthenticated"].orValue("") in ["enabled", "true"] - name: metadata expression: '{"my-new-metadata": "my-new-value"}' - authorizations: + deny: # if force_unauthenticated -> 401 - - match: variables.force_unauthenticated + - match: > + variables.force_unauthenticated response: > envoy .Denied(401) .WithBody("Authentication Failed") .Response() - # if force_authorized -> 200 - - match: variables.force_authorized + # if force_unauthenticated -> 403 + - match: > + !variables.force_authorized response: > + envoy + .Denied(403) + .WithBody("Unauthorized Request") + .Response() + allow: + # else -> 200 + - response: > envoy .Allowed() .WithHeader("x-validated-by", "my-security-checkpoint") @@ -33,9 +42,3 @@ spec: .WithResponseHeader("x-add-custom-response-header", "added") .Response() .WithMetadata(variables.metadata) - # else -> 403 - - response: > - envoy - .Denied(403) - .WithBody("Unauthorized Request") - .Response() diff --git a/website/docs/reference/apis/policy.v1alpha1.md b/website/docs/reference/apis/policy.v1alpha1.md index 5ae855c9..c6de0a96 100644 --- a/website/docs/reference/apis/policy.v1alpha1.md +++ b/website/docs/reference/apis/policy.v1alpha1.md @@ -51,6 +51,7 @@ auto_generated: true | `failurePolicy` | [`admissionregistration/v1.FailurePolicyType`](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.28/#failurepolicytype-v1-admissionregistration) | | |

FailurePolicy defines how to handle failures for the policy. Failures can occur from CEL expression parse errors, type check errors, runtime errors and invalid or mis-configured policy definitions. FailurePolicy does not define how validations that evaluate to false are handled. Allowed values are Ignore or Fail. Defaults to Fail.

| | `matchConditions` | [`[]admissionregistration/v1.MatchCondition`](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.28/#matchcondition-v1-admissionregistration) | | |

MatchConditions is a list of conditions that must be met for a request to be validated. An empty list of matchConditions matches all requests. The exact matching logic is (in order): 1. If ANY matchCondition evaluates to FALSE, the policy is skipped. 2. If ALL matchConditions evaluate to TRUE, the policy is evaluated. 3. If any matchCondition evaluates to an error (but none are FALSE): - If failurePolicy=Fail, reject the request - If failurePolicy=Ignore, the policy is skipped

| | `variables` | [`[]admissionregistration/v1.Variable`](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.28/#variable-v1-admissionregistration) | | |

Variables contain definitions of variables that can be used in composition of other expressions. Each variable is defined as a named CEL expression. The variables defined here will be available under `variables` in other expressions of the policy except MatchConditions because MatchConditions are evaluated before the rest of the policy. The expression of a variable can refer to other variables defined earlier in the list but not those after. Thus, Variables must be sorted by the order of first appearance and acyclic.

| -| `authorizations` | [`[]Authorization`](#envoy-kyverno-io-v1alpha1-Authorization) | | |

Authorizations contain CEL expressions which is used to apply the authorization.

| +| `deny` | [`[]Authorization`](#envoy-kyverno-io-v1alpha1-Authorization) | | |

Deny contain CEL expressions which is used to deny a request.

| +| `allow` | [`[]Authorization`](#envoy-kyverno-io-v1alpha1-Authorization) | | |

Allow contain CEL expressions which is used to allow a request.

| \ No newline at end of file