diff --git a/DockerHub-README.md b/DockerHub-README.md index 0fd3ae149..d7900be26 100644 --- a/DockerHub-README.md +++ b/DockerHub-README.md @@ -107,9 +107,6 @@ mechanisms: type: jwt default_rule: - methods: - - GET - - POST execute: - authenticator: anonymous_authenticator - authorizer: deny_all_requests @@ -124,11 +121,11 @@ providers: Create a rule file (`rule.yaml`) with the following contents: ```yaml -version: "1alpha3" +version: "1alpha4" rules: - id: test-rule match: - url: http://<**>/<**> + path: /** forward_to: host: upstream execute: diff --git a/charts/heimdall/Chart.yaml b/charts/heimdall/Chart.yaml index e521bcb7f..7e36e24b3 100644 --- a/charts/heimdall/Chart.yaml +++ b/charts/heimdall/Chart.yaml @@ -17,7 +17,7 @@ apiVersion: v2 name: heimdall description: A cloud native Identity Aware Proxy and Access Control Decision Service -version: 0.13.1 +version: 0.14.0 appVersion: latest kubeVersion: ^1.19.0 type: application @@ -43,5 +43,4 @@ keywords: - iap - auth-proxy - identity-aware-proxy - - decision-api - auth-filter diff --git a/charts/heimdall/crds/ruleset.yaml b/charts/heimdall/crds/ruleset.yaml index 6c50e05b3..87b3552d9 100644 --- a/charts/heimdall/crds/ruleset.yaml +++ b/charts/heimdall/crds/ruleset.yaml @@ -27,7 +27,7 @@ spec: singular: ruleset listKind: RuleSetList versions: - - name: v1alpha3 + - name: v1alpha4 served: true storage: true schema: @@ -75,20 +75,66 @@ spec: description: How to match the rule type: object required: - - url + - path properties: - url: - description: The url to match + path: + description: The path to match type: string - maxLength: 512 - strategy: - description: Strategy to match the url. Can either be regex or glob. - type: string - maxLength: 5 - default: glob - enum: - - regex - - glob + maxLength: 256 + backtracking_enabled: + description: Wither this rule allows backtracking. Defaults to the value inherited from the default rule + type: boolean + with: + description: Additional constraints during request matching + type: object + properties: + methods: + description: The HTTP methods to match + type: array + minItems: 1 + items: + type: string + maxLength: 16 + enum: + - "CONNECT" + - "!CONNECT" + - "DELETE" + - "!DELETE" + - "GET" + - "!GET" + - "HEAD" + - "!HEAD" + - "OPTIONS" + - "!OPTIONS" + - "PATCH" + - "!PATCH" + - "POST" + - "!POST" + - "PUT" + - "!PUT" + - "TRACE" + - "!TRACE" + - "ALL" + scheme: + description: The HTTP scheme, which should be matched. If not set, http and https are matched + type: string + maxLength: 5 + host_glob: + description: Glob expression to match the host if required. If not set, all hosts are matched. Mutually exclusive with 'host_regex'. + type: string + maxLength: 512 + host_regex: + description: Regular expression to match the host if required. If not set, all hosts are matched. Mutually exclusive with 'host_glob'. + type: string + maxLength: 512 + path_glob: + description: Additional glob expression the matched path should be matched against. Mutual exclusive with 'regex'. + type: string + maxLength: 256 + path_regex: + description: Additional regular expression the matched path should be matched against. Mutual exclusive with 'glob' + type: string + maxLength: 256 forward_to: description: Where to forward the request to. Required only if heimdall is used in proxy operation mode. type: object @@ -125,33 +171,6 @@ spec: items: type: string maxLength: 128 - methods: - description: The allowed HTTP methods - type: array - minItems: 1 - items: - type: string - maxLength: 16 - enum: - - "CONNECT" - - "!CONNECT" - - "DELETE" - - "!DELETE" - - "GET" - - "!GET" - - "HEAD" - - "!HEAD" - - "OPTIONS" - - "!OPTIONS" - - "PATCH" - - "!PATCH" - - "POST" - - "!POST" - - "PUT" - - "!PUT" - - "TRACE" - - "!TRACE" - - "ALL" execute: description: The pipeline mechanisms to execute type: array diff --git a/charts/heimdall/templates/demo/configmap.yaml b/charts/heimdall/templates/demo/configmap.yaml index 1de165d69..1866e5a21 100644 --- a/charts/heimdall/templates/demo/configmap.yaml +++ b/charts/heimdall/templates/demo/configmap.yaml @@ -74,9 +74,6 @@ data: type: noop default_rule: - methods: - - GET - - POST execute: - authenticator: anonymous_authenticator - authorizer: deny_all_requests diff --git a/charts/heimdall/templates/demo/test-rule.yaml b/charts/heimdall/templates/demo/test-rule.yaml index 1ce7b4b40..cccdeaa58 100644 --- a/charts/heimdall/templates/demo/test-rule.yaml +++ b/charts/heimdall/templates/demo/test-rule.yaml @@ -15,7 +15,7 @@ # SPDX-License-Identifier: Apache-2.0 {{- if .Values.demo.enabled }} -apiVersion: heimdall.dadrus.github.com/v1alpha3 +apiVersion: heimdall.dadrus.github.com/v1alpha4 kind: RuleSet metadata: name: {{ include "heimdall.demo.fullname" . }}-test-rule @@ -26,7 +26,7 @@ spec: rules: - id: public-access match: - url: http://<**>/pub/<**> + path: /pub/** forward_to: host: {{ include "heimdall.demo.fullname" . }}.heimdall-demo.svc.cluster.local:8080 execute: @@ -35,7 +35,7 @@ spec: - finalizer: noop_finalizer - id: anonymous-access match: - url: http://<**>/anon/<**> + path: /anon/** forward_to: host: {{ include "heimdall.demo.fullname" . }}.heimdall-demo.svc.cluster.local:8080 execute: diff --git a/charts/heimdall/templates/heimdall/admissioncontroller.yaml b/charts/heimdall/templates/heimdall/admissioncontroller.yaml index 8f7bfecd1..56665b98b 100644 --- a/charts/heimdall/templates/heimdall/admissioncontroller.yaml +++ b/charts/heimdall/templates/heimdall/admissioncontroller.yaml @@ -23,7 +23,7 @@ webhooks: {{- end }} rules: - apiGroups: ["heimdall.dadrus.github.com"] - apiVersions: ["v1alpha3"] + apiVersions: ["v1alpha4"] operations: ["CREATE", "UPDATE"] resources: ["rulesets"] scope: "Namespaced" diff --git a/cmd/validate/ruleset.go b/cmd/validate/ruleset.go index 5fe271a5d..b1b5cfbf4 100644 --- a/cmd/validate/ruleset.go +++ b/cmd/validate/ruleset.go @@ -24,10 +24,11 @@ import ( "github.com/spf13/cobra" "github.com/dadrus/heimdall/internal/config" + "github.com/dadrus/heimdall/internal/heimdall" "github.com/dadrus/heimdall/internal/rules" - "github.com/dadrus/heimdall/internal/rules/event" "github.com/dadrus/heimdall/internal/rules/mechanisms" "github.com/dadrus/heimdall/internal/rules/provider/filesystem" + "github.com/dadrus/heimdall/internal/rules/rule" ) // NewValidateRulesCommand represents the "validate rules" command. @@ -55,8 +56,6 @@ func NewValidateRulesCommand() *cobra.Command { } func validateRuleSet(cmd *cobra.Command, args []string) error { - const queueSize = 50 - envPrefix, _ := cmd.Flags().GetString("env-config-prefix") logger := zerolog.Nop() @@ -90,14 +89,17 @@ func validateRuleSet(cmd *cobra.Command, args []string) error { return err } - queue := make(event.RuleSetChangedEventQueue, queueSize) - - defer close(queue) - - provider, err := filesystem.NewProvider(conf, rules.NewRuleSetProcessor(queue, rFactory, logger), logger) + provider, err := filesystem.NewProvider(conf, rules.NewRuleSetProcessor(&noopRepository{}, rFactory), logger) if err != nil { return err } return provider.Start(context.Background()) } + +type noopRepository struct{} + +func (*noopRepository) FindRule(_ heimdall.Context) (rule.Rule, error) { return nil, nil } +func (*noopRepository) AddRuleSet(_ string, _ []rule.Rule) error { return nil } +func (*noopRepository) UpdateRuleSet(_ string, _ []rule.Rule) error { return nil } +func (*noopRepository) DeleteRuleSet(_ string) error { return nil } diff --git a/cmd/validate/ruleset_test.go b/cmd/validate/ruleset_test.go index 7d8b29f90..a58d322fe 100644 --- a/cmd/validate/ruleset_test.go +++ b/cmd/validate/ruleset_test.go @@ -103,7 +103,7 @@ func TestRunValidateRulesCommand(t *testing.T) { proxyMode: true, confFile: "test_data/config.yaml", rulesFile: "test_data/invalid-ruleset-for-proxy-usage.yaml", - expError: "no forward_to", + expError: "requires forward_to", }, { uc: "everything is valid for proxy mode usage", diff --git a/cmd/validate/test_data/config.yaml b/cmd/validate/test_data/config.yaml index 424aae07d..fabb54b2d 100644 --- a/cmd/validate/test_data/config.yaml +++ b/cmd/validate/test_data/config.yaml @@ -171,9 +171,7 @@ mechanisms: to: http://127.0.0.1:4433/self-service/login/browser?return_to={{ .Request.URL | urlenc }} default_rule: - methods: - - GET - - POST + backtracking_enabled: false execute: - authenticator: anonymous_authenticator - finalizer: jwt @@ -189,8 +187,8 @@ providers: watch_interval: 5m endpoints: - url: http://foo.bar/rules.yaml - rule_path_match_prefix: /foo - enable_http_cache: true + http_cache: + enabled: true - url: http://bar.foo/rules.yaml headers: bla: bla @@ -209,10 +207,8 @@ providers: buckets: - url: gs://my-bucket prefix: service1 - rule_path_match_prefix: /service1 - url: gs://my-bucket prefix: service2 - rule_path_match_prefix: /service2 - url: s3://my-bucket/my-rule-set kubernetes: diff --git a/cmd/validate/test_data/invalid-ruleset-for-proxy-usage.yaml b/cmd/validate/test_data/invalid-ruleset-for-proxy-usage.yaml index eac2bd642..ca8616f88 100644 --- a/cmd/validate/test_data/invalid-ruleset-for-proxy-usage.yaml +++ b/cmd/validate/test_data/invalid-ruleset-for-proxy-usage.yaml @@ -1,13 +1,13 @@ -version: "1alpha3" +version: "1alpha4" name: test-rule-set rules: - id: rule:foo match: - url: http://foo.bar/<**> - strategy: glob -# methods: # reuses default -# - GET -# - POST + path: /** + with: + scheme: http + host_glob: foo.bar + methods: [ GET, POST ] execute: - authenticator: unauthorized_authenticator - authenticator: jwt_authenticator1 diff --git a/cmd/validate/test_data/valid-ruleset.yaml b/cmd/validate/test_data/valid-ruleset.yaml index 6008de8d3..c13b97839 100644 --- a/cmd/validate/test_data/valid-ruleset.yaml +++ b/cmd/validate/test_data/valid-ruleset.yaml @@ -1,19 +1,22 @@ -version: "1alpha3" +version: "1alpha4" name: test-rule-set rules: - id: rule:foo match: - url: http://foo.bar/<**> - strategy: glob + path: /** + backtracking_enabled: true + with: + scheme: http + host_glob: foo.bar + methods: + - POST + - PUT forward_to: host: bar.foo rewrite: strip_path_prefix: /foo add_path_prefix: /baz strip_query_parameters: [boo] -# methods: # reuses default -# - GET -# - POST execute: - authenticator: unauthorized_authenticator - authenticator: jwt_authenticator1 diff --git a/docs/content/_index.adoc b/docs/content/_index.adoc index f667e4b11..d8f9a5810 100644 --- a/docs/content/_index.adoc +++ b/docs/content/_index.adoc @@ -15,7 +15,7 @@ Use declarative techniques you are already familiar with [source, yaml] ---- -apiVersion: heimdall.dadrus.github.com/v1alpha3 +apiVersion: heimdall.dadrus.github.com/v1alpha4 kind: RuleSet metadata: name: My awesome service @@ -23,7 +23,10 @@ spec: rules: - id: my_api_rule match: - url: http://127.0.0.1:9090/api/<**> + path: /api/** + with: + scheme: http + host_glob: 127.0.0.1:9090 execute: - authenticator: keycloak - authorizer: opa diff --git a/docs/content/docs/concepts/operating_modes.adoc b/docs/content/docs/concepts/operating_modes.adoc index 4b18da8d9..48dc375a0 100644 --- a/docs/content/docs/concepts/operating_modes.adoc +++ b/docs/content/docs/concepts/operating_modes.adoc @@ -76,9 +76,12 @@ And there is a rule, which allows anonymous requests and sets a header with subj ---- id: rule:my-service:anonymous-api-access match: - url: http://my-backend-service/my-service/api -methods: - - GET + path: /my-service/api + with: + scheme: http + host_glob: my-backend-service + methods: + - GET execute: - authenticator: anonymous-authn - finalizer: id-header @@ -144,11 +147,12 @@ And there is a rule, which allows anonymous requests and sets a header with subj ---- id: rule:my-service:anonymous-api-access match: - url: <**>/my-service/api + path: /my-service/api + with: + methods: + - GET forward_to: host: my-backend-service:8888 -methods: - - GET execute: - authenticator: anonymous-authn - finalizer: id-header diff --git a/docs/content/docs/concepts/rules.adoc b/docs/content/docs/concepts/rules.adoc index 4ee4752f8..48b6403d5 100644 --- a/docs/content/docs/concepts/rules.adoc +++ b/docs/content/docs/concepts/rules.adoc @@ -34,26 +34,32 @@ To minimize the memory footprint, heimdall instanciates all defined mechanisms o The diagram below sketches the logic executed by heimdall for each and every incoming request. -[mermaid, format=svg, width=70%] +[mermaid, format=svg] .... flowchart TD - req[Request] --> findRule{1: any\nrule\nmatching\nurl?} - findRule -->|yes| methodCheck{2: method\nallowed?} - findRule -->|no| err1[404 Not Found] - methodCheck -->|yes| regularPipeline[3: execute\nauthentication & authorization\npipeline] - methodCheck -->|no| err2[405 Method Not Allowed] + req[Request] --> findRule{1: any\nrule\nmatching\nrequest?} + findRule -->|no| err2[404 Not Found] + findRule -->|yes| regularPipeline[2: execute\nauthentication & authorization\npipeline] regularPipeline --> failed{failed?} failed -->|yes| errPipeline[execute error pipeline] - failed -->|no| success[4: forward request,\nrespectively respond\nto the API gateway] - errPipeline --> errResult[5: result of the\nused error handler] + failed -->|no| success[3: forward request,\nrespectively respond\nto the API gateway] + errPipeline --> errResult[4: result of the\nused error handler] .... -. *Any rule matching url?* - This is the first step executed by heimdall in which it tries to find a rule matching the request url. The information about the scheme, host, path and query is taken either from the URL itself, or if present and allowed, from the `X-Forwarded-Proto`, `X-Forwarded-Host`, or `X-Forwarded-Uri` headers of the incoming request. The request is denied if there is no matching rule, respectively no default rule. Otherwise, the rule specific pipeline is executed. When heimdall is evaluating the rules against the request url it takes the first matching one. -. *Method allowed?* - As soon as a rule matching the request is found (which might also be the default rule if specified and there was no regular rule matching the request), a check is done whether the used HTTP method is allowed or not. The information about the HTTP method is either taken from the request itself or, if present and allowed, from the `X-Forwarded-Method` header. -. *Execute authentication & authorization pipeline* - when the above steps succeed, the mechanisms defined in this pipeline are executed. +. *Any rule matching request?* - This is the first step executed by heimdall in which it tries to find a link:{{< relref "#_matching_of_rules" >}}[matching rule]. If there is no matching rule, heimdall either falls back to the default rule if available, or the request is denied. Otherwise, the rule specific authentication & authorization pipeline is executed. +. *Execute authentication & authorization pipeline* - when a rule is matched, the mechanisms defined in its authentication & authorization pipeline are executed. . *Forward request, respectively respond to the API gateway* - when the above steps succeed, heimdall, depending on the link:{{< relref "/docs/concepts/operating_modes.adoc" >}}[operating mode], responds with, respectively forwards whatever was defined in the pipeline (usually this is a set of HTTP headers). Otherwise . *Execute error pipeline* is executed if any of the mechanisms, defined in the authentiction & authorization pipeline fail. This again results in a response, this time however, based on the definition in the used error handler. +== Matching of Rules + +As written above, an link:{{< relref "/docs/rules/regular_rule.adoc" >}}[upstream specific rule] is only executed when it matches an incoming request. + +The actual matching happens via the requests URL path, which is guaranteed to happen with O(log(n)) time complexity and is based on the path expressions specified in the loaded rules. These expressions support usage of (named) wildcards to capture segments of the matched path. The implementation ensures, that more specific path expressions are matched first regardless of the placement of rules in a link:{{< relref "/docs/concepts/provider.adoc#_rule_sets" >}}[rule set]. + +Additional conditions, like the host, the HTTP method, or application of regular or glob expressions can also be taken into account, allowing different rules for the same path expressions. The information about the HTTP method, scheme, host, path and query is taken either from the request itself, or if present and allowed, from the `X-Forwarded-Proto`, `X-Forwarded-Host`, `X-Forwarded-Uri` and `X-Forwarded-Method` headers of the incoming request. + +There is also an option to have backtracking to a rule with a less specific path expression, if the actual specific path is matched, but the above said additional conditions are not satisfied. == Default Rule & Inheritance @@ -71,7 +77,6 @@ Imagine, the concept of a rule is e.g. an interface written in Java defining the [source, java] ---- public interface Rule { - public boolean checkMethods(methods []String) public void executeAuthenticationStage(req Request) public void executeAuthorizationStage(req Request) public void executeFinalizationStage(req Request) @@ -84,8 +89,8 @@ And the logic described in link:{{< relref "#_execution_of_rules" >}}[Execution [source, java] ---- Rule rule = findMatchingRule(req) -if (!rule.checkMethods(req)) { - throw new MethodNotAllowedError() +if (rule == null) { + throw new NotFoundError() } try { @@ -110,7 +115,6 @@ Since there is some default behaviour in place, like error handling, if the erro [source, java] ---- public abstract class BaseRule implements Rule { - public abstract boolean checkMethods(methods []String) public abstract void executeAuthenticationStage(req Request) public void executeAuthorizationStage(req Request) {} public void executeFinalizationStage(req Request) {} @@ -118,12 +122,11 @@ public abstract class BaseRule implements Rule { } ---- -If there is no default rule configured, an upstream specific rule can then be considered as a class inheriting from that `BaseRule` and must implement at least the two `checkMethods` and `executeAuthenticationStage` methods, similar to what is shown below +If there is no default rule configured, an upstream specific rule can then be considered as a class inheriting from that `BaseRule` and must implement at least the `executeAuthenticationStage` method, similar to what is shown below [source, java] ---- public class MySpecificRule extends BaseRule { - public boolean checkMethods(methods []String) { ... } public void executeAuthenticationStage(req Request) { ... } } ---- @@ -133,7 +136,6 @@ If however, there is a default rule configured, on one hand, it can be considere [source, java] ---- public class DefaultRule extends BaseRule { - public boolean checkMethods(methods []String) { ... } public void executeAuthenticationStage(req Request) { ... } public void executeAuthorizationStage(req Request) { ... } public void executeFinalizationStage(req Request) { ... } @@ -141,7 +143,7 @@ public class DefaultRule extends BaseRule { } ---- -with at least the aforesaid two `checkMethods` and `executeAuthenticationStage` methods being implemented as this is also required for the regular rule. +with at least the aforesaid `executeAuthenticationStage` method being implemented, as this is also required for the regular rule. On the other hand, the definition of a regular, respectively upstream specific rule is then not a class deriving from the `BaseRule`, but from the `DefaultRule`. That way, upstream specific rules are only required, if the behavior of the default rule would not fit the given requirements of a particular service, respectively endpoint. So, if e.g. a rule requires only the authentication stage to be different from the default rule, you would only specify the required authentication mechanisms. That would result in something like shown in the snippet below. diff --git a/docs/content/docs/configuration/reference.adoc b/docs/content/docs/configuration/reference.adoc index b1d7726bd..cd4137135 100644 --- a/docs/content/docs/configuration/reference.adoc +++ b/docs/content/docs/configuration/reference.adoc @@ -368,9 +368,7 @@ mechanisms: - '*/*' default_rule: - methods: - - GET - - POST + backtracking_enabled: false execute: - authenticator: anonymous_authenticator - finalizer: jwt @@ -386,8 +384,8 @@ providers: watch_interval: 5m endpoints: - url: http://foo.bar/ruleset1 - expected_path_prefix: /foo/bar - enable_http_cache: false + http_cache: + enabled: false - url: http://foo.bar/ruleset2 retry: give_up_after: 5s @@ -406,10 +404,8 @@ providers: buckets: - url: gs://my-bucket prefix: service1 - rule_path_match_prefix: /service1 - url: azblob://my-bucket prefix: service2 - rule_path_match_prefix: /service2 - url: s3://my-bucket/my-rule-set kubernetes: diff --git a/docs/content/docs/configuration/types.adoc b/docs/content/docs/configuration/types.adoc index ac5fb22fd..658038971 100644 --- a/docs/content/docs/configuration/types.adoc +++ b/docs/content/docs/configuration/types.adoc @@ -581,7 +581,6 @@ Following types are available: * `authorization_error` (*) - used if an authorizer failed to authorize the subject. E.g. an authorizer is configured to use an expression on the given subject and request context, but that expression returned with an error. Error of this type results by default in `403 Forbidden` response if the default error handler was used to handle such error. * `communication_error` (*) - this error is used to signal a communication error while communicating to a remote system during the execution of the pipeline of the matched rule. Timeouts of DNSs errors result in such an error. Error of this type results by default in `502 Bad Gateway` HTTP code if handled by the default error handler. * `internal_error` - used if heimdall run into an internal error condition while processing the request. E.g. something went wrong while unmarshalling a JSON object, or if there was a configuration error, which couldn't be raised while loading a rule, etc. Results by default in `500 Internal Server Error` response to the caller. -* `method_error` - this error is used to signal that a matched rule does not allow usage of the HTTP method used to submit the request. Error of this type results by default in `405 Method Not Allowed` HTTP code. * `no_rule_error` - this error is used to signal, there is no matching rule to handle the given request. Error of this type results by default in `404 Not Found` HTTP code. * `precondition_error` (*) - used if the request does not contain required/expected data. E.g. if an authenticator could not find a cookie configured. Error of this type results by default in `400 Bad Request` HTTP code if handled by the default error handler. diff --git a/docs/content/docs/getting_started/protect_an_app.adoc b/docs/content/docs/getting_started/protect_an_app.adoc index 0e864941a..47def0505 100644 --- a/docs/content/docs/getting_started/protect_an_app.adoc +++ b/docs/content/docs/getting_started/protect_an_app.adoc @@ -118,11 +118,11 @@ providers: + [source, yaml] ---- -version: "1alpha3" +version: "1alpha4" rules: - id: demo:public # <1> match: - url: http://<**>/public + path: /public forward_to: host: upstream:8081 execute: @@ -131,7 +131,9 @@ rules: - id: demo:protected # <2> match: - url: http://<**>/<{user,admin}> + path: /:user + with: + path_glob: {/user,/admin} forward_to: host: upstream:8081 execute: diff --git a/docs/content/docs/mechanisms/evaluation_objects.adoc b/docs/content/docs/mechanisms/evaluation_objects.adoc index 5729214a3..56158df7e 100644 --- a/docs/content/docs/mechanisms/evaluation_objects.adoc +++ b/docs/content/docs/mechanisms/evaluation_objects.adoc @@ -72,28 +72,46 @@ This object contains information about the request handled by heimdall and has t + The HTTP method used, like `GET`, `POST`, etc. +[#_url_captures] * *`URL`*: _URL_ + The URL of the matched request. This object has the following properties and methods: -** *`Scheme`*: _string_ +** *`Captures`*: _map_ + -The HTTP scheme part of the url +Allows accessing of the values captured by the named wildcards used in the matching path expression of the rule. + ** *`Host`*: _string_ + -The host part of the url +The host part of the url. + +** *`Hostname()`*: _method_ ++ +This method returns the host name stripping any valid port number if present. + +** *`Port()`*: _method_ ++ +Returns the port part of the `Host`, without the leading colon. If `Host` doesn't contain a valid numeric port, returns an empty string. + ** *`Path`*: _string_ + -The path part of the url +The path part of the url. + +** *`Query()`*: _method_ ++ +The parsed query with each key-value pair being a string to array of strings mapping. + ** *`RawQuery`*: _string_ + The raw query part of the url. + +** *`Scheme`*: _string_ ++ +The HTTP scheme part of the url. + ** *`String()`*: _method_ + This method returns the URL as valid URL string of a form `scheme:host/path?query`. -** *`Query()`*: _method_ -+ -The parsed query with each key-value pair being a string to array of strings mapping. * *`ClientIPAddresses`*: _string array_ + @@ -152,8 +170,9 @@ Request = { Url: { Scheme: "https", Host: "localhost", - Path: "/test", - RawQuery: "baz=zab&baz=bar&foo=bar" + Path: "/test/abc", + RawQuery: "baz=zab&baz=bar&foo=bar", + Captures: { "value": "abc" } }, ClientIP: ["127.0.0.1", "10.10.10.10"] } @@ -262,7 +281,7 @@ This will result in the following JSON object: ---- ==== -.Access the last part of the path +.Access to captured path segments ==== Imagine, we have a `POST` request to the URL `\http://foobar.baz/zab/1234`, with `1234` being the identifier of a file, which should be updated with the contents sent in the body of the request, and you would like to control access to the aforesaid object using e.g. OpenFGA. This can be achieved with the following authorizer: @@ -277,7 +296,7 @@ config: { "user": "user:{{ .Subject.ID }}", "relation": "write", - "object": "file:{{ splitList "/" .Request.URL.Path | last }}" + "object": "file:{{ .Request.URL.Captures.id }}" } expressions: - expression: | diff --git a/docs/content/docs/rules/default_rule.adoc b/docs/content/docs/rules/default_rule.adoc index 8ec82ac69..cc151794a 100644 --- a/docs/content/docs/rules/default_rule.adoc +++ b/docs/content/docs/rules/default_rule.adoc @@ -16,11 +16,17 @@ description: Heimdall lets you not only define upstream service specific rules, The configuration of the default rule can be done by making use of the `default_rule` property and configuring the options shown below. -NOTE: The default rule does not support all the properties, which can be configured in an link:{{< relref "regular_rule.adoc" >}}[regular rule]. E.g. it can not be used to forward requests to an upstream service, heimdall is protecting. So, if you operate heimdall in the reverse proxy mode, the default rule should be configured to reject requests. Otherwise, heimdall will respond with an error. +[NOTE] +==== +The default rule does not support all the properties, which can be configured in an link:{{< relref "regular_rule.adoc" >}}[regular rule]. + +* It can not be used to forward requests to an upstream service, heimdall is protecting. So, if you operate heimdall in the reverse proxy mode, the default rule should be configured to reject requests. Otherwise, heimdall will respond with an error. +* A default rule does also reject requests with encoded slashes in the path of the URL with `400 Bad Request`, which can be configured on the level of a regular rule. +==== -* *`methods`*: _string array_ (optional) +* *`backtracking_enabled`*: _boolean_ (optional) + -Which HTTP methods (`GET`, `POST`, `PATCH`, etc) are allowed. Expansion using `ALL` and removal by prefixing the method with an `!` is supported as with the regular rules. Defaults to an empty array. If the default rule is defined and the upstream service API specific rule (see also link:{{< relref "regular_rule.adoc#_configuration" >}}[Rule Configuration] does not override it, no methods will be accepted, effectively resulting in `405 Method Not Allowed` response to Heimdall's client for any urls matched by that particular rule. +Enables or disables backtracking while matching the rules globally. Defaults to `false`. * *`execute`*: _link:{{< relref "regular_rule.adoc#_authentication_authorization_pipeline" >}}[Authentication & Authorization Pipeline]_ (mandatory) + @@ -35,9 +41,6 @@ Which error handler mechanisms to use if any of the mechanisms, defined in the ` [source, yaml] ---- default_rule: - methods: - - GET - - PATCH execute: - authenticator: session_cookie_from_kratos_authn - authenticator: oauth2_introspect_token_from_keycloak_authn @@ -47,7 +50,7 @@ default_rule: - error_handler: authenticate_with_kratos_eh ---- -This example defines a default rule, which allows HTTP `GET` and `PATCH` requests on any URL (will respond with `405 Method Not Allowed` for any other HTTP method used by a client). The authentication 6 authorization pipeline consists of two authenticators, with `session_cookie_from_kratos_authn` being the first and `oauth2_introspect_token_from_keycloak_authn` being the fallback (if the first one fails), a `deny_all_requests_authz` authorizer and the `create_jwt` finalizer. The error pipeline is configured to execute only the `authenticate_with_kratos_eh` error handler. +This example defines a default rule, with the authentication 6 authorization pipeline consisting of two authenticators, with `session_cookie_from_kratos_authn` being the first and `oauth2_introspect_token_from_keycloak_authn` being the fallback one (if the first one fails), a `deny_all_requests_authz` authorizer and the `create_jwt` finalizer. The error pipeline is configured to execute only the `authenticate_with_kratos_eh` error handler. Obviously, the authentication & authorization pipeline (defined in the `execute` property) of this default rule will always result in an error due to `deny_all_requests_authz`. This way it is thought to provide secure defaults and let the upstream specific (regular) rules override at least the part dealing with authorization. Such an upstream specific rule could then look like follows: @@ -55,11 +58,11 @@ Obviously, the authentication & authorization pipeline (defined in the `execute` ---- id: rule:my-service:protected-api match: - url: http://my-service.local/foo + path: /foo execute: - authorizer: allow_all_requests_authz ---- -Take a look at how `methods`, `on_error`, as well as the authenticators and finalizers from the `execute` definition of the default rule are reused. Easy, no? +Take a look at how `on_error`, as well as the authenticators and finalizers from the `execute` definition of the default rule are reused. Easy, no? ==== diff --git a/docs/content/docs/rules/providers.adoc b/docs/content/docs/rules/providers.adoc index cf947351b..a33b07e07 100644 --- a/docs/content/docs/rules/providers.adoc +++ b/docs/content/docs/rules/providers.adoc @@ -44,15 +44,17 @@ WARNING: All environment variables, used in the rule set files must be known in ==== [source, yaml] ---- -version: "1alpha3" +version: "1alpha4" name: my-rule-set rules: - id: rule:1 match: - url: https://my-service1.local/<**> + path: /** + with: + host_glob: my-service1.local + methods: [ "GET" ] forward_to: host: ${UPSTREAM_HOST:="default-backend:8080"} - methods: [ "GET" ] execute: - authorizer: foobar ---- @@ -99,15 +101,11 @@ Following configuration options are supported: + Whether the configured `endpoints` should be polled for updates. Defaults to `0s` (polling disabled). -* *`endpoints`*: _RuleSetEndpoint array_ (mandatory) +* *`endpoints`*: _link:{{< relref "/docs/configuration/types.adoc#_endpoint" >}}[Endpoint] array_ (mandatory) + -Each entry of that array supports all the properties defined by link:{{< relref "/docs/configuration/types.adoc#_endpoint" >}}[Endpoint], except `method`, which is always `GET`. As with the link:{{< relref "/docs/configuration/types.adoc#_endpoint" >}}[Endpoint] type, at least the `url` must be configured. Following properties are defined in addition: -+ -** *`rule_path_match_prefix`*: _string_ (optional) -+ -This property can be used to create kind of a namespace for the rule sets retrieved from the different endpoints. If set, the provider checks whether the urls specified in all rules retrieved from the referenced endpoint have the defined path prefix. If not, a warning is emitted and the rule set is ignored. This can be used to ensure a rule retrieved from one endpoint does not collide with a rule from another endpoint. +Each entry of that array supports all the properties defined by link:{{< relref "/docs/configuration/types.adoc#_endpoint" >}}[Endpoint], except `method`, which is always `GET`. As with the link:{{< relref "/docs/configuration/types.adoc#_endpoint" >}}[Endpoint] type, at least the `url` must be configured. -NOTE: HTTP caching according to https://www.rfc-editor.org/rfc/rfc7234[RFC 7234] is enabled by default. It can be disabled by setting `http_cache.enabled` to `false`. +NOTE: HTTP caching according to https://www.rfc-editor.org/rfc/rfc7234[RFC 7234] is enabled by default. It can be disabled on the particular endpoint by setting `http_cache.enabled` to `false`. === Examples @@ -128,9 +126,7 @@ http_endpoint: Here, the provider is configured to poll the two defined rule set endpoints for changes every 5 minutes. -The configuration for the first endpoint instructs heimdall to ensure all urls defined in the rules coming from that endpoint must match the defined path prefix. - -The configuration for the second endpoint defines the `rule_path_match_prefix` as well. It also defines a couple of other properties. One to ensure the communication to that endpoint is more resilient by setting the `retry` options and since this endpoint is protected by an API key, it defines the corresponding options as well. +The configuration for both endpoints instructs heimdall to disable HTTP caching. The configuration of the second endpoint uses a couple of additional properties. One to ensure the communication to that endpoint is more resilient by setting the `retry` options and since this endpoint is protected by an API key, it defines the corresponding options as well. [source, yaml] ---- @@ -138,9 +134,11 @@ http_endpoint: watch_interval: 5m endpoints: - url: http://foo.bar/ruleset1 - rule_path_match_prefix: /foo/bar + http_cache: + enabled: false - url: http://foo.bar/ruleset2 - rule_path_match_prefix: /bar/foo + http_cache: + enabled: false retry: give_up_after: 5s max_delay: 250ms @@ -183,10 +181,6 @@ The actual url to the bucket or to a specific blob in the bucket. ** *`prefix`*: _string_ (optional) + Indicates that only blobs with a key starting with this prefix should be retrieved -+ -** *`rule_path_match_prefix`*: _string_ (optional) -+ -Creates kind of a namespace for the rule sets retrieved from the blobs. If set, the provider checks whether the urls patterns specified in all rules retrieved from the referenced bucket have the defined path prefix. If that rule is violated, a warning is emitted and the rule set is ignored. This can be used to ensure a rule retrieved from one endpoint does not override a rule from another endpoint. The differentiation which storage is used is based on the URL scheme. These are: @@ -222,17 +216,14 @@ cloud_blob: buckets: - url: gs://my-bucket prefix: service1 - rule_path_match_prefix: /service1 - url: gs://my-bucket prefix: service2 - rule_path_match_prefix: /service2 - url: s3://my-bucket/my-rule-set?region=us-west-1 ---- Here, the provider is configured to poll multiple buckets with rule sets for changes every 2 minutes. The first two bucket reference configurations reference actually the same bucket on Google Cloud Storage, but different blobs based on the configured blob prefix. The first one will let heimdall loading only those blobs, which start with `service1`, the second only those, which start with `service2`. -As `rule_path_match_prefix` are defined for both as well, heimdall will ensure, that rule sets loaded from the corresponding blobs will not overlap in their url matching definitions. The last one instructs heimdall to load rule set from a specific blob, namely a blob named `my-rule-set`, which resides on the `my-bucket` AWS S3 bucket, which is located in the `us-west-1` AWS region. diff --git a/docs/content/docs/rules/regular_rule.adoc b/docs/content/docs/rules/regular_rule.adoc index 60df11ada..c196f1012 100644 --- a/docs/content/docs/rules/regular_rule.adoc +++ b/docs/content/docs/rules/regular_rule.adoc @@ -24,68 +24,70 @@ The unique identifier of a rule. It must be unique across all rules loaded by th * *`match`*: _RuleMatcher_ (mandatory) + -Defines how to match a rule and supports the following properties: +Defines how to match a rule and supports the following properties (see also link:{{< relref "#_rule_matching_specificity_backtracking" >}}[Rule Matching Specificity & Backtracking] for more details): -** *`url`*: _string_ (mandatory) +** *`path`*: _link:{{< relref "#_path_expression" >}}[PathExpression]_ (mandatory) + -Glob or Regex pattern of the endpoints of your upstream service, which this rule should apply to. Query parameters are ignored. +The path expression describing the paths of incoming requests this rule is supposed to match. Supports usage of simple and free (named) wildcards. -** *`strategy`*: _string_ (optional) +** *`backtracking_enabled`*: _boolean_ (optional) + -Which strategy to use for matching of the value, provided in the `url` property. Can be one of: +This property can only be used together with the additional matching conditions (see the `with` property below). Enables or disables backtracking if a request is matched based on the `path` expression, but the additional matching conditions are not satisfied. Inherited from the default rule and defaults to the settings in that rule. If enabled, the lookup will traverse back to a rule with a less specific path expression and potentially (depending on the evaluation of additional conditions defined on that level) match it. -*** `regex` - to match `url` expressions by making use of regular expressions. Internally, heimdall makes use of Heimdall uses https://github.com/dlclark/regexp2[dlclark/regexp2] to implement this strategy. Head over to linked resource to get more insights about possible options. +** *`with`*: _MatchConditions_ (optional) + -.Regular expressions patterns -==== -* `\https://mydomain.com/` matches `\https://mydomain.com/` and doesn't match `\https://mydomain.com/foo` or `\https://mydomain.com`. -* `://mydomain.com/<.*>` matches `\https://mydomain.com/` and `\http://mydomain.com/foo`. Doesn't match `\https://other-domain.com/` or `\https://mydomain.com`. -* `\http://mydomain.com/<[[:digit:]]+>` matches `\http://mydomain.com/123`, but doesn't match `\http://mydomain/abc`. -* `\http://mydomain.com/<(?!protected).*>` matches `\http://mydomain.com/resource`, but doesn't match `\http://mydomain.com/protected`. -==== +Additional conditions, which all must hold true to have the request matched and the pipeline of this rule executed. This way, you can define different rules for the same path but with different conditions, e.g. to define separate rules for read and write requests to the same resource. -*** `glob` - to match `url` expressions by making use of glob expressions. Internally, heimdall makes use of Heimdall uses https://github.com/gobwas/glob[gobwas/glob] to implement this strategy. Head over to linked resource to get more insights about possible options. +*** *`host_glob`*: _string_ (optional) + -.Glob patterns -==== -* `\https://mydomain.com/` matches `\https://mydomain.com/man` and does not match `\http://mydomain.com/foo`. -* `\https://mydomain.com/<{foo*,bar*}>` matches `\https://mydomain.com/foo` or `\https://mydomain.com/bar` and doesn't match `\https://mydomain.com/any`. -==== +A https://github.com/gobwas/glob[glob expression] which should be satisfied by the host of the incoming request. `.` is used as a delimiter. That means `*` will match anything until the next `.`. Mutually exclusive with `host_regex`. -* *`allow_encoded_slashes`*: _string_ (optional) +*** *`host_regex`*: _string_ (optional) + -Defines how to handle url-encoded slashes in url paths while matching and forwarding the requests. Can be set to the one of the following values, defaulting to `off`: - -** *`off`* - Reject requests containing encoded slashes. Means, if the request URL contains an url-encoded slash (`%2F`), the rule will not match it. -** *`on`* - Accept requests using encoded slashes, decoding them and making it transparent for the rules and the upstream url. That is, the `%2F` becomes a `/` and will be treated as such in all places. -** *`no_decode`* - Accept requests using encoded slashes, but not touching them and showing them to the rules and the upstream. That is, the `%2F` just remains as is. +Regular expression to match the host. Mutually exclusive with `host_glob`. +*** *`scheme`*: _string_ (optional) + -CAUTION: Since the proxy integrating with heimdall, heimdall by itself, and the upstream service, all may treat the url-encoded slashes differently, accepting requests with url-encoded slashes can, depending on your rules, lead to https://cwe.mitre.org/data/definitions/436.html[Interpretation Conflict] vulnerabilities resulting in privilege escalations. +Expected HTTP scheme. If not specified, both http and https are accepted. -* *`methods`*: _string array_ (optional) +*** *`methods`*: _string array_ (optional) + -Which HTTP methods (`GET`, `POST`, `PATCH`, etc) are allowed for the matched URL. If not specified, every request to that URL will result in `405 Method Not Allowed` response from heimdall. If all methods should be allowed, one can use a special `ALL` placeholder. If all, except some specific methods should be allowed, one can specify `ALL` and remove specific methods by adding the `!` sign to the to be removed method. In that case you have to specify the value in braces. See also examples below. +Which HTTP methods (`GET`, `POST`, `PATCH`, etc) are allowed. If not specified, all methods are allowed. If all, except some specific methods should be allowed, one can specify `ALL` and remove specific methods by adding the `!` sign to the to be removed method. In that case you have to specify the value in braces. See also examples below. + -.Methods list which effectively expands to all HTTP methods -==== [source, yaml] ---- +# Methods list which effectively expands to all HTTP methods methods: - ALL ---- -==== + -.Methods list consisting of all HTTP methods without `TRACE` and `OPTIONS` -==== [source, yaml] ---- +# Methods list consisting of all HTTP methods without `TRACE` and `OPTIONS` methods: - ALL - "!TRACE" - "!OPTIONS" ---- -==== + +*** *`path_glob`*: _string_ (optional) ++ +A https://github.com/gobwas/glob[glob expression], which should be satisfied by the path of the incoming request. `/` is used as a delimiter. That means `*` will match anything until the next `/`. Mutually exclusive with `path_regex`. + +*** *`path_regex`*: _string_ (optional) ++ +A regular expression, which should be satisfied by the path of the incoming request. Mutually exclusive with `path_glob`. + +* *`allow_encoded_slashes`*: _string_ (optional) ++ +Defines how to handle url-encoded slashes in url paths while matching and forwarding the requests. Can be set to the one of the following values, defaulting to `off`: + +** *`off`* - Reject requests containing encoded slashes. Means, if the request URL contains an url-encoded slash (`%2F`), the rule will not match it. +** *`on`* - Accept requests with encoded slashes. As soon as a rule is matched, encoded slashes present in the path of the request are, decoded and made transparent for the matched rule and the upstream service. That is, the `%2F` becomes a `/` and will be treated as such in all places. +** *`no_decode`* - Accept requests using encoded slashes without touching them. That is, the `%2F` just remains as is. + ++ +CAUTION: Since the proxy integrating with heimdall, heimdall by itself, and the upstream service, all may treat the url-encoded slashes differently, accepting requests with url-encoded slashes can, depending on your rules, lead to https://cwe.mitre.org/data/definitions/436.html[Interpretation Conflict] vulnerabilities resulting in privilege escalations. * *`forward_to`*: _RequestForwarder_ (mandatory in Proxy operation mode) + @@ -131,16 +133,18 @@ Which error handler mechanisms to use if any of the mechanisms, defined in the ` ---- id: rule:foo:bar match: - url: http://my-service.local/<**> - strategy: glob + path: /** + with: + scheme: http + host_glob: my-service.local + methods: + - GET + - POST forward_to: host: backend-a:8080 rewrite: scheme: http strip_path_prefix: /api/v1 -methods: - - GET - - POST execute: # the following just demonstrates how to make use of specific # mechanisms in the simplest possible form @@ -162,6 +166,110 @@ on_error: ---- ==== +== Path Expression + +Path expressions are used to match the incoming requests. When specifying these, you can make use of two types of wildcards: + +- free wildcard, which can be defined using `*` and +- single wildcard, which can be defined using `:` + +Both can be named and unnamed, with named wildcards allowing accessing of the matched segments in the pipeline of the rule using the defined name as a key on the link:{{< relref "/docs/mechanisms/evaluation_objects.adoc#_url_captures" >}}[`Request.URL.Captures`] object. Unnamed free wildcard is defined as `\**` and unnamed single wildcard is defined as `:*`. A named wildcard uses some identifier instead of the `*`, so like `*name` for free wildcard and `:name` for single wildcard. + +The value of the path segment, respectively path segments available via the wildcard name is decoded. E.g. if you define the to be matched path in a rule as `/file/:name`, and the actual path of the request is `/file/%5Bid%5D`, you'll get `[id]` when accessing the captured path segment via the `name` key. Not every path encoded value is decoded though. Decoding of encoded slashes happens only if `allow_encoded_slashes` was set to `on`. + +There are some simple rules, which must be followed while using wildcards: + +- One can use as many single wildcards, as needed in any segment +- A segment must start with `:` or `*` to define a wildcard +- No segments are allowed after a free (named) wildcard +- If a regular segment must start with `:` or `*`, but should not be considered as a wildcard, it must be escaped with `\`. + +Here some path examples: + +- `/apples/and/bananas` - Matches exactly the given path +- `/apples/and/:something` - Matches `/apples/and/bananas`, `/apples/and/oranges` and alike, but not `/apples/and/bananas/andmore` or `/apples/or/bananas`. Since a named single wildcard is used, the actual value of the path segment matched by `:something` can be accessed in the rule pipeline using `something` as a key. +- `/apples/:junction/:something` - Similar to above. But will also match `/apples/or/bananas` in addition to `/apples/and/bananas` and `/apples/and/oranges`. +- `/apples/and/some:thing` - Matches exactly `/apples/and/some:thing` +- `/apples/and/some*\*` - Matches exactly `/apples/and/some**` +- `/apples/**` - Matches any path starting with `/apples/`, like `/apples/and/bananas` but not `/apples/`. +- `/apples/*remainingpath` - Same as above, but uses a named free wildcard +- `/apples/**/bananas` - Is invalid, as there is a path segment after a free wildcard +- `/apples/\*remainingpath` - Matches exactly `/apples/*remainingpath` + +Here is an example demonstrating the usage of a single named wildcard: + +[source, yaml] +---- +id: rule:1 +match: + path: /files/:uuid/delete + with: + host_glob: hosty.mchostface + execute: + - authorizer: openfga_check + config: + payload: | + { + "user": "{{ .Subject.ID }}", + "relation": "can_delete", + "object": "file:{{ .Request.URL.Captures.uuid }}" + } +---- + +== Rule Matching Specificity & Backtracking + +The implementation ensures, that more specific path expressions are matched first regardless of the placement of rules in a rule set. +Indeed, the more specific rules are matched first even the corresponding rules are defined in different rule sets. + +When the path expression is matched to a request, additional conditions, if present in the rule's matching definition, are evaluated. Only if these succeeded, the pipeline of the rule is executed. + +CAUTION: If there are multiple rules for the same path expression with matching additional conditions, the first matching rule is taken. The matching order depends on the rule sequence in the rule set. + +If there is no matching rule, backtracking, if enabled, will take place and the next less specific rule may be matched. Backtracking stops if either + +* a less specific rule is successfully matched (incl. the evaluation of additional expressions), or +* a less specific rule is not matched and does not allow backtracking. + +The following examples demonstrates the aspects described above. + +Imagine, there are the following rules + +[source, yaml] +---- +id: rule1 +match: + path: /files/** +execute: + - +---- + +[source, yaml] +---- +id: rule2 +match: + path: /files/:team/:name + backtracking_enabled: true + with: + path_regex: ^/files/(team1|team2)/.* +execute: + - +---- + +[source, yaml] +---- +id: rule3 +match: + path: /files/team3/:name +execute: + - +---- + +The request to `/files/team1/document.pdf` will be matched by the rule with id `rule2` as it is more specific to `rule1`. So the pipeline of `rule2` will be executed. + +The request to `/files/team3/document.pdf` will be matched by the `rule3` as it is more specific than `rule1` and `rule2`. Again the corresponding pipeline will be executed. + +However, even the request to `/files/team4/document.pdf` will be matched by `rule2`, the regular expression `^/files/(team1|team2)/.*` will fail. Here, since `backtracking_enabled` is set to `true` backtracking will start and the request will be matched by the `rule1` and its pipeline will be then executed. + == Authentication & Authorization Pipeline As described in the link:{{< relref "/docs/concepts/pipelines.adoc" >}}[Concepts] section, this pipeline consists of mechanisms, previously configured in the link:{{< relref "/docs/mechanisms/catalogue.adoc" >}}[mechanisms catalogue], organized in stages as described below, with authentication stage (consisting of link:{{< relref "/docs/mechanisms/authenticators.adoc" >}}[authenticators]) being mandatory. diff --git a/docs/content/docs/rules/rule_sets.adoc b/docs/content/docs/rules/rule_sets.adoc index faa0383ae..9fb3acaa8 100644 --- a/docs/content/docs/rules/rule_sets.adoc +++ b/docs/content/docs/rules/rule_sets.adoc @@ -27,7 +27,7 @@ Available properties are: * *`version`*: _string_ (mandatory) + -The version schema of the rule set. The current version of heimdall supports only the version `1alpha3`. +The version schema of the rule set. The current version of heimdall supports only the version `1alpha4`. * *`name`*: _string_ (optional) + @@ -44,19 +44,25 @@ An imaginary rule set file defining two rules could look like shown below. [source, yaml] ---- -version: "1alpha3" +version: "1alpha4" name: my-rule-set rules: - id: rule:1 match: - url: https://my-service1.local/<**> - methods: [ "GET" ] + path: /** + with: + methods: [ "GET" ] + scheme: https + host_glob: my-service1.local execute: - authorizer: foobar - id: rule:2 match: - url: https://my-service2.local/<**> - methods: [ "GET" ] + path: /** + with: + scheme: https + host_glob: my-service2.local + methods: [ "GET" ] execute: - authorizer: barfoo ---- @@ -70,7 +76,7 @@ If you operate heimdall in kubernetes, most probably, you would like to make use * *`apiVersion`*: _string_ (mandatory) + -The api version of the custom resource definition, the given rule set is based on. The current version of heimdall supports only `heimdall.dadrus.github.com/v1alpha3` version. +The api version of the custom resource definition, the given rule set is based on. The current version of heimdall supports only `heimdall.dadrus.github.com/v1alpha4` version. * *`kind`*: _string_ (mandatory) + @@ -108,7 +114,7 @@ $ kubectl apply -f https://raw.githubusercontent.com/dadrus/heimdall/main/charts ==== [source, yaml] ---- -apiVersion: heimdall.dadrus.github.com/v1alpha3 +apiVersion: heimdall.dadrus.github.com/v1alpha4 kind: RuleSet metadata: name: "" @@ -117,7 +123,10 @@ spec: rules: - id: "" match: - url: http://127.0.0.1:9090/foo/<**> + path: /foo/** + with: + scheme: https + host_glob: 127.0.0.1:9090 execute: - authenticator: foo - authorizer: bar diff --git a/docs/content/guides/authz/openfga.adoc b/docs/content/guides/authz/openfga.adoc index beb22c663..67e3efb38 100644 --- a/docs/content/guides/authz/openfga.adoc +++ b/docs/content/guides/authz/openfga.adoc @@ -241,17 +241,15 @@ Note or write down the value of `authorization_model_id`. + [source, yaml] ---- -version: "1alpha3" +version: "1alpha4" rules: - id: access_document # <1> match: - url: http://<**>/document/<**> # <2> + path: /document/:id # <2> + with: + methods: [ GET, POST, DELETE ] forward_to: # <3> host: upstream:8081 - methods: - - GET - - POST - - DELETE execute: - authenticator: jwt_auth # <4> - authorizer: openfga_check # <5> @@ -266,16 +264,16 @@ rules: {{- else -}} unknown {{- end -}} object: > - document:{{- splitList "/" .Request.URL.Path | last -}} # <9> + document:{{- .Request.URL.Captures.id -}} # <9> - finalizer: create_jwt # <10> - id: list_documents # <11> match: - url: http://<**>/documents # <12> + path: /documents # <12> + with: + methods: [ GET ] # <14> forward_to: # <13> host: upstream:8081 - methods: - - GET # <14> execute: # <15> - authenticator: jwt_auth - contextualizer: openfga_list @@ -297,7 +295,7 @@ rules: <6> Replace the value here with the store id, you've received in step 6 <7> Replace the value here with the authorization model id, you've received in step 7 <8> Here, we set the relation depending on the used HTTP request method -<9> Our object reference. We use the last URL path fragment as the id of the document +<9> Our object reference. We use the value captured by the wildcard named `id`. <10> Reference to the previously configured finalizer to create a JWT to be forwarded to our upstream service <11> This is our second rule. It has the id `list_documents`. <12> And matches any request of the form `/documents` diff --git a/docs/content/guides/proxies/nginx.adoc b/docs/content/guides/proxies/nginx.adoc index 5719fc3a2..3125961c6 100644 --- a/docs/content/guides/proxies/nginx.adoc +++ b/docs/content/guides/proxies/nginx.adoc @@ -43,8 +43,6 @@ location @error401 { * If there is no matching rule on heimdall side, heimdall responds with `404 Not Found`, which, as said above will be treated by NGINX as error. To avoid such situations, you can define a link:{{< relref "/docs/rules/default_rule.adoc" >}}[default rule], which is anyway recommended to have secure defaults -* If a heimdall rule is matched, but is configured to not allow a particular HTTP method, `405 Method Not Allowed` response code is returned. That will result in `500` returned by NGINX due to the reasons written above. To overcome that, you can configure heimdall to respond with another HTTP response code using the `respond` property on the level of the link:{{< relref "/docs/services/decision.adoc" >}}[decision service] configuration. - == Vanilla NGINX Since NGINX is highly configurable and heimdall supports different integration options, you can use any of the configuration examples given below. All of these enable heimdall to build the URL of the protected backend server for rule matching purposes. diff --git a/docs/openapi/specification.yaml b/docs/openapi/specification.yaml index 56bdfd4fd..ff38b5bbf 100644 --- a/docs/openapi/specification.yaml +++ b/docs/openapi/specification.yaml @@ -439,22 +439,22 @@ paths: "uid": "ce409862-eae0-4704-b7d5-46634efdaf9b", "kind": { "group": "heimdall.dadrus.github.com", - "version": "v1alpha3", + "version": "v1alpha4", "kind": "RuleSet" }, "resource": { "group": "heimdall.dadrus.github.com", - "version": "v1alpha3", + "version": "v1alpha4", "resource": "rulesets" }, "requestKind": { "group": "heimdall.dadrus.github.com", - "version": "v1alpha3", + "version": "v1alpha4", "kind": "RuleSet" }, "requestResource": { "group": "heimdall.dadrus.github.com", - "version": "v1alpha3", + "version": "v1alpha4", "resource": "rulesets" }, "name": "echo-app-rules", @@ -468,11 +468,11 @@ paths: ] }, "object": { - "apiVersion": "heimdall.dadrus.github.com/v1alpha3", + "apiVersion": "heimdall.dadrus.github.com/v1alpha4", "kind": "RuleSet", "metadata": { "annotations": { - "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"heimdall.dadrus.github.com/v1alpha3\",\"kind\":\"RuleSet\",\"metadata\":{\"annotations\":{},\"labels\":{\"app.kubernetes.io/name\":\"echo-app\"},\"name\":\"echo-app-rules\",\"namespace\":\"quickstarts\"},\"spec\":{\"rules\":[{\"execute\":[{\"authorizer\":\"allow_all_requests\"},{\"finalizer\":\"noop_finalizer\"}],\"forward_to\":{\"host\":\"echo-app.quickstarts.svc.cluster.local:8080\"},\"id\":\"public-access\",\"match\":{\"url\":\"\\u003c**\\u003e://\\u003c**\\u003e/pub/\\u003c**\\u003e\"}},{\"execute\":[{\"authorizer\":\"allow_all_requests\"}],\"forward_to\":{\"host\":\"echo-app.quickstarts.svc.cluster.local:8080\"},\"id\":\"anonymous-access\",\"match\":{\"url\":\"\\u003c**\\u003e://\\u003c**\\u003e/anon/\\u003c**\\u003e\"}},{\"execute\":[{\"authenticator\":\"deny_authenticator\"}],\"forward_to\":{\"host\":\"echo-app.quickstarts.svc.cluster.local:8080\"},\"id\":\"redirect\",\"match\":{\"url\":\"\\u003c**\\u003e://\\u003c**\\u003e/redir/\\u003c**\\u003e\"}}]}}\n" + "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"heimdall.dadrus.github.com/v1alpha4\",\"kind\":\"RuleSet\",\"metadata\":{\"annotations\":{},\"labels\":{\"app.kubernetes.io/name\":\"echo-app\"},\"name\":\"echo-app-rules\",\"namespace\":\"quickstarts\"},\"spec\":{\"rules\":[{\"execute\":[{\"authorizer\":\"allow_all_requests\"},{\"finalizer\":\"noop_finalizer\"}],\"forward_to\":{\"host\":\"echo-app.quickstarts.svc.cluster.local:8080\"},\"id\":\"public-access\",\"match\":{\"url\":\"\\u003c**\\u003e://\\u003c**\\u003e/pub/\\u003c**\\u003e\"}},{\"execute\":[{\"authorizer\":\"allow_all_requests\"}],\"forward_to\":{\"host\":\"echo-app.quickstarts.svc.cluster.local:8080\"},\"id\":\"anonymous-access\",\"match\":{\"url\":\"\\u003c**\\u003e://\\u003c**\\u003e/anon/\\u003c**\\u003e\"}},{\"execute\":[{\"authenticator\":\"deny_authenticator\"}],\"forward_to\":{\"host\":\"echo-app.quickstarts.svc.cluster.local:8080\"},\"id\":\"redirect\",\"match\":{\"url\":\"\\u003c**\\u003e://\\u003c**\\u003e/redir/\\u003c**\\u003e\"}}]}}\n" }, "creationTimestamp": "2023-10-25T17:13:37Z", "generation": 1, @@ -481,7 +481,7 @@ paths: }, "managedFields": [ { - "apiVersion": "heimdall.dadrus.github.com/v1alpha3", + "apiVersion": "heimdall.dadrus.github.com/v1alpha4", "fieldsType": "FieldsV1", "fieldsV1": { "f:metadata": { @@ -526,8 +526,7 @@ paths: }, "id": "public-access", "match": { - "strategy": "glob", - "url": "<**>://<**>/pub/<**>" + "path": "/pub/**" } }, { @@ -541,8 +540,7 @@ paths: }, "id": "anonymous-access", "match": { - "strategy": "glob", - "url": "<**>://<**>/anon/<**>" + "path": "/anon/**" } }, { @@ -556,8 +554,7 @@ paths: }, "id": "redirect", "match": { - "strategy": "glob", - "url": "<**>://<**>/redir/<**>" + "path": "/redir/**" } } ] diff --git a/example_config.yaml b/example_config.yaml index 4d859f586..1ceff650c 100644 --- a/example_config.yaml +++ b/example_config.yaml @@ -177,9 +177,7 @@ mechanisms: to: http://127.0.0.1:4433/self-service/login/browser?origin={{ .Request.URL | urlenc }} default_rule: - methods: - - GET - - POST + backtracking_enabled: false execute: - authenticator: anonymous_authenticator - finalizer: jwt @@ -195,8 +193,8 @@ providers: watch_interval: 5m endpoints: - url: http://foo.bar/rules.yaml - rule_path_match_prefix: /foo - enable_http_cache: false + http_cache: + enabled: false - url: http://bar.foo/rules.yaml headers: bla: bla @@ -215,10 +213,8 @@ providers: buckets: - url: gs://my-bucket prefix: service1 - rule_path_match_prefix: /service1 - url: gs://my-bucket prefix: service2 - rule_path_match_prefix: /service2 - url: s3://my-bucket/my-rule-set kubernetes: diff --git a/example_rules.yaml b/example_rules.yaml index 5dfe52936..712a9851f 100644 --- a/example_rules.yaml +++ b/example_rules.yaml @@ -1,15 +1,18 @@ -version: "1alpha3" +version: "1alpha4" name: test-rule-set rules: - id: rule:foo match: - url: http://foo.bar/<**> - strategy: glob + path: /** + backtracking_enabled: false + with: + methods: + - GET + - POST + host_glob: foo.bar + scheme: http forward_to: host: bar.foo -# methods: # reuses default -# - GET -# - POST execute: - authenticator: unauthorized_authenticator - authenticator: jwt_authenticator diff --git a/examples/README.md b/examples/README.md index ffaa97ef2..fb84eb068 100644 --- a/examples/README.md +++ b/examples/README.md @@ -6,4 +6,6 @@ Those examples, which are based on docker compose are located in the `docker-com To be able to run the docker compose examples, you'll need Docker and docker-compose installed. -To be able to run the Kubernetes based examples, you'll need just, kubectl, kustomize, helm and a k8s cluster. Latter can also be created locally using kind. The examples are indeed using it. \ No newline at end of file +To be able to run the Kubernetes based examples, you'll need just, kubectl, kustomize, helm and a k8s cluster. Latter can also be created locally using kind. The examples are indeed using it. + +Please note: The main branch may have breaking changes (see pending release PRs for details under https://github.com/dadrus/heimdall/pulls) which would make the usage of the referenced heimdall images impossible (even though the configuration files and rules reflect the latest changes). In such situations you'll have to build a heimdall image by yourself and update the setups to use it. \ No newline at end of file diff --git a/examples/docker-compose/quickstarts/README.md b/examples/docker-compose/quickstarts/README.md index abce2ea23..60630b4ce 100644 --- a/examples/docker-compose/quickstarts/README.md +++ b/examples/docker-compose/quickstarts/README.md @@ -2,6 +2,9 @@ This directory contains examples described in the getting started section of the documentation. The demonstration of the decision operation mode is done via integration with some reverse proxies. +**Note:** The main branch may have breaking changes (see pending release PRs for details under https://github.com/dadrus/heimdall/pulls) which would make the usage of the referenced heimdall images impossible (even though the configuration files and rules reflect the latest changes). In such situations you'll have to build a heimdall image by yourself and update the setups to use it. + + # Proxy Mode Quickstart In that setup heimdall is not integrated with any other reverse proxy. diff --git a/examples/docker-compose/quickstarts/heimdall-config.yaml b/examples/docker-compose/quickstarts/heimdall-config.yaml index a651f7020..93f4877f2 100644 --- a/examples/docker-compose/quickstarts/heimdall-config.yaml +++ b/examples/docker-compose/quickstarts/heimdall-config.yaml @@ -41,9 +41,6 @@ mechanisms: type: noop default_rule: - methods: - - GET - - POST execute: - authenticator: deny_all - finalizer: create_jwt diff --git a/examples/docker-compose/quickstarts/upstream-rules.yaml b/examples/docker-compose/quickstarts/upstream-rules.yaml index 68b9ed363..1c3380372 100644 --- a/examples/docker-compose/quickstarts/upstream-rules.yaml +++ b/examples/docker-compose/quickstarts/upstream-rules.yaml @@ -1,8 +1,10 @@ -version: "1alpha3" +version: "1alpha4" rules: - id: demo:public match: - url: http://<**>/public + path: /public + with: + methods: [ GET, POST ] forward_to: host: upstream:8081 execute: @@ -11,7 +13,10 @@ rules: - id: demo:protected match: - url: http://<**>/<{user,admin}> + path: /:user + with: + path_regex: ^/(user|admin) + methods: [ GET, POST ] forward_to: host: upstream:8081 execute: diff --git a/examples/kubernetes/README.md b/examples/kubernetes/README.md index e05eb541c..e30433a7e 100644 --- a/examples/kubernetes/README.md +++ b/examples/kubernetes/README.md @@ -2,6 +2,8 @@ This directory contains working examples described in the getting started, as well as in the integration guides of the documentation. The demonstration of the decision operation mode is done via integration with the corresponding ingress controllers. As of now, these are [Contour](https://projectcontour.io), the [NGINX Ingress Controller](https://docs.nginx.com/nginx-ingress-controller/) and [HAProxy Ingress Controller](https://haproxy-ingress.github.io/). +**Note:** The main branch may have breaking changes (see pending release PRs for details under https://github.com/dadrus/heimdall/pulls) which would make the usage of the referenced heimdall images impossible (even though the configuration files and rules reflect the latest changes). In such situations you'll have to build a heimdall image by yourself and update the setups to use it. + # Prerequisites To be able to install and play with quickstarts, you need diff --git a/examples/kubernetes/metallb/configure.sh b/examples/kubernetes/metallb/configure.sh index f4ca67d98..f61b73d2c 100755 --- a/examples/kubernetes/metallb/configure.sh +++ b/examples/kubernetes/metallb/configure.sh @@ -1,6 +1,6 @@ #!/bin/bash -KIND_SUBNET=$(docker network inspect kind -f "{{(index .IPAM.Config 1).Subnet}}") +KIND_SUBNET=$(docker network inspect kind -f "{{(index .IPAM.Config 0).Subnet}}") METALLB_IP_START=$(echo ${KIND_SUBNET} | sed "s@0.0/16@255.200@") METALLB_IP_END=$(echo ${KIND_SUBNET} | sed "s@0.0/16@255.250@") METALLB_IP_RANGE="${METALLB_IP_START}-${METALLB_IP_END}" diff --git a/examples/kubernetes/quickstarts/demo-app/base/rules.yaml b/examples/kubernetes/quickstarts/demo-app/base/rules.yaml index 877ecd6bd..ad08590ed 100644 --- a/examples/kubernetes/quickstarts/demo-app/base/rules.yaml +++ b/examples/kubernetes/quickstarts/demo-app/base/rules.yaml @@ -1,4 +1,4 @@ -apiVersion: heimdall.dadrus.github.com/v1alpha3 +apiVersion: heimdall.dadrus.github.com/v1alpha4 kind: RuleSet metadata: name: echo-app-rules @@ -9,14 +9,14 @@ spec: rules: - id: public-access match: - url: <**>://<**>/pub/<**> + path: /pub/** forward_to: # only required for proxy operation mode host: echo-app.quickstarts.svc.cluster.local:8080 execute: - authorizer: allow_all_requests - id: anonymous-access match: - url: <**>://<**>/anon/<**> + path: /anon/** forward_to: # only required for proxy operation mode host: echo-app.quickstarts.svc.cluster.local:8080 execute: @@ -24,7 +24,7 @@ spec: - finalizer: create_jwt - id: redirect match: - url: <**>://<**>/redir/<**> + path: /redir/** forward_to: # only required for proxy operation mode host: echo-app.quickstarts.svc.cluster.local:8080 execute: diff --git a/examples/kubernetes/quickstarts/heimdall/config.yaml b/examples/kubernetes/quickstarts/heimdall/config.yaml index be481829e..c69ce89de 100644 --- a/examples/kubernetes/quickstarts/heimdall/config.yaml +++ b/examples/kubernetes/quickstarts/heimdall/config.yaml @@ -34,10 +34,8 @@ mechanisms: if: type(Error) == authentication_error config: to: http://foo.bar?origin={{ .Request.URL | urlenc }} + default_rule: - methods: - - GET - - POST execute: - authenticator: anonymous_authenticator - authorizer: deny_all_requests diff --git a/examples/kubernetes/quickstarts/heimdall1/config.yaml b/examples/kubernetes/quickstarts/heimdall1/config.yaml index 4b6a57c9b..e1f7e1294 100644 --- a/examples/kubernetes/quickstarts/heimdall1/config.yaml +++ b/examples/kubernetes/quickstarts/heimdall1/config.yaml @@ -34,10 +34,8 @@ mechanisms: if: type(Error) == authentication_error config: to: http://foo.bar?origin={{ .Request.URL | urlenc }} + default_rule: - methods: - - GET - - POST execute: - authenticator: anonymous_authenticator - authorizer: deny_all_requests diff --git a/examples/kubernetes/quickstarts/proxy-demo/heimdall-config.yaml b/examples/kubernetes/quickstarts/proxy-demo/heimdall-config.yaml index 53cfbe86c..af69a99d1 100644 --- a/examples/kubernetes/quickstarts/proxy-demo/heimdall-config.yaml +++ b/examples/kubernetes/quickstarts/proxy-demo/heimdall-config.yaml @@ -38,9 +38,6 @@ data: to: http://foo.bar?origin={{ .Request.URL | urlenc }} default_rule: - methods: - - GET - - POST execute: - authenticator: anonymous_authenticator - authorizer: deny_all_requests diff --git a/examples/kubernetes/quickstarts/proxy-demo/heimdall-rules.yaml b/examples/kubernetes/quickstarts/proxy-demo/heimdall-rules.yaml index 076866291..6c0eae814 100644 --- a/examples/kubernetes/quickstarts/proxy-demo/heimdall-rules.yaml +++ b/examples/kubernetes/quickstarts/proxy-demo/heimdall-rules.yaml @@ -8,11 +8,11 @@ metadata: immutable: true data: rules.yaml: | - version: "1alpha3" + version: "1alpha4" rules: - id: public-access match: - url: <**>://<**>/pub/<**> + path: /pub/** forward_to: host: localhost:8080 rewrite: @@ -22,7 +22,7 @@ data: - id: anonymous-access match: - url: <**>://<**>/anon/<**> + path: /anon/** forward_to: host: localhost:8080 rewrite: @@ -33,7 +33,7 @@ data: - id: redirect match: - url: <**>://<**>/redir/<**> + path: /redir/** forward_to: host: localhost:8080 rewrite: diff --git a/go.mod b/go.mod index f6689950c..0f1362489 100644 --- a/go.mod +++ b/go.mod @@ -39,7 +39,6 @@ require ( github.com/knadh/koanf/providers/rawbytes v0.1.0 github.com/knadh/koanf/providers/structs v0.1.0 github.com/knadh/koanf/v2 v2.1.1 - github.com/ory/ladon v1.3.0 github.com/pkg/errors v0.9.1 github.com/pquerna/cachecontrol v0.2.0 github.com/prometheus/client_golang v1.19.0 diff --git a/go.sum b/go.sum index 5eb8af04a..cdb2a55da 100644 --- a/go.sum +++ b/go.sum @@ -310,8 +310,6 @@ github.com/onsi/gomega v1.31.1 h1:KYppCUK+bUgAZwHOu7EXVBKyQA6ILvOESHkn/tgoqvo= github.com/onsi/gomega v1.31.1/go.mod h1:y40C95dwAD1Nz36SsEnxvfFe8FFfNxzI5eJ0EYGyAy0= github.com/openzipkin/zipkin-go v0.4.2 h1:zjqfqHjUpPmB3c1GlCvvgsM1G4LkvqQbBDueDOCg/jA= github.com/openzipkin/zipkin-go v0.4.2/go.mod h1:ZeVkFjuuBiSy13y8vpSDCjMi9GoI3hPpCJSBx/EYFhY= -github.com/ory/ladon v1.3.0 h1:35Rc3O8d+mhFWxzmKs6Qj/ETQEHGEI5BmWQf8wtqFHk= -github.com/ory/ladon v1.3.0/go.mod h1:DyhUMpMSmkC2xWjXsCcfuueCO2jkWrjAYu2RfeXD8/c= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= diff --git a/internal/config/default_rule.go b/internal/config/default_rule.go index e905435bd..d7bf87e42 100644 --- a/internal/config/default_rule.go +++ b/internal/config/default_rule.go @@ -17,7 +17,7 @@ package config type DefaultRule struct { - Methods []string `koanf:"methods"` - Execute []MechanismConfig `koanf:"execute"` - ErrorHandler []MechanismConfig `koanf:"on_error"` + BacktrackingEnabled bool `koanf:"backtracking_enabled"` + Execute []MechanismConfig `koanf:"execute"` + ErrorHandler []MechanismConfig `koanf:"on_error"` } diff --git a/internal/config/serve.go b/internal/config/serve.go index b93b59f5c..434fb150a 100644 --- a/internal/config/serve.go +++ b/internal/config/serve.go @@ -80,7 +80,6 @@ type RespondConfig struct { ArgumentError ResponseOverride `koanf:"argument_error"` AuthenticationError ResponseOverride `koanf:"authentication_error"` AuthorizationError ResponseOverride `koanf:"authorization_error"` - BadMethodError ResponseOverride `koanf:"method_error"` CommunicationError ResponseOverride `koanf:"communication_error"` InternalError ResponseOverride `koanf:"internal_error"` NoRuleError ResponseOverride `koanf:"no_rule_error"` diff --git a/internal/config/test_data/test_config.yaml b/internal/config/test_data/test_config.yaml index 26060a256..194a9d78b 100644 --- a/internal/config/test_data/test_config.yaml +++ b/internal/config/test_data/test_config.yaml @@ -28,8 +28,6 @@ serve: code: 404 authorization_error: code: 404 - method_error: - code: 400 communication_error: code: 502 internal_error: @@ -472,9 +470,7 @@ mechanisms: to: http://127.0.0.1:4433/self-service/login/browser?return_to={{ .Request.URL | urlenc }} default_rule: - methods: - - GET - - POST + backtracking_enabled: false execute: - authenticator: anonymous_authenticator - finalizer: jwt @@ -491,8 +487,8 @@ providers: watch_interval: 5m endpoints: - url: http://foo.bar/rules.yaml - rule_path_match_prefix: /foo - enable_http_cache: true + http_cache: + enabled: true - url: http://bar.foo/rules.yaml headers: bla: bla @@ -511,10 +507,8 @@ providers: buckets: - url: gs://my-bucket prefix: service1 - rule_path_match_prefix: /service1 - url: gs://my-bucket prefix: service2 - rule_path_match_prefix: /service2 - url: s3://my-bucket/my-rule-set kubernetes: diff --git a/internal/handler/decision/service.go b/internal/handler/decision/service.go index f9322a3d0..5cacfb118 100644 --- a/internal/handler/decision/service.go +++ b/internal/handler/decision/service.go @@ -57,7 +57,6 @@ func newService( errorhandler.WithAuthenticationErrorCode(cfg.Respond.With.AuthenticationError.Code), errorhandler.WithAuthorizationErrorCode(cfg.Respond.With.AuthorizationError.Code), errorhandler.WithCommunicationErrorCode(cfg.Respond.With.CommunicationError.Code), - errorhandler.WithMethodErrorCode(cfg.Respond.With.BadMethodError.Code), errorhandler.WithNoRuleErrorCode(cfg.Respond.With.NoRuleError.Code), errorhandler.WithInternalServerErrorCode(cfg.Respond.With.InternalError.Code), ) diff --git a/internal/handler/decision/service_test.go b/internal/handler/decision/service_test.go index fc2861f0b..0463b9f99 100644 --- a/internal/handler/decision/service_test.go +++ b/internal/handler/decision/service_test.go @@ -96,13 +96,13 @@ func TestHandleDecisionEndpointRequest(t *testing.T) { configureMocks: func(t *testing.T, exec *mocks4.ExecutorMock) { t.Helper() - exec.EXPECT().Execute(mock.Anything).Return(nil, heimdall.ErrMethodNotAllowed) + exec.EXPECT().Execute(mock.Anything).Return(nil, heimdall.ErrNoRuleFound) }, assertResponse: func(t *testing.T, err error, response *http.Response) { t.Helper() require.NoError(t, err) - assert.Equal(t, http.StatusMethodNotAllowed, response.StatusCode) + assert.Equal(t, http.StatusNotFound, response.StatusCode) data, err := io.ReadAll(response.Body) require.NoError(t, err) diff --git a/internal/handler/envoyextauth/grpcv3/handler_test.go b/internal/handler/envoyextauth/grpcv3/handler_test.go index 03e9ae14e..db107e1d9 100644 --- a/internal/handler/envoyextauth/grpcv3/handler_test.go +++ b/internal/handler/envoyextauth/grpcv3/handler_test.go @@ -70,17 +70,17 @@ func TestHandleDecisionEndpointRequest(t *testing.T) { configureMocks: func(t *testing.T, exec *mocks2.ExecutorMock) { t.Helper() - exec.EXPECT().Execute(mock.Anything).Return(nil, heimdall.ErrMethodNotAllowed) + exec.EXPECT().Execute(mock.Anything).Return(nil, heimdall.ErrNoRuleFound) }, assertResponse: func(t *testing.T, err error, response *envoy_auth.CheckResponse) { t.Helper() require.NoError(t, err) - assert.Equal(t, int32(codes.InvalidArgument), response.GetStatus().GetCode()) + assert.Equal(t, int32(codes.NotFound), response.GetStatus().GetCode()) deniedResponse := response.GetDeniedResponse() require.NotNil(t, deniedResponse) - assert.Equal(t, typev3.StatusCode(http.StatusMethodNotAllowed), deniedResponse.GetStatus().GetCode()) + assert.Equal(t, typev3.StatusCode(http.StatusNotFound), deniedResponse.GetStatus().GetCode()) assert.Empty(t, deniedResponse.GetBody()) assert.Empty(t, deniedResponse.GetHeaders()) }, diff --git a/internal/handler/envoyextauth/grpcv3/request_context.go b/internal/handler/envoyextauth/grpcv3/request_context.go index b3d784d75..7521683a2 100644 --- a/internal/handler/envoyextauth/grpcv3/request_context.go +++ b/internal/handler/envoyextauth/grpcv3/request_context.go @@ -95,7 +95,7 @@ func (r *RequestContext) Request() *heimdall.Request { return &heimdall.Request{ RequestFunctions: r, Method: r.reqMethod, - URL: r.reqURL, + URL: &heimdall.URL{URL: *r.reqURL}, ClientIPAddresses: r.ips, } } diff --git a/internal/handler/envoyextauth/grpcv3/service.go b/internal/handler/envoyextauth/grpcv3/service.go index ba9449378..d1ae92186 100644 --- a/internal/handler/envoyextauth/grpcv3/service.go +++ b/internal/handler/envoyextauth/grpcv3/service.go @@ -72,7 +72,6 @@ func newService( errorhandler.WithAuthenticationErrorCode(service.Respond.With.AuthenticationError.Code), errorhandler.WithAuthorizationErrorCode(service.Respond.With.AuthorizationError.Code), errorhandler.WithCommunicationErrorCode(service.Respond.With.CommunicationError.Code), - errorhandler.WithMethodErrorCode(service.Respond.With.BadMethodError.Code), errorhandler.WithNoRuleErrorCode(service.Respond.With.NoRuleError.Code), errorhandler.WithInternalServerErrorCode(service.Respond.With.InternalError.Code), ), diff --git a/internal/handler/middleware/grpc/errorhandler/defaults.go b/internal/handler/middleware/grpc/errorhandler/defaults.go index 39f6f56ba..846d4a32b 100644 --- a/internal/handler/middleware/grpc/errorhandler/defaults.go +++ b/internal/handler/middleware/grpc/errorhandler/defaults.go @@ -27,7 +27,6 @@ var defaultOptions = opts{ //nolint:gochecknoglobals authorizationError: responseWith(codes.PermissionDenied, http.StatusForbidden), communicationError: responseWith(codes.DeadlineExceeded, http.StatusBadGateway), preconditionError: responseWith(codes.InvalidArgument, http.StatusBadRequest), - badMethodError: responseWith(codes.InvalidArgument, http.StatusMethodNotAllowed), noRuleError: responseWith(codes.NotFound, http.StatusNotFound), internalError: responseWith(codes.Internal, http.StatusInternalServerError), } diff --git a/internal/handler/middleware/grpc/errorhandler/interceptor.go b/internal/handler/middleware/grpc/errorhandler/interceptor.go index c9081f615..023735977 100644 --- a/internal/handler/middleware/grpc/errorhandler/interceptor.go +++ b/internal/handler/middleware/grpc/errorhandler/interceptor.go @@ -67,8 +67,6 @@ func (h *interceptor) intercept( return h.communicationError(err, h.verboseErrors, acceptType(req)) case errors.Is(err, heimdall.ErrArgument): return h.preconditionError(err, h.verboseErrors, acceptType(req)) - case errors.Is(err, heimdall.ErrMethodNotAllowed): - return h.badMethodError(err, h.verboseErrors, acceptType(req)) case errors.Is(err, heimdall.ErrNoRuleFound): return h.noRuleError(err, h.verboseErrors, acceptType(req)) case errors.Is(err, &heimdall.RedirectError{}): diff --git a/internal/handler/middleware/grpc/errorhandler/interceptor_test.go b/internal/handler/middleware/grpc/errorhandler/interceptor_test.go index 9ffee8458..59d098484 100644 --- a/internal/handler/middleware/grpc/errorhandler/interceptor_test.go +++ b/internal/handler/middleware/grpc/errorhandler/interceptor_test.go @@ -164,28 +164,6 @@ func TestErrorInterceptor(t *testing.T) { expHTTPCode: http.StatusBadRequest, expBody: "

argument error

", }, - { - uc: "method error default", - interceptor: New(), - err: heimdall.ErrMethodNotAllowed, - expGRPCCode: codes.InvalidArgument, - expHTTPCode: http.StatusMethodNotAllowed, - }, - { - uc: "method error overridden", - interceptor: New(WithMethodErrorCode(http.StatusContinue)), - err: heimdall.ErrMethodNotAllowed, - expGRPCCode: codes.InvalidArgument, - expHTTPCode: http.StatusContinue, - }, - { - uc: "method error verbose", - interceptor: New(WithVerboseErrors(true)), - err: heimdall.ErrMethodNotAllowed, - expGRPCCode: codes.InvalidArgument, - expHTTPCode: http.StatusMethodNotAllowed, - expBody: "

method not allowed

", - }, { uc: "no rule error default", interceptor: New(), diff --git a/internal/handler/middleware/grpc/errorhandler/options.go b/internal/handler/middleware/grpc/errorhandler/options.go index 4567a45f1..8eac5ca6a 100644 --- a/internal/handler/middleware/grpc/errorhandler/options.go +++ b/internal/handler/middleware/grpc/errorhandler/options.go @@ -24,7 +24,6 @@ type opts struct { authorizationError func(err error, verbose bool, mimeType string) (any, error) communicationError func(err error, verbose bool, mimeType string) (any, error) preconditionError func(err error, verbose bool, mimeType string) (any, error) - badMethodError func(err error, verbose bool, mimeType string) (any, error) noRuleError func(err error, verbose bool, mimeType string) (any, error) internalError func(err error, verbose bool, mimeType string) (any, error) } @@ -71,14 +70,6 @@ func WithInternalServerErrorCode(code int) Option { } } -func WithMethodErrorCode(code int) Option { - return func(o *opts) { - if code > 0 { - o.badMethodError = responseWith(codes.InvalidArgument, code) - } - } -} - func WithNoRuleErrorCode(code int) Option { return func(o *opts) { if code > 0 { diff --git a/internal/handler/middleware/http/errorhandler/defaults.go b/internal/handler/middleware/http/errorhandler/defaults.go index 493441432..06981c425 100644 --- a/internal/handler/middleware/http/errorhandler/defaults.go +++ b/internal/handler/middleware/http/errorhandler/defaults.go @@ -26,7 +26,6 @@ func defaultOptions() *opts { defaults.onAuthorizationError = errorWriter(defaults, http.StatusForbidden) defaults.onCommunicationError = errorWriter(defaults, http.StatusBadGateway) defaults.onPreconditionError = errorWriter(defaults, http.StatusBadRequest) - defaults.onBadMethodError = errorWriter(defaults, http.StatusMethodNotAllowed) defaults.onNoRuleError = errorWriter(defaults, http.StatusNotFound) defaults.onInternalError = errorWriter(defaults, http.StatusInternalServerError) diff --git a/internal/handler/middleware/http/errorhandler/error_handler.go b/internal/handler/middleware/http/errorhandler/error_handler.go index 0fc932814..0bdfa920f 100644 --- a/internal/handler/middleware/http/errorhandler/error_handler.go +++ b/internal/handler/middleware/http/errorhandler/error_handler.go @@ -58,8 +58,6 @@ func (h *errorHandler) HandleError(rw http.ResponseWriter, req *http.Request, er h.onCommunicationError(rw, req, err) case errors.Is(err, heimdall.ErrArgument): h.onPreconditionError(rw, req, err) - case errors.Is(err, heimdall.ErrMethodNotAllowed): - h.onBadMethodError(rw, req, err) case errors.Is(err, heimdall.ErrNoRuleFound): h.onNoRuleError(rw, req, err) case errors.Is(err, &heimdall.RedirectError{}): diff --git a/internal/handler/middleware/http/errorhandler/error_handler_test.go b/internal/handler/middleware/http/errorhandler/error_handler_test.go index 7b49d562c..f394858e8 100644 --- a/internal/handler/middleware/http/errorhandler/error_handler_test.go +++ b/internal/handler/middleware/http/errorhandler/error_handler_test.go @@ -136,25 +136,6 @@ func TestHandlerHandle(t *testing.T) { expCode: http.StatusBadRequest, expBody: "

argument error

", }, - { - uc: "method error default", - handler: New(), - err: errorchain.New(heimdall.ErrMethodNotAllowed), - expCode: http.StatusMethodNotAllowed, - }, - { - uc: "method error overridden", - handler: New(WithMethodErrorCode(http.StatusContinue)), - err: errorchain.New(heimdall.ErrMethodNotAllowed), - expCode: http.StatusContinue, - }, - { - uc: "method error verbose without mime type", - handler: New(WithVerboseErrors(true)), - err: errorchain.New(heimdall.ErrMethodNotAllowed), - expCode: http.StatusMethodNotAllowed, - expBody: "

method not allowed

", - }, { uc: "no rule error default", handler: New(), diff --git a/internal/handler/middleware/http/errorhandler/options.go b/internal/handler/middleware/http/errorhandler/options.go index 34c031af9..0d6d5ad39 100644 --- a/internal/handler/middleware/http/errorhandler/options.go +++ b/internal/handler/middleware/http/errorhandler/options.go @@ -26,7 +26,6 @@ type opts struct { onAuthorizationError func(rw http.ResponseWriter, req *http.Request, err error) onCommunicationError func(rw http.ResponseWriter, req *http.Request, err error) onPreconditionError func(rw http.ResponseWriter, req *http.Request, err error) - onBadMethodError func(rw http.ResponseWriter, req *http.Request, err error) onNoRuleError func(rw http.ResponseWriter, req *http.Request, err error) onInternalError func(rw http.ResponseWriter, req *http.Request, err error) } @@ -73,14 +72,6 @@ func WithInternalServerErrorCode(code int) Option { } } -func WithMethodErrorCode(code int) Option { - return func(o *opts) { - if code != 0 { - o.onBadMethodError = errorWriter(o, code) - } - } -} - func WithNoRuleErrorCode(code int) Option { return func(o *opts) { if code != 0 { diff --git a/internal/handler/proxy/service.go b/internal/handler/proxy/service.go index 8c90cf428..295910537 100644 --- a/internal/handler/proxy/service.go +++ b/internal/handler/proxy/service.go @@ -96,7 +96,6 @@ func newService( errorhandler.WithAuthenticationErrorCode(cfg.Respond.With.AuthenticationError.Code), errorhandler.WithAuthorizationErrorCode(cfg.Respond.With.AuthorizationError.Code), errorhandler.WithCommunicationErrorCode(cfg.Respond.With.CommunicationError.Code), - errorhandler.WithMethodErrorCode(cfg.Respond.With.BadMethodError.Code), errorhandler.WithNoRuleErrorCode(cfg.Respond.With.NoRuleError.Code), errorhandler.WithInternalServerErrorCode(cfg.Respond.With.InternalError.Code), ) diff --git a/internal/handler/proxy/service_test.go b/internal/handler/proxy/service_test.go index ffccc0e68..c51d8027d 100644 --- a/internal/handler/proxy/service_test.go +++ b/internal/handler/proxy/service_test.go @@ -159,7 +159,7 @@ func TestProxyService(t *testing.T) { configureMocks: func(t *testing.T, exec *mocks4.ExecutorMock, _ *url.URL) { t.Helper() - exec.EXPECT().Execute(mock.Anything).Return(nil, heimdall.ErrMethodNotAllowed) + exec.EXPECT().Execute(mock.Anything).Return(nil, heimdall.ErrNoRuleFound) }, assertResponse: func(t *testing.T, err error, upstreamCalled bool, resp *http.Response) { t.Helper() @@ -167,7 +167,7 @@ func TestProxyService(t *testing.T) { require.False(t, upstreamCalled) require.NoError(t, err) - assert.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) data, err := io.ReadAll(resp.Body) require.NoError(t, err) diff --git a/internal/handler/requestcontext/request_context.go b/internal/handler/requestcontext/request_context.go index 420dcc3d8..0ff1e3ff4 100644 --- a/internal/handler/requestcontext/request_context.go +++ b/internal/handler/requestcontext/request_context.go @@ -132,7 +132,7 @@ func (r *RequestContext) Request() *heimdall.Request { r.hmdlReq = &heimdall.Request{ RequestFunctions: r, Method: r.reqMethod, - URL: r.reqURL, + URL: &heimdall.URL{URL: *r.reqURL}, ClientIPAddresses: r.requestClientIPs(), } } diff --git a/internal/heimdall/context.go b/internal/heimdall/context.go index fc81e66a7..27e54e1c7 100644 --- a/internal/heimdall/context.go +++ b/internal/heimdall/context.go @@ -45,10 +45,16 @@ type RequestFunctions interface { Body() any } +type URL struct { + url.URL + + Captures map[string]string +} + type Request struct { RequestFunctions Method string - URL *url.URL + URL *URL ClientIPAddresses []string } diff --git a/internal/heimdall/errors.go b/internal/heimdall/errors.go index e09e30972..ef1569c19 100644 --- a/internal/heimdall/errors.go +++ b/internal/heimdall/errors.go @@ -29,7 +29,6 @@ var ( ErrCommunicationTimeout = errors.New("communication timeout error") ErrConfiguration = errors.New("configuration error") ErrInternal = errors.New("internal error") - ErrMethodNotAllowed = errors.New("method not allowed") ErrNoRuleFound = errors.New("no rule found") ) diff --git a/internal/heimdall/mocks/context.go b/internal/heimdall/mocks/context.go index 4fe9c7575..d1e140c5f 100644 --- a/internal/heimdall/mocks/context.go +++ b/internal/heimdall/mocks/context.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.23.1. DO NOT EDIT. +// Code generated by mockery v2.42.1. DO NOT EDIT. package mocks @@ -94,6 +94,10 @@ func (_c *ContextMock_AddHeaderForUpstream_Call) RunAndReturn(run func(string, s func (_m *ContextMock) AppContext() context.Context { ret := _m.Called() + if len(ret) == 0 { + panic("no return value specified for AppContext") + } + var r0 context.Context if rf, ok := ret.Get(0).(func() context.Context); ok { r0 = rf() @@ -137,6 +141,10 @@ func (_c *ContextMock_AppContext_Call) RunAndReturn(run func() context.Context) func (_m *ContextMock) Request() *heimdall.Request { ret := _m.Called() + if len(ret) == 0 { + panic("no return value specified for Request") + } + var r0 *heimdall.Request if rf, ok := ret.Get(0).(func() *heimdall.Request); ok { r0 = rf() @@ -213,6 +221,10 @@ func (_c *ContextMock_SetPipelineError_Call) RunAndReturn(run func(error)) *Cont func (_m *ContextMock) Signer() heimdall.JWTSigner { ret := _m.Called() + if len(ret) == 0 { + panic("no return value specified for Signer") + } + var r0 heimdall.JWTSigner if rf, ok := ret.Get(0).(func() heimdall.JWTSigner); ok { r0 = rf() @@ -252,13 +264,12 @@ func (_c *ContextMock_Signer_Call) RunAndReturn(run func() heimdall.JWTSigner) * return _c } -type mockConstructorTestingTNewContextMock interface { +// NewContextMock creates a new instance of ContextMock. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewContextMock(t interface { mock.TestingT Cleanup(func()) -} - -// NewContextMock creates a new instance of ContextMock. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. -func NewContextMock(t mockConstructorTestingTNewContextMock) *ContextMock { +}) *ContextMock { mock := &ContextMock{} mock.Mock.Test(t) diff --git a/internal/heimdall/mocks/request_functions.go b/internal/heimdall/mocks/request_functions.go index 65e29a008..0b59f78ec 100644 --- a/internal/heimdall/mocks/request_functions.go +++ b/internal/heimdall/mocks/request_functions.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.23.1. DO NOT EDIT. +// Code generated by mockery v2.42.1. DO NOT EDIT. package mocks @@ -21,6 +21,10 @@ func (_m *RequestFunctionsMock) EXPECT() *RequestFunctionsMock_Expecter { func (_m *RequestFunctionsMock) Body() interface{} { ret := _m.Called() + if len(ret) == 0 { + panic("no return value specified for Body") + } + var r0 interface{} if rf, ok := ret.Get(0).(func() interface{}); ok { r0 = rf() @@ -64,6 +68,10 @@ func (_c *RequestFunctionsMock_Body_Call) RunAndReturn(run func() interface{}) * func (_m *RequestFunctionsMock) Cookie(name string) string { ret := _m.Called(name) + if len(ret) == 0 { + panic("no return value specified for Cookie") + } + var r0 string if rf, ok := ret.Get(0).(func(string) string); ok { r0 = rf(name) @@ -106,6 +114,10 @@ func (_c *RequestFunctionsMock_Cookie_Call) RunAndReturn(run func(string) string func (_m *RequestFunctionsMock) Header(name string) string { ret := _m.Called(name) + if len(ret) == 0 { + panic("no return value specified for Header") + } + var r0 string if rf, ok := ret.Get(0).(func(string) string); ok { r0 = rf(name) @@ -148,6 +160,10 @@ func (_c *RequestFunctionsMock_Header_Call) RunAndReturn(run func(string) string func (_m *RequestFunctionsMock) Headers() map[string]string { ret := _m.Called() + if len(ret) == 0 { + panic("no return value specified for Headers") + } + var r0 map[string]string if rf, ok := ret.Get(0).(func() map[string]string); ok { r0 = rf() @@ -187,13 +203,12 @@ func (_c *RequestFunctionsMock_Headers_Call) RunAndReturn(run func() map[string] return _c } -type mockConstructorTestingTNewRequestFunctionsMock interface { +// NewRequestFunctionsMock creates a new instance of RequestFunctionsMock. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewRequestFunctionsMock(t interface { mock.TestingT Cleanup(func()) -} - -// NewRequestFunctionsMock creates a new instance of RequestFunctionsMock. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. -func NewRequestFunctionsMock(t mockConstructorTestingTNewRequestFunctionsMock) *RequestFunctionsMock { +}) *RequestFunctionsMock { mock := &RequestFunctionsMock{} mock.Mock.Test(t) diff --git a/internal/rules/cel_execution_condition_test.go b/internal/rules/cel_execution_condition_test.go index 95218a554..9ff6e609a 100644 --- a/internal/rules/cel_execution_condition_test.go +++ b/internal/rules/cel_execution_condition_test.go @@ -104,12 +104,12 @@ func TestCelExecutionConditionCanExecute(t *testing.T) { ctx.EXPECT().Request().Return(&heimdall.Request{ Method: http.MethodGet, - URL: &url.URL{ + URL: &heimdall.URL{URL: url.URL{ Scheme: "http", Host: "localhost", Path: "/test", RawQuery: "foo=bar&baz=zab", - }, + }}, ClientIPAddresses: []string{"127.0.0.1", "10.10.10.10"}, }) diff --git a/internal/rules/config/backend.go b/internal/rules/config/backend.go index 424a428a1..4ca0df1e6 100644 --- a/internal/rules/config/backend.go +++ b/internal/rules/config/backend.go @@ -23,32 +23,28 @@ import ( ) type Backend struct { - Host string `json:"host" yaml:"host"` - URLRewriter *URLRewriter `json:"rewrite" yaml:"rewrite"` + Host string `json:"host" yaml:"host" validate:"required"` //nolint:tagalign + URLRewriter *URLRewriter `json:"rewrite" yaml:"rewrite" validate:"omitnil"` //nolint:tagalign } -func (f *Backend) CreateURL(value *url.URL) *url.URL { +func (b *Backend) CreateURL(value *url.URL) *url.URL { upstreamURL := &url.URL{ Scheme: value.Scheme, - Host: f.Host, + Host: b.Host, Path: value.Path, RawPath: value.RawPath, RawQuery: value.RawQuery, } - if f.URLRewriter != nil { - f.URLRewriter.Rewrite(upstreamURL) + if b.URLRewriter != nil { + b.URLRewriter.Rewrite(upstreamURL) } return upstreamURL } -func (f *Backend) DeepCopyInto(out *Backend) { - if f == nil { - return - } - - jsonStr, _ := json.Marshal(f) +func (b *Backend) DeepCopyInto(out *Backend) { + jsonStr, _ := json.Marshal(b) // we cannot do anything with an error here as // the interface implemented here doesn't support diff --git a/internal/rules/config/decoder.go b/internal/rules/config/decoder.go index 36a38391c..752c031ec 100644 --- a/internal/rules/config/decoder.go +++ b/internal/rules/config/decoder.go @@ -28,7 +28,6 @@ func DecodeConfig(input any, output any) error { dec, err := mapstructure.NewDecoder( &mapstructure.DecoderConfig{ DecodeHook: mapstructure.ComposeDecodeHookFunc( - matcherDecodeHookFunc, mapstructure.StringToTimeDurationHookFunc(), ), Result: output, diff --git a/internal/rules/config/encoded_slash_handling.go b/internal/rules/config/encoded_slash_handling.go new file mode 100644 index 000000000..d7d158c12 --- /dev/null +++ b/internal/rules/config/encoded_slash_handling.go @@ -0,0 +1,9 @@ +package config + +type EncodedSlashesHandling string + +const ( + EncodedSlashesOff EncodedSlashesHandling = "off" + EncodedSlashesOn EncodedSlashesHandling = "on" + EncodedSlashesOnNoDecode EncodedSlashesHandling = "no_decode" +) diff --git a/internal/rules/config/mapstructure_decoder.go b/internal/rules/config/mapstructure_decoder.go deleted file mode 100644 index a8ff0bd02..000000000 --- a/internal/rules/config/mapstructure_decoder.go +++ /dev/null @@ -1,81 +0,0 @@ -// Copyright 2022 Dimitrij Drus -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// -// SPDX-License-Identifier: Apache-2.0 - -package config - -import ( - "errors" - "fmt" - "reflect" - - "github.com/dadrus/heimdall/internal/x" -) - -var ( - ErrURLMissing = errors.New("url property not present") - ErrURLType = errors.New("bad url type") - ErrStrategyType = errors.New("bad strategy type") - ErrUnsupportedStrategy = errors.New("unsupported strategy") -) - -func matcherDecodeHookFunc(from reflect.Type, to reflect.Type, data any) (any, error) { - if to != reflect.TypeOf(Matcher{}) { - return data, nil - } - - if from.Kind() != reflect.String && from.Kind() != reflect.Map { - return data, nil - } - - if from.Kind() == reflect.String { - // nolint: forcetypeassert - // already checked above - return Matcher{URL: data.(string), Strategy: "glob"}, nil - } - - // nolint: forcetypeassert - // already checked above - values := data.(map[string]any) - - var strategyValue string - - URL, urlPresent := values["url"] - if !urlPresent { - return nil, ErrURLMissing - } - - urlValue, ok := URL.(string) - if !ok { - return nil, ErrURLType - } - - strategy, strategyPresent := values["strategy"] - if strategyPresent { - strategyValue, ok = strategy.(string) - if !ok { - return nil, ErrStrategyType - } - - if strategyValue != "glob" && strategyValue != "regex" { - return nil, fmt.Errorf("%w: %s", ErrUnsupportedStrategy, strategyValue) - } - } - - return Matcher{ - URL: urlValue, - Strategy: x.IfThenElse(strategyPresent, strategyValue, "glob"), - }, nil -} diff --git a/internal/rules/config/mapstructure_decoder_test.go b/internal/rules/config/mapstructure_decoder_test.go deleted file mode 100644 index 6af3d069c..000000000 --- a/internal/rules/config/mapstructure_decoder_test.go +++ /dev/null @@ -1,164 +0,0 @@ -// Copyright 2022 Dimitrij Drus -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// -// SPDX-License-Identifier: Apache-2.0 - -package config - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/dadrus/heimdall/internal/x/testsupport" -) - -func TestMatcherDecodeHookFunc(t *testing.T) { - t.Parallel() - - type Typ struct { - Matcher Matcher `json:"match"` - } - - for _, tc := range []struct { - uc string - config []byte - assert func(t *testing.T, err error, matcher *Matcher) - }{ - { - uc: "specified as string", - config: []byte(`match: foo.bar`), - assert: func(t *testing.T, err error, matcher *Matcher) { - t.Helper() - - require.NoError(t, err) - assert.Equal(t, "foo.bar", matcher.URL) - assert.Equal(t, "glob", matcher.Strategy) - }, - }, - { - uc: "specified as structured type without url", - config: []byte(` -match: - strategy: foo -`), - assert: func(t *testing.T, err error, _ *Matcher) { - t.Helper() - - require.Error(t, err) - assert.Contains(t, err.Error(), ErrURLMissing.Error()) - }, - }, - { - uc: "specified as structured type with bad url type", - config: []byte(` -match: - url: 1 -`), - assert: func(t *testing.T, err error, _ *Matcher) { - t.Helper() - - require.Error(t, err) - assert.Contains(t, err.Error(), ErrURLType.Error()) - }, - }, - { - uc: "specified as structured type with bad strategy type", - config: []byte(` -match: - url: foo.bar - strategy: true -`), - assert: func(t *testing.T, err error, _ *Matcher) { - t.Helper() - - require.Error(t, err) - assert.Contains(t, err.Error(), ErrStrategyType.Error()) - }, - }, - { - uc: "specified as structured type with unsupported strategy", - config: []byte(` -match: - url: foo.bar - strategy: foo -`), - assert: func(t *testing.T, err error, _ *Matcher) { - t.Helper() - - require.Error(t, err) - assert.Contains(t, err.Error(), ErrUnsupportedStrategy.Error()) - }, - }, - { - uc: "specified as structured type without strategy specified", - config: []byte(` -match: - url: foo.bar -`), - assert: func(t *testing.T, err error, matcher *Matcher) { - t.Helper() - - require.NoError(t, err) - assert.Equal(t, "foo.bar", matcher.URL) - assert.Equal(t, "glob", matcher.Strategy) - }, - }, - { - uc: "specified as structured type with glob strategy specified", - config: []byte(` -match: - url: foo.bar - strategy: glob -`), - assert: func(t *testing.T, err error, matcher *Matcher) { - t.Helper() - - require.NoError(t, err) - assert.Equal(t, "foo.bar", matcher.URL) - assert.Equal(t, "glob", matcher.Strategy) - }, - }, - { - uc: "specified as structured type with regex strategy specified", - config: []byte(` -match: - url: foo.bar - strategy: regex -`), - assert: func(t *testing.T, err error, matcher *Matcher) { - t.Helper() - - require.NoError(t, err) - assert.Equal(t, "foo.bar", matcher.URL) - assert.Equal(t, "regex", matcher.Strategy) - }, - }, - } { - t.Run(tc.uc, func(t *testing.T) { - // GIVEN - raw, err := testsupport.DecodeTestConfig(tc.config) - require.NoError(t, err) - - var typ Typ - - // WHEN - err = DecodeConfig(raw, &typ) - - // THEN - tc.assert(t, err, &typ.Matcher) - }) - } -} diff --git a/internal/rules/config/matcher.go b/internal/rules/config/matcher.go index 92f0549da..2121525e6 100644 --- a/internal/rules/config/matcher.go +++ b/internal/rules/config/matcher.go @@ -16,31 +16,18 @@ package config -import ( - "github.com/goccy/go-json" - - "github.com/dadrus/heimdall/internal/x/stringx" -) - type Matcher struct { - URL string `json:"url" yaml:"url"` - Strategy string `json:"strategy" yaml:"strategy"` + Path string `json:"path" yaml:"path" validate:"required"` //nolint:lll,tagalign + BacktrackingEnabled *bool `json:"backtracking_enabled" yaml:"backtracking_enabled" validate:"excluded_without=With"` //nolint:lll,tagalign + With *MatcherConstraints `json:"with" yaml:"with" validate:"omitnil,required"` //nolint:lll,tagalign } -func (m *Matcher) UnmarshalJSON(data []byte) error { - if data[0] == '"' { - // data contains just the url matching value - m.URL = stringx.ToString(data[1 : len(data)-1]) - m.Strategy = "glob" +func (m *Matcher) DeepCopyInto(out *Matcher) { + *out = *m - return nil - } + if m.With != nil { + in, out := m.With, out.With - var rawData map[string]any - - if err := json.Unmarshal(data, &rawData); err != nil { - return err + in.DeepCopyInto(out) } - - return DecodeConfig(rawData, m) } diff --git a/internal/rules/config/matcher_constraints.go b/internal/rules/config/matcher_constraints.go new file mode 100644 index 000000000..c51086b74 --- /dev/null +++ b/internal/rules/config/matcher_constraints.go @@ -0,0 +1,46 @@ +package config + +import "slices" + +type MatcherConstraints struct { + Scheme string `json:"scheme" yaml:"scheme" validate:"omitempty,oneof=http https"` //nolint:tagalign + Methods []string `json:"methods" yaml:"methods" validate:"omitempty,dive,required"` //nolint:tagalign + HostGlob string `json:"host_glob" yaml:"host_glob" validate:"excluded_with=HostRegex"` //nolint:tagalign + HostRegex string `json:"host_regex" yaml:"host_regex" validate:"excluded_with=HostGlob"` //nolint:tagalign + PathGlob string `json:"path_glob" yaml:"path_glob" validate:"excluded_with=PathRegex"` //nolint:tagalign + PathRegex string `json:"path_regex" yaml:"path_regex" validate:"excluded_with=PathGlob"` //nolint:tagalign +} + +func (mc *MatcherConstraints) ToRequestMatcher(slashHandling EncodedSlashesHandling) (RequestMatcher, error) { + if mc == nil { + return compositeMatcher{}, nil + } + + hostMatcher, err := createHostMatcher(mc.HostGlob, mc.HostRegex) + if err != nil { + return nil, err + } + + pathMatcher, err := createPathMatcher(mc.PathGlob, mc.PathRegex, slashHandling) + if err != nil { + return nil, err + } + + methodMatcher, err := createMethodMatcher(mc.Methods) + if err != nil { + return nil, err + } + + return compositeMatcher{ + schemeMatcher(mc.Scheme), + methodMatcher, + hostMatcher, + pathMatcher, + }, nil +} + +func (mc *MatcherConstraints) DeepCopyInto(out *MatcherConstraints) { + *out = *mc + + out.Methods = slices.Clone(mc.Methods) +} diff --git a/internal/rules/config/matcher_constraints_test.go b/internal/rules/config/matcher_constraints_test.go new file mode 100644 index 000000000..d5d840c7e --- /dev/null +++ b/internal/rules/config/matcher_constraints_test.go @@ -0,0 +1,88 @@ +package config + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/dadrus/heimdall/internal/heimdall" +) + +func TestMatcherConstraintsToRequestMatcher(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + uc string + constraints *MatcherConstraints + slashHandling EncodedSlashesHandling + assert func(t *testing.T, matcher RequestMatcher, err error) + }{ + { + uc: "no constraints", + assert: func(t *testing.T, matcher RequestMatcher, err error) { + t.Helper() + + require.NoError(t, err) + assert.Empty(t, matcher) + }, + }, + { + uc: "host matcher creation fails", + constraints: &MatcherConstraints{HostRegex: "?>?<*??"}, + assert: func(t *testing.T, _ RequestMatcher, err error) { + t.Helper() + + require.Error(t, err) + require.ErrorIs(t, err, heimdall.ErrConfiguration) + require.ErrorContains(t, err, "filed to compile host expression") + }, + }, + { + uc: "path matcher creation fails", + constraints: &MatcherConstraints{PathRegex: "?>?<*??"}, + assert: func(t *testing.T, _ RequestMatcher, err error) { + t.Helper() + + require.Error(t, err) + require.ErrorIs(t, err, heimdall.ErrConfiguration) + require.ErrorContains(t, err, "filed to compile path expression") + }, + }, + { + uc: "method matcher creation fails", + constraints: &MatcherConstraints{Methods: []string{""}}, + assert: func(t *testing.T, _ RequestMatcher, err error) { + t.Helper() + + require.Error(t, err) + require.ErrorIs(t, err, heimdall.ErrConfiguration) + require.ErrorContains(t, err, "methods list contains empty values") + }, + }, + { + uc: "with all matchers", + constraints: &MatcherConstraints{ + Methods: []string{"GET"}, + Scheme: "https", + HostRegex: "^example.com", + PathGlob: "/foo/bar/*", + }, + assert: func(t *testing.T, matcher RequestMatcher, err error) { + t.Helper() + + require.NoError(t, err) + assert.Len(t, matcher, 4) + + assert.Contains(t, matcher, schemeMatcher("https")) + assert.Contains(t, matcher, methodMatcher{"GET"}) + }, + }, + } { + t.Run(tc.uc, func(t *testing.T) { + matcher, err := tc.constraints.ToRequestMatcher(tc.slashHandling) + + tc.assert(t, matcher, err) + }) + } +} diff --git a/internal/rules/config/matcher_test.go b/internal/rules/config/matcher_test.go index b3ccd652d..d8851a261 100644 --- a/internal/rules/config/matcher_test.go +++ b/internal/rules/config/matcher_test.go @@ -1,104 +1,60 @@ -// Copyright 2022 Dimitrij Drus -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// -// SPDX-License-Identifier: Apache-2.0 - package config import ( "testing" - "github.com/goccy/go-json" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func TestMatcherUnmarshalJSON(t *testing.T) { +func TestMatcherDeepCopyInto(t *testing.T) { t.Parallel() - type Typ struct { - Matcher Matcher `json:"match"` - } - for _, tc := range []struct { uc string - config []byte - assert func(t *testing.T, err error, matcher *Matcher) + in *Matcher + assert func(t *testing.T, out *Matcher) }{ { - uc: "specified as string", - config: []byte(`{ "match": "foo.bar" }`), - assert: func(t *testing.T, err error, matcher *Matcher) { + uc: "with path only", + in: &Matcher{Path: "/foo/bar"}, + assert: func(t *testing.T, out *Matcher) { t.Helper() - require.NoError(t, err) - assert.Equal(t, "foo.bar", matcher.URL) - assert.Equal(t, "glob", matcher.Strategy) + assert.Equal(t, "/foo/bar", out.Path) + assert.Nil(t, out.With) }, }, { - uc: "specified as structured type with invalid json structure", - config: []byte(`{ -"match": { - strategy: foo -} -}`), - assert: func(t *testing.T, err error, _ *Matcher) { + uc: "with path and simple constraints", + in: &Matcher{Path: "/foo/bar", With: &MatcherConstraints{Scheme: "http"}}, + assert: func(t *testing.T, out *Matcher) { t.Helper() - require.Error(t, err) - assert.Contains(t, err.Error(), "invalid character") + assert.Equal(t, "/foo/bar", out.Path) + require.NotNil(t, out.With) + assert.Equal(t, "http", out.With.Scheme) }, }, { - uc: "specified as structured type without url", - config: []byte(`{ -"match": { - "strategy": "foo" -} -}`), - assert: func(t *testing.T, err error, _ *Matcher) { - t.Helper() - - require.Error(t, err) - assert.Contains(t, err.Error(), ErrURLMissing.Error()) - }, - }, - { - uc: "specified as structured type without strategy specified", - config: []byte(`{ -"match": { - "url": "foo.bar" -} -}`), - assert: func(t *testing.T, err error, matcher *Matcher) { + uc: "with path and complex constraints", + in: &Matcher{Path: "/foo/bar", With: &MatcherConstraints{Methods: []string{"GET"}, Scheme: "http"}}, + assert: func(t *testing.T, out *Matcher) { t.Helper() - require.NoError(t, err) - assert.Equal(t, "foo.bar", matcher.URL) - assert.Equal(t, "glob", matcher.Strategy) + assert.Equal(t, "/foo/bar", out.Path) + require.NotNil(t, out.With) + assert.Equal(t, "http", out.With.Scheme) + assert.ElementsMatch(t, out.With.Methods, []string{"GET"}) }, }, } { t.Run(tc.uc, func(t *testing.T) { - var typ Typ + out := new(Matcher) - // WHEN - err := json.Unmarshal(tc.config, &typ) + tc.in.DeepCopyInto(out) - // THEN - tc.assert(t, err, &typ.Matcher) + tc.assert(t, out) }) } } diff --git a/internal/rules/config/mocks/request_matcher.go b/internal/rules/config/mocks/request_matcher.go new file mode 100644 index 000000000..c8360085f --- /dev/null +++ b/internal/rules/config/mocks/request_matcher.go @@ -0,0 +1,81 @@ +// Code generated by mockery v2.42.1. DO NOT EDIT. + +package mocks + +import ( + heimdall "github.com/dadrus/heimdall/internal/heimdall" + mock "github.com/stretchr/testify/mock" +) + +// RequestMatcherMock is an autogenerated mock type for the RequestMatcher type +type RequestMatcherMock struct { + mock.Mock +} + +type RequestMatcherMock_Expecter struct { + mock *mock.Mock +} + +func (_m *RequestMatcherMock) EXPECT() *RequestMatcherMock_Expecter { + return &RequestMatcherMock_Expecter{mock: &_m.Mock} +} + +// Matches provides a mock function with given fields: request +func (_m *RequestMatcherMock) Matches(request *heimdall.Request) error { + ret := _m.Called(request) + + if len(ret) == 0 { + panic("no return value specified for Matches") + } + + var r0 error + if rf, ok := ret.Get(0).(func(*heimdall.Request) error); ok { + r0 = rf(request) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// RequestMatcherMock_Matches_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Matches' +type RequestMatcherMock_Matches_Call struct { + *mock.Call +} + +// Matches is a helper method to define mock.On call +// - request *heimdall.Request +func (_e *RequestMatcherMock_Expecter) Matches(request interface{}) *RequestMatcherMock_Matches_Call { + return &RequestMatcherMock_Matches_Call{Call: _e.mock.On("Matches", request)} +} + +func (_c *RequestMatcherMock_Matches_Call) Run(run func(request *heimdall.Request)) *RequestMatcherMock_Matches_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(*heimdall.Request)) + }) + return _c +} + +func (_c *RequestMatcherMock_Matches_Call) Return(_a0 error) *RequestMatcherMock_Matches_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *RequestMatcherMock_Matches_Call) RunAndReturn(run func(*heimdall.Request) error) *RequestMatcherMock_Matches_Call { + _c.Call.Return(run) + return _c +} + +// NewRequestMatcherMock creates a new instance of RequestMatcherMock. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewRequestMatcherMock(t interface { + mock.TestingT + Cleanup(func()) +}) *RequestMatcherMock { + mock := &RequestMatcherMock{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/internal/rules/config/parser_test.go b/internal/rules/config/parser_test.go index ed2256733..4e111f820 100644 --- a/internal/rules/config/parser_test.go +++ b/internal/rules/config/parser_test.go @@ -53,62 +53,257 @@ func TestParseRules(t *testing.T) { assert: func(t *testing.T, err error, ruleSet *RuleSet) { t.Helper() + require.Error(t, err) + require.ErrorIs(t, err, ErrEmptyRuleSet) + require.Nil(t, ruleSet) + }, + }, + { + uc: "Empty JSON content", + contentType: "application/json", + assert: func(t *testing.T, err error, ruleSet *RuleSet) { + t.Helper() + require.ErrorIs(t, err, ErrEmptyRuleSet) require.Nil(t, ruleSet) }, }, { - uc: "JSON content type and not empty contents", + uc: "JSON rule set without rules", contentType: "application/json", content: []byte(`{ "version": "1", "name": "foo", -"rules": [{"id": "bar"}] +"rules": [] }`), assert: func(t *testing.T, err error, ruleSet *RuleSet) { t.Helper() - require.NoError(t, err) - require.NotNil(t, ruleSet) - assert.Equal(t, "1", ruleSet.Version) - assert.Equal(t, "foo", ruleSet.Name) - assert.Len(t, ruleSet.Rules, 1) + require.Error(t, err) + require.ErrorIs(t, err, heimdall.ErrConfiguration) + require.Contains(t, err.Error(), "'rules' must contain more than 0 items") + require.Nil(t, ruleSet) }, }, { - uc: "JSON content type with validation error", + uc: "JSON rule set with a rule without required elements", contentType: "application/json", content: []byte(`{ "version": "1", "name": "foo", -"rules": [{"id": "bar", "allow_encoded_slashes": "foo"}] +"rules": [{"forward_to": {"host":"foo.bar"}}] }`), assert: func(t *testing.T, err error, ruleSet *RuleSet) { t.Helper() + require.Error(t, err) require.ErrorIs(t, err, heimdall.ErrConfiguration) + require.Contains(t, err.Error(), "'rules'[0].'id' is a required field") + require.Contains(t, err.Error(), "'rules'[0].'match' is a required field") + require.Contains(t, err.Error(), "'rules'[0].'execute' must contain more than 0 items") require.Nil(t, ruleSet) }, }, { - uc: "JSON content type and empty contents", + uc: "JSON rule set with a rule which match definition does not contain required fields", contentType: "application/json", + content: []byte(`{ +"version": "1", +"name": "foo", +"rules": [{"id": "foo", "match":{"with": {"host_glob":"**"}}, "execute": [{"authenticator":"test"}]}] +}`), assert: func(t *testing.T, err error, ruleSet *RuleSet) { t.Helper() - require.ErrorIs(t, err, ErrEmptyRuleSet) + require.Error(t, err) + require.ErrorIs(t, err, heimdall.ErrConfiguration) + require.Contains(t, err.Error(), "'rules'[0].'match'.'path' is a required field") + require.Nil(t, ruleSet) + }, + }, + { + uc: "JSON rule set with a rule which match definition contains conflicting fields for host matching", + contentType: "application/json", + content: []byte(`{ +"version": "1", +"name": "foo", +"rules": [ + { + "id": "foo", + "match":{"path":"/foo/bar", "with": {"host_glob":"**", "host_regex":"**"}}, + "execute": [{"authenticator":"test"}] + }] +}`), + assert: func(t *testing.T, err error, ruleSet *RuleSet) { + t.Helper() + + require.Error(t, err) + require.ErrorIs(t, err, heimdall.ErrConfiguration) + require.Contains(t, err.Error(), "'rules'[0].'match'.'with'.'host_glob' is an excluded field") + require.Contains(t, err.Error(), "'rules'[0].'match'.'with'.'host_regex' is an excluded field") + require.Nil(t, ruleSet) + }, + }, + { + uc: "JSON rule set with a rule which match definition contains conflicting fields for path matching", + contentType: "application/json", + content: []byte(`{ +"version": "1", +"name": "foo", +"rules": [ + { + "id": "foo", + "match":{"path":"/foo/bar", "with": {"path_glob":"**", "path_regex":"**"}}, + "execute": [{"authenticator":"test"}] + }] +}`), + assert: func(t *testing.T, err error, ruleSet *RuleSet) { + t.Helper() + + require.Error(t, err) + require.ErrorIs(t, err, heimdall.ErrConfiguration) + require.Contains(t, err.Error(), "'rules'[0].'match'.'with'.'path_glob' is an excluded field") + require.Contains(t, err.Error(), "'rules'[0].'match'.'with'.'path_regex' is an excluded field") + require.Nil(t, ruleSet) + }, + }, + { + uc: "JSON rule set with a rule which match definition contains unsupported scheme", + contentType: "application/json", + content: []byte(`{ +"version": "1", +"name": "foo", +"rules": [ + { + "id": "foo", + "match":{"path":"/foo/bar", "with": {"scheme":"foo", "methods":["ALL"]}}, + "execute": [{"authenticator":"test"}] + }] +}`), + assert: func(t *testing.T, err error, ruleSet *RuleSet) { + t.Helper() + + require.Error(t, err) + require.ErrorIs(t, err, heimdall.ErrConfiguration) + require.Contains(t, err.Error(), "'rules'[0].'match'.'with'.'scheme' must be one of [http https]") + require.Nil(t, ruleSet) + }, + }, + { + uc: "JSON rule set with a rule with forward_to without host", + contentType: "application/json", + content: []byte(`{ +"version": "1", +"name": "foo", +"rules": [ + { + "id": "foo", + "match":{"path":"/foo/bar"}, + "execute": [{"authenticator":"test"}], + "forward_to": { "rewrite": {"scheme": "http"}} + }] +}`), + assert: func(t *testing.T, err error, ruleSet *RuleSet) { + t.Helper() + + require.Error(t, err) + require.ErrorIs(t, err, heimdall.ErrConfiguration) + require.Contains(t, err.Error(), "'rules'[0].'forward_to'.'host' is a required field") + require.Nil(t, ruleSet) + }, + }, + { + uc: "JSON rule set with invalid allow_encoded_slashes settings", + contentType: "application/json", + content: []byte(`{ +"version": "1", +"name": "foo", +"rules": [ + { + "id": "foo", + "match":{"path":"/foo/bar"}, + "allow_encoded_slashes": "foo", + "execute": [{"authenticator":"test"}] + }] +}`), + assert: func(t *testing.T, err error, ruleSet *RuleSet) { + t.Helper() + + require.ErrorIs(t, err, heimdall.ErrConfiguration) + require.Contains(t, err.Error(), "'rules'[0].'allow_encoded_slashes' must be one of [off on no_decode]") + require.Nil(t, ruleSet) + }, + }, + { + uc: "JSON rule set with invalid backtracking_enabled settings", + contentType: "application/json", + content: []byte(`{ +"version": "1", +"name": "foo", +"rules": [ + { + "id": "foo", + "match":{"path":"/foo/bar", "backtracking_enabled": true }, + "execute": [{"authenticator":"test"}] + }] +}`), + assert: func(t *testing.T, err error, ruleSet *RuleSet) { + t.Helper() + + require.ErrorIs(t, err, heimdall.ErrConfiguration) + require.Contains(t, err.Error(), "'backtracking_enabled' is an excluded field") require.Nil(t, ruleSet) }, }, { - uc: "YAML content type and not empty contents", + uc: "Valid JSON rule set", + contentType: "application/json", + content: []byte(`{ +"version": "1", +"name": "foo", +"rules": [ + { + "id": "foo", + "match":{"path":"/foo/bar", "with": { "methods": ["ALL"] }, "backtracking_enabled": true }, + "execute": [{"authenticator":"test"}] + }] +}`), + assert: func(t *testing.T, err error, ruleSet *RuleSet) { + t.Helper() + + require.NoError(t, err) + require.NotNil(t, ruleSet) + assert.Equal(t, "1", ruleSet.Version) + assert.Equal(t, "foo", ruleSet.Name) + assert.Len(t, ruleSet.Rules, 1) + + rul := ruleSet.Rules[0] + require.NotNil(t, rul) + assert.Equal(t, "foo", rul.ID) + assert.Equal(t, "/foo/bar", rul.Matcher.Path) + assert.ElementsMatch(t, []string{"ALL"}, rul.Matcher.With.Methods) + assert.Len(t, rul.Execute, 1) + assert.Equal(t, "test", rul.Execute[0]["authenticator"]) + }, + }, + { + uc: "Valid YAML rule set", contentType: "application/yaml", content: []byte(` version: "1" name: foo rules: - id: bar - allow_encoded_slashes: off + match: + path: /foo/bar + with: + methods: + - GET + forward_to: + host: test + allow_encoded_slashes: no_decode + execute: + - authenticator: test `), assert: func(t *testing.T, err error, ruleSet *RuleSet) { t.Helper() @@ -118,6 +313,14 @@ rules: assert.Equal(t, "1", ruleSet.Version) assert.Equal(t, "foo", ruleSet.Name) assert.Len(t, ruleSet.Rules, 1) + rul := ruleSet.Rules[0] + require.NotNil(t, rul) + assert.Equal(t, "bar", rul.ID) + assert.Equal(t, "/foo/bar", rul.Matcher.Path) + assert.ElementsMatch(t, []string{"GET"}, rul.Matcher.With.Methods) + assert.Equal(t, EncodedSlashesOnNoDecode, rul.EncodedSlashesHandling) + assert.Len(t, rul.Execute, 1) + assert.Equal(t, "test", rul.Execute[0]["authenticator"]) }, }, { @@ -191,6 +394,10 @@ version: "1" name: foo rules: - id: bar + match: + path: foo + execute: + - authenticator: test `), assert: func(t *testing.T, err error, ruleSet *RuleSet) { t.Helper() @@ -200,7 +407,11 @@ rules: assert.Equal(t, "1", ruleSet.Version) assert.Equal(t, "foo", ruleSet.Name) assert.Len(t, ruleSet.Rules, 1) - assert.Equal(t, "bar", ruleSet.Rules[0].ID) + + rul := ruleSet.Rules[0] + require.NotNil(t, rul) + assert.Equal(t, "bar", rul.ID) + assert.Equal(t, "foo", rul.Matcher.Path) }, }, { @@ -229,6 +440,10 @@ version: "1" name: ${FOO} rules: - id: bar + match: + path: foo + execute: + - authenticator: test `), assert: func(t *testing.T, err error, ruleSet *RuleSet) { t.Helper() @@ -238,7 +453,11 @@ rules: assert.Equal(t, "1", ruleSet.Version) assert.Equal(t, "bar", ruleSet.Name) assert.Len(t, ruleSet.Rules, 1) - assert.Equal(t, "bar", ruleSet.Rules[0].ID) + + rul := ruleSet.Rules[0] + require.NotNil(t, rul) + assert.Equal(t, "bar", rul.ID) + assert.Equal(t, "foo", rul.Matcher.Path) }, }, { @@ -248,6 +467,10 @@ version: "1" name: ${FOO} rules: - id: bar + match: + path: foo + execute: + - authenticator: test `), assert: func(t *testing.T, err error, ruleSet *RuleSet) { t.Helper() @@ -257,7 +480,11 @@ rules: assert.Equal(t, "1", ruleSet.Version) assert.Equal(t, "${FOO}", ruleSet.Name) assert.Len(t, ruleSet.Rules, 1) - assert.Equal(t, "bar", ruleSet.Rules[0].ID) + + rul := ruleSet.Rules[0] + require.NotNil(t, rul) + assert.Equal(t, "bar", rul.ID) + assert.Equal(t, "foo", rul.Matcher.Path) }, }, } { diff --git a/internal/rules/config/pattern_matcher.go b/internal/rules/config/pattern_matcher.go new file mode 100644 index 000000000..7206d66bb --- /dev/null +++ b/internal/rules/config/pattern_matcher.go @@ -0,0 +1,61 @@ +package config + +import ( + "errors" + "regexp" + + "github.com/gobwas/glob" +) + +var ( + ErrNoGlobPatternDefined = errors.New("no glob pattern defined") + ErrNoRegexPatternDefined = errors.New("no regex pattern defined") +) + +type ( + patternMatcher interface { + match(pattern string) bool + } + + globMatcher struct { + compiled glob.Glob + } + + regexpMatcher struct { + compiled *regexp.Regexp + } +) + +func (m *globMatcher) match(value string) bool { + return m.compiled.Match(value) +} + +func (m *regexpMatcher) match(matchAgainst string) bool { + return m.compiled.MatchString(matchAgainst) +} + +func newGlobMatcher(pattern string, separator rune) (patternMatcher, error) { + if len(pattern) == 0 { + return nil, ErrNoGlobPatternDefined + } + + compiled, err := glob.Compile(pattern, separator) + if err != nil { + return nil, err + } + + return &globMatcher{compiled: compiled}, nil +} + +func newRegexMatcher(pattern string) (patternMatcher, error) { + if len(pattern) == 0 { + return nil, ErrNoRegexPatternDefined + } + + compiled, err := regexp.Compile(pattern) + if err != nil { + return nil, err + } + + return ®expMatcher{compiled: compiled}, nil +} diff --git a/internal/rules/config/pattern_matcher_test.go b/internal/rules/config/pattern_matcher_test.go new file mode 100644 index 000000000..c03570275 --- /dev/null +++ b/internal/rules/config/pattern_matcher_test.go @@ -0,0 +1,136 @@ +package config + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRegexPatternMatcher(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + uc string + expression string + matches string + assert func(t *testing.T, err error, matched bool) + }{ + { + uc: "with empty expression", + assert: func(t *testing.T, err error, _ bool) { + t.Helper() + + require.Error(t, err) + require.ErrorIs(t, err, ErrNoRegexPatternDefined) + }, + }, + { + uc: "with bad regex expression", + expression: "?>?<*??", + assert: func(t *testing.T, err error, _ bool) { + t.Helper() + + require.Error(t, err) + assert.Contains(t, err.Error(), "error parsing regexp") + }, + }, + { + uc: "doesn't match", + expression: "^/foo/(bar|baz)/zab", + matches: "/foo/zab/zab", + assert: func(t *testing.T, err error, matched bool) { + t.Helper() + + require.NoError(t, err) + assert.False(t, matched) + }, + }, + { + uc: "successful", + expression: "^/foo/(bar|baz)/zab", + matches: "/foo/bar/zab", + assert: func(t *testing.T, err error, matched bool) { + t.Helper() + + require.NoError(t, err) + assert.True(t, matched) + }, + }, + } { + t.Run(tc.uc, func(t *testing.T) { + var matched bool + + matcher, err := newRegexMatcher(tc.expression) + if matcher != nil { + matched = matcher.match(tc.matches) + } + + tc.assert(t, err, matched) + }) + } +} + +func TestGlobPatternMatcher(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + uc string + expression string + matches string + assert func(t *testing.T, err error, matched bool) + }{ + { + uc: "with empty expression", + assert: func(t *testing.T, err error, _ bool) { + t.Helper() + + require.Error(t, err) + require.ErrorIs(t, err, ErrNoGlobPatternDefined) + }, + }, + { + uc: "with bad glob expression", + expression: "!*][)(*", + assert: func(t *testing.T, err error, _ bool) { + t.Helper() + + require.Error(t, err) + assert.Contains(t, err.Error(), "unexpected end of input") + }, + }, + { + uc: "doesn't match", + expression: "{/**.foo,/**.bar}", + matches: "/foo.baz", + assert: func(t *testing.T, err error, matched bool) { + t.Helper() + + require.NoError(t, err) + assert.False(t, matched) + }, + }, + { + uc: "successful", + expression: "{/**.foo,/**.bar}", + matches: "/foo.bar", + assert: func(t *testing.T, err error, matched bool) { + t.Helper() + + require.NoError(t, err) + assert.True(t, matched) + }, + }, + } { + t.Run(tc.uc, func(t *testing.T) { + var matched bool + + matcher, err := newGlobMatcher(tc.expression, '/') + if matcher != nil { + matched = matcher.match(tc.matches) + } + + tc.assert(t, err, matched) + }) + } +} diff --git a/internal/rules/config/request_matcher.go b/internal/rules/config/request_matcher.go new file mode 100644 index 000000000..63d2c3cd6 --- /dev/null +++ b/internal/rules/config/request_matcher.go @@ -0,0 +1,167 @@ +package config + +import ( + "errors" + "net/http" + "net/url" + "slices" + "strings" + + "github.com/dadrus/heimdall/internal/heimdall" + "github.com/dadrus/heimdall/internal/x/errorchain" + "github.com/dadrus/heimdall/internal/x/slicex" +) + +// nolint: gochecknoglobals +var spaceReplacer = strings.NewReplacer("\t", "", "\n", "", "\v", "", "\f", "", "\r", "", " ", "") + +var ( + ErrRequestSchemeMismatch = errors.New("request scheme mismatch") + ErrRequestMethodMismatch = errors.New("request method mismatch") + ErrRequestHostMismatch = errors.New("request host mismatch") + ErrRequestPathMismatch = errors.New("request path mismatch") +) + +//go:generate mockery --name RequestMatcher --structname RequestMatcherMock + +type RequestMatcher interface { + Matches(request *heimdall.Request) error +} + +type compositeMatcher []RequestMatcher + +func (c compositeMatcher) Matches(request *heimdall.Request) error { + for _, matcher := range c { + if err := matcher.Matches(request); err != nil { + return err + } + } + + return nil +} + +type alwaysMatcher struct{} + +func (alwaysMatcher) match(_ string) bool { return true } + +type schemeMatcher string + +func (s schemeMatcher) Matches(request *heimdall.Request) error { + if len(s) != 0 && string(s) != request.URL.Scheme { + return errorchain.NewWithMessagef(ErrRequestSchemeMismatch, "expected %s, got %s", s, request.URL.Scheme) + } + + return nil +} + +type methodMatcher []string + +func (m methodMatcher) Matches(request *heimdall.Request) error { + if len(m) == 0 { + return nil + } + + if !slices.Contains(m, request.Method) { + return errorchain.NewWithMessagef(ErrRequestMethodMismatch, "%s is not expected", request.Method) + } + + return nil +} + +type hostMatcher struct { + patternMatcher +} + +func (m *hostMatcher) Matches(request *heimdall.Request) error { + if !m.match(request.URL.Host) { + return errorchain.NewWithMessagef(ErrRequestHostMismatch, "%s is not expected", request.URL.Host) + } + + return nil +} + +type pathMatcher struct { + patternMatcher + + slashHandling EncodedSlashesHandling +} + +func (m *pathMatcher) Matches(request *heimdall.Request) error { + var path string + if len(request.URL.RawPath) == 0 || m.slashHandling == EncodedSlashesOn { + path = request.URL.Path + } else { + unescaped, _ := url.PathUnescape(strings.ReplaceAll(request.URL.RawPath, "%2F", "$$$escaped-slash$$$")) + path = strings.ReplaceAll(unescaped, "$$$escaped-slash$$$", "%2F") + } + + if !m.match(path) { + return errorchain.NewWithMessagef(ErrRequestPathMismatch, "%s is not expected", path) + } + + return nil +} + +func createMethodMatcher(methods []string) (methodMatcher, error) { + if len(methods) == 0 { + return methodMatcher{}, nil + } + + if slices.Contains(methods, "ALL") { + methods = slices.DeleteFunc(methods, func(method string) bool { return method == "ALL" }) + + methods = append(methods, + http.MethodGet, http.MethodHead, http.MethodPost, http.MethodPut, http.MethodPatch, + http.MethodDelete, http.MethodConnect, http.MethodOptions, http.MethodTrace) + } + + slices.SortFunc(methods, strings.Compare) + + methods = slices.Compact(methods) + if res := slicex.Filter(methods, func(s string) bool { return len(s) == 0 }); len(res) != 0 { + return nil, errorchain.NewWithMessage(heimdall.ErrConfiguration, + "methods list contains empty values. have you forgotten to put the corresponding value into braces?") + } + + tbr := slicex.Filter(methods, func(s string) bool { return strings.HasPrefix(s, "!") }) + methods = slicex.Subtract(methods, tbr) + tbr = slicex.Map[string, string](tbr, func(s string) string { return strings.TrimPrefix(s, "!") }) + + return slicex.Subtract(methods, tbr), nil +} + +func createPathMatcher( + globExpression string, regexExpression string, slashHandling EncodedSlashesHandling, +) (*pathMatcher, error) { + matcher, err := createPatternMatcher(globExpression, '/', regexExpression) + if err != nil { + return nil, errorchain.NewWithMessage(heimdall.ErrConfiguration, + "filed to compile path expression").CausedBy(err) + } + + return &pathMatcher{matcher, slashHandling}, nil +} + +func createHostMatcher(globExpression string, regexExpression string) (*hostMatcher, error) { + matcher, err := createPatternMatcher(globExpression, '.', regexExpression) + if err != nil { + return nil, errorchain.NewWithMessage(heimdall.ErrConfiguration, + "filed to compile host expression").CausedBy(err) + } + + return &hostMatcher{matcher}, nil +} + +func createPatternMatcher(globExpression string, globSeparator rune, regexExpression string) (patternMatcher, error) { + glob := spaceReplacer.Replace(globExpression) + regex := spaceReplacer.Replace(regexExpression) + + switch { + case len(glob) != 0: + return newGlobMatcher(glob, globSeparator) + case len(regex) != 0: + return newRegexMatcher(regex) + default: + return alwaysMatcher{}, nil + } +} diff --git a/internal/rules/config/request_matcher_test.go b/internal/rules/config/request_matcher_test.go new file mode 100644 index 000000000..d98c23069 --- /dev/null +++ b/internal/rules/config/request_matcher_test.go @@ -0,0 +1,389 @@ +package config + +import ( + "net/http" + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/dadrus/heimdall/internal/heimdall" +) + +func TestCreateMethodMatcher(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + uc string + configured []string + expected methodMatcher + shouldError bool + }{ + { + uc: "empty configuration", + expected: methodMatcher{}, + }, + { + uc: "empty method in list", + configured: []string{"FOO", ""}, + shouldError: true, + }, + { + uc: "duplicates should be removed", + configured: []string{"BAR", "BAZ", "BAZ", "FOO", "FOO", "ZAB"}, + expected: methodMatcher{"BAR", "BAZ", "FOO", "ZAB"}, + }, + { + uc: "only ALL configured", + configured: []string{"ALL"}, + expected: methodMatcher{ + http.MethodConnect, http.MethodDelete, http.MethodGet, http.MethodHead, http.MethodOptions, + http.MethodPatch, http.MethodPost, http.MethodPut, http.MethodTrace, + }, + }, + { + uc: "ALL without POST and TRACE", + configured: []string{"ALL", "!POST", "!TRACE"}, + expected: methodMatcher{ + http.MethodConnect, http.MethodDelete, http.MethodGet, http.MethodHead, + http.MethodOptions, http.MethodPatch, http.MethodPut, + }, + }, + { + uc: "ALL with duplicates and without POST and TRACE", + configured: []string{"ALL", "GET", "!POST", "!TRACE", "!TRACE"}, + expected: methodMatcher{ + http.MethodConnect, http.MethodDelete, http.MethodGet, http.MethodHead, + http.MethodOptions, http.MethodPatch, http.MethodPut, + }, + }, + } { + t.Run(tc.uc, func(t *testing.T) { + // WHEN + res, err := createMethodMatcher(tc.configured) + + // THEN + if tc.shouldError { + require.Error(t, err) + } else { + require.Equal(t, tc.expected, res) + } + }) + } +} + +func TestCreatePathMatcher(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + uc string + glob string + regex string + assert func(t *testing.T, mather *pathMatcher, err error) + }{ + { + uc: "empty configuration", + assert: func(t *testing.T, mather *pathMatcher, err error) { + t.Helper() + + require.NoError(t, err) + assert.IsType(t, alwaysMatcher{}, mather.patternMatcher) + }, + }, + { + uc: "valid glob expression", + glob: "/**", + assert: func(t *testing.T, mather *pathMatcher, err error) { + t.Helper() + + require.NoError(t, err) + assert.IsType(t, &globMatcher{}, mather.patternMatcher) + }, + }, + { + uc: "invalid glob expression", + glob: "!*][)(*", + assert: func(t *testing.T, _ *pathMatcher, err error) { + t.Helper() + + require.Error(t, err) + }, + }, + { + uc: "valid regex expression", + regex: ".*", + assert: func(t *testing.T, mather *pathMatcher, err error) { + t.Helper() + + require.NoError(t, err) + assert.IsType(t, ®expMatcher{}, mather.patternMatcher) + }, + }, + { + uc: "invalid regex expression", + regex: "?>?<*??", + assert: func(t *testing.T, _ *pathMatcher, err error) { + t.Helper() + + require.Error(t, err) + }, + }, + } { + t.Run(tc.uc, func(t *testing.T) { + hm, err := createPathMatcher(tc.glob, tc.regex, EncodedSlashesOnNoDecode) + + tc.assert(t, hm, err) + }) + } +} + +func TestCreateHostMatcher(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + uc string + glob string + regex string + assert func(t *testing.T, mather *hostMatcher, err error) + }{ + { + uc: "empty configuration", + assert: func(t *testing.T, mather *hostMatcher, err error) { + t.Helper() + + require.NoError(t, err) + assert.IsType(t, alwaysMatcher{}, mather.patternMatcher) + }, + }, + { + uc: "valid glob expression", + glob: "/**", + assert: func(t *testing.T, mather *hostMatcher, err error) { + t.Helper() + + require.NoError(t, err) + assert.IsType(t, &globMatcher{}, mather.patternMatcher) + }, + }, + { + uc: "invalid glob expression", + glob: "!*][)(*", + assert: func(t *testing.T, _ *hostMatcher, err error) { + t.Helper() + + require.Error(t, err) + }, + }, + { + uc: "valid regex expression", + regex: ".*", + assert: func(t *testing.T, mather *hostMatcher, err error) { + t.Helper() + + require.NoError(t, err) + assert.IsType(t, ®expMatcher{}, mather.patternMatcher) + }, + }, + { + uc: "invalid regex expression", + regex: "?>?<*??", + assert: func(t *testing.T, _ *hostMatcher, err error) { + t.Helper() + + require.Error(t, err) + }, + }, + } { + t.Run(tc.uc, func(t *testing.T) { + hm, err := createHostMatcher(tc.glob, tc.regex) + + tc.assert(t, hm, err) + }) + } +} + +func TestSchemeMatcherMatches(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + uc string + matcher schemeMatcher + toMatch string + matches bool + }{ + {uc: "matches any schemes", matcher: schemeMatcher(""), toMatch: "foo", matches: true}, + {uc: "matches", matcher: schemeMatcher("http"), toMatch: "http", matches: true}, + {uc: "does not match", matcher: schemeMatcher("http"), toMatch: "https", matches: false}, + } { + t.Run(tc.uc, func(t *testing.T) { + err := tc.matcher.Matches(&heimdall.Request{URL: &heimdall.URL{URL: url.URL{Scheme: tc.toMatch}}}) + + if tc.matches { + require.NoError(t, err) + } else { + require.Error(t, err) + } + }) + } +} + +func TestMethodMatcherMatches(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + uc string + matcher methodMatcher + toMatch string + matches bool + }{ + {uc: "matches any methods", matcher: methodMatcher{}, toMatch: "GET", matches: true}, + {uc: "matches", matcher: methodMatcher{"GET"}, toMatch: "GET", matches: true}, + {uc: "does not match", matcher: methodMatcher{"GET"}, toMatch: "POST", matches: false}, + } { + t.Run(tc.uc, func(t *testing.T) { + err := tc.matcher.Matches(&heimdall.Request{Method: tc.toMatch}) + + if tc.matches { + require.NoError(t, err) + } else { + require.Error(t, err) + } + }) + } +} + +func TestHostMatcherMatches(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + uc string + expression string + toMatch string + matches bool + }{ + {uc: "matches any host", expression: "**", toMatch: "foo.example.com", matches: true}, + {uc: "matches", expression: "example.com", toMatch: "example.com", matches: true}, + {uc: "does not match", expression: "example.com", toMatch: "foo.example.com", matches: false}, + } { + t.Run(tc.uc, func(t *testing.T) { + hm, err := createHostMatcher(tc.expression, "") + require.NoError(t, err) + + err = hm.Matches(&heimdall.Request{URL: &heimdall.URL{URL: url.URL{Host: tc.toMatch}}}) + + if tc.matches { + require.NoError(t, err) + } else { + require.Error(t, err) + } + }) + } +} + +func TestPathMatcherMatches(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + uc string + expression string + slashEncoding EncodedSlashesHandling + toMatch string + matches bool + }{ + { + uc: "matches any path", + slashEncoding: EncodedSlashesOn, + toMatch: "foo.example.com", + matches: true, + }, + { + uc: "matches path containing encoded slash with slash encoding on", + expression: "/foo/bar/*", + slashEncoding: EncodedSlashesOn, + toMatch: "foo%2Fbar/baz", + matches: true, + }, + { + uc: "matches path containing encoded slash without slash decoding", + expression: "/foo%2Fbar/*", + slashEncoding: EncodedSlashesOnNoDecode, + toMatch: "foo%2Fbar/baz", + matches: true, + }, + { + uc: "does not match path containing encoded slash with slash encoding on", + expression: "foo/bar", + slashEncoding: EncodedSlashesOn, + toMatch: "foo%2Fbar/baz", + matches: false, + }, + { + uc: "does not match path containing encoded slash without slash encoding", + expression: "foo%2Fbar", + slashEncoding: EncodedSlashesOnNoDecode, + toMatch: "foo%2Fbar/baz", + matches: false, + }, + } { + t.Run(tc.uc, func(t *testing.T) { + hm, err := createPathMatcher(tc.expression, "", tc.slashEncoding) + require.NoError(t, err) + + uri, err := url.Parse("https://example.com/" + tc.toMatch) + require.NoError(t, err) + + err = hm.Matches(&heimdall.Request{URL: &heimdall.URL{URL: *uri}}) + + if tc.matches { + require.NoError(t, err) + } else { + require.Error(t, err) + } + }) + } +} + +func TestCompositeMatcherMatches(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + uc string + matcher compositeMatcher + method string + scheme string + matches bool + }{ + { + uc: "matches anything", + matcher: compositeMatcher{}, + method: "GET", + scheme: "foo", + matches: true, + }, + { + uc: "matches", + matcher: compositeMatcher{methodMatcher{"GET"}, schemeMatcher("https")}, + method: "GET", + scheme: "https", + matches: true, + }, + { + uc: "does not match", + matcher: compositeMatcher{methodMatcher{"POST"}}, + method: "GET", + scheme: "https", + matches: false, + }, + } { + t.Run(tc.uc, func(t *testing.T) { + err := tc.matcher.Matches(&heimdall.Request{Method: tc.method, URL: &heimdall.URL{URL: url.URL{Scheme: tc.scheme}}}) + + if tc.matches { + require.NoError(t, err) + } else { + require.Error(t, err) + } + }) + } +} diff --git a/internal/rules/config/rule.go b/internal/rules/config/rule.go index 8648fdaf7..ed1e9b10e 100644 --- a/internal/rules/config/rule.go +++ b/internal/rules/config/rule.go @@ -17,46 +17,50 @@ package config import ( - "github.com/dadrus/heimdall/internal/config" -) + "crypto" + "fmt" -type EncodedSlashesHandling string + "github.com/goccy/go-json" -const ( - EncodedSlashesOff EncodedSlashesHandling = "off" - EncodedSlashesOn EncodedSlashesHandling = "on" - EncodedSlashesNoDecode EncodedSlashesHandling = "no_decode" + "github.com/dadrus/heimdall/internal/config" + "github.com/dadrus/heimdall/internal/heimdall" ) type Rule struct { - ID string `json:"id" yaml:"id"` + ID string `json:"id" yaml:"id" validate:"required"` //nolint:lll,tagalign EncodedSlashesHandling EncodedSlashesHandling `json:"allow_encoded_slashes" yaml:"allow_encoded_slashes" validate:"omitempty,oneof=off on no_decode"` //nolint:lll,tagalign - RuleMatcher Matcher `json:"match" yaml:"match"` - Backend *Backend `json:"forward_to" yaml:"forward_to"` - Methods []string `json:"methods" yaml:"methods"` - Execute []config.MechanismConfig `json:"execute" yaml:"execute"` + Matcher Matcher `json:"match" yaml:"match" validate:"required"` //nolint:lll,tagalign + Backend *Backend `json:"forward_to" yaml:"forward_to" validate:"omitnil"` //nolint:lll,tagalign + Execute []config.MechanismConfig `json:"execute" yaml:"execute" validate:"gt=0,dive,required"` //nolint:lll,tagalign ErrorHandler []config.MechanismConfig `json:"on_error" yaml:"on_error"` } -func (in *Rule) DeepCopyInto(out *Rule) { - *out = *in - out.RuleMatcher = in.RuleMatcher +func (r *Rule) Hash() ([]byte, error) { + rawRuleConfig, err := json.Marshal(r) + if err != nil { + return nil, fmt.Errorf("%w: failed to create hash", heimdall.ErrInternal) + } - if in.Backend != nil { - in, out := in.Backend, out.Backend + md := crypto.SHA256.New() + md.Write(rawRuleConfig) - in.DeepCopyInto(out) - } + return md.Sum(nil), nil +} + +func (r *Rule) DeepCopyInto(out *Rule) { + *out = *r - if in.Methods != nil { - in, out := &in.Methods, &out.Methods + inm, outm := &r.Matcher, &out.Matcher + inm.DeepCopyInto(outm) - *out = make([]string, len(*in)) - copy(*out, *in) + if r.Backend != nil { + in, out := r.Backend, out.Backend + + in.DeepCopyInto(out) } - if in.Execute != nil { - in, out := &in.Execute, &out.Execute + if r.Execute != nil { + in, out := &r.Execute, &out.Execute *out = make([]config.MechanismConfig, len(*in)) for i := range *in { @@ -64,8 +68,8 @@ func (in *Rule) DeepCopyInto(out *Rule) { } } - if in.ErrorHandler != nil { - in, out := &in.ErrorHandler, &out.ErrorHandler + if r.ErrorHandler != nil { + in, out := &r.ErrorHandler, &out.ErrorHandler *out = make([]config.MechanismConfig, len(*in)) for i := range *in { @@ -74,13 +78,13 @@ func (in *Rule) DeepCopyInto(out *Rule) { } } -func (in *Rule) DeepCopy() *Rule { - if in == nil { +func (r *Rule) DeepCopy() *Rule { + if r == nil { return nil } out := new(Rule) - in.DeepCopyInto(out) + r.DeepCopyInto(out) return out } diff --git a/internal/rules/config/rule_set.go b/internal/rules/config/rule_set.go index 8e1b33524..d52f73d47 100644 --- a/internal/rules/config/rule_set.go +++ b/internal/rules/config/rule_set.go @@ -17,11 +17,7 @@ package config import ( - "strings" "time" - - "github.com/dadrus/heimdall/internal/heimdall" - "github.com/dadrus/heimdall/internal/x/errorchain" ) type MetaData struct { @@ -33,23 +29,7 @@ type MetaData struct { type RuleSet struct { MetaData - Version string `json:"version" yaml:"version"` + Version string `json:"version" yaml:"version" validate:"required"` //nolint:tagalign Name string `json:"name" yaml:"name"` - Rules []Rule `json:"rules" validate:"dive" yaml:"rules"` -} - -func (rs RuleSet) VerifyPathPrefix(prefix string) error { - for _, rule := range rs.Rules { - if strings.HasPrefix(rule.RuleMatcher.URL, "/") && - // only path is specified - !strings.HasPrefix(rule.RuleMatcher.URL, prefix) || - // patterns are specified before the path - // There should be a better way to check it - !strings.Contains(rule.RuleMatcher.URL, prefix) { - return errorchain.NewWithMessage(heimdall.ErrConfiguration, - "path prefix validation failed for rule ID=%s") - } - } - - return nil + Rules []Rule `json:"rules" yaml:"rules" validate:"gt=0,dive,required"` //nolint:tagalign } diff --git a/internal/rules/config/rule_set_test.go b/internal/rules/config/rule_set_test.go deleted file mode 100644 index 03002dfb3..000000000 --- a/internal/rules/config/rule_set_test.go +++ /dev/null @@ -1,55 +0,0 @@ -// Copyright 2023 Dimitrij Drus -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// -// SPDX-License-Identifier: Apache-2.0 - -package config - -import ( - "testing" - - "github.com/stretchr/testify/require" -) - -func TestRuleSetConfigurationVerifyPathPrefixPathPrefixVerify(t *testing.T) { - t.Parallel() - - for _, tc := range []struct { - uc string - prefix string - url string - fail bool - }{ - {uc: "path only and without required prefix", prefix: "/foo/bar", url: "/bar/foo/moo", fail: true}, - {uc: "path only with required prefix", prefix: "/foo/bar", url: "/foo/bar/moo", fail: false}, - {uc: "full url and without required prefix", prefix: "/foo/bar", url: "https://<**>/bar/foo/moo", fail: true}, - {uc: "full url with required prefix", prefix: "/foo/bar", url: "https://<**>/foo/bar/moo", fail: false}, - } { - t.Run(tc.uc, func(t *testing.T) { - // GIVEN - rs := RuleSet{ - Rules: []Rule{{RuleMatcher: Matcher{URL: tc.url}}}, - } - - // WHEN - err := rs.VerifyPathPrefix(tc.prefix) - - if tc.fail { - require.Error(t, err) - } else { - require.NoError(t, err) - } - }) - } -} diff --git a/internal/rules/config/rule_test.go b/internal/rules/config/rule_test.go index 42d653976..e97f955a2 100644 --- a/internal/rules/config/rule_test.go +++ b/internal/rules/config/rule_test.go @@ -33,9 +33,16 @@ func TestRuleConfigDeepCopyInto(t *testing.T) { in := Rule{ ID: "foo", - RuleMatcher: Matcher{ - URL: "bar", - Strategy: "glob", + Matcher: Matcher{ + Path: "bar", + With: &MatcherConstraints{ + Methods: []string{"GET", "PATCH"}, + Scheme: "https", + HostGlob: "**.example.com", + HostRegex: ".*\\.example.com", + PathGlob: "**.css", + PathRegex: ".*\\.css", + }, }, Backend: &Backend{ Host: "baz", @@ -46,7 +53,6 @@ func TestRuleConfigDeepCopyInto(t *testing.T) { QueryParamsToRemove: []string{"baz"}, }, }, - Methods: []string{"GET", "PATCH"}, Execute: []config.MechanismConfig{{"foo": "bar"}}, ErrorHandler: []config.MechanismConfig{{"bar": "foo"}}, } @@ -56,10 +62,14 @@ func TestRuleConfigDeepCopyInto(t *testing.T) { // THEN assert.Equal(t, in.ID, out.ID) - assert.Equal(t, in.RuleMatcher.URL, out.RuleMatcher.URL) + assert.Equal(t, in.Matcher.Path, out.Matcher.Path) + assert.Equal(t, in.Matcher.With.Methods, out.Matcher.With.Methods) + assert.Equal(t, in.Matcher.With.Scheme, out.Matcher.With.Scheme) + assert.Equal(t, in.Matcher.With.HostGlob, out.Matcher.With.HostGlob) + assert.Equal(t, in.Matcher.With.HostRegex, out.Matcher.With.HostRegex) + assert.Equal(t, in.Matcher.With.PathGlob, out.Matcher.With.PathGlob) + assert.Equal(t, in.Matcher.With.PathRegex, out.Matcher.With.PathRegex) assert.Equal(t, in.Backend, out.Backend) - assert.Equal(t, in.RuleMatcher.Strategy, out.RuleMatcher.Strategy) - assert.Equal(t, in.Methods, out.Methods) assert.Equal(t, in.Execute, out.Execute) assert.Equal(t, in.ErrorHandler, out.ErrorHandler) } @@ -70,9 +80,16 @@ func TestRuleConfigDeepCopy(t *testing.T) { // GIVEN in := Rule{ ID: "foo", - RuleMatcher: Matcher{ - URL: "bar", - Strategy: "glob", + Matcher: Matcher{ + Path: "bar", + With: &MatcherConstraints{ + Methods: []string{"GET", "PATCH"}, + Scheme: "https", + HostGlob: "**.example.com", + HostRegex: ".*\\.example.com", + PathGlob: "**.css", + PathRegex: ".*\\.css", + }, }, Backend: &Backend{ Host: "baz", @@ -83,7 +100,6 @@ func TestRuleConfigDeepCopy(t *testing.T) { QueryParamsToRemove: []string{"baz"}, }, }, - Methods: []string{"GET", "PATCH"}, Execute: []config.MechanismConfig{{"foo": "bar"}}, ErrorHandler: []config.MechanismConfig{{"bar": "foo"}}, } @@ -97,10 +113,14 @@ func TestRuleConfigDeepCopy(t *testing.T) { // but same contents assert.Equal(t, in.ID, out.ID) - assert.Equal(t, in.RuleMatcher.URL, out.RuleMatcher.URL) + assert.Equal(t, in.Matcher.Path, out.Matcher.Path) + assert.Equal(t, in.Matcher.With.Methods, out.Matcher.With.Methods) + assert.Equal(t, in.Matcher.With.Scheme, out.Matcher.With.Scheme) + assert.Equal(t, in.Matcher.With.HostGlob, out.Matcher.With.HostGlob) + assert.Equal(t, in.Matcher.With.HostRegex, out.Matcher.With.HostRegex) + assert.Equal(t, in.Matcher.With.PathGlob, out.Matcher.With.PathGlob) + assert.Equal(t, in.Matcher.With.PathRegex, out.Matcher.With.PathRegex) assert.Equal(t, in.Backend, out.Backend) - assert.Equal(t, in.RuleMatcher.Strategy, out.RuleMatcher.Strategy) - assert.Equal(t, in.Methods, out.Methods) assert.Equal(t, in.Execute, out.Execute) assert.Equal(t, in.ErrorHandler, out.ErrorHandler) } diff --git a/internal/rules/config/version.go b/internal/rules/config/version.go index 96168624e..9cbd87d6b 100644 --- a/internal/rules/config/version.go +++ b/internal/rules/config/version.go @@ -16,4 +16,4 @@ package config -const CurrentRuleSetVersion = "1alpha3" +const CurrentRuleSetVersion = "1alpha4" diff --git a/internal/rules/event/event.go b/internal/rules/event/event.go deleted file mode 100644 index 8b84813f3..000000000 --- a/internal/rules/event/event.go +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright 2022 Dimitrij Drus -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// -// SPDX-License-Identifier: Apache-2.0 - -package event - -import ( - "github.com/dadrus/heimdall/internal/rules/rule" -) - -type ChangeType uint32 - -// These are the generalized file operations that can trigger a notification. -const ( - Create ChangeType = 1 << iota - Remove - Update -) - -func (t ChangeType) String() string { - switch t { - case Create: - return "Create" - case Remove: - return "Remove" - case Update: - return "Update" - default: - return "Unknown" - } -} - -type RuleSetChanged struct { - Source string - Name string - Rules []rule.Rule - ChangeType ChangeType -} diff --git a/internal/rules/event/queue.go b/internal/rules/event/queue.go deleted file mode 100644 index c03d20352..000000000 --- a/internal/rules/event/queue.go +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright 2022 Dimitrij Drus -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// -// SPDX-License-Identifier: Apache-2.0 - -package event - -type RuleSetChangedEventQueue chan RuleSetChanged diff --git a/internal/rules/mechanisms/authenticators/extractors/composite_extract_strategy_test.go b/internal/rules/mechanisms/authenticators/extractors/composite_extract_strategy_test.go index c7fca593c..bf29ebdcf 100644 --- a/internal/rules/mechanisms/authenticators/extractors/composite_extract_strategy_test.go +++ b/internal/rules/mechanisms/authenticators/extractors/composite_extract_strategy_test.go @@ -70,7 +70,7 @@ func TestCompositeExtractHeaderValueWithScheme(t *testing.T) { ctx := mocks.NewContextMock(t) ctx.EXPECT().Request().Return(&heimdall.Request{ RequestFunctions: fnt, - URL: &url.URL{}, + URL: &heimdall.URL{URL: url.URL{}}, }) strategy := CompositeExtractStrategy{ diff --git a/internal/rules/mechanisms/authenticators/extractors/query_parameter_extract_strategy_test.go b/internal/rules/mechanisms/authenticators/extractors/query_parameter_extract_strategy_test.go index 3c98c86b7..8ff44b20c 100644 --- a/internal/rules/mechanisms/authenticators/extractors/query_parameter_extract_strategy_test.go +++ b/internal/rules/mechanisms/authenticators/extractors/query_parameter_extract_strategy_test.go @@ -40,7 +40,7 @@ func TestExtractQueryParameter(t *testing.T) { ctx := mocks.NewContextMock(t) ctx.EXPECT().Request().Return(&heimdall.Request{ RequestFunctions: fnt, - URL: &url.URL{RawQuery: fmt.Sprintf("%s=%s", queryParam, queryParamValue)}, + URL: &heimdall.URL{URL: url.URL{RawQuery: fmt.Sprintf("%s=%s", queryParam, queryParamValue)}}, }) strategy := QueryParameterExtractStrategy{Name: queryParam} @@ -62,7 +62,7 @@ func TestExtractNotExistingQueryParameterValue(t *testing.T) { ctx := mocks.NewContextMock(t) ctx.EXPECT().Request().Return(&heimdall.Request{ RequestFunctions: fnt, - URL: &url.URL{}, + URL: &heimdall.URL{}, }) strategy := QueryParameterExtractStrategy{Name: "Test-Cookie"} diff --git a/internal/rules/mechanisms/authorizers/cel_authorizer_test.go b/internal/rules/mechanisms/authorizers/cel_authorizer_test.go index 904f7f868..7fb9f77ab 100644 --- a/internal/rules/mechanisms/authorizers/cel_authorizer_test.go +++ b/internal/rules/mechanisms/authorizers/cel_authorizer_test.go @@ -290,6 +290,7 @@ expressions: - expression: Request.Cookie("FooCookie") == "barfoo" - expression: Request.URL.String() == "http://localhost/test?foo=bar&baz=zab" - expression: Request.URL.Path.split("/").last() == "test" + - expression: Request.URL.Captures.foo == "bar" `), configureContextAndSubject: func(t *testing.T, ctx *mocks.ContextMock, sub *subject.Subject) { t.Helper() @@ -308,11 +309,14 @@ expressions: ctx.EXPECT().Request().Return(&heimdall.Request{ RequestFunctions: reqf, Method: http.MethodGet, - URL: &url.URL{ - Scheme: "http", - Host: "localhost", - Path: "/test", - RawQuery: "foo=bar&baz=zab", + URL: &heimdall.URL{ + URL: url.URL{ + Scheme: "http", + Host: "localhost", + Path: "/test", + RawQuery: "foo=bar&baz=zab", + }, + Captures: map[string]string{"foo": "bar"}, }, ClientIPAddresses: []string{"127.0.0.1", "10.10.10.10"}, }) diff --git a/internal/rules/mechanisms/authorizers/remote_authorizer_test.go b/internal/rules/mechanisms/authorizers/remote_authorizer_test.go index 0f17e4771..ca2f64961 100644 --- a/internal/rules/mechanisms/authorizers/remote_authorizer_test.go +++ b/internal/rules/mechanisms/authorizers/remote_authorizer_test.go @@ -201,7 +201,7 @@ values: "Subject": &subject.Subject{ID: "bar"}, "Request": &heimdall.Request{ RequestFunctions: rfunc, - URL: &url.URL{Scheme: "http", Host: "foo.bar", Path: "/foo/bar"}, + URL: &heimdall.URL{URL: url.URL{Scheme: "http", Host: "foo.bar", Path: "/foo/bar"}}, }, }) require.NoError(t, err) diff --git a/internal/rules/mechanisms/cellib/requests_test.go b/internal/rules/mechanisms/cellib/requests_test.go index e9bbaaa06..a1913e400 100644 --- a/internal/rules/mechanisms/cellib/requests_test.go +++ b/internal/rules/mechanisms/cellib/requests_test.go @@ -38,8 +38,8 @@ func TestRequests(t *testing.T) { ) require.NoError(t, err) - rawURI := "http://localhost/foo/bar?foo=bar&foo=baz&bar=foo" - uri, err := url.Parse("http://localhost/foo/bar?foo=bar&foo=baz&bar=foo") + rawURI := "http://localhost:8080/foo/bar?foo=bar&foo=baz&bar=foo" + uri, err := url.Parse(rawURI) require.NoError(t, err) reqf := mocks.NewRequestFunctionsMock(t) @@ -50,9 +50,12 @@ func TestRequests(t *testing.T) { reqf.EXPECT().Body().Return(map[string]any{"foo": []any{"bar"}}) req := &heimdall.Request{ - RequestFunctions: reqf, - Method: http.MethodHead, - URL: uri, + RequestFunctions: reqf, + Method: http.MethodHead, + URL: &heimdall.URL{ + URL: *uri, + Captures: map[string]string{"foo": "bar"}, + }, ClientIPAddresses: []string{"127.0.0.1"}, } @@ -61,6 +64,11 @@ func TestRequests(t *testing.T) { }{ {expr: `Request.Method == "HEAD"`}, {expr: `Request.URL.String() == "` + rawURI + `"`}, + {expr: `Request.URL.Captures.foo == "bar"`}, + {expr: `Request.URL.Query().bar == ["foo"]`}, + {expr: `Request.URL.Host == "localhost:8080"`}, + {expr: `Request.URL.Hostname() == "localhost"`}, + {expr: `Request.URL.Port() == "8080"`}, {expr: `Request.Cookie("foo") == "bar"`}, {expr: `Request.Header("bar") == "baz"`}, {expr: `Request.Header("zab").contains("bar")`}, diff --git a/internal/rules/mechanisms/cellib/urls.go b/internal/rules/mechanisms/cellib/urls.go index 33f81bb5b..b9f6bf989 100644 --- a/internal/rules/mechanisms/cellib/urls.go +++ b/internal/rules/mechanisms/cellib/urls.go @@ -17,7 +17,6 @@ package cellib import ( - "net/url" "reflect" "github.com/google/cel-go/cel" @@ -25,6 +24,8 @@ import ( "github.com/google/cel-go/common/types/ref" "github.com/google/cel-go/common/types/traits" "github.com/google/cel-go/ext" + + "github.com/dadrus/heimdall/internal/heimdall" ) func Urls() cel.EnvOption { @@ -42,16 +43,16 @@ func (urlsLib) ProgramOptions() []cel.ProgramOption { } func (urlsLib) CompileOptions() []cel.EnvOption { - urlType := cel.ObjectType(reflect.TypeOf(url.URL{}).String(), traits.ReceiverType) + urlType := cel.ObjectType(reflect.TypeOf(heimdall.URL{}).String(), traits.ReceiverType) return []cel.EnvOption{ - ext.NativeTypes(reflect.TypeOf(&url.URL{})), + ext.NativeTypes(reflect.TypeOf(&heimdall.URL{})), cel.Function("String", cel.MemberOverload("url_String", []*cel.Type{urlType}, cel.StringType, cel.UnaryBinding(func(value ref.Val) ref.Val { // nolint: forcetypeassert - return types.String(value.Value().(*url.URL).String()) + return types.String(value.Value().(*heimdall.URL).String()) }), ), ), @@ -60,7 +61,25 @@ func (urlsLib) CompileOptions() []cel.EnvOption { []*cel.Type{urlType}, cel.MapType(types.StringType, cel.ListType(cel.StringType)), cel.UnaryBinding(func(value ref.Val) ref.Val { // nolint: forcetypeassert - return types.NewDynamicMap(types.DefaultTypeAdapter, value.Value().(*url.URL).Query()) + return types.NewDynamicMap(types.DefaultTypeAdapter, value.Value().(*heimdall.URL).Query()) + }), + ), + ), + cel.Function("Hostname", + cel.MemberOverload("url_Hostname", + []*cel.Type{urlType}, types.StringType, + cel.UnaryBinding(func(value ref.Val) ref.Val { + // nolint: forcetypeassert + return types.String(value.Value().(*heimdall.URL).Hostname()) + }), + ), + ), + cel.Function("Port", + cel.MemberOverload("url_Port", + []*cel.Type{urlType}, types.StringType, + cel.UnaryBinding(func(value ref.Val) ref.Val { + // nolint: forcetypeassert + return types.String(value.Value().(*heimdall.URL).Port()) }), ), ), diff --git a/internal/rules/mechanisms/cellib/urls_test.go b/internal/rules/mechanisms/cellib/urls_test.go index 447298c11..30e05663e 100644 --- a/internal/rules/mechanisms/cellib/urls_test.go +++ b/internal/rules/mechanisms/cellib/urls_test.go @@ -22,6 +22,8 @@ import ( "github.com/google/cel-go/cel" "github.com/stretchr/testify/require" + + "github.com/dadrus/heimdall/internal/heimdall" ) func TestUrls(t *testing.T) { @@ -33,8 +35,8 @@ func TestUrls(t *testing.T) { ) require.NoError(t, err) - rawURI := "http://localhost/foo/bar?foo=bar&foo=baz&bar=foo" - uri, err := url.Parse("http://localhost/foo/bar?foo=bar&foo=baz&bar=foo") + rawURI := "http://localhost:8080/foo/bar?foo=bar&foo=baz&bar=foo" + uri, err := url.Parse(rawURI) require.NoError(t, err) for _, tc := range []struct { @@ -43,6 +45,11 @@ func TestUrls(t *testing.T) { {expr: `uri.String() == "` + rawURI + `"`}, {expr: `uri.Query() == {"foo":["bar", "baz"], "bar": ["foo"]}`}, {expr: `uri.Query().bar == ["foo"]`}, + {expr: `uri.Host == "localhost:8080"`}, + {expr: `uri.Hostname() == "localhost"`}, + {expr: `uri.Port() == "8080"`}, + {expr: `uri.Captures.zab == "baz"`}, + {expr: `uri.Path == "/foo/bar"`}, } { t.Run(tc.expr, func(t *testing.T) { ast, iss := env.Compile(tc.expr) @@ -58,7 +65,7 @@ func TestUrls(t *testing.T) { prg, err := env.Program(ast, cel.EvalOptions(cel.OptOptimize)) require.NoError(t, err) - out, _, err := prg.Eval(map[string]any{"uri": uri}) + out, _, err := prg.Eval(map[string]any{"uri": &heimdall.URL{URL: *uri, Captures: map[string]string{"zab": "baz"}}}) require.NoError(t, err) require.Equal(t, true, out.Value()) //nolint:testifylint }) diff --git a/internal/rules/mechanisms/contextualizers/generic_contextualizer_test.go b/internal/rules/mechanisms/contextualizers/generic_contextualizer_test.go index 0ef8da5d1..19d47cc31 100644 --- a/internal/rules/mechanisms/contextualizers/generic_contextualizer_test.go +++ b/internal/rules/mechanisms/contextualizers/generic_contextualizer_test.go @@ -873,7 +873,7 @@ func TestGenericContextualizerExecute(t *testing.T) { &heimdall.Request{ RequestFunctions: reqf, Method: http.MethodPost, - URL: &url.URL{Scheme: "http", Host: "foobar.baz", Path: "zab"}, + URL: &heimdall.URL{URL: url.URL{Scheme: "http", Host: "foobar.baz", Path: "zab"}}, }) }, assert: func(t *testing.T, err error, sub *subject.Subject) { diff --git a/internal/rules/mechanisms/errorhandlers/redirect_error_handler_test.go b/internal/rules/mechanisms/errorhandlers/redirect_error_handler_test.go index 983c47b27..1c2b4bdaa 100644 --- a/internal/rules/mechanisms/errorhandlers/redirect_error_handler_test.go +++ b/internal/rules/mechanisms/errorhandlers/redirect_error_handler_test.go @@ -141,7 +141,9 @@ if: type(Error) == authentication_error ctx := mocks.NewContextMock(t) ctx.EXPECT().Request(). - Return(&heimdall.Request{URL: &url.URL{Scheme: "http", Host: "foobar.baz", Path: "zab"}}) + Return(&heimdall.Request{ + URL: &heimdall.URL{URL: url.URL{Scheme: "http", Host: "foobar.baz", Path: "zab"}}, + }) toURL, err := redEH.to.Render(map[string]any{ "Request": ctx.Request(), @@ -382,7 +384,7 @@ if: type(Error) == authentication_error requestURL, err := url.Parse("http://test.org") require.NoError(t, err) - ctx.EXPECT().Request().Return(&heimdall.Request{URL: requestURL}) + ctx.EXPECT().Request().Return(&heimdall.Request{URL: &heimdall.URL{URL: *requestURL}}) ctx.EXPECT().SetPipelineError(mock.MatchedBy(func(redirErr *heimdall.RedirectError) bool { t.Helper() diff --git a/internal/rules/mechanisms/template/template_test.go b/internal/rules/mechanisms/template/template_test.go index 137629c8b..9a505f307 100644 --- a/internal/rules/mechanisms/template/template_test.go +++ b/internal/rules/mechanisms/template/template_test.go @@ -40,9 +40,11 @@ func TestTemplateRender(t *testing.T) { ctx := mocks.NewContextMock(t) ctx.EXPECT().Request().Return(&heimdall.Request{ - RequestFunctions: reqf, - Method: http.MethodPatch, - URL: &url.URL{Scheme: "http", Host: "foobar.baz", Path: "zab", RawQuery: "my_query_param=query_value"}, + RequestFunctions: reqf, + Method: http.MethodPatch, + URL: &heimdall.URL{ + URL: url.URL{Scheme: "http", Host: "foobar.baz", Path: "zab", RawQuery: "my_query_param=query_value"}, + }, ClientIPAddresses: []string{"192.168.1.1"}, }) diff --git a/internal/rules/mocks/pattern_matcher.go b/internal/rules/mocks/pattern_matcher.go new file mode 100644 index 000000000..82b834990 --- /dev/null +++ b/internal/rules/mocks/pattern_matcher.go @@ -0,0 +1,78 @@ +// Code generated by mockery v2.42.1. DO NOT EDIT. + +package mocks + +import mock "github.com/stretchr/testify/mock" + +// PatternMatcherMock is an autogenerated mock type for the patternMatcher type +type PatternMatcherMock struct { + mock.Mock +} + +type PatternMatcherMock_Expecter struct { + mock *mock.Mock +} + +func (_m *PatternMatcherMock) EXPECT() *PatternMatcherMock_Expecter { + return &PatternMatcherMock_Expecter{mock: &_m.Mock} +} + +// Match provides a mock function with given fields: pattern +func (_m *PatternMatcherMock) Match(pattern string) bool { + ret := _m.Called(pattern) + + if len(ret) == 0 { + panic("no return value specified for Match") + } + + var r0 bool + if rf, ok := ret.Get(0).(func(string) bool); ok { + r0 = rf(pattern) + } else { + r0 = ret.Get(0).(bool) + } + + return r0 +} + +// PatternMatcherMock_Match_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Match' +type PatternMatcherMock_Match_Call struct { + *mock.Call +} + +// Match is a helper method to define mock.On call +// - pattern string +func (_e *PatternMatcherMock_Expecter) Match(pattern interface{}) *PatternMatcherMock_Match_Call { + return &PatternMatcherMock_Match_Call{Call: _e.mock.On("Match", pattern)} +} + +func (_c *PatternMatcherMock_Match_Call) Run(run func(pattern string)) *PatternMatcherMock_Match_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *PatternMatcherMock_Match_Call) Return(_a0 bool) *PatternMatcherMock_Match_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *PatternMatcherMock_Match_Call) RunAndReturn(run func(string) bool) *PatternMatcherMock_Match_Call { + _c.Call.Return(run) + return _c +} + +// NewPatternMatcherMock creates a new instance of PatternMatcherMock. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewPatternMatcherMock(t interface { + mock.TestingT + Cleanup(func()) +}) *PatternMatcherMock { + mock := &PatternMatcherMock{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/internal/rules/module.go b/internal/rules/module.go index 9c8c6a090..602c6b1e6 100644 --- a/internal/rules/module.go +++ b/internal/rules/module.go @@ -17,45 +17,19 @@ package rules import ( - "context" - - "github.com/rs/zerolog" "go.uber.org/fx" - "github.com/dadrus/heimdall/internal/rules/event" "github.com/dadrus/heimdall/internal/rules/provider" - "github.com/dadrus/heimdall/internal/rules/rule" ) -const defaultQueueSize = 20 - // Module is invoked on app bootstrapping. // nolint: gochecknoglobals var Module = fx.Options( fx.Provide( - fx.Annotate( - func(logger zerolog.Logger) event.RuleSetChangedEventQueue { - logger.Debug().Msg("Creating rule set event queue.") - - return make(event.RuleSetChangedEventQueue, defaultQueueSize) - }, - fx.OnStop( - func(queue event.RuleSetChangedEventQueue, logger zerolog.Logger) { - logger.Debug().Msg("Closing rule set event queue") - - close(queue) - }, - ), - ), NewRuleFactory, - fx.Annotate( - newRepository, - fx.OnStart(func(ctx context.Context, o *repository) error { return o.Start(ctx) }), - fx.OnStop(func(ctx context.Context, o *repository) error { return o.Stop(ctx) }), - ), - func(r *repository) rule.Repository { return r }, - newRuleExecutor, + newRepository, NewRuleSetProcessor, + newRuleExecutor, ), provider.Module, ) diff --git a/internal/rules/patternmatcher/glob_matcher.go b/internal/rules/patternmatcher/glob_matcher.go deleted file mode 100644 index fa94009fe..000000000 --- a/internal/rules/patternmatcher/glob_matcher.go +++ /dev/null @@ -1,107 +0,0 @@ -// Copyright 2022 Dimitrij Drus -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// -// SPDX-License-Identifier: Apache-2.0 - -package patternmatcher - -import ( - "bytes" - "errors" - - "github.com/gobwas/glob" -) - -var ( - ErrUnbalancedPattern = errors.New("unbalanced pattern") - ErrNoGlobPatternDefined = errors.New("no glob pattern defined") -) - -type globMatcher struct { - compiled glob.Glob -} - -func (m *globMatcher) Match(value string) bool { - return m.compiled.Match(value) -} - -func newGlobMatcher(pattern string) (*globMatcher, error) { - if len(pattern) == 0 { - return nil, ErrNoGlobPatternDefined - } - - compiled, err := compileGlob(pattern, '<', '>') - if err != nil { - return nil, err - } - - return &globMatcher{compiled: compiled}, nil -} - -func compileGlob(pattern string, delimiterStart, delimiterEnd rune) (glob.Glob, error) { - // Check if it is well-formed. - idxs, errBraces := delimiterIndices(pattern, delimiterStart, delimiterEnd) - if errBraces != nil { - return nil, errBraces - } - - buffer := bytes.NewBufferString("") - - var end int - for ind := 0; ind < len(idxs); ind += 2 { - // Set all values we are interested in. - raw := pattern[end:idxs[ind]] - end = idxs[ind+1] - patt := pattern[idxs[ind]+1 : end-1] - - buffer.WriteString(glob.QuoteMeta(raw)) - buffer.WriteString(patt) - } - - // Add the remaining. - raw := pattern[end:] - buffer.WriteString(glob.QuoteMeta(raw)) - - // Compile full regexp. - return glob.Compile(buffer.String(), '.', '/') -} - -// delimiterIndices returns the first level delimiter indices from a string. -// It returns an error in case of unbalanced delimiters. -func delimiterIndices(value string, delimiterStart, delimiterEnd rune) ([]int, error) { - var level, idx int - - idxs := make([]int, 0) - - for ind := range len(value) { - switch value[ind] { - case byte(delimiterStart): - if level++; level == 1 { - idx = ind - } - case byte(delimiterEnd): - if level--; level == 0 { - idxs = append(idxs, idx, ind+1) - } else if level < 0 { - return nil, ErrUnbalancedPattern - } - } - } - - if level != 0 { - return nil, ErrUnbalancedPattern - } - - return idxs, nil -} diff --git a/internal/rules/patternmatcher/glob_matcher_test.go b/internal/rules/patternmatcher/glob_matcher_test.go deleted file mode 100644 index e4f2da732..000000000 --- a/internal/rules/patternmatcher/glob_matcher_test.go +++ /dev/null @@ -1,185 +0,0 @@ -// Copyright 2022 Dimitrij Drus -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// -// SPDX-License-Identifier: Apache-2.0 - -package patternmatcher - -import ( - "strconv" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestDelimiterIndices(t *testing.T) { - t.Parallel() - - for tn, tc := range []struct { - input string - out []int - err error - }{ - {input: "<", err: ErrUnbalancedPattern}, - {input: ">", err: ErrUnbalancedPattern}, - {input: ">>", err: ErrUnbalancedPattern}, - {input: "><>", err: ErrUnbalancedPattern}, - {input: "foo.barvar", err: ErrUnbalancedPattern}, - {input: "foo.bar>var", err: ErrUnbalancedPattern}, - {input: "foo.bar<<>>", out: []int{7, 11}}, - {input: "foo.bar<<>><>", out: []int{7, 11, 11, 13}}, - {input: "foo.bar<<>><>tt<>", out: []int{7, 11, 11, 13, 15, 17}}, - } { - t.Run(strconv.Itoa(tn), func(t *testing.T) { - out, err := delimiterIndices(tc.input, '<', '>') - assert.Equal(t, tc.out, out) - assert.Equal(t, tc.err, err) - }) - } -} - -func TestIsMatch(t *testing.T) { - t.Parallel() - - for _, tc := range []struct { - uc string - pattern string - matchAgainst string - shouldMatch bool - }{ - { - uc: "question mark1", - pattern: `urn:foo:`, - matchAgainst: "urn:foo:user", - shouldMatch: false, - }, - { - uc: "question mark2", - pattern: `urn:foo:`, - matchAgainst: "urn:foo:u", - shouldMatch: true, - }, - { - uc: "question mark3", - pattern: `urn:foo:`, - matchAgainst: "urn:foo:", - shouldMatch: false, - }, - { - uc: "question mark4", - pattern: `urn:foo:&&`, - matchAgainst: "urn:foo:w&&r", - shouldMatch: true, - }, - { - uc: "question mark5 - both as a special char and a literal", - pattern: `urn:foo:?`, - matchAgainst: "urn:foo:w&r", - shouldMatch: false, - }, - { - uc: "question mark5 - both as a special char and a literal1", - pattern: `urn:foo:?`, - matchAgainst: "urn:foo:w?r", - shouldMatch: true, - }, - { - uc: "asterisk", - pattern: `urn:foo:<*>`, - matchAgainst: "urn:foo:user", - shouldMatch: true, - }, - { - uc: "asterisk1", - pattern: `urn:foo:<*>`, - matchAgainst: "urn:foo:", - shouldMatch: true, - }, - { - uc: "asterisk2", - pattern: `urn:foo:<*>:<*>`, - matchAgainst: "urn:foo:usr:swen", - shouldMatch: true, - }, - { - uc: "asterisk: both as a special char and a literal", - pattern: `*:foo:<*>:<*>`, - matchAgainst: "urn:foo:usr:swen", - shouldMatch: false, - }, - { - uc: "asterisk: both as a special char and a literal1", - pattern: `*:foo:<*>:<*>`, - matchAgainst: "*:foo:usr:swen", - shouldMatch: true, - }, - { - uc: "asterisk + question mark", - pattern: `urn:foo:<*>:role:`, - matchAgainst: "urn:foo:usr:role:a", - shouldMatch: true, - }, - { - uc: "asterisk + question mark1", - pattern: `urn:foo:<*>:role:`, - matchAgainst: "urn:foo:usr:role:admin", - shouldMatch: false, - }, - { - uc: "square brackets", - pattern: `urn:foo:`, - matchAgainst: "urn:foo:moon", - shouldMatch: false, - }, - { - uc: "square brackets1", - pattern: `urn:foo:`, - matchAgainst: "urn:foo:man", - shouldMatch: true, - }, - { - uc: "square brackets2", - pattern: `urn:foo:`, - matchAgainst: "urn:foo:man", - shouldMatch: false, - }, - { - uc: "square brackets3", - pattern: `urn:foo:`, - matchAgainst: "urn:foo:min", - shouldMatch: true, - }, - { - uc: "asterisk matches only one path segment", - pattern: `http://example.com/<*>`, - matchAgainst: "http://example.com/foo/bar", - shouldMatch: false, - }, - } { - t.Run(tc.uc, func(t *testing.T) { - // GIVEN - matcher, err := newGlobMatcher(tc.pattern) - require.NoError(t, err) - - // WHEN - matched := matcher.Match(tc.matchAgainst) - - // THEN - assert.Equal(t, tc.shouldMatch, matched) - }) - } -} diff --git a/internal/rules/patternmatcher/pattern_matcher.go b/internal/rules/patternmatcher/pattern_matcher.go deleted file mode 100644 index 4a0ae68d4..000000000 --- a/internal/rules/patternmatcher/pattern_matcher.go +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright 2022 Dimitrij Drus -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// -// SPDX-License-Identifier: Apache-2.0 - -package patternmatcher - -import ( - "errors" -) - -var ErrUnsupportedPatternMatcher = errors.New("unsupported pattern matcher") - -type PatternMatcher interface { - Match(value string) bool -} - -func NewPatternMatcher(typ, pattern string) (PatternMatcher, error) { - switch typ { - case "glob": - return newGlobMatcher(pattern) - case "regex": - return newRegexMatcher(pattern) - default: - return nil, ErrUnsupportedPatternMatcher - } -} diff --git a/internal/rules/patternmatcher/regex_matcher.go b/internal/rules/patternmatcher/regex_matcher.go deleted file mode 100644 index 7b223b3d6..000000000 --- a/internal/rules/patternmatcher/regex_matcher.go +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright 2022 Dimitrij Drus -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// -// SPDX-License-Identifier: Apache-2.0 - -package patternmatcher - -import ( - "errors" - - "github.com/dlclark/regexp2" - "github.com/ory/ladon/compiler" -) - -var ErrNoRegexPatternDefined = errors.New("no glob pattern defined") - -type regexpMatcher struct { - compiled *regexp2.Regexp -} - -func newRegexMatcher(pattern string) (*regexpMatcher, error) { - if len(pattern) == 0 { - return nil, ErrNoRegexPatternDefined - } - - compiled, err := compiler.CompileRegex(pattern, '<', '>') - if err != nil { - return nil, err - } - - return ®expMatcher{compiled: compiled}, nil -} - -func (m *regexpMatcher) Match(matchAgainst string) bool { - // ignoring error as it will be set on timeouts, which basically is the same as match miss - ok, _ := m.compiled.MatchString(matchAgainst) - - return ok -} diff --git a/internal/rules/provider/cloudblob/provider_test.go b/internal/rules/provider/cloudblob/provider_test.go index 82726c556..2e8db962b 100644 --- a/internal/rules/provider/cloudblob/provider_test.go +++ b/internal/rules/provider/cloudblob/provider_test.go @@ -110,7 +110,6 @@ buckets: - url: s3://foobar - url: s3://barfoo/foo&foo=bar prefix: bar - rule_path_match_prefix: baz `), assert: func(t *testing.T, err error, prov *provider) { t.Helper() @@ -243,6 +242,10 @@ version: "1" name: test rules: - id: foo + match: + path: /foo + execute: + - authenticator: test ` _, err := backend.PutObject(bucketName, "test-rule", @@ -287,6 +290,10 @@ version: "1" name: test rules: - id: foo + match: + path: /foo + execute: + - authenticator: test ` _, err := backend.PutObject(bucketName, "test-rule", @@ -336,6 +343,10 @@ version: "1" name: test rules: - id: foo + match: + path: /foo + execute: + - authenticator: test ` _, err := backend.PutObject(bucketName, "test-rule1", @@ -350,6 +361,10 @@ version: "1" name: test rules: - id: bar + match: + path: /bar + execute: + - authenticator: test ` _, err := backend.PutObject(bucketName, "test-rule2", @@ -422,8 +437,11 @@ version: "1" name: test rules: - id: foo + match: + path: /foo + execute: + - authenticator: test ` - _, err := backend.PutObject(bucketName, "test-rule", map[string]string{"Content-Type": "application/yaml"}, strings.NewReader(data), int64(len(data))) @@ -434,8 +452,11 @@ version: "1" name: test rules: - id: bar + match: + path: /bar + execute: + - authenticator: test ` - _, err := backend.PutObject(bucketName, "test-rule", map[string]string{"Content-Type": "application/yaml"}, strings.NewReader(data), int64(len(data))) @@ -446,8 +467,11 @@ version: "1" name: test rules: - id: baz + match: + path: /baz + execute: + - authenticator: test ` - _, err := backend.PutObject(bucketName, "test-rule", map[string]string{"Content-Type": "application/yaml"}, strings.NewReader(data), int64(len(data))) diff --git a/internal/rules/provider/cloudblob/ruleset_endpoint.go b/internal/rules/provider/cloudblob/ruleset_endpoint.go index 34e16cea2..43017ccba 100644 --- a/internal/rules/provider/cloudblob/ruleset_endpoint.go +++ b/internal/rules/provider/cloudblob/ruleset_endpoint.go @@ -35,9 +35,8 @@ import ( ) type ruleSetEndpoint struct { - URL *url.URL `mapstructure:"url"` - Prefix string `mapstructure:"prefix"` - RulesPathPrefix string `mapstructure:"rule_path_match_prefix"` + URL *url.URL `mapstructure:"url"` + Prefix string `mapstructure:"prefix"` } func (e *ruleSetEndpoint) ID() string { @@ -125,10 +124,6 @@ func (e *ruleSetEndpoint) readRuleSet(ctx context.Context, bucket *blob.Bucket, CausedBy(err) } - if err = contents.VerifyPathPrefix(e.RulesPathPrefix); err != nil { - return nil, err - } - contents.Hash = attrs.MD5 contents.Source = fmt.Sprintf("%s@%s", key, e.ID()) contents.ModTime = attrs.ModTime diff --git a/internal/rules/provider/cloudblob/ruleset_endpoint_test.go b/internal/rules/provider/cloudblob/ruleset_endpoint_test.go index fdaa54f4a..aac46b338 100644 --- a/internal/rules/provider/cloudblob/ruleset_endpoint_test.go +++ b/internal/rules/provider/cloudblob/ruleset_endpoint_test.go @@ -169,46 +169,6 @@ func TestFetchRuleSets(t *testing.T) { require.Empty(t, ruleSets) }, }, - { - uc: "rule set with path prefix validation error", - endpoint: ruleSetEndpoint{ - URL: &url.URL{ - Scheme: "s3", - Host: bucketName, - RawQuery: fmt.Sprintf("endpoint=%s&disableSSL=true&s3ForcePathStyle=true®ion=eu-central-1", srv.URL), - }, - RulesPathPrefix: "foo/bar", - }, - setup: func(t *testing.T) { - t.Helper() - - data := ` -{ - "version": "1", - "name": "test", - "rules": [{ - "id": "foobar", - "match": "http://<**>/bar/foo/api", - "methods": ["GET", "POST"], - "execute": [ - { "authenticator": "foobar" } - ] - }] -}` - - _, err := backend.PutObject(bucketName, "test-rule", - map[string]string{"Content-Type": "application/json"}, - strings.NewReader(data), int64(len(data))) - require.NoError(t, err) - }, - assert: func(t *testing.T, err error, _ []*config.RuleSet) { - t.Helper() - - require.Error(t, err) - require.ErrorIs(t, err, heimdall.ErrConfiguration) - assert.Contains(t, err.Error(), "path prefix validation") - }, - }, { uc: "multiple valid rule sets in yaml and json formats", endpoint: ruleSetEndpoint{ @@ -217,7 +177,6 @@ func TestFetchRuleSets(t *testing.T) { Host: bucketName, RawQuery: fmt.Sprintf("endpoint=%s&disableSSL=true&s3ForcePathStyle=true®ion=eu-central-1", srv.URL), }, - RulesPathPrefix: "foo/bar", }, setup: func(t *testing.T) { t.Helper() @@ -228,8 +187,14 @@ func TestFetchRuleSets(t *testing.T) { "name": "test", "rules": [{ "id": "foobar", - "match": "http://<**>/foo/bar/api1", - "methods": ["GET", "POST"], + "match": { + "path": "/foo/bar/api1", + "with": { + "scheme": "http", + "host_glob": "**", + "methods": ["GET", "POST"] + } + }, "execute": [ { "authenticator": "foobar" } ] @@ -241,13 +206,17 @@ version: "1" name: test2 rules: - id: barfoo - match: http://<**>/foo/bar/api2 - methods: - - GET - - POST + match: + path: /foo/bar/api2 + with: + scheme: http + host_glob: "**" + methods: + - GET + - POST execute: - - authenticator: barfoo` - + - authenticator: barfoo +` _, err := backend.PutObject(bucketName, "test-rule1", map[string]string{"Content-Type": "application/json"}, strings.NewReader(ruleSet1), int64(len(ruleSet1))) @@ -294,8 +263,14 @@ rules: "name": "test1", "rules": [{ "id": "foobar", - "match": "http://<**>/foo/bar/api1", - "methods": ["GET", "POST"], + "match": { + "path": "/foo/bar/api1", + "with": { + "scheme": "http", + "host_glob": "**", + "methods": ["GET", "POST"] + } + }, "execute": [ { "authenticator": "foobar" } ] @@ -306,8 +281,14 @@ rules: "name": "test2", "rules": [{ "id": "barfoo", - "url": "http://<**>/foo/bar/api2", - "methods": ["GET", "POST"], + "match": { + "path": "/foo/bar/api2", + "with": { + "scheme": "http", + "host_glob": "**", + "methods": ["GET", "POST"] + } + }, "execute": [ { "authenticator": "barfoo" } ] @@ -400,8 +381,14 @@ rules: "name": "test", "rules": [{ "id": "foobar", - "match": "http://<**>/foo/bar/api1", - "methods": ["GET", "POST"], + "match": { + "path": "/foo/bar/api1", + "with": { + "scheme": "http", + "host_glob": "**", + "methods": ["GET", "POST"] + } + }, "execute": [ { "authenticator": "foobar" } ] diff --git a/internal/rules/provider/filesystem/provider_test.go b/internal/rules/provider/filesystem/provider_test.go index 3d7bfb406..f06c23f43 100644 --- a/internal/rules/provider/filesystem/provider_test.go +++ b/internal/rules/provider/filesystem/provider_test.go @@ -202,6 +202,10 @@ func TestProviderLifecycle(t *testing.T) { version: "1" rules: - id: foo + match: + path: /foo/bar + execute: + - authenticator: test `) require.NoError(t, err) @@ -251,6 +255,10 @@ rules: version: "2" rules: - id: foo + match: + path: /foo/bar + execute: + - authenticator: test `) require.NoError(t, err) @@ -290,6 +298,10 @@ rules: version: "1" rules: - id: foo + match: + path: /foo/bar + execute: + - authenticator: test `) require.NoError(t, err) @@ -322,6 +334,10 @@ rules: version: "1" rules: - id: foo + match: + path: /foo/bar + execute: + - authenticator: test `) require.NoError(t, err) @@ -369,6 +385,10 @@ rules: version: "1" rules: - id: foo + match: + path: /foo + execute: + - authenticator: test `) require.NoError(t, err) @@ -381,6 +401,10 @@ rules: version: "1" rules: - id: foo + match: + path: /foo + execute: + - authenticator: test `) require.NoError(t, err) @@ -393,6 +417,10 @@ rules: version: "2" rules: - id: bar + match: + path: /bar + execute: + - authenticator: test `) require.NoError(t, err) diff --git a/internal/rules/provider/httpendpoint/provider_test.go b/internal/rules/provider/httpendpoint/provider_test.go index ecacf4439..8d175c961 100644 --- a/internal/rules/provider/httpendpoint/provider_test.go +++ b/internal/rules/provider/httpendpoint/provider_test.go @@ -262,6 +262,12 @@ version: "1" name: test rules: - id: foo + match: + path: /foo + with: + methods: [ "GET" ] + execute: + - authenticator: test `)) require.NoError(t, err) }, @@ -304,6 +310,12 @@ version: "1" name: test rules: - id: bar + match: + path: /bar + with: + methods: [ "GET" ] + execute: + - authenticator: test `)) require.NoError(t, err) }, @@ -351,6 +363,12 @@ version: "1" name: test rules: - id: foo + match: + path: /foo + with: + methods: [ GET ] + execute: + - authenticator: test `)) require.NoError(t, err) case 2: @@ -362,6 +380,12 @@ version: "2" name: test rules: - id: bar + match: + path: /bar + with: + methods: [ GET ] + execute: + - authenticator: test `)) require.NoError(t, err) } @@ -427,6 +451,10 @@ version: "1" name: test rules: - id: bar + match: + path: /bar + execute: + - authenticator: test `)) require.NoError(t, err) case 2: @@ -436,6 +464,10 @@ version: "1" name: test rules: - id: baz + match: + path: /baz + execute: + - authenticator: test `)) require.NoError(t, err) case 3: @@ -445,6 +477,10 @@ version: "1" name: test rules: - id: foo + match: + path: /foo + execute: + - authenticator: test `)) require.NoError(t, err) default: @@ -454,6 +490,10 @@ version: "1" name: test rules: - id: foz + match: + path: /foz + execute: + - authenticator: test `)) require.NoError(t, err) } @@ -524,6 +564,10 @@ version: "1" name: test rules: - id: bar + match: + path: /bar + execute: + - authenticator: test `)) require.NoError(t, err) }, @@ -569,6 +613,10 @@ version: "1" name: test rules: - id: bar + match: + path: /bar + execute: + - authenticator: test `)) require.NoError(t, err) }, @@ -612,6 +660,10 @@ version: "1" name: test rules: - id: foo + match: + path: /foo + execute: + - authenticator: test `)) require.NoError(t, err) }, @@ -649,6 +701,10 @@ version: "1" name: test rules: - id: bar + match: + path: /bar + execute: + - authenticator: test `)) require.NoError(t, err) } else { @@ -658,6 +714,10 @@ version: "1" name: test rules: - id: baz + match: + path: /baz + execute: + - authenticator: test `)) require.NoError(t, err) } @@ -700,6 +760,10 @@ version: "1" name: test rules: - id: bar + match: + path: /bar + execute: + - authenticator: test `)) require.NoError(t, err) } else { diff --git a/internal/rules/provider/httpendpoint/ruleset_endpoint.go b/internal/rules/provider/httpendpoint/ruleset_endpoint.go index ecfacebb9..3f7c78cac 100644 --- a/internal/rules/provider/httpendpoint/ruleset_endpoint.go +++ b/internal/rules/provider/httpendpoint/ruleset_endpoint.go @@ -33,8 +33,6 @@ import ( type ruleSetEndpoint struct { endpoint.Endpoint `mapstructure:",squash"` - - RulesPathPrefix string `mapstructure:"rule_path_match_prefix"` } func (e *ruleSetEndpoint) ID() string { return e.URL } @@ -78,10 +76,6 @@ func (e *ruleSetEndpoint) FetchRuleSet(ctx context.Context) (*config.RuleSet, er CausedBy(err) } - if err = ruleSet.VerifyPathPrefix(e.RulesPathPrefix); err != nil { - return nil, err - } - ruleSet.Hash = md.Sum(nil) ruleSet.Source = "http_endpoint:" + e.ID() ruleSet.ModTime = time.Now() diff --git a/internal/rules/provider/httpendpoint/ruleset_endpoint_test.go b/internal/rules/provider/httpendpoint/ruleset_endpoint_test.go index e26b39ce0..3f5e83d64 100644 --- a/internal/rules/provider/httpendpoint/ruleset_endpoint_test.go +++ b/internal/rules/provider/httpendpoint/ruleset_endpoint_test.go @@ -134,6 +134,8 @@ version: "1" name: test rules: - id: bar + match: + path: /bar `)) require.NoError(t, err) }, @@ -182,6 +184,12 @@ version: "1" name: test rules: - id: foo + match: + path: /foo + with: + methods: [ GET ] + execute: + - authenticator: test `)) require.NoError(t, err) }, @@ -212,7 +220,7 @@ rules: "version": "1", "name": "test", "rules": [ - { "id": "foo" } + { "id": "foo", "match": { "path": "/foo", "with": { "methods" : ["GET"] }}, "execute": [{ "authenticator": "test"}] } ] }`)) require.NoError(t, err) @@ -229,13 +237,12 @@ rules: }, }, { - uc: "valid rule set with path only url glob with path prefix violation", + uc: "valid rule set with full url glob", ep: &ruleSetEndpoint{ Endpoint: endpoint.Endpoint{ URL: srv.URL, Method: http.MethodGet, }, - RulesPathPrefix: "/foo/bar", }, writeResponse: func(t *testing.T, w http.ResponseWriter) { t.Helper() @@ -245,67 +252,18 @@ rules: "version": "1", "name": "test", "rules": [ - { "id": "foo", "match":"/bar/foo/<**>" } - ] -}`)) - require.NoError(t, err) - }, - assert: func(t *testing.T, err error, _ *config.RuleSet) { - t.Helper() - - require.Error(t, err) - require.ErrorIs(t, err, heimdall.ErrConfiguration) - assert.Contains(t, err.Error(), "path prefix validation") - }, - }, - { - uc: "valid rule set with full url glob with path prefix violation", - ep: &ruleSetEndpoint{ - Endpoint: endpoint.Endpoint{ - URL: srv.URL, - Method: http.MethodGet, - }, - RulesPathPrefix: "/foo/bar", - }, - writeResponse: func(t *testing.T, w http.ResponseWriter) { - t.Helper() - - w.Header().Set("Content-Type", "application/json") - _, err := w.Write([]byte(`{ - "version": "1", - "name": "test", - "rules": [ - { "id": "foo", "match":"<**>://moobar.local:9090/bar/foo/<**>" } - ] -}`)) - require.NoError(t, err) - }, - assert: func(t *testing.T, err error, _ *config.RuleSet) { - t.Helper() - - require.Error(t, err) - require.ErrorIs(t, err, heimdall.ErrConfiguration) - assert.Contains(t, err.Error(), "path prefix validation") - }, - }, - { - uc: "valid rule set with full url glob without path prefix violation", - ep: &ruleSetEndpoint{ - Endpoint: endpoint.Endpoint{ - URL: srv.URL, - Method: http.MethodGet, - }, - RulesPathPrefix: "/foo/bar", - }, - writeResponse: func(t *testing.T, w http.ResponseWriter) { - t.Helper() - - w.Header().Set("Content-Type", "application/json") - _, err := w.Write([]byte(`{ - "version": "1", - "name": "test", - "rules": [ - { "id": "foo", "match":"<**>://moobar.local:9090/foo/bar/<**>" } + { + "id": "foo", + "match": { + "path": "/foo/bar/:*", + "with": { + "host_glob": "moobar.local:9090", + "path_glob": "/foo/bar/**", + "methods": [ "GET" ] + } + }, + "execute": [{ "authenticator": "test"}] + } ] }`)) require.NoError(t, err) diff --git a/internal/rules/provider/kubernetes/admissioncontroller/controller_test.go b/internal/rules/provider/kubernetes/admissioncontroller/controller_test.go index cb2c4baf6..ce31272c8 100644 --- a/internal/rules/provider/kubernetes/admissioncontroller/controller_test.go +++ b/internal/rules/provider/kubernetes/admissioncontroller/controller_test.go @@ -45,7 +45,7 @@ import ( "github.com/dadrus/heimdall/internal/config" config2 "github.com/dadrus/heimdall/internal/rules/config" - "github.com/dadrus/heimdall/internal/rules/provider/kubernetes/api/v1alpha3" + "github.com/dadrus/heimdall/internal/rules/provider/kubernetes/api/v1alpha4" "github.com/dadrus/heimdall/internal/rules/rule/mocks" "github.com/dadrus/heimdall/internal/x" "github.com/dadrus/heimdall/internal/x/pkix/pemx" @@ -114,10 +114,10 @@ func TestControllerLifecycle(t *testing.T) { Namespace: "test", Name: "test-rules", Operation: admissionv1.Create, - Kind: metav1.GroupVersionKind{Group: v1alpha3.GroupName, Version: v1alpha3.GroupVersion, Kind: "RuleSet"}, - Resource: metav1.GroupVersionResource{Group: v1alpha3.GroupName, Version: v1alpha3.GroupVersion, Resource: "rulesets"}, - RequestKind: &metav1.GroupVersionKind{Group: v1alpha3.GroupName, Version: v1alpha3.GroupVersion, Kind: "RuleSet"}, - RequestResource: &metav1.GroupVersionResource{Group: v1alpha3.GroupName, Version: v1alpha3.GroupVersion, Resource: "rulesets"}, + Kind: metav1.GroupVersionKind{Group: v1alpha4.GroupName, Version: v1alpha4.GroupVersion, Kind: "RuleSet"}, + Resource: metav1.GroupVersionResource{Group: v1alpha4.GroupName, Version: v1alpha4.GroupVersion, Resource: "rulesets"}, + RequestKind: &metav1.GroupVersionKind{Group: v1alpha4.GroupName, Version: v1alpha4.GroupVersion, Kind: "RuleSet"}, + RequestResource: &metav1.GroupVersionResource{Group: v1alpha4.GroupName, Version: v1alpha4.GroupVersion, Resource: "rulesets"}, }, } @@ -195,9 +195,9 @@ func TestControllerLifecycle(t *testing.T) { request: func(t *testing.T, URL string) *http.Request { t.Helper() - ruleSet := v1alpha3.RuleSet{ + ruleSet := v1alpha4.RuleSet{ TypeMeta: metav1.TypeMeta{ - APIVersion: fmt.Sprintf("%s/%s", v1alpha3.GroupName, v1alpha3.GroupVersion), + APIVersion: fmt.Sprintf("%s/%s", v1alpha4.GroupName, v1alpha4.GroupVersion), Kind: "RuleSet", }, ObjectMeta: metav1.ObjectMeta{ @@ -208,7 +208,7 @@ func TestControllerLifecycle(t *testing.T) { Generation: 1, CreationTimestamp: metav1.NewTime(time.Now()), }, - Spec: v1alpha3.RuleSetSpec{AuthClassName: "foo"}, + Spec: v1alpha4.RuleSetSpec{AuthClassName: "foo"}, } data, err := json.Marshal(&ruleSet) require.NoError(t, err) @@ -253,9 +253,9 @@ func TestControllerLifecycle(t *testing.T) { request: func(t *testing.T, URL string) *http.Request { t.Helper() - ruleSet := v1alpha3.RuleSet{ + ruleSet := v1alpha4.RuleSet{ TypeMeta: metav1.TypeMeta{ - APIVersion: fmt.Sprintf("%s/%s", v1alpha3.GroupName, v1alpha3.GroupVersion), + APIVersion: fmt.Sprintf("%s/%s", v1alpha4.GroupName, v1alpha4.GroupVersion), Kind: "RuleSet", }, ObjectMeta: metav1.ObjectMeta{ @@ -266,14 +266,17 @@ func TestControllerLifecycle(t *testing.T) { Generation: 1, CreationTimestamp: metav1.NewTime(time.Now()), }, - Spec: v1alpha3.RuleSetSpec{ + Spec: v1alpha4.RuleSetSpec{ AuthClassName: authClass, Rules: []config2.Rule{ { ID: "test", - RuleMatcher: config2.Matcher{ - URL: "http://foo.bar", - Strategy: "glob", + Matcher: config2.Matcher{ + Path: "/foo.bar", + With: &config2.MatcherConstraints{ + Scheme: "http", + Methods: []string{http.MethodGet}, + }, }, Backend: &config2.Backend{ Host: "baz", @@ -284,7 +287,6 @@ func TestControllerLifecycle(t *testing.T) { QueryParamsToRemove: []string{"baz"}, }, }, - Methods: []string{http.MethodGet}, Execute: []config.MechanismConfig{ {"authenticator": "authn"}, {"authorizer": "authz"}, @@ -310,7 +312,7 @@ func TestControllerLifecycle(t *testing.T) { setupRuleFactory: func(t *testing.T, factory *mocks.FactoryMock) { t.Helper() - factory.EXPECT().CreateRule("1alpha3", mock.Anything, mock.Anything). + factory.EXPECT().CreateRule("1alpha4", mock.Anything, mock.Anything). Once().Return(nil, errors.New("Test error")) }, assert: func(t *testing.T, err error, resp *http.Response) { @@ -346,9 +348,9 @@ func TestControllerLifecycle(t *testing.T) { request: func(t *testing.T, URL string) *http.Request { t.Helper() - ruleSet := v1alpha3.RuleSet{ + ruleSet := v1alpha4.RuleSet{ TypeMeta: metav1.TypeMeta{ - APIVersion: fmt.Sprintf("%s/%s", v1alpha3.GroupName, v1alpha3.GroupVersion), + APIVersion: fmt.Sprintf("%s/%s", v1alpha4.GroupName, v1alpha4.GroupVersion), Kind: "RuleSet", }, ObjectMeta: metav1.ObjectMeta{ @@ -359,14 +361,17 @@ func TestControllerLifecycle(t *testing.T) { Generation: 1, CreationTimestamp: metav1.NewTime(time.Now()), }, - Spec: v1alpha3.RuleSetSpec{ + Spec: v1alpha4.RuleSetSpec{ AuthClassName: authClass, Rules: []config2.Rule{ { ID: "test", - RuleMatcher: config2.Matcher{ - URL: "http://foo.bar", - Strategy: "glob", + Matcher: config2.Matcher{ + Path: "/foo.bar", + With: &config2.MatcherConstraints{ + Scheme: "http", + Methods: []string{http.MethodGet}, + }, }, Backend: &config2.Backend{ Host: "baz", @@ -377,7 +382,6 @@ func TestControllerLifecycle(t *testing.T) { QueryParamsToRemove: []string{"baz"}, }, }, - Methods: []string{http.MethodGet}, Execute: []config.MechanismConfig{ {"authenticator": "authn"}, {"authorizer": "authz"}, @@ -403,7 +407,7 @@ func TestControllerLifecycle(t *testing.T) { setupRuleFactory: func(t *testing.T, factory *mocks.FactoryMock) { t.Helper() - factory.EXPECT().CreateRule("1alpha3", mock.Anything, mock.Anything). + factory.EXPECT().CreateRule("1alpha4", mock.Anything, mock.Anything). Once().Return(nil, nil) }, assert: func(t *testing.T, err error, resp *http.Response) { diff --git a/internal/rules/provider/kubernetes/admissioncontroller/validator.go b/internal/rules/provider/kubernetes/admissioncontroller/validator.go index 3f56956d9..9d294b2b9 100644 --- a/internal/rules/provider/kubernetes/admissioncontroller/validator.go +++ b/internal/rules/provider/kubernetes/admissioncontroller/validator.go @@ -28,7 +28,7 @@ import ( "github.com/dadrus/heimdall/internal/rules/config" "github.com/dadrus/heimdall/internal/rules/provider/kubernetes/admissioncontroller/admission" - "github.com/dadrus/heimdall/internal/rules/provider/kubernetes/api/v1alpha3" + "github.com/dadrus/heimdall/internal/rules/provider/kubernetes/api/v1alpha4" "github.com/dadrus/heimdall/internal/rules/rule" ) @@ -88,18 +88,18 @@ func (rv *rulesetValidator) Handle(ctx context.Context, req *admission.Request) return admission.NewResponse(http.StatusOK, "RuleSet valid") } -func (rv *rulesetValidator) ruleSetFrom(req *admission.Request) (*v1alpha3.RuleSet, error) { +func (rv *rulesetValidator) ruleSetFrom(req *admission.Request) (*v1alpha4.RuleSet, error) { if req.Kind.Kind != "RuleSet" { return nil, ErrInvalidObject } - p := &v1alpha3.RuleSet{} + p := &v1alpha4.RuleSet{} err := json.Unmarshal(req.Object.Raw, p) return p, err } func (rv *rulesetValidator) mapVersion(_ string) string { - // currently the only possible version is v1alpha3, which is mapped to the version "1alpha3" used internally - return "1alpha3" + // currently the only possible version is v1alpha4, which is mapped to the version "1alpha4" used internally + return "1alpha4" } diff --git a/internal/rules/provider/kubernetes/api/v1alpha3/client.go b/internal/rules/provider/kubernetes/api/v1alpha4/client.go similarity index 97% rename from internal/rules/provider/kubernetes/api/v1alpha3/client.go rename to internal/rules/provider/kubernetes/api/v1alpha4/client.go index 7b2e79b71..5520aca62 100644 --- a/internal/rules/provider/kubernetes/api/v1alpha3/client.go +++ b/internal/rules/provider/kubernetes/api/v1alpha4/client.go @@ -14,7 +14,7 @@ // // SPDX-License-Identifier: Apache-2.0 -package v1alpha3 +package v1alpha4 import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -26,7 +26,7 @@ import ( const ( GroupName = "heimdall.dadrus.github.com" - GroupVersion = "v1alpha3" + GroupVersion = "v1alpha4" ) func addKnownTypes(gv schema.GroupVersion) func(scheme *runtime.Scheme) error { diff --git a/internal/rules/provider/kubernetes/api/v1alpha3/client_test.go b/internal/rules/provider/kubernetes/api/v1alpha4/client_test.go similarity index 80% rename from internal/rules/provider/kubernetes/api/v1alpha3/client_test.go rename to internal/rules/provider/kubernetes/api/v1alpha4/client_test.go index 9d6f9bf60..cd39dface 100644 --- a/internal/rules/provider/kubernetes/api/v1alpha3/client_test.go +++ b/internal/rules/provider/kubernetes/api/v1alpha4/client_test.go @@ -14,7 +14,7 @@ // // SPDX-License-Identifier: Apache-2.0 -package v1alpha3 +package v1alpha4 import ( "context" @@ -38,9 +38,9 @@ const watchResponse = `{ ` const response = `{ - "apiVersion": "heimdall.dadrus.github.com/v1alpha3", + "apiVersion": "heimdall.dadrus.github.com/v1alpha4", "items": [{ - "apiVersion": "heimdall.dadrus.github.com/v1alpha3", + "apiVersion": "heimdall.dadrus.github.com/v1alpha4", "kind": "RuleSet", "metadata": { "name": "test-rule-set", @@ -56,16 +56,23 @@ const response = `{ { "authorizer": "test_authz" } ], "id": "test:rule", - "matching_strategy": "glob", - "match": "http://127.0.0.1:9090/foobar/<{foos*}>", + "match": { + "path": "/foobar/:*", + "with": { + "scheme": "http", + "host_glob": "127.0.0.1:*", + "path_glob": "/foobar/foos*", + "methods": ["GET", "POST"] + } + }, "forward_to": { - "host": "foo.bar", - "rewrite": { - "scheme": "https", - "strip_path_prefix": "/foo", - "add_path_prefix": "/baz", - "strip_query_parameters": ["boo"] - } + "host": "foo.bar", + "rewrite": { + "scheme": "https", + "strip_path_prefix": "/foo", + "add_path_prefix": "/baz", + "strip_query_parameters": ["boo"] + } } } ] @@ -126,7 +133,7 @@ func verifyRuleSetList(t *testing.T, rls *RuleSetList) { ruleSet := rls.Items[0] assert.Equal(t, "RuleSet", ruleSet.Kind) - assert.Equal(t, "heimdall.dadrus.github.com/v1alpha3", ruleSet.APIVersion) + assert.Equal(t, "heimdall.dadrus.github.com/v1alpha4", ruleSet.APIVersion) assert.Equal(t, "test-rule-set", ruleSet.Name) assert.Equal(t, "foo", ruleSet.Namespace) assert.Equal(t, "foobar", ruleSet.Spec.AuthClassName) @@ -134,9 +141,11 @@ func verifyRuleSetList(t *testing.T, rls *RuleSetList) { rule := ruleSet.Spec.Rules[0] assert.Equal(t, "test:rule", rule.ID) - assert.Equal(t, "glob", rule.RuleMatcher.Strategy) - assert.Equal(t, "http://127.0.0.1:9090/foobar/<{foos*}>", rule.RuleMatcher.URL) - assert.Empty(t, rule.Methods) + assert.Equal(t, "/foobar/:*", rule.Matcher.Path) + assert.Equal(t, "http", rule.Matcher.With.Scheme) + assert.Equal(t, "127.0.0.1:*", rule.Matcher.With.HostGlob) + assert.Equal(t, "/foobar/foos*", rule.Matcher.With.PathGlob) + assert.ElementsMatch(t, rule.Matcher.With.Methods, []string{"GET", "POST"}) assert.Empty(t, rule.ErrorHandler) assert.Equal(t, "https://foo.bar/baz/bar?foo=bar", rule.Backend.CreateURL(&url.URL{ Scheme: "http", diff --git a/internal/rules/provider/kubernetes/api/v1alpha3/json_patch.go b/internal/rules/provider/kubernetes/api/v1alpha4/json_patch.go similarity index 99% rename from internal/rules/provider/kubernetes/api/v1alpha3/json_patch.go rename to internal/rules/provider/kubernetes/api/v1alpha4/json_patch.go index ed9560aaa..2aba8b37d 100644 --- a/internal/rules/provider/kubernetes/api/v1alpha3/json_patch.go +++ b/internal/rules/provider/kubernetes/api/v1alpha4/json_patch.go @@ -14,7 +14,7 @@ // // SPDX-License-Identifier: Apache-2.0 -package v1alpha3 +package v1alpha4 import ( "github.com/goccy/go-json" diff --git a/internal/rules/provider/kubernetes/api/v1alpha3/mocks/client.go b/internal/rules/provider/kubernetes/api/v1alpha4/mocks/client.go similarity index 74% rename from internal/rules/provider/kubernetes/api/v1alpha3/mocks/client.go rename to internal/rules/provider/kubernetes/api/v1alpha4/mocks/client.go index 2405f7232..398d164f9 100644 --- a/internal/rules/provider/kubernetes/api/v1alpha3/mocks/client.go +++ b/internal/rules/provider/kubernetes/api/v1alpha4/mocks/client.go @@ -1,9 +1,9 @@ -// Code generated by mockery v2.23.1. DO NOT EDIT. +// Code generated by mockery v2.42.1. DO NOT EDIT. package mocks import ( - "github.com/dadrus/heimdall/internal/rules/provider/kubernetes/api/v1alpha3" + v1alpha4 "github.com/dadrus/heimdall/internal/rules/provider/kubernetes/api/v1alpha4" mock "github.com/stretchr/testify/mock" ) @@ -21,15 +21,19 @@ func (_m *ClientMock) EXPECT() *ClientMock_Expecter { } // RuleSetRepository provides a mock function with given fields: namespace -func (_m *ClientMock) RuleSetRepository(namespace string) v1alpha3.RuleSetRepository { +func (_m *ClientMock) RuleSetRepository(namespace string) v1alpha4.RuleSetRepository { ret := _m.Called(namespace) - var r0 v1alpha3.RuleSetRepository - if rf, ok := ret.Get(0).(func(string) v1alpha3.RuleSetRepository); ok { + if len(ret) == 0 { + panic("no return value specified for RuleSetRepository") + } + + var r0 v1alpha4.RuleSetRepository + if rf, ok := ret.Get(0).(func(string) v1alpha4.RuleSetRepository); ok { r0 = rf(namespace) } else { if ret.Get(0) != nil { - r0 = ret.Get(0).(v1alpha3.RuleSetRepository) + r0 = ret.Get(0).(v1alpha4.RuleSetRepository) } } @@ -54,23 +58,22 @@ func (_c *ClientMock_RuleSetRepository_Call) Run(run func(namespace string)) *Cl return _c } -func (_c *ClientMock_RuleSetRepository_Call) Return(_a0 v1alpha3.RuleSetRepository) *ClientMock_RuleSetRepository_Call { +func (_c *ClientMock_RuleSetRepository_Call) Return(_a0 v1alpha4.RuleSetRepository) *ClientMock_RuleSetRepository_Call { _c.Call.Return(_a0) return _c } -func (_c *ClientMock_RuleSetRepository_Call) RunAndReturn(run func(string) v1alpha3.RuleSetRepository) *ClientMock_RuleSetRepository_Call { +func (_c *ClientMock_RuleSetRepository_Call) RunAndReturn(run func(string) v1alpha4.RuleSetRepository) *ClientMock_RuleSetRepository_Call { _c.Call.Return(run) return _c } -type mockConstructorTestingTNewClientMock interface { +// NewClientMock creates a new instance of ClientMock. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewClientMock(t interface { mock.TestingT Cleanup(func()) -} - -// NewClientMock creates a new instance of ClientMock. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. -func NewClientMock(t mockConstructorTestingTNewClientMock) *ClientMock { +}) *ClientMock { mock := &ClientMock{} mock.Mock.Test(t) diff --git a/internal/rules/provider/kubernetes/api/v1alpha3/mocks/rule_set_repository.go b/internal/rules/provider/kubernetes/api/v1alpha4/mocks/rule_set_repository.go similarity index 78% rename from internal/rules/provider/kubernetes/api/v1alpha3/mocks/rule_set_repository.go rename to internal/rules/provider/kubernetes/api/v1alpha4/mocks/rule_set_repository.go index d0051ce10..43e1a2c83 100644 --- a/internal/rules/provider/kubernetes/api/v1alpha3/mocks/rule_set_repository.go +++ b/internal/rules/provider/kubernetes/api/v1alpha4/mocks/rule_set_repository.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.23.1. DO NOT EDIT. +// Code generated by mockery v2.42.1. DO NOT EDIT. package mocks @@ -10,7 +10,7 @@ import ( v1 "k8s.io/apimachinery/pkg/apis/meta/v1" - v1alpha3 "github.com/dadrus/heimdall/internal/rules/provider/kubernetes/api/v1alpha3" + v1alpha4 "github.com/dadrus/heimdall/internal/rules/provider/kubernetes/api/v1alpha4" watch "k8s.io/apimachinery/pkg/watch" ) @@ -29,19 +29,23 @@ func (_m *RuleSetRepositoryMock) EXPECT() *RuleSetRepositoryMock_Expecter { } // Get provides a mock function with given fields: ctx, key, opts -func (_m *RuleSetRepositoryMock) Get(ctx context.Context, key types.NamespacedName, opts v1.GetOptions) (*v1alpha3.RuleSet, error) { +func (_m *RuleSetRepositoryMock) Get(ctx context.Context, key types.NamespacedName, opts v1.GetOptions) (*v1alpha4.RuleSet, error) { ret := _m.Called(ctx, key, opts) - var r0 *v1alpha3.RuleSet + if len(ret) == 0 { + panic("no return value specified for Get") + } + + var r0 *v1alpha4.RuleSet var r1 error - if rf, ok := ret.Get(0).(func(context.Context, types.NamespacedName, v1.GetOptions) (*v1alpha3.RuleSet, error)); ok { + if rf, ok := ret.Get(0).(func(context.Context, types.NamespacedName, v1.GetOptions) (*v1alpha4.RuleSet, error)); ok { return rf(ctx, key, opts) } - if rf, ok := ret.Get(0).(func(context.Context, types.NamespacedName, v1.GetOptions) *v1alpha3.RuleSet); ok { + if rf, ok := ret.Get(0).(func(context.Context, types.NamespacedName, v1.GetOptions) *v1alpha4.RuleSet); ok { r0 = rf(ctx, key, opts) } else { if ret.Get(0) != nil { - r0 = ret.Get(0).(*v1alpha3.RuleSet) + r0 = ret.Get(0).(*v1alpha4.RuleSet) } } @@ -74,30 +78,34 @@ func (_c *RuleSetRepositoryMock_Get_Call) Run(run func(ctx context.Context, key return _c } -func (_c *RuleSetRepositoryMock_Get_Call) Return(_a0 *v1alpha3.RuleSet, _a1 error) *RuleSetRepositoryMock_Get_Call { +func (_c *RuleSetRepositoryMock_Get_Call) Return(_a0 *v1alpha4.RuleSet, _a1 error) *RuleSetRepositoryMock_Get_Call { _c.Call.Return(_a0, _a1) return _c } -func (_c *RuleSetRepositoryMock_Get_Call) RunAndReturn(run func(context.Context, types.NamespacedName, v1.GetOptions) (*v1alpha3.RuleSet, error)) *RuleSetRepositoryMock_Get_Call { +func (_c *RuleSetRepositoryMock_Get_Call) RunAndReturn(run func(context.Context, types.NamespacedName, v1.GetOptions) (*v1alpha4.RuleSet, error)) *RuleSetRepositoryMock_Get_Call { _c.Call.Return(run) return _c } // List provides a mock function with given fields: ctx, opts -func (_m *RuleSetRepositoryMock) List(ctx context.Context, opts v1.ListOptions) (*v1alpha3.RuleSetList, error) { +func (_m *RuleSetRepositoryMock) List(ctx context.Context, opts v1.ListOptions) (*v1alpha4.RuleSetList, error) { ret := _m.Called(ctx, opts) - var r0 *v1alpha3.RuleSetList + if len(ret) == 0 { + panic("no return value specified for List") + } + + var r0 *v1alpha4.RuleSetList var r1 error - if rf, ok := ret.Get(0).(func(context.Context, v1.ListOptions) (*v1alpha3.RuleSetList, error)); ok { + if rf, ok := ret.Get(0).(func(context.Context, v1.ListOptions) (*v1alpha4.RuleSetList, error)); ok { return rf(ctx, opts) } - if rf, ok := ret.Get(0).(func(context.Context, v1.ListOptions) *v1alpha3.RuleSetList); ok { + if rf, ok := ret.Get(0).(func(context.Context, v1.ListOptions) *v1alpha4.RuleSetList); ok { r0 = rf(ctx, opts) } else { if ret.Get(0) != nil { - r0 = ret.Get(0).(*v1alpha3.RuleSetList) + r0 = ret.Get(0).(*v1alpha4.RuleSetList) } } @@ -129,34 +137,38 @@ func (_c *RuleSetRepositoryMock_List_Call) Run(run func(ctx context.Context, opt return _c } -func (_c *RuleSetRepositoryMock_List_Call) Return(_a0 *v1alpha3.RuleSetList, _a1 error) *RuleSetRepositoryMock_List_Call { +func (_c *RuleSetRepositoryMock_List_Call) Return(_a0 *v1alpha4.RuleSetList, _a1 error) *RuleSetRepositoryMock_List_Call { _c.Call.Return(_a0, _a1) return _c } -func (_c *RuleSetRepositoryMock_List_Call) RunAndReturn(run func(context.Context, v1.ListOptions) (*v1alpha3.RuleSetList, error)) *RuleSetRepositoryMock_List_Call { +func (_c *RuleSetRepositoryMock_List_Call) RunAndReturn(run func(context.Context, v1.ListOptions) (*v1alpha4.RuleSetList, error)) *RuleSetRepositoryMock_List_Call { _c.Call.Return(run) return _c } // PatchStatus provides a mock function with given fields: ctx, patch, opts -func (_m *RuleSetRepositoryMock) PatchStatus(ctx context.Context, patch v1alpha3.Patch, opts v1.PatchOptions) (*v1alpha3.RuleSet, error) { +func (_m *RuleSetRepositoryMock) PatchStatus(ctx context.Context, patch v1alpha4.Patch, opts v1.PatchOptions) (*v1alpha4.RuleSet, error) { ret := _m.Called(ctx, patch, opts) - var r0 *v1alpha3.RuleSet + if len(ret) == 0 { + panic("no return value specified for PatchStatus") + } + + var r0 *v1alpha4.RuleSet var r1 error - if rf, ok := ret.Get(0).(func(context.Context, v1alpha3.Patch, v1.PatchOptions) (*v1alpha3.RuleSet, error)); ok { + if rf, ok := ret.Get(0).(func(context.Context, v1alpha4.Patch, v1.PatchOptions) (*v1alpha4.RuleSet, error)); ok { return rf(ctx, patch, opts) } - if rf, ok := ret.Get(0).(func(context.Context, v1alpha3.Patch, v1.PatchOptions) *v1alpha3.RuleSet); ok { + if rf, ok := ret.Get(0).(func(context.Context, v1alpha4.Patch, v1.PatchOptions) *v1alpha4.RuleSet); ok { r0 = rf(ctx, patch, opts) } else { if ret.Get(0) != nil { - r0 = ret.Get(0).(*v1alpha3.RuleSet) + r0 = ret.Get(0).(*v1alpha4.RuleSet) } } - if rf, ok := ret.Get(1).(func(context.Context, v1alpha3.Patch, v1.PatchOptions) error); ok { + if rf, ok := ret.Get(1).(func(context.Context, v1alpha4.Patch, v1.PatchOptions) error); ok { r1 = rf(ctx, patch, opts) } else { r1 = ret.Error(1) @@ -172,25 +184,25 @@ type RuleSetRepositoryMock_PatchStatus_Call struct { // PatchStatus is a helper method to define mock.On call // - ctx context.Context -// - patch v1alpha3.Patch +// - patch v1alpha4.Patch // - opts v1.PatchOptions func (_e *RuleSetRepositoryMock_Expecter) PatchStatus(ctx interface{}, patch interface{}, opts interface{}) *RuleSetRepositoryMock_PatchStatus_Call { return &RuleSetRepositoryMock_PatchStatus_Call{Call: _e.mock.On("PatchStatus", ctx, patch, opts)} } -func (_c *RuleSetRepositoryMock_PatchStatus_Call) Run(run func(ctx context.Context, patch v1alpha3.Patch, opts v1.PatchOptions)) *RuleSetRepositoryMock_PatchStatus_Call { +func (_c *RuleSetRepositoryMock_PatchStatus_Call) Run(run func(ctx context.Context, patch v1alpha4.Patch, opts v1.PatchOptions)) *RuleSetRepositoryMock_PatchStatus_Call { _c.Call.Run(func(args mock.Arguments) { - run(args[0].(context.Context), args[1].(v1alpha3.Patch), args[2].(v1.PatchOptions)) + run(args[0].(context.Context), args[1].(v1alpha4.Patch), args[2].(v1.PatchOptions)) }) return _c } -func (_c *RuleSetRepositoryMock_PatchStatus_Call) Return(_a0 *v1alpha3.RuleSet, _a1 error) *RuleSetRepositoryMock_PatchStatus_Call { +func (_c *RuleSetRepositoryMock_PatchStatus_Call) Return(_a0 *v1alpha4.RuleSet, _a1 error) *RuleSetRepositoryMock_PatchStatus_Call { _c.Call.Return(_a0, _a1) return _c } -func (_c *RuleSetRepositoryMock_PatchStatus_Call) RunAndReturn(run func(context.Context, v1alpha3.Patch, v1.PatchOptions) (*v1alpha3.RuleSet, error)) *RuleSetRepositoryMock_PatchStatus_Call { +func (_c *RuleSetRepositoryMock_PatchStatus_Call) RunAndReturn(run func(context.Context, v1alpha4.Patch, v1.PatchOptions) (*v1alpha4.RuleSet, error)) *RuleSetRepositoryMock_PatchStatus_Call { _c.Call.Return(run) return _c } @@ -199,6 +211,10 @@ func (_c *RuleSetRepositoryMock_PatchStatus_Call) RunAndReturn(run func(context. func (_m *RuleSetRepositoryMock) Watch(ctx context.Context, opts v1.ListOptions) (watch.Interface, error) { ret := _m.Called(ctx, opts) + if len(ret) == 0 { + panic("no return value specified for Watch") + } + var r0 watch.Interface var r1 error if rf, ok := ret.Get(0).(func(context.Context, v1.ListOptions) (watch.Interface, error)); ok { @@ -250,13 +266,12 @@ func (_c *RuleSetRepositoryMock_Watch_Call) RunAndReturn(run func(context.Contex return _c } -type mockConstructorTestingTNewRuleSetRepositoryMock interface { +// NewRuleSetRepositoryMock creates a new instance of RuleSetRepositoryMock. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewRuleSetRepositoryMock(t interface { mock.TestingT Cleanup(func()) -} - -// NewRuleSetRepositoryMock creates a new instance of RuleSetRepositoryMock. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. -func NewRuleSetRepositoryMock(t mockConstructorTestingTNewRuleSetRepositoryMock) *RuleSetRepositoryMock { +}) *RuleSetRepositoryMock { mock := &RuleSetRepositoryMock{} mock.Mock.Test(t) diff --git a/internal/rules/provider/kubernetes/api/v1alpha3/rule_set_repository.go b/internal/rules/provider/kubernetes/api/v1alpha4/rule_set_repository.go similarity index 98% rename from internal/rules/provider/kubernetes/api/v1alpha3/rule_set_repository.go rename to internal/rules/provider/kubernetes/api/v1alpha4/rule_set_repository.go index 84fe9b74c..446bd8474 100644 --- a/internal/rules/provider/kubernetes/api/v1alpha3/rule_set_repository.go +++ b/internal/rules/provider/kubernetes/api/v1alpha4/rule_set_repository.go @@ -14,7 +14,7 @@ // // SPDX-License-Identifier: Apache-2.0 -package v1alpha3 +package v1alpha4 import ( "context" diff --git a/internal/rules/provider/kubernetes/api/v1alpha3/rule_set_repository_impl.go b/internal/rules/provider/kubernetes/api/v1alpha4/rule_set_repository_impl.go similarity index 99% rename from internal/rules/provider/kubernetes/api/v1alpha3/rule_set_repository_impl.go rename to internal/rules/provider/kubernetes/api/v1alpha4/rule_set_repository_impl.go index 4fc0f29ee..72c4cda8c 100644 --- a/internal/rules/provider/kubernetes/api/v1alpha3/rule_set_repository_impl.go +++ b/internal/rules/provider/kubernetes/api/v1alpha4/rule_set_repository_impl.go @@ -14,7 +14,7 @@ // // SPDX-License-Identifier: Apache-2.0 -package v1alpha3 +package v1alpha4 import ( "context" diff --git a/internal/rules/provider/kubernetes/api/v1alpha3/types.go b/internal/rules/provider/kubernetes/api/v1alpha4/types.go similarity index 99% rename from internal/rules/provider/kubernetes/api/v1alpha3/types.go rename to internal/rules/provider/kubernetes/api/v1alpha4/types.go index b7f7791b5..8fd24f56e 100644 --- a/internal/rules/provider/kubernetes/api/v1alpha3/types.go +++ b/internal/rules/provider/kubernetes/api/v1alpha4/types.go @@ -14,7 +14,7 @@ // // SPDX-License-Identifier: Apache-2.0 -package v1alpha3 +package v1alpha4 //go:generate controller-gen object paths=$GOFILE diff --git a/internal/rules/provider/kubernetes/api/v1alpha3/zz_generated.deepcopy.go b/internal/rules/provider/kubernetes/api/v1alpha4/zz_generated.deepcopy.go similarity index 99% rename from internal/rules/provider/kubernetes/api/v1alpha3/zz_generated.deepcopy.go rename to internal/rules/provider/kubernetes/api/v1alpha4/zz_generated.deepcopy.go index 619a7bfce..6b0ace544 100644 --- a/internal/rules/provider/kubernetes/api/v1alpha3/zz_generated.deepcopy.go +++ b/internal/rules/provider/kubernetes/api/v1alpha4/zz_generated.deepcopy.go @@ -3,7 +3,7 @@ // Code generated by controller-gen. DO NOT EDIT. -package v1alpha3 +package v1alpha4 import ( "github.com/dadrus/heimdall/internal/rules/config" diff --git a/internal/rules/provider/kubernetes/provider.go b/internal/rules/provider/kubernetes/provider.go index 81d9f2bbf..6a9b61d1b 100644 --- a/internal/rules/provider/kubernetes/provider.go +++ b/internal/rules/provider/kubernetes/provider.go @@ -44,7 +44,7 @@ import ( "github.com/dadrus/heimdall/internal/heimdall" config2 "github.com/dadrus/heimdall/internal/rules/config" "github.com/dadrus/heimdall/internal/rules/provider/kubernetes/admissioncontroller" - "github.com/dadrus/heimdall/internal/rules/provider/kubernetes/api/v1alpha3" + "github.com/dadrus/heimdall/internal/rules/provider/kubernetes/api/v1alpha4" "github.com/dadrus/heimdall/internal/rules/rule" "github.com/dadrus/heimdall/internal/x" "github.com/dadrus/heimdall/internal/x/errorchain" @@ -56,7 +56,7 @@ type ConfigFactory func() (*rest.Config, error) type provider struct { p rule.SetProcessor l zerolog.Logger - cl v1alpha3.Client + cl v1alpha4.Client adc admissioncontroller.AdmissionController cancel context.CancelFunc configured bool @@ -91,7 +91,7 @@ func newProvider( TLS *config.TLS `mapstructure:"tls"` } - client, err := v1alpha3.NewClient(k8sConf) + client, err := v1alpha4.NewClient(k8sConf) if err != nil { return nil, errorchain.NewWithMessage(heimdall.ErrConfiguration, "failed creating client for connecting to kubernetes cluster").CausedBy(err) @@ -129,7 +129,7 @@ func (p *provider) newController(ctx context.Context, namespace string) (cache.S ListFunc: func(opts metav1.ListOptions) (runtime.Object, error) { return repository.List(ctx, opts) }, WatchFunc: func(opts metav1.ListOptions) (watch.Interface, error) { return repository.Watch(ctx, opts) }, }, - &v1alpha3.RuleSet{}, + &v1alpha4.RuleSet{}, 0, cache.FilteringResourceEventHandler{ FilterFunc: p.filter, @@ -207,7 +207,7 @@ func (p *provider) Stop(ctx context.Context) error { func (p *provider) filter(obj any) bool { // should never be of a different type. ok if panics - rs := obj.(*v1alpha3.RuleSet) // nolint: forcetypeassert + rs := obj.(*v1alpha4.RuleSet) // nolint: forcetypeassert return rs.Spec.AuthClassName == p.ac } @@ -220,7 +220,7 @@ func (p *provider) addRuleSet(obj any) { p.l.Info().Msg("New rule set received") // should never be of a different type. ok if panics - rs := obj.(*v1alpha3.RuleSet) // nolint: forcetypeassert + rs := obj.(*v1alpha4.RuleSet) // nolint: forcetypeassert conf := p.toRuleSetConfiguration(rs) if err := p.p.OnCreated(conf); err != nil { @@ -230,7 +230,7 @@ func (p *provider) addRuleSet(obj any) { context.Background(), rs, metav1.ConditionFalse, - v1alpha3.ConditionRuleSetActivationFailed, + v1alpha4.ConditionRuleSetActivationFailed, 1, 0, fmt.Sprintf("%s instance failed loading RuleSet, reason: %s", p.id, err.Error()), @@ -240,7 +240,7 @@ func (p *provider) addRuleSet(obj any) { context.Background(), rs, metav1.ConditionTrue, - v1alpha3.ConditionRuleSetActive, + v1alpha4.ConditionRuleSetActive, 1, 1, p.id+" instance successfully loaded RuleSet", @@ -254,8 +254,8 @@ func (p *provider) updateRuleSet(oldObj, newObj any) { } // should never be of a different type. ok if panics - newRS := newObj.(*v1alpha3.RuleSet) // nolint: forcetypeassert - oldRS := oldObj.(*v1alpha3.RuleSet) // nolint: forcetypeassert + newRS := newObj.(*v1alpha4.RuleSet) // nolint: forcetypeassert + oldRS := oldObj.(*v1alpha4.RuleSet) // nolint: forcetypeassert if oldRS.Generation == newRS.Generation { // we're only interested in Spec updates. Changes in metadata or status are not of relevance @@ -273,7 +273,7 @@ func (p *provider) updateRuleSet(oldObj, newObj any) { context.Background(), newRS, metav1.ConditionFalse, - v1alpha3.ConditionRuleSetActivationFailed, + v1alpha4.ConditionRuleSetActivationFailed, 0, -1, fmt.Sprintf("%s instance failed updating RuleSet, reason: %s", p.id, err.Error()), @@ -283,7 +283,7 @@ func (p *provider) updateRuleSet(oldObj, newObj any) { context.Background(), newRS, metav1.ConditionTrue, - v1alpha3.ConditionRuleSetActive, + v1alpha4.ConditionRuleSetActive, 0, 0, p.id+" instance successfully reloaded RuleSet", @@ -299,7 +299,7 @@ func (p *provider) deleteRuleSet(obj any) { p.l.Info().Msg("Rule set deletion received") // should never be of a different type. ok if panics - rs := obj.(*v1alpha3.RuleSet) // nolint: forcetypeassert + rs := obj.(*v1alpha4.RuleSet) // nolint: forcetypeassert conf := p.toRuleSetConfiguration(rs) if err := p.p.OnDeleted(conf); err != nil { @@ -309,7 +309,7 @@ func (p *provider) deleteRuleSet(obj any) { context.Background(), rs, metav1.ConditionTrue, - v1alpha3.ConditionRuleSetUnloadingFailed, + v1alpha4.ConditionRuleSetUnloadingFailed, 0, 0, p.id+" instance failed unloading RuleSet, reason: "+err.Error(), @@ -319,7 +319,7 @@ func (p *provider) deleteRuleSet(obj any) { context.Background(), rs, metav1.ConditionFalse, - v1alpha3.ConditionRuleSetUnloaded, + v1alpha4.ConditionRuleSetUnloaded, -1, -1, p.id+" instance dropped RuleSet", @@ -327,7 +327,7 @@ func (p *provider) deleteRuleSet(obj any) { } } -func (p *provider) toRuleSetConfiguration(rs *v1alpha3.RuleSet) *config2.RuleSet { +func (p *provider) toRuleSetConfiguration(rs *v1alpha4.RuleSet) *config2.RuleSet { return &config2.RuleSet{ MetaData: config2.MetaData{ Source: fmt.Sprintf("%s:%s:%s", ProviderType, rs.Namespace, rs.UID), @@ -340,15 +340,15 @@ func (p *provider) toRuleSetConfiguration(rs *v1alpha3.RuleSet) *config2.RuleSet } func (p *provider) mapVersion(_ string) string { - // currently the only possible version is v1alpha3, which is mapped to the version "1alpha3" used internally - return "1alpha3" + // currently the only possible version is v1alpha4, which is mapped to the version "1alpha4" used internally + return "1alpha4" } func (p *provider) updateStatus( ctx context.Context, - rs *v1alpha3.RuleSet, + rs *v1alpha4.RuleSet, status metav1.ConditionStatus, - reason v1alpha3.ConditionReason, + reason v1alpha4.ConditionReason, matchIncrement int, usageIncrement int, msg string, @@ -360,7 +360,7 @@ func (p *provider) updateStatus( conditionType := p.id + "/Reconciliation" - if reason == v1alpha3.ConditionControllerStopped || reason == v1alpha3.ConditionRuleSetUnloaded { + if reason == v1alpha4.ConditionControllerStopped || reason == v1alpha4.ConditionRuleSetUnloaded { meta.RemoveStatusCondition(&modRS.Status.Conditions, conditionType) } else { meta.SetStatusCondition(&modRS.Status.Conditions, metav1.Condition{ @@ -382,7 +382,7 @@ func (p *provider) updateStatus( _, err := repository.PatchStatus( p.l.WithContext(ctx), - v1alpha3.NewJSONPatch(rs, modRS, true), + v1alpha4.NewJSONPatch(rs, modRS, true), metav1.PatchOptions{}, ) if err == nil { @@ -422,10 +422,10 @@ func (p *provider) updateStatus( func (p *provider) finalize(ctx context.Context) { for _, rs := range slicex.Filter( // nolint: forcetypeassert - slicex.Map(p.store.List(), func(s any) *v1alpha3.RuleSet { return s.(*v1alpha3.RuleSet) }), - func(set *v1alpha3.RuleSet) bool { return set.Spec.AuthClassName == p.ac }, + slicex.Map(p.store.List(), func(s any) *v1alpha4.RuleSet { return s.(*v1alpha4.RuleSet) }), + func(set *v1alpha4.RuleSet) bool { return set.Spec.AuthClassName == p.ac }, ) { - p.updateStatus(ctx, rs, metav1.ConditionFalse, v1alpha3.ConditionControllerStopped, -1, -1, + p.updateStatus(ctx, rs, metav1.ConditionFalse, v1alpha4.ConditionControllerStopped, -1, -1, p.id+" instance stopped") } } diff --git a/internal/rules/provider/kubernetes/provider_test.go b/internal/rules/provider/kubernetes/provider_test.go index e708e58e6..00d6b40d6 100644 --- a/internal/rules/provider/kubernetes/provider_test.go +++ b/internal/rules/provider/kubernetes/provider_test.go @@ -41,7 +41,7 @@ import ( "github.com/dadrus/heimdall/internal/config" "github.com/dadrus/heimdall/internal/heimdall" config2 "github.com/dadrus/heimdall/internal/rules/config" - "github.com/dadrus/heimdall/internal/rules/provider/kubernetes/api/v1alpha3" + "github.com/dadrus/heimdall/internal/rules/provider/kubernetes/api/v1alpha4" "github.com/dadrus/heimdall/internal/rules/rule/mocks" "github.com/dadrus/heimdall/internal/x" "github.com/dadrus/heimdall/internal/x/testsupport" @@ -116,18 +116,18 @@ func TestNewProvider(t *testing.T) { } type RuleSetResourceHandler struct { - statusUpdates []*v1alpha3.RuleSetStatus + statusUpdates []*v1alpha4.RuleSetStatus listCallIdx int watchCallIdx int updateStatusCallIdx int - rsCurrent v1alpha3.RuleSet + rsCurrent v1alpha4.RuleSet - rsUpdatedEvt chan v1alpha3.RuleSet - rsCurrentEvt chan v1alpha3.RuleSet + rsUpdatedEvt chan v1alpha4.RuleSet + rsCurrentEvt chan v1alpha4.RuleSet - updateStatus func(rs v1alpha3.RuleSet, callIdx int) (*metav1.Status, error) - watchEvent func(rs v1alpha3.RuleSet, callIdx int) (watch.Event, error) + updateStatus func(rs v1alpha4.RuleSet, callIdx int) (*metav1.Status, error) + watchEvent func(rs v1alpha4.RuleSet, callIdx int) (watch.Event, error) } func (h *RuleSetResourceHandler) close() { @@ -145,7 +145,7 @@ func (h *RuleSetResourceHandler) handle(t *testing.T, r *http.Request, w http.Re case r.URL.Query().Get("watch") == "true": h.watchCallIdx++ h.writeWatchResponse(t, w) - case r.URL.Path == "/apis/heimdall.dadrus.github.com/v1alpha3/rulesets": + case r.URL.Path == "/apis/heimdall.dadrus.github.com/v1alpha4/rulesets": h.listCallIdx++ h.writeListResponse(t, w) default: @@ -171,7 +171,7 @@ func (h *RuleSetResourceHandler) writeWatchResponse(t *testing.T, w http.Respons return } - h.rsCurrent = *wEvt.Object.(*v1alpha3.RuleSet) // nolint: forcetypeassert + h.rsCurrent = *wEvt.Object.(*v1alpha4.RuleSet) // nolint: forcetypeassert h.rsCurrentEvt <- h.rsCurrent @@ -192,9 +192,9 @@ func (h *RuleSetResourceHandler) writeWatchResponse(t *testing.T, w http.Respons func (h *RuleSetResourceHandler) writeListResponse(t *testing.T, w http.ResponseWriter) { t.Helper() - rs := v1alpha3.RuleSet{ + rs := v1alpha4.RuleSet{ TypeMeta: metav1.TypeMeta{ - APIVersion: fmt.Sprintf("%s/%s", v1alpha3.GroupName, v1alpha3.GroupVersion), + APIVersion: fmt.Sprintf("%s/%s", v1alpha4.GroupName, v1alpha4.GroupVersion), Kind: "RuleSet", }, ObjectMeta: metav1.ObjectMeta{ @@ -205,14 +205,18 @@ func (h *RuleSetResourceHandler) writeListResponse(t *testing.T, w http.Response Generation: 1, CreationTimestamp: metav1.NewTime(time.Now()), }, - Spec: v1alpha3.RuleSetSpec{ + Spec: v1alpha4.RuleSetSpec{ AuthClassName: "bar", Rules: []config2.Rule{ { ID: "test", - RuleMatcher: config2.Matcher{ - URL: "http://foo.bar", - Strategy: "glob", + Matcher: config2.Matcher{ + Path: "/", + With: &config2.MatcherConstraints{ + Scheme: "http", + HostGlob: "foo.bar", + Methods: []string{http.MethodGet}, + }, }, Backend: &config2.Backend{ Host: "baz", @@ -223,7 +227,6 @@ func (h *RuleSetResourceHandler) writeListResponse(t *testing.T, w http.Response QueryParamsToRemove: []string{"baz"}, }, }, - Methods: []string{http.MethodGet}, Execute: []config.MechanismConfig{ {"authenticator": "authn"}, {"authorizer": "authz"}, @@ -233,13 +236,13 @@ func (h *RuleSetResourceHandler) writeListResponse(t *testing.T, w http.Response }, } - rsl := v1alpha3.RuleSetList{ + rsl := v1alpha4.RuleSetList{ TypeMeta: metav1.TypeMeta{ - APIVersion: fmt.Sprintf("%s/%s", v1alpha3.GroupName, v1alpha3.GroupVersion), + APIVersion: fmt.Sprintf("%s/%s", v1alpha4.GroupName, v1alpha4.GroupVersion), Kind: "RuleSetList", }, ListMeta: metav1.ListMeta{ResourceVersion: "735820"}, - Items: []v1alpha3.RuleSet{rs}, + Items: []v1alpha4.RuleSet{rs}, } h.rsUpdatedEvt <- rs @@ -272,7 +275,7 @@ func (h *RuleSetResourceHandler) writeUpdateStatusResponse(t *testing.T, r *http updatedRS, err := patch.Apply(rawRS) require.NoError(t, err) - var newRS v1alpha3.RuleSet + var newRS v1alpha4.RuleSet err = json.Unmarshal(updatedRS, &newRS) require.NoError(t, err) @@ -331,15 +334,15 @@ func TestProviderLifecycle(t *testing.T) { for _, tc := range []struct { uc string conf []byte - watchEvent func(rs v1alpha3.RuleSet, callIdx int) (watch.Event, error) - updateStatus func(rs v1alpha3.RuleSet, callIdx int) (*metav1.Status, error) + watchEvent func(rs v1alpha4.RuleSet, callIdx int) (watch.Event, error) + updateStatus func(rs v1alpha4.RuleSet, callIdx int) (*metav1.Status, error) setupProcessor func(t *testing.T, processor *mocks.RuleSetProcessorMock) - assert func(t *testing.T, statusList *[]*v1alpha3.RuleSetStatus, processor *mocks.RuleSetProcessorMock) + assert func(t *testing.T, statusList *[]*v1alpha4.RuleSetStatus, processor *mocks.RuleSetProcessorMock) }{ { uc: "rule set added", conf: []byte("auth_class: bar"), - watchEvent: func(rs v1alpha3.RuleSet, callIdx int) (watch.Event, error) { + watchEvent: func(rs v1alpha4.RuleSet, callIdx int) (watch.Event, error) { switch callIdx { case 1: return watch.Event{Type: watch.Modified, Object: &rs}, nil @@ -354,24 +357,25 @@ func TestProviderLifecycle(t *testing.T) { Run(mock2.NewArgumentCaptor[*config2.RuleSet](&processor.Mock, "captor1").Capture). Return(nil).Once() }, - assert: func(t *testing.T, statusList *[]*v1alpha3.RuleSetStatus, processor *mocks.RuleSetProcessorMock) { + assert: func(t *testing.T, statusList *[]*v1alpha4.RuleSetStatus, processor *mocks.RuleSetProcessorMock) { t.Helper() time.Sleep(250 * time.Millisecond) ruleSet := mock2.ArgumentCaptorFrom[*config2.RuleSet](&processor.Mock, "captor1").Value() assert.Contains(t, ruleSet.Source, "kubernetes:foo:dfb2a2f1-1ad2-4d8c-8456-516fc94abb86") - assert.Equal(t, "1alpha3", ruleSet.Version) + assert.Equal(t, "1alpha4", ruleSet.Version) assert.Equal(t, "test-rule", ruleSet.Name) assert.Len(t, ruleSet.Rules, 1) rule := ruleSet.Rules[0] assert.Equal(t, "test", rule.ID) - assert.Equal(t, "http://foo.bar", rule.RuleMatcher.URL) + assert.Equal(t, "http", rule.Matcher.With.Scheme) + assert.Equal(t, "foo.bar", rule.Matcher.With.HostGlob) + assert.Equal(t, "/", rule.Matcher.Path) + assert.Len(t, rule.Matcher.With.Methods, 1) + assert.Contains(t, rule.Matcher.With.Methods, http.MethodGet) assert.Equal(t, "baz", rule.Backend.Host) - assert.Equal(t, "glob", rule.RuleMatcher.Strategy) - assert.Len(t, rule.Methods, 1) - assert.Contains(t, rule.Methods, http.MethodGet) assert.Empty(t, rule.ErrorHandler) assert.Len(t, rule.Execute, 2) assert.Equal(t, "authn", rule.Execute[0]["authenticator"]) @@ -383,13 +387,13 @@ func TestProviderLifecycle(t *testing.T) { assert.Len(t, (*statusList)[0].Conditions, 1) condition := (*statusList)[0].Conditions[0] assert.Equal(t, metav1.ConditionTrue, condition.Status) - assert.Equal(t, v1alpha3.ConditionRuleSetActive, v1alpha3.ConditionReason(condition.Reason)) + assert.Equal(t, v1alpha4.ConditionRuleSetActive, v1alpha4.ConditionReason(condition.Reason)) }, }, { uc: "adding rule set fails", conf: []byte("auth_class: bar"), - watchEvent: func(rs v1alpha3.RuleSet, _ int) (watch.Event, error) { + watchEvent: func(rs v1alpha4.RuleSet, _ int) (watch.Event, error) { return watch.Event{Type: watch.Bookmark, Object: &rs}, nil }, setupProcessor: func(t *testing.T, processor *mocks.RuleSetProcessorMock) { @@ -397,7 +401,7 @@ func TestProviderLifecycle(t *testing.T) { processor.EXPECT().OnCreated(mock.Anything).Return(testsupport.ErrTestPurpose).Once() }, - assert: func(t *testing.T, statusList *[]*v1alpha3.RuleSetStatus, _ *mocks.RuleSetProcessorMock) { + assert: func(t *testing.T, statusList *[]*v1alpha4.RuleSetStatus, _ *mocks.RuleSetProcessorMock) { t.Helper() time.Sleep(250 * time.Millisecond) @@ -408,13 +412,13 @@ func TestProviderLifecycle(t *testing.T) { assert.Len(t, (*statusList)[0].Conditions, 1) condition := (*statusList)[0].Conditions[0] assert.Equal(t, metav1.ConditionFalse, condition.Status) - assert.Equal(t, v1alpha3.ConditionRuleSetActivationFailed, v1alpha3.ConditionReason(condition.Reason)) + assert.Equal(t, v1alpha4.ConditionRuleSetActivationFailed, v1alpha4.ConditionReason(condition.Reason)) }, }, { uc: "a ruleset is added and then removed", conf: []byte("auth_class: bar"), - watchEvent: func(rs v1alpha3.RuleSet, callIdx int) (watch.Event, error) { + watchEvent: func(rs v1alpha4.RuleSet, callIdx int) (watch.Event, error) { switch callIdx { case 1: return watch.Event{Type: watch.Modified, Object: &rs}, nil @@ -424,7 +428,7 @@ func TestProviderLifecycle(t *testing.T) { return watch.Event{Type: watch.Bookmark, Object: &rs}, nil } }, - updateStatus: func(rs v1alpha3.RuleSet, callIdx int) (*metav1.Status, error) { + updateStatus: func(rs v1alpha4.RuleSet, callIdx int) (*metav1.Status, error) { switch callIdx { case 2: return &metav1.Status{ @@ -453,24 +457,25 @@ func TestProviderLifecycle(t *testing.T) { Run(mock2.NewArgumentCaptor[*config2.RuleSet](&processor.Mock, "captor2").Capture). Return(nil).Once() }, - assert: func(t *testing.T, statusList *[]*v1alpha3.RuleSetStatus, processor *mocks.RuleSetProcessorMock) { + assert: func(t *testing.T, statusList *[]*v1alpha4.RuleSetStatus, processor *mocks.RuleSetProcessorMock) { t.Helper() time.Sleep(250 * time.Millisecond) ruleSet := mock2.ArgumentCaptorFrom[*config2.RuleSet](&processor.Mock, "captor1").Value() assert.Equal(t, "kubernetes:foo:dfb2a2f1-1ad2-4d8c-8456-516fc94abb86", ruleSet.Source) - assert.Equal(t, "1alpha3", ruleSet.Version) + assert.Equal(t, "1alpha4", ruleSet.Version) assert.Equal(t, "test-rule", ruleSet.Name) assert.Len(t, ruleSet.Rules, 1) createdRule := ruleSet.Rules[0] assert.Equal(t, "test", createdRule.ID) - assert.Equal(t, "http://foo.bar", createdRule.RuleMatcher.URL) + assert.Equal(t, "http", createdRule.Matcher.With.Scheme) + assert.Equal(t, "foo.bar", createdRule.Matcher.With.HostGlob) + assert.Equal(t, "/", createdRule.Matcher.Path) + assert.Len(t, createdRule.Matcher.With.Methods, 1) + assert.Contains(t, createdRule.Matcher.With.Methods, http.MethodGet) assert.Equal(t, "baz", createdRule.Backend.Host) - assert.Equal(t, "glob", createdRule.RuleMatcher.Strategy) - assert.Len(t, createdRule.Methods, 1) - assert.Contains(t, createdRule.Methods, http.MethodGet) assert.Empty(t, createdRule.ErrorHandler) assert.Len(t, createdRule.Execute, 2) assert.Equal(t, "authn", createdRule.Execute[0]["authenticator"]) @@ -478,7 +483,7 @@ func TestProviderLifecycle(t *testing.T) { ruleSet = mock2.ArgumentCaptorFrom[*config2.RuleSet](&processor.Mock, "captor2").Value() assert.Equal(t, "kubernetes:foo:dfb2a2f1-1ad2-4d8c-8456-516fc94abb86", ruleSet.Source) - assert.Equal(t, "1alpha3", ruleSet.Version) + assert.Equal(t, "1alpha4", ruleSet.Version) assert.Equal(t, "test-rule", ruleSet.Name) assert.Len(t, *statusList, 1) @@ -487,13 +492,13 @@ func TestProviderLifecycle(t *testing.T) { assert.Len(t, (*statusList)[0].Conditions, 1) condition := (*statusList)[0].Conditions[0] assert.Equal(t, metav1.ConditionTrue, condition.Status) - assert.Equal(t, v1alpha3.ConditionRuleSetActive, v1alpha3.ConditionReason(condition.Reason)) + assert.Equal(t, v1alpha4.ConditionRuleSetActive, v1alpha4.ConditionReason(condition.Reason)) }, }, { uc: "a ruleset is added with failing status update", conf: []byte("auth_class: bar"), - watchEvent: func(rs v1alpha3.RuleSet, callIdx int) (watch.Event, error) { + watchEvent: func(rs v1alpha4.RuleSet, callIdx int) (watch.Event, error) { switch callIdx { case 1: rv, err := strconv.Atoi(rs.ResourceVersion) @@ -506,7 +511,7 @@ func TestProviderLifecycle(t *testing.T) { return watch.Event{Type: watch.Bookmark, Object: &rs}, nil } }, - updateStatus: func(_ v1alpha3.RuleSet, _ int) (*metav1.Status, error) { + updateStatus: func(_ v1alpha4.RuleSet, _ int) (*metav1.Status, error) { return nil, errors.New("test error") }, setupProcessor: func(t *testing.T, processor *mocks.RuleSetProcessorMock) { @@ -516,24 +521,25 @@ func TestProviderLifecycle(t *testing.T) { Run(mock2.NewArgumentCaptor[*config2.RuleSet](&processor.Mock, "captor1").Capture). Return(nil).Once() }, - assert: func(t *testing.T, statusList *[]*v1alpha3.RuleSetStatus, processor *mocks.RuleSetProcessorMock) { + assert: func(t *testing.T, statusList *[]*v1alpha4.RuleSetStatus, processor *mocks.RuleSetProcessorMock) { t.Helper() time.Sleep(250 * time.Millisecond) ruleSet := mock2.ArgumentCaptorFrom[*config2.RuleSet](&processor.Mock, "captor1").Value() assert.Equal(t, "kubernetes:foo:dfb2a2f1-1ad2-4d8c-8456-516fc94abb86", ruleSet.Source) - assert.Equal(t, "1alpha3", ruleSet.Version) + assert.Equal(t, "1alpha4", ruleSet.Version) assert.Equal(t, "test-rule", ruleSet.Name) assert.Len(t, ruleSet.Rules, 1) createdRule := ruleSet.Rules[0] assert.Equal(t, "test", createdRule.ID) - assert.Equal(t, "http://foo.bar", createdRule.RuleMatcher.URL) + assert.Equal(t, "http", createdRule.Matcher.With.Scheme) + assert.Equal(t, "foo.bar", createdRule.Matcher.With.HostGlob) + assert.Equal(t, "/", createdRule.Matcher.Path) + assert.Len(t, createdRule.Matcher.With.Methods, 1) + assert.Contains(t, createdRule.Matcher.With.Methods, http.MethodGet) assert.Equal(t, "baz", createdRule.Backend.Host) - assert.Equal(t, "glob", createdRule.RuleMatcher.Strategy) - assert.Len(t, createdRule.Methods, 1) - assert.Contains(t, createdRule.Methods, http.MethodGet) assert.Empty(t, createdRule.ErrorHandler) assert.Len(t, createdRule.Execute, 2) assert.Equal(t, "authn", createdRule.Execute[0]["authenticator"]) @@ -545,7 +551,7 @@ func TestProviderLifecycle(t *testing.T) { { uc: "a ruleset is added with conflicting status update", conf: []byte("auth_class: bar"), - watchEvent: func(rs v1alpha3.RuleSet, callIdx int) (watch.Event, error) { + watchEvent: func(rs v1alpha4.RuleSet, callIdx int) (watch.Event, error) { switch callIdx { case 1: rv, err := strconv.Atoi(rs.ResourceVersion) @@ -558,7 +564,7 @@ func TestProviderLifecycle(t *testing.T) { return watch.Event{Type: watch.Bookmark, Object: &rs}, nil } }, - updateStatus: func(rs v1alpha3.RuleSet, callIdx int) (*metav1.Status, error) { + updateStatus: func(rs v1alpha4.RuleSet, callIdx int) (*metav1.Status, error) { switch callIdx { case 1: return &metav1.Status{ @@ -583,24 +589,25 @@ func TestProviderLifecycle(t *testing.T) { Run(mock2.NewArgumentCaptor[*config2.RuleSet](&processor.Mock, "captor1").Capture). Return(nil).Once() }, - assert: func(t *testing.T, statusList *[]*v1alpha3.RuleSetStatus, processor *mocks.RuleSetProcessorMock) { + assert: func(t *testing.T, statusList *[]*v1alpha4.RuleSetStatus, processor *mocks.RuleSetProcessorMock) { t.Helper() time.Sleep(250 * time.Millisecond) ruleSet := mock2.ArgumentCaptorFrom[*config2.RuleSet](&processor.Mock, "captor1").Value() assert.Equal(t, "kubernetes:foo:dfb2a2f1-1ad2-4d8c-8456-516fc94abb86", ruleSet.Source) - assert.Equal(t, "1alpha3", ruleSet.Version) + assert.Equal(t, "1alpha4", ruleSet.Version) assert.Equal(t, "test-rule", ruleSet.Name) assert.Len(t, ruleSet.Rules, 1) createdRule := ruleSet.Rules[0] assert.Equal(t, "test", createdRule.ID) - assert.Equal(t, "http://foo.bar", createdRule.RuleMatcher.URL) + assert.Equal(t, "http", createdRule.Matcher.With.Scheme) + assert.Equal(t, "foo.bar", createdRule.Matcher.With.HostGlob) + assert.Equal(t, "/", createdRule.Matcher.Path) + assert.Len(t, createdRule.Matcher.With.Methods, 1) + assert.Contains(t, createdRule.Matcher.With.Methods, http.MethodGet) assert.Equal(t, "baz", createdRule.Backend.Host) - assert.Equal(t, "glob", createdRule.RuleMatcher.Strategy) - assert.Len(t, createdRule.Methods, 1) - assert.Contains(t, createdRule.Methods, http.MethodGet) assert.Empty(t, createdRule.ErrorHandler) assert.Len(t, createdRule.Execute, 2) assert.Equal(t, "authn", createdRule.Execute[0]["authenticator"]) @@ -612,13 +619,13 @@ func TestProviderLifecycle(t *testing.T) { assert.Len(t, (*statusList)[0].Conditions, 1) condition := (*statusList)[0].Conditions[0] assert.Equal(t, metav1.ConditionTrue, condition.Status) - assert.Equal(t, v1alpha3.ConditionRuleSetActive, v1alpha3.ConditionReason(condition.Reason)) + assert.Equal(t, v1alpha4.ConditionRuleSetActive, v1alpha4.ConditionReason(condition.Reason)) }, }, { uc: "removing rule set fails", conf: []byte("auth_class: bar"), - watchEvent: func(rs v1alpha3.RuleSet, callIdx int) (watch.Event, error) { + watchEvent: func(rs v1alpha4.RuleSet, callIdx int) (watch.Event, error) { switch callIdx { case 1: return watch.Event{Type: watch.Modified, Object: &rs}, nil @@ -635,7 +642,7 @@ func TestProviderLifecycle(t *testing.T) { processor.EXPECT().OnCreated(mock.Anything).Return(nil).Once() processor.EXPECT().OnDeleted(mock.Anything).Return(testsupport.ErrTestPurpose).Once() }, - assert: func(t *testing.T, statusList *[]*v1alpha3.RuleSetStatus, _ *mocks.RuleSetProcessorMock) { + assert: func(t *testing.T, statusList *[]*v1alpha4.RuleSetStatus, _ *mocks.RuleSetProcessorMock) { t.Helper() time.Sleep(250 * time.Millisecond) @@ -645,19 +652,19 @@ func TestProviderLifecycle(t *testing.T) { assert.Len(t, (*statusList)[0].Conditions, 1) condition := (*statusList)[0].Conditions[0] assert.Equal(t, metav1.ConditionTrue, condition.Status) - assert.Equal(t, v1alpha3.ConditionRuleSetActive, v1alpha3.ConditionReason(condition.Reason)) + assert.Equal(t, v1alpha4.ConditionRuleSetActive, v1alpha4.ConditionReason(condition.Reason)) assert.Equal(t, "1/1", (*statusList)[1].ActiveIn) assert.Len(t, (*statusList)[1].Conditions, 1) condition = (*statusList)[1].Conditions[0] assert.Equal(t, metav1.ConditionTrue, condition.Status) - assert.Equal(t, v1alpha3.ConditionRuleSetUnloadingFailed, v1alpha3.ConditionReason(condition.Reason)) + assert.Equal(t, v1alpha4.ConditionRuleSetUnloadingFailed, v1alpha4.ConditionReason(condition.Reason)) }, }, { uc: "a ruleset is added and then updated", conf: []byte("auth_class: bar"), - watchEvent: func(rs v1alpha3.RuleSet, callIdx int) (watch.Event, error) { + watchEvent: func(rs v1alpha4.RuleSet, callIdx int) (watch.Event, error) { switch callIdx { case 1: return watch.Event{Type: watch.Modified, Object: &rs}, nil @@ -667,14 +674,18 @@ func TestProviderLifecycle(t *testing.T) { rs.ResourceVersion = strconv.Itoa(rv + 1) rs.Generation++ - rs.Spec = v1alpha3.RuleSetSpec{ + rs.Spec = v1alpha4.RuleSetSpec{ AuthClassName: "bar", Rules: []config2.Rule{ { ID: "test", - RuleMatcher: config2.Matcher{ - URL: "http://foo.bar", - Strategy: "glob", + Matcher: config2.Matcher{ + Path: "/", + With: &config2.MatcherConstraints{ + Scheme: "http", + HostGlob: "foo.bar", + Methods: []string{http.MethodGet}, + }, }, Backend: &config2.Backend{ Host: "bar", @@ -685,7 +696,6 @@ func TestProviderLifecycle(t *testing.T) { QueryParamsToRemove: []string{"baz"}, }, }, - Methods: []string{http.MethodGet}, Execute: []config.MechanismConfig{ {"authenticator": "test_authn"}, {"authorizer": "test_authz"}, @@ -711,24 +721,25 @@ func TestProviderLifecycle(t *testing.T) { Run(mock2.NewArgumentCaptor[*config2.RuleSet](&processor.Mock, "captor2").Capture). Return(nil).Once() }, - assert: func(t *testing.T, statusList *[]*v1alpha3.RuleSetStatus, processor *mocks.RuleSetProcessorMock) { + assert: func(t *testing.T, statusList *[]*v1alpha4.RuleSetStatus, processor *mocks.RuleSetProcessorMock) { t.Helper() time.Sleep(250 * time.Millisecond) ruleSet := mock2.ArgumentCaptorFrom[*config2.RuleSet](&processor.Mock, "captor1").Value() assert.Equal(t, "kubernetes:foo:dfb2a2f1-1ad2-4d8c-8456-516fc94abb86", ruleSet.Source) - assert.Equal(t, "1alpha3", ruleSet.Version) + assert.Equal(t, "1alpha4", ruleSet.Version) assert.Equal(t, "test-rule", ruleSet.Name) assert.Len(t, ruleSet.Rules, 1) createdRule := ruleSet.Rules[0] assert.Equal(t, "test", createdRule.ID) - assert.Equal(t, "http://foo.bar", createdRule.RuleMatcher.URL) + assert.Equal(t, "http", createdRule.Matcher.With.Scheme) + assert.Equal(t, "foo.bar", createdRule.Matcher.With.HostGlob) + assert.Equal(t, "/", createdRule.Matcher.Path) + assert.Len(t, createdRule.Matcher.With.Methods, 1) + assert.Contains(t, createdRule.Matcher.With.Methods, http.MethodGet) assert.Equal(t, "baz", createdRule.Backend.Host) - assert.Equal(t, "glob", createdRule.RuleMatcher.Strategy) - assert.Len(t, createdRule.Methods, 1) - assert.Contains(t, createdRule.Methods, http.MethodGet) assert.Empty(t, createdRule.ErrorHandler) assert.Len(t, createdRule.Execute, 2) assert.Equal(t, "authn", createdRule.Execute[0]["authenticator"]) @@ -736,17 +747,18 @@ func TestProviderLifecycle(t *testing.T) { ruleSet = mock2.ArgumentCaptorFrom[*config2.RuleSet](&processor.Mock, "captor2").Value() assert.Equal(t, "kubernetes:foo:dfb2a2f1-1ad2-4d8c-8456-516fc94abb86", ruleSet.Source) - assert.Equal(t, "1alpha3", ruleSet.Version) + assert.Equal(t, "1alpha4", ruleSet.Version) assert.Equal(t, "test-rule", ruleSet.Name) assert.Len(t, ruleSet.Rules, 1) updatedRule := ruleSet.Rules[0] assert.Equal(t, "test", updatedRule.ID) - assert.Equal(t, "http://foo.bar", updatedRule.RuleMatcher.URL) + assert.Equal(t, "http", createdRule.Matcher.With.Scheme) + assert.Equal(t, "foo.bar", createdRule.Matcher.With.HostGlob) + assert.Equal(t, "/", createdRule.Matcher.Path) + assert.Len(t, createdRule.Matcher.With.Methods, 1) + assert.Contains(t, createdRule.Matcher.With.Methods, http.MethodGet) assert.Equal(t, "bar", updatedRule.Backend.Host) - assert.Equal(t, "glob", updatedRule.RuleMatcher.Strategy) - assert.Len(t, updatedRule.Methods, 1) - assert.Contains(t, updatedRule.Methods, http.MethodGet) assert.Empty(t, updatedRule.ErrorHandler) assert.Len(t, updatedRule.Execute, 2) assert.Equal(t, "test_authn", updatedRule.Execute[0]["authenticator"]) @@ -757,19 +769,19 @@ func TestProviderLifecycle(t *testing.T) { assert.Len(t, (*statusList)[0].Conditions, 1) condition := (*statusList)[0].Conditions[0] assert.Equal(t, metav1.ConditionTrue, condition.Status) - assert.Equal(t, v1alpha3.ConditionRuleSetActive, v1alpha3.ConditionReason(condition.Reason)) + assert.Equal(t, v1alpha4.ConditionRuleSetActive, v1alpha4.ConditionReason(condition.Reason)) assert.Equal(t, "1/1", (*statusList)[1].ActiveIn) assert.Len(t, (*statusList)[1].Conditions, 1) condition = (*statusList)[1].Conditions[0] assert.Equal(t, metav1.ConditionTrue, condition.Status) - assert.Equal(t, v1alpha3.ConditionRuleSetActive, v1alpha3.ConditionReason(condition.Reason)) + assert.Equal(t, v1alpha4.ConditionRuleSetActive, v1alpha4.ConditionReason(condition.Reason)) }, }, { uc: "a ruleset is added and then updated with a mismatching authClassName", conf: []byte("auth_class: bar"), - watchEvent: func(rs v1alpha3.RuleSet, callIdx int) (watch.Event, error) { + watchEvent: func(rs v1alpha4.RuleSet, callIdx int) (watch.Event, error) { switch callIdx { case 1: rs.Status.ActiveIn = "1/1" @@ -800,24 +812,25 @@ func TestProviderLifecycle(t *testing.T) { Run(mock2.NewArgumentCaptor[*config2.RuleSet](&processor.Mock, "captor2").Capture). Return(nil).Once() }, - assert: func(t *testing.T, statusList *[]*v1alpha3.RuleSetStatus, processor *mocks.RuleSetProcessorMock) { + assert: func(t *testing.T, statusList *[]*v1alpha4.RuleSetStatus, processor *mocks.RuleSetProcessorMock) { t.Helper() time.Sleep(250 * time.Millisecond) ruleSet := mock2.ArgumentCaptorFrom[*config2.RuleSet](&processor.Mock, "captor1").Value() assert.Equal(t, "kubernetes:foo:dfb2a2f1-1ad2-4d8c-8456-516fc94abb86", ruleSet.Source) - assert.Equal(t, "1alpha3", ruleSet.Version) + assert.Equal(t, "1alpha4", ruleSet.Version) assert.Equal(t, "test-rule", ruleSet.Name) assert.Len(t, ruleSet.Rules, 1) createdRule := ruleSet.Rules[0] assert.Equal(t, "test", createdRule.ID) - assert.Equal(t, "http://foo.bar", createdRule.RuleMatcher.URL) + assert.Equal(t, "http", createdRule.Matcher.With.Scheme) + assert.Equal(t, "foo.bar", createdRule.Matcher.With.HostGlob) + assert.Equal(t, "/", createdRule.Matcher.Path) + assert.Len(t, createdRule.Matcher.With.Methods, 1) + assert.Contains(t, createdRule.Matcher.With.Methods, http.MethodGet) assert.Equal(t, "baz", createdRule.Backend.Host) - assert.Equal(t, "glob", createdRule.RuleMatcher.Strategy) - assert.Len(t, createdRule.Methods, 1) - assert.Contains(t, createdRule.Methods, http.MethodGet) assert.Empty(t, createdRule.ErrorHandler) assert.Len(t, createdRule.Execute, 2) assert.Equal(t, "authn", createdRule.Execute[0]["authenticator"]) @@ -825,17 +838,18 @@ func TestProviderLifecycle(t *testing.T) { ruleSet = mock2.ArgumentCaptorFrom[*config2.RuleSet](&processor.Mock, "captor2").Value() assert.Equal(t, "kubernetes:foo:dfb2a2f1-1ad2-4d8c-8456-516fc94abb86", ruleSet.Source) - assert.Equal(t, "1alpha3", ruleSet.Version) + assert.Equal(t, "1alpha4", ruleSet.Version) assert.Equal(t, "test-rule", ruleSet.Name) assert.Len(t, ruleSet.Rules, 1) deleteRule := ruleSet.Rules[0] assert.Equal(t, "test", deleteRule.ID) - assert.Equal(t, "http://foo.bar", deleteRule.RuleMatcher.URL) + assert.Equal(t, "http", createdRule.Matcher.With.Scheme) + assert.Equal(t, "foo.bar", createdRule.Matcher.With.HostGlob) + assert.Equal(t, "/", createdRule.Matcher.Path) + assert.Len(t, createdRule.Matcher.With.Methods, 1) + assert.Contains(t, createdRule.Matcher.With.Methods, http.MethodGet) assert.Equal(t, "baz", deleteRule.Backend.Host) - assert.Equal(t, "glob", deleteRule.RuleMatcher.Strategy) - assert.Len(t, deleteRule.Methods, 1) - assert.Contains(t, deleteRule.Methods, http.MethodGet) assert.Empty(t, deleteRule.ErrorHandler) assert.Len(t, deleteRule.Execute, 2) assert.Equal(t, "authn", deleteRule.Execute[0]["authenticator"]) @@ -846,13 +860,13 @@ func TestProviderLifecycle(t *testing.T) { assert.Len(t, (*statusList)[0].Conditions, 1) condition := (*statusList)[0].Conditions[0] assert.Equal(t, metav1.ConditionTrue, condition.Status) - assert.Equal(t, v1alpha3.ConditionRuleSetActive, v1alpha3.ConditionReason(condition.Reason)) + assert.Equal(t, v1alpha4.ConditionRuleSetActive, v1alpha4.ConditionReason(condition.Reason)) }, }, { uc: "failed updating rule set", conf: []byte("auth_class: bar"), - watchEvent: func(rs v1alpha3.RuleSet, callIdx int) (watch.Event, error) { + watchEvent: func(rs v1alpha4.RuleSet, callIdx int) (watch.Event, error) { switch callIdx { case 1: return watch.Event{Type: watch.Modified, Object: &rs}, nil @@ -862,14 +876,18 @@ func TestProviderLifecycle(t *testing.T) { rs.ResourceVersion = strconv.Itoa(rv + 1) rs.Generation++ - rs.Spec = v1alpha3.RuleSetSpec{ + rs.Spec = v1alpha4.RuleSetSpec{ AuthClassName: "bar", Rules: []config2.Rule{ { ID: "test", - RuleMatcher: config2.Matcher{ - URL: "http://foo.bar", - Strategy: "glob", + Matcher: config2.Matcher{ + Path: "/", + With: &config2.MatcherConstraints{ + Scheme: "http", + HostGlob: "foo.bar", + Methods: []string{http.MethodGet}, + }, }, Backend: &config2.Backend{ Host: "bar", @@ -880,7 +898,6 @@ func TestProviderLifecycle(t *testing.T) { QueryParamsToRemove: []string{"baz"}, }, }, - Methods: []string{http.MethodGet}, Execute: []config.MechanismConfig{ {"authenticator": "test_authn"}, {"authorizer": "test_authz"}, @@ -901,7 +918,7 @@ func TestProviderLifecycle(t *testing.T) { processor.EXPECT().OnCreated(mock.Anything).Return(nil).Once() processor.EXPECT().OnUpdated(mock.Anything).Return(testsupport.ErrTestPurpose).Once() }, - assert: func(t *testing.T, statusList *[]*v1alpha3.RuleSetStatus, _ *mocks.RuleSetProcessorMock) { + assert: func(t *testing.T, statusList *[]*v1alpha4.RuleSetStatus, _ *mocks.RuleSetProcessorMock) { t.Helper() time.Sleep(250 * time.Millisecond) @@ -911,13 +928,13 @@ func TestProviderLifecycle(t *testing.T) { assert.Len(t, (*statusList)[0].Conditions, 1) condition := (*statusList)[0].Conditions[0] assert.Equal(t, metav1.ConditionTrue, condition.Status) - assert.Equal(t, v1alpha3.ConditionRuleSetActive, v1alpha3.ConditionReason(condition.Reason)) + assert.Equal(t, v1alpha4.ConditionRuleSetActive, v1alpha4.ConditionReason(condition.Reason)) assert.Equal(t, "0/1", (*statusList)[1].ActiveIn) assert.Len(t, (*statusList)[1].Conditions, 1) condition = (*statusList)[1].Conditions[0] assert.Equal(t, metav1.ConditionFalse, condition.Status) - assert.Equal(t, v1alpha3.ConditionRuleSetActivationFailed, v1alpha3.ConditionReason(condition.Reason)) + assert.Equal(t, v1alpha4.ConditionRuleSetActivationFailed, v1alpha4.ConditionReason(condition.Reason)) }, }, } { @@ -927,8 +944,8 @@ func TestProviderLifecycle(t *testing.T) { require.NoError(t, err) handler := &RuleSetResourceHandler{ - rsUpdatedEvt: make(chan v1alpha3.RuleSet, 2), - rsCurrentEvt: make(chan v1alpha3.RuleSet, 2), + rsUpdatedEvt: make(chan v1alpha4.RuleSet, 2), + rsCurrentEvt: make(chan v1alpha4.RuleSet, 2), watchEvent: tc.watchEvent, updateStatus: tc.updateStatus, } diff --git a/internal/rules/repository_impl.go b/internal/rules/repository_impl.go index c846b1f2a..5bfafcb78 100644 --- a/internal/rules/repository_impl.go +++ b/internal/rules/repository_impl.go @@ -18,264 +18,199 @@ package rules import ( "bytes" - "context" - "net/url" + "slices" "sync" - "github.com/rs/zerolog" - "github.com/dadrus/heimdall/internal/heimdall" - "github.com/dadrus/heimdall/internal/rules/event" "github.com/dadrus/heimdall/internal/rules/rule" "github.com/dadrus/heimdall/internal/x" "github.com/dadrus/heimdall/internal/x/errorchain" + "github.com/dadrus/heimdall/internal/x/radixtree" "github.com/dadrus/heimdall/internal/x/slicex" ) -func newRepository( - queue event.RuleSetChangedEventQueue, - ruleFactory rule.Factory, - logger zerolog.Logger, -) *repository { +type repository struct { + dr rule.Rule + + knownRules []rule.Rule + knownRulesMutex sync.Mutex + + index *radixtree.Tree[rule.Rule] + rulesTreeMutex sync.RWMutex +} + +func newRepository(ruleFactory rule.Factory) rule.Repository { return &repository{ dr: x.IfThenElseExec(ruleFactory.HasDefaultRule(), func() rule.Rule { return ruleFactory.DefaultRule() }, func() rule.Rule { return nil }), - logger: logger, - queue: queue, - quit: make(chan bool), + index: radixtree.New[rule.Rule]( + radixtree.WithValuesConstraints(func(oldValues []rule.Rule, newValue rule.Rule) bool { + // only rules from the same rule set can be placed in one node + return len(oldValues) == 0 || oldValues[0].SrcID() == newValue.SrcID() + }), + ), } } -type repository struct { - dr rule.Rule - logger zerolog.Logger - - rules []rule.Rule - mutex sync.RWMutex - - queue event.RuleSetChangedEventQueue - quit chan bool -} +func (r *repository) FindRule(ctx heimdall.Context) (rule.Rule, error) { + request := ctx.Request() -func (r *repository) FindRule(requestURL *url.URL) (rule.Rule, error) { - r.mutex.RLock() - defer r.mutex.RUnlock() + r.rulesTreeMutex.RLock() + defer r.rulesTreeMutex.RUnlock() - for _, rul := range r.rules { - if rul.MatchesURL(requestURL) { - return rul, nil + entry, err := r.index.Find( + x.IfThenElse(len(request.URL.RawPath) != 0, request.URL.RawPath, request.URL.Path), + radixtree.MatcherFunc[rule.Rule](func(candidate rule.Rule) bool { return candidate.Matches(ctx) }), + ) + if err != nil { + if r.dr != nil { + return r.dr, nil } - } - if r.dr != nil { - return r.dr, nil + return nil, errorchain.NewWithMessagef(heimdall.ErrNoRuleFound, + "no applicable rule found for %s", request.URL.String()) } - return nil, errorchain.NewWithMessagef(heimdall.ErrNoRuleFound, - "no applicable rule found for %s", requestURL.String()) -} - -func (r *repository) Start(_ context.Context) error { - r.logger.Info().Msg("Starting rule definition loader") - - go r.watchRuleSetChanges() + request.URL.Captures = entry.Parameters - return nil + return entry.Value, nil } -func (r *repository) Stop(_ context.Context) error { - r.logger.Info().Msg("Tearing down rule definition loader") - - r.quit <- true +func (r *repository) AddRuleSet(_ string, rules []rule.Rule) error { + r.knownRulesMutex.Lock() + defer r.knownRulesMutex.Unlock() - close(r.quit) + tmp := r.index.Clone() - return nil -} - -func (r *repository) watchRuleSetChanges() { - for { - select { - case evt, ok := <-r.queue: - if !ok { - r.logger.Debug().Msg("Rule set definition queue closed") - } - - switch evt.ChangeType { - case event.Create: - r.addRuleSet(evt.Source, evt.Rules) - case event.Update: - r.updateRuleSet(evt.Source, evt.Rules) - case event.Remove: - r.deleteRuleSet(evt.Source) - } - case <-r.quit: - r.logger.Info().Msg("Rule definition loader stopped") - - return - } + if err := r.addRulesTo(tmp, rules); err != nil { + return err } -} -func (r *repository) addRuleSet(srcID string, rules []rule.Rule) { - // create rules - r.logger.Info().Str("_src", srcID).Msg("Adding rule set") + r.knownRules = append(r.knownRules, rules...) - r.mutex.Lock() - defer r.mutex.Unlock() + r.rulesTreeMutex.Lock() + r.index = tmp + r.rulesTreeMutex.Unlock() - // add them - r.addRules(rules) + return nil } -func (r *repository) updateRuleSet(srcID string, rules []rule.Rule) { +func (r *repository) UpdateRuleSet(srcID string, rules []rule.Rule) error { // create rules - r.logger.Info().Str("_src", srcID).Msg("Updating rule set") + r.knownRulesMutex.Lock() + defer r.knownRulesMutex.Unlock() // find all rules for the given src id - applicable := func() []rule.Rule { - r.mutex.Lock() - defer r.mutex.Unlock() + applicable := slicex.Filter(r.knownRules, func(r rule.Rule) bool { return r.SrcID() == srcID }) - return slicex.Filter(r.rules, func(r rule.Rule) bool { return r.SrcID() == srcID }) - }() + // find new rules, as well as those, which have been changed. + toBeAdded := slicex.Filter(rules, func(newRule rule.Rule) bool { + candidate := newRule.(*ruleImpl) //nolint: forcetypeassert - // find new rules - newRules := slicex.Filter(rules, func(r rule.Rule) bool { - var known bool + ruleIsNew := !slices.ContainsFunc(applicable, func(existingRule rule.Rule) bool { + return existingRule.ID() == newRule.ID() + }) - for _, existing := range applicable { - if existing.ID() == r.ID() { - known = true + ruleChanged := slices.ContainsFunc(applicable, func(existingRule rule.Rule) bool { + existing := existingRule.(*ruleImpl) //nolint: forcetypeassert - break - } - } + return existing.ID() == candidate.ID() && !bytes.Equal(existing.hash, candidate.hash) + }) - return !known + return ruleIsNew || ruleChanged }) - // find updated rules - updatedRules := slicex.Filter(rules, func(r rule.Rule) bool { - loaded := r.(*ruleImpl) // nolint: forcetypeassert + // find deleted rules, as well as those, which have been changed. + toBeDeleted := slicex.Filter(applicable, func(existingRule rule.Rule) bool { + existing := existingRule.(*ruleImpl) //nolint: forcetypeassert - var updated bool + ruleGone := !slices.ContainsFunc(rules, func(newRule rule.Rule) bool { + return newRule.ID() == existingRule.ID() + }) - for _, existing := range applicable { - known := existing.(*ruleImpl) // nolint: forcetypeassert + ruleChanged := slices.ContainsFunc(rules, func(newRule rule.Rule) bool { + candidate := newRule.(*ruleImpl) //nolint: forcetypeassert - if known.id == loaded.id && !bytes.Equal(known.hash, loaded.hash) { - updated = true + return existing.ID() == candidate.ID() && !bytes.Equal(existing.hash, candidate.hash) + }) - break - } - } - - return updated + return ruleGone || ruleChanged }) - // find deleted rules - deletedRules := slicex.Filter(applicable, func(r rule.Rule) bool { - var present bool + tmp := r.index.Clone() - for _, loaded := range rules { - if loaded.ID() == r.ID() { - present = true + // delete rules + if err := r.removeRulesFrom(tmp, toBeDeleted); err != nil { + return err + } - break - } - } + // add rules + if err := r.addRulesTo(tmp, toBeAdded); err != nil { + return err + } - return !present + r.knownRules = slices.DeleteFunc(r.knownRules, func(loaded rule.Rule) bool { + return slices.Contains(toBeDeleted, loaded) }) + r.knownRules = append(r.knownRules, toBeAdded...) - func() { - r.mutex.Lock() - defer r.mutex.Unlock() - - // remove deleted rules - r.removeRules(deletedRules) + r.rulesTreeMutex.Lock() + r.index = tmp + r.rulesTreeMutex.Unlock() - // replace updated rules - r.replaceRules(updatedRules) - - // add new rules - r.addRules(newRules) - }() + return nil } -func (r *repository) deleteRuleSet(srcID string) { - r.logger.Info().Str("_src", srcID).Msg("Deleting rule set") - - r.mutex.Lock() - defer r.mutex.Unlock() +func (r *repository) DeleteRuleSet(srcID string) error { + r.knownRulesMutex.Lock() + defer r.knownRulesMutex.Unlock() // find all rules for the given src id - applicable := slicex.Filter(r.rules, func(r rule.Rule) bool { return r.SrcID() == srcID }) - - // remove them - r.removeRules(applicable) -} - -func (r *repository) addRules(rules []rule.Rule) { - for _, rul := range rules { - r.rules = append(r.rules, rul) - - r.logger.Debug().Str("_src", rul.SrcID()).Str("_id", rul.ID()).Msg("Rule added") - } -} - -func (r *repository) removeRules(rules []rule.Rule) { - // find all indexes for affected rules - var idxs []int + applicable := slicex.Filter(r.knownRules, func(r rule.Rule) bool { return r.SrcID() == srcID }) - for idx, rul := range r.rules { - for _, tbd := range rules { - if rul.SrcID() == tbd.SrcID() && rul.ID() == tbd.ID() { - idxs = append(idxs, idx) + tmp := r.index.Clone() - r.logger.Debug().Str("_src", rul.SrcID()).Str("_id", rul.ID()).Msg("Rule removed") - } - } + // remove them + if err := r.removeRulesFrom(tmp, applicable); err != nil { + return err } - // if all rules should be dropped, just create a new slice - if len(idxs) == len(r.rules) { - r.rules = nil - - return - } + r.knownRules = slices.DeleteFunc(r.knownRules, func(r rule.Rule) bool { + return slices.Contains(applicable, r) + }) - // move the elements from the end of the rules slice to the found positions - // and set the corresponding "emptied" values to nil - for i, idx := range idxs { - tailIdx := len(r.rules) - (1 + i) + r.rulesTreeMutex.Lock() + r.index = tmp + r.rulesTreeMutex.Unlock() - r.rules[idx] = r.rules[tailIdx] + return nil +} - // the below re-slice preserves the capacity of the slice. - // this is required to avoid memory leaks - r.rules[tailIdx] = nil +func (r *repository) addRulesTo(tree *radixtree.Tree[rule.Rule], rules []rule.Rule) error { + for _, rul := range rules { + if err := tree.Add( + rul.PathExpression(), + rul, + radixtree.WithBacktracking[rule.Rule](rul.BacktrackingEnabled())); err != nil { + return errorchain.NewWithMessagef(heimdall.ErrInternal, "failed adding rule ID='%s'", rul.ID()). + CausedBy(err) + } } - // re-slice - r.rules = r.rules[:len(r.rules)-len(idxs)] + return nil } -func (r *repository) replaceRules(rules []rule.Rule) { - for _, updated := range rules { - for idx, existing := range r.rules { - if updated.SrcID() == existing.SrcID() && existing.ID() == updated.ID() { - r.rules[idx] = updated - - r.logger.Debug(). - Str("_src", existing.SrcID()). - Str("_id", existing.ID()). - Msg("Rule updated") - - break - } +func (r *repository) removeRulesFrom(tree *radixtree.Tree[rule.Rule], tbdRules []rule.Rule) error { + for _, rul := range tbdRules { + if err := tree.Delete( + rul.PathExpression(), + radixtree.MatcherFunc[rule.Rule](func(existing rule.Rule) bool { return existing.SameAs(rul) }), + ); err != nil { + return errorchain.NewWithMessagef(heimdall.ErrInternal, "failed deleting rule ID='%s'", rul.ID()). + CausedBy(err) } } + + return nil } diff --git a/internal/rules/repository_impl_test.go b/internal/rules/repository_impl_test.go index 9d84f17ee..af7e8f957 100644 --- a/internal/rules/repository_impl_test.go +++ b/internal/rules/repository_impl_test.go @@ -18,45 +18,212 @@ package rules import ( "context" + "net/http" "net/url" "testing" - "time" - "github.com/rs/zerolog" - "github.com/rs/zerolog/log" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "github.com/dadrus/heimdall/internal/heimdall" - "github.com/dadrus/heimdall/internal/rules/event" - "github.com/dadrus/heimdall/internal/rules/patternmatcher" + mocks2 "github.com/dadrus/heimdall/internal/heimdall/mocks" + "github.com/dadrus/heimdall/internal/rules/config" + mocks3 "github.com/dadrus/heimdall/internal/rules/config/mocks" "github.com/dadrus/heimdall/internal/rules/rule" "github.com/dadrus/heimdall/internal/rules/rule/mocks" "github.com/dadrus/heimdall/internal/x" + "github.com/dadrus/heimdall/internal/x/radixtree" ) -func TestRepositoryAddAndRemoveRulesFromSameRuleSet(t *testing.T) { +func TestRepositoryAddRuleSetWithoutViolation(t *testing.T) { t.Parallel() // GIVEN - repo := newRepository(nil, &ruleFactory{}, *zerolog.Ctx(context.Background())) + repo := newRepository(&ruleFactory{}).(*repository) //nolint: forcetypeassert + rules := []rule.Rule{ + &ruleImpl{id: "1", srcID: "1", pathExpression: "/foo/1"}, + } + + // WHEN + err := repo.AddRuleSet("1", rules) + + // THEN + require.NoError(t, err) + assert.Len(t, repo.knownRules, 1) + assert.False(t, repo.index.Empty()) + assert.ElementsMatch(t, repo.knownRules, rules) + _, err = repo.index.Find("/foo/1", radixtree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) + require.NoError(t, err) +} + +func TestRepositoryAddRuleSetWithViolation(t *testing.T) { + t.Parallel() + + // GIVEN + repo := newRepository(&ruleFactory{}).(*repository) //nolint: forcetypeassert + rules1 := []rule.Rule{&ruleImpl{id: "1", srcID: "1", pathExpression: "/foo/1"}} + rules2 := []rule.Rule{&ruleImpl{id: "2", srcID: "2", pathExpression: "/foo/1"}} + + require.NoError(t, repo.AddRuleSet("1", rules1)) + + // WHEN + err := repo.AddRuleSet("2", rules2) + + // THEN + require.Error(t, err) + require.ErrorIs(t, err, radixtree.ErrConstraintsViolation) + + assert.Len(t, repo.knownRules, 1) + assert.False(t, repo.index.Empty()) + assert.ElementsMatch(t, repo.knownRules, rules1) + _, err = repo.index.Find("/foo/1", radixtree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) + require.NoError(t, err) +} + +func TestRepositoryRemoveRuleSet(t *testing.T) { + t.Parallel() + + // GIVEN + repo := newRepository(&ruleFactory{}).(*repository) //nolint: forcetypeassert + rules1 := []rule.Rule{ + &ruleImpl{id: "1", srcID: "1", pathExpression: "/foo/1"}, + &ruleImpl{id: "2", srcID: "1", pathExpression: "/foo/2"}, + &ruleImpl{id: "3", srcID: "1", pathExpression: "/foo/3"}, + &ruleImpl{id: "4", srcID: "1", pathExpression: "/foo/4"}, + } + + require.NoError(t, repo.AddRuleSet("1", rules1)) + assert.Len(t, repo.knownRules, 4) + assert.False(t, repo.index.Empty()) + + // WHEN + err := repo.DeleteRuleSet("1") + + // THEN + require.NoError(t, err) + assert.Empty(t, repo.knownRules) + assert.True(t, repo.index.Empty()) +} + +func TestRepositoryRemoveRulesFromDifferentRuleSets(t *testing.T) { + t.Parallel() + + // GIVEN + repo := newRepository(&ruleFactory{}).(*repository) //nolint: forcetypeassert + + rules1 := []rule.Rule{ + &ruleImpl{id: "1", srcID: "bar", pathExpression: "/bar/1"}, + &ruleImpl{id: "3", srcID: "bar", pathExpression: "/bar/3"}, + &ruleImpl{id: "4", srcID: "bar", pathExpression: "/bar/4"}, + } + rules2 := []rule.Rule{ + &ruleImpl{id: "2", srcID: "baz", pathExpression: "/baz/2"}, + } + rules3 := []rule.Rule{ + &ruleImpl{id: "4", srcID: "foo", pathExpression: "/foo/4"}, + } + + // WHEN + require.NoError(t, repo.AddRuleSet("bar", rules1)) + require.NoError(t, repo.AddRuleSet("baz", rules2)) + require.NoError(t, repo.AddRuleSet("foo", rules3)) + + // THEN + assert.Len(t, repo.knownRules, 5) + assert.False(t, repo.index.Empty()) + + // WHEN + err := repo.DeleteRuleSet("bar") + + // THEN + require.NoError(t, err) + assert.Len(t, repo.knownRules, 2) + assert.ElementsMatch(t, repo.knownRules, []rule.Rule{rules2[0], rules3[0]}) + + _, err = repo.index.Find("/bar/1", radixtree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) + assert.Error(t, err) //nolint:testifylint + + _, err = repo.index.Find("/bar/3", radixtree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) + assert.Error(t, err) //nolint:testifylint + + _, err = repo.index.Find("/bar/4", radixtree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) + assert.Error(t, err) //nolint:testifylint + + _, err = repo.index.Find("/baz/2", radixtree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) + assert.NoError(t, err) //nolint:testifylint + + _, err = repo.index.Find("/foo/4", radixtree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) + assert.NoError(t, err) //nolint:testifylint // WHEN - repo.addRuleSet("bar", []rule.Rule{ - &ruleImpl{id: "1", srcID: "bar"}, - &ruleImpl{id: "2", srcID: "bar"}, - &ruleImpl{id: "3", srcID: "bar"}, - &ruleImpl{id: "4", srcID: "bar"}, - }) + err = repo.DeleteRuleSet("foo") // THEN - assert.Len(t, repo.rules, 4) + require.NoError(t, err) + assert.Len(t, repo.knownRules, 1) + assert.ElementsMatch(t, repo.knownRules, []rule.Rule{rules2[0]}) + + _, err = repo.index.Find("/foo/4", radixtree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) + assert.Error(t, err) //nolint:testifylint + + _, err = repo.index.Find("/baz/2", radixtree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) + assert.NoError(t, err) //nolint:testifylint + + // WHEN + err = repo.DeleteRuleSet("baz") + + // THEN + require.NoError(t, err) + assert.Empty(t, repo.knownRules) + assert.True(t, repo.index.Empty()) +} + +func TestRepositoryUpdateRuleSet(t *testing.T) { + t.Parallel() + + // GIVEN + repo := newRepository(&ruleFactory{}).(*repository) //nolint: forcetypeassert + + initialRules := []rule.Rule{ + &ruleImpl{id: "1", srcID: "1", pathExpression: "/bar/1", hash: []byte{1}}, + &ruleImpl{id: "2", srcID: "1", pathExpression: "/bar/2", hash: []byte{1}}, + &ruleImpl{id: "3", srcID: "1", pathExpression: "/bar/3", hash: []byte{1}}, + &ruleImpl{id: "4", srcID: "1", pathExpression: "/bar/4", hash: []byte{1}}, + } + + require.NoError(t, repo.AddRuleSet("1", initialRules)) + + updatedRules := []rule.Rule{ + &ruleImpl{id: "1", srcID: "1", pathExpression: "/bar/1", hash: []byte{2}}, // changed + // rule with id 2 is deleted + &ruleImpl{id: "3", srcID: "1", pathExpression: "/foo/3", hash: []byte{2}}, // changed and path expression changed + &ruleImpl{id: "4", srcID: "1", pathExpression: "/bar/4", hash: []byte{1}}, // same as before + } // WHEN - repo.deleteRuleSet("bar") + err := repo.UpdateRuleSet("1", updatedRules) // THEN - assert.Empty(t, repo.rules) + require.NoError(t, err) + + assert.Len(t, repo.knownRules, 3) + assert.False(t, repo.index.Empty()) + + _, err = repo.index.Find("/bar/1", radixtree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) + assert.NoError(t, err) //nolint:testifylint + + _, err = repo.index.Find("/bar/2", radixtree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) + assert.Error(t, err) //nolint:testifylint + + _, err = repo.index.Find("/bar/3", radixtree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) + assert.Error(t, err) //nolint:testifylint + + _, err = repo.index.Find("/foo/3", radixtree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) + assert.NoError(t, err) //nolint:testifylint + + _, err = repo.index.Find("/bar/4", radixtree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) + assert.NoError(t, err) //nolint:testifylint } func TestRepositoryFindRule(t *testing.T) { @@ -70,7 +237,7 @@ func TestRepositoryFindRule(t *testing.T) { assert func(t *testing.T, err error, rul rule.Rule) }{ { - uc: "no matching rule without default rule", + uc: "no matching rule", requestURL: &url.URL{Scheme: "http", Host: "foo.bar", Path: "/baz"}, configureFactory: func(t *testing.T, factory *mocks.FactoryMock) { t.Helper() @@ -85,7 +252,7 @@ func TestRepositoryFindRule(t *testing.T) { }, }, { - uc: "no matching rule with default rule", + uc: "matches default rule", requestURL: &url.URL{Scheme: "http", Host: "foo.bar", Path: "/baz"}, configureFactory: func(t *testing.T, factory *mocks.FactoryMock) { t.Helper() @@ -101,8 +268,8 @@ func TestRepositoryFindRule(t *testing.T) { }, }, { - uc: "matching rule", - requestURL: &url.URL{Scheme: "http", Host: "foo.bar", Path: "/baz"}, + uc: "matches upstream rule", + requestURL: &url.URL{Scheme: "http", Host: "foo.bar", Path: "/baz/bar"}, configureFactory: func(t *testing.T, factory *mocks.FactoryMock) { t.Helper() @@ -111,28 +278,20 @@ func TestRepositoryFindRule(t *testing.T) { addRules: func(t *testing.T, repo *repository) { t.Helper() - repo.rules = append(repo.rules, + err := repo.AddRuleSet("baz", []rule.Rule{ &ruleImpl{ - id: "test1", - srcID: "bar", - urlMatcher: func() patternmatcher.PatternMatcher { - matcher, _ := patternmatcher.NewPatternMatcher("glob", - "http://heimdall.test.local/baz") - - return matcher - }(), - }, - &ruleImpl{ - id: "test2", - srcID: "baz", - urlMatcher: func() patternmatcher.PatternMatcher { - matcher, _ := patternmatcher.NewPatternMatcher("glob", - "http://foo.bar/baz") - - return matcher + id: "test2", + srcID: "baz", + pathExpression: "/baz/bar", + matcher: func() config.RequestMatcher { + rm := mocks3.NewRequestMatcherMock(t) + rm.EXPECT().Matches(mock.Anything).Return(nil) + + return rm }(), }, - ) + }) + require.NoError(t, err) }, assert: func(t *testing.T, err error, rul rule.Rule) { t.Helper() @@ -156,206 +315,20 @@ func TestRepositoryFindRule(t *testing.T) { factory := mocks.NewFactoryMock(t) tc.configureFactory(t, factory) - repo := newRepository(nil, factory, *zerolog.Ctx(context.Background())) + repo := newRepository(factory).(*repository) //nolint: forcetypeassert addRules(t, repo) - // WHEN - rul, err := repo.FindRule(tc.requestURL) - - // THEN - tc.assert(t, err, rul) - }) - } -} - -func TestRepositoryAddAndRemoveRulesFromDifferentRuleSets(t *testing.T) { - t.Parallel() - - // GIVEN - repo := newRepository(nil, &ruleFactory{}, *zerolog.Ctx(context.Background())) - - // WHEN - repo.addRules([]rule.Rule{ - &ruleImpl{id: "1", srcID: "bar"}, - &ruleImpl{id: "2", srcID: "baz"}, - &ruleImpl{id: "3", srcID: "bar"}, - &ruleImpl{id: "4", srcID: "bar"}, - &ruleImpl{id: "4", srcID: "foo"}, - }) - - // THEN - assert.Len(t, repo.rules, 5) - - // WHEN - repo.deleteRuleSet("bar") - - // THEN - assert.Len(t, repo.rules, 2) - assert.ElementsMatch(t, repo.rules, []rule.Rule{ - &ruleImpl{id: "2", srcID: "baz"}, - &ruleImpl{id: "4", srcID: "foo"}, - }) - - // WHEN - repo.deleteRuleSet("foo") - - // THEN - assert.Len(t, repo.rules, 1) - assert.ElementsMatch(t, repo.rules, []rule.Rule{ - &ruleImpl{id: "2", srcID: "baz"}, - }) - - // WHEN - repo.deleteRuleSet("baz") - - // THEN - assert.Empty(t, repo.rules) -} - -func TestRepositoryRuleSetLifecycleManagement(t *testing.T) { - t.Parallel() - - for _, tc := range []struct { - uc string - events []event.RuleSetChanged - assert func(t *testing.T, repo *repository) - }{ - { - uc: "empty rule set definition", - events: []event.RuleSetChanged{{Source: "test", ChangeType: event.Create}}, - assert: func(t *testing.T, repo *repository) { - t.Helper() - - assert.Empty(t, repo.rules) - }, - }, - { - uc: "rule set with one rule", - events: []event.RuleSetChanged{ - { - Source: "test", - ChangeType: event.Create, - Rules: []rule.Rule{&ruleImpl{id: "rule:foo", srcID: "test"}}, - }, - }, - assert: func(t *testing.T, repo *repository) { - t.Helper() - - assert.Len(t, repo.rules, 1) - assert.Equal(t, &ruleImpl{id: "rule:foo", srcID: "test"}, repo.rules[0]) - }, - }, - { - uc: "multiple rule sets", - events: []event.RuleSetChanged{ - { - Source: "test1", - ChangeType: event.Create, - Rules: []rule.Rule{&ruleImpl{id: "rule:bar", srcID: "test1"}}, - }, - { - Source: "test2", - ChangeType: event.Create, - Rules: []rule.Rule{&ruleImpl{id: "rule:foo", srcID: "test2"}}, - }, - }, - assert: func(t *testing.T, repo *repository) { - t.Helper() - - assert.Len(t, repo.rules, 2) - assert.Equal(t, &ruleImpl{id: "rule:bar", srcID: "test1"}, repo.rules[0]) - assert.Equal(t, &ruleImpl{id: "rule:foo", srcID: "test2"}, repo.rules[1]) - }, - }, - { - uc: "multiple rule sets created and one of these deleted", - events: []event.RuleSetChanged{ - { - Source: "test1", - ChangeType: event.Create, - Rules: []rule.Rule{&ruleImpl{id: "rule:bar", srcID: "test1"}}, - }, - { - Source: "test2", - ChangeType: event.Create, - Rules: []rule.Rule{&ruleImpl{id: "rule:foo", srcID: "test2"}}, - }, - { - Source: "test2", - ChangeType: event.Remove, - }, - }, - assert: func(t *testing.T, repo *repository) { - t.Helper() - - assert.Len(t, repo.rules, 1) - assert.Equal(t, &ruleImpl{id: "rule:bar", srcID: "test1"}, repo.rules[0]) - }, - }, - { - uc: "multiple rule sets created and one updated", - events: []event.RuleSetChanged{ - { - Source: "test1", - ChangeType: event.Create, - Rules: []rule.Rule{&ruleImpl{id: "rule:bar", srcID: "test1"}}, - }, - { - Source: "test2", - ChangeType: event.Create, - Rules: []rule.Rule{ - &ruleImpl{id: "rule:bar", srcID: "test2", hash: []byte{1}}, - &ruleImpl{id: "rule:foo2", srcID: "test2", hash: []byte{2}}, - &ruleImpl{id: "rule:foo3", srcID: "test2", hash: []byte{3}}, - &ruleImpl{id: "rule:foo4", srcID: "test2", hash: []byte{4}}, - }, - }, - { - Source: "test2", - ChangeType: event.Update, - Rules: []rule.Rule{ - &ruleImpl{id: "rule:bar", srcID: "test2", hash: []byte{5}}, // updated - &ruleImpl{id: "rule:foo2", srcID: "test2", hash: []byte{2}}, // as before - // &ruleImpl{id: "rule:foo3", srcID: "test2", hash: []byte{3}}, // deleted - &ruleImpl{id: "rule:foo4", srcID: "test2", hash: []byte{4}}, // as before - }, - }, - }, - assert: func(t *testing.T, repo *repository) { - t.Helper() - - require.Len(t, repo.rules, 4) - assert.ElementsMatch(t, repo.rules, []rule.Rule{ - &ruleImpl{id: "rule:bar", srcID: "test1"}, - &ruleImpl{id: "rule:bar", srcID: "test2", hash: []byte{5}}, - &ruleImpl{id: "rule:foo2", srcID: "test2", hash: []byte{2}}, - &ruleImpl{id: "rule:foo4", srcID: "test2", hash: []byte{4}}, - }) - }, - }, - } { - t.Run("case="+tc.uc, func(t *testing.T) { - // GIVEN - ctx := context.Background() - - queue := make(event.RuleSetChangedEventQueue, 10) - defer close(queue) - - repo := newRepository(queue, &ruleFactory{}, log.Logger) - require.NoError(t, repo.Start(ctx)) - - defer repo.Stop(ctx) + req := &heimdall.Request{Method: http.MethodGet, URL: &heimdall.URL{URL: *tc.requestURL}} + ctx := mocks2.NewContextMock(t) + ctx.EXPECT().AppContext().Maybe().Return(context.TODO()) + ctx.EXPECT().Request().Return(req) // WHEN - for _, evt := range tc.events { - queue <- evt - } - - time.Sleep(100 * time.Millisecond) + rul, err := repo.FindRule(ctx) // THEN - tc.assert(t, repo) + tc.assert(t, err, rul) }) } } diff --git a/internal/rules/rule/mocks/factory.go b/internal/rules/rule/mocks/factory.go index dae8918ff..af0296480 100644 --- a/internal/rules/rule/mocks/factory.go +++ b/internal/rules/rule/mocks/factory.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.23.1. DO NOT EDIT. +// Code generated by mockery v2.42.1. DO NOT EDIT. package mocks @@ -26,6 +26,10 @@ func (_m *FactoryMock) EXPECT() *FactoryMock_Expecter { func (_m *FactoryMock) CreateRule(version string, srcID string, ruleConfig config.Rule) (rule.Rule, error) { ret := _m.Called(version, srcID, ruleConfig) + if len(ret) == 0 { + panic("no return value specified for CreateRule") + } + var r0 rule.Rule var r1 error if rf, ok := ret.Get(0).(func(string, string, config.Rule) (rule.Rule, error)); ok { @@ -82,6 +86,10 @@ func (_c *FactoryMock_CreateRule_Call) RunAndReturn(run func(string, string, con func (_m *FactoryMock) DefaultRule() rule.Rule { ret := _m.Called() + if len(ret) == 0 { + panic("no return value specified for DefaultRule") + } + var r0 rule.Rule if rf, ok := ret.Get(0).(func() rule.Rule); ok { r0 = rf() @@ -125,6 +133,10 @@ func (_c *FactoryMock_DefaultRule_Call) RunAndReturn(run func() rule.Rule) *Fact func (_m *FactoryMock) HasDefaultRule() bool { ret := _m.Called() + if len(ret) == 0 { + panic("no return value specified for HasDefaultRule") + } + var r0 bool if rf, ok := ret.Get(0).(func() bool); ok { r0 = rf() @@ -162,13 +174,12 @@ func (_c *FactoryMock_HasDefaultRule_Call) RunAndReturn(run func() bool) *Factor return _c } -type mockConstructorTestingTNewFactoryMock interface { +// NewFactoryMock creates a new instance of FactoryMock. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewFactoryMock(t interface { mock.TestingT Cleanup(func()) -} - -// NewFactoryMock creates a new instance of FactoryMock. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. -func NewFactoryMock(t mockConstructorTestingTNewFactoryMock) *FactoryMock { +}) *FactoryMock { mock := &FactoryMock{} mock.Mock.Test(t) diff --git a/internal/rules/rule/mocks/repository.go b/internal/rules/rule/mocks/repository.go index d59c4edea..345b7d240 100644 --- a/internal/rules/rule/mocks/repository.go +++ b/internal/rules/rule/mocks/repository.go @@ -1,12 +1,12 @@ -// Code generated by mockery v2.23.1. DO NOT EDIT. +// Code generated by mockery v2.42.1. DO NOT EDIT. package mocks import ( - url "net/url" + heimdall "github.com/dadrus/heimdall/internal/heimdall" + mock "github.com/stretchr/testify/mock" rule "github.com/dadrus/heimdall/internal/rules/rule" - mock "github.com/stretchr/testify/mock" ) // RepositoryMock is an autogenerated mock type for the Repository type @@ -22,25 +22,122 @@ func (_m *RepositoryMock) EXPECT() *RepositoryMock_Expecter { return &RepositoryMock_Expecter{mock: &_m.Mock} } -// FindRule provides a mock function with given fields: _a0 -func (_m *RepositoryMock) FindRule(_a0 *url.URL) (rule.Rule, error) { - ret := _m.Called(_a0) +// AddRuleSet provides a mock function with given fields: srcID, rules +func (_m *RepositoryMock) AddRuleSet(srcID string, rules []rule.Rule) error { + ret := _m.Called(srcID, rules) + + if len(ret) == 0 { + panic("no return value specified for AddRuleSet") + } + + var r0 error + if rf, ok := ret.Get(0).(func(string, []rule.Rule) error); ok { + r0 = rf(srcID, rules) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// RepositoryMock_AddRuleSet_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddRuleSet' +type RepositoryMock_AddRuleSet_Call struct { + *mock.Call +} + +// AddRuleSet is a helper method to define mock.On call +// - srcID string +// - rules []rule.Rule +func (_e *RepositoryMock_Expecter) AddRuleSet(srcID interface{}, rules interface{}) *RepositoryMock_AddRuleSet_Call { + return &RepositoryMock_AddRuleSet_Call{Call: _e.mock.On("AddRuleSet", srcID, rules)} +} + +func (_c *RepositoryMock_AddRuleSet_Call) Run(run func(srcID string, rules []rule.Rule)) *RepositoryMock_AddRuleSet_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string), args[1].([]rule.Rule)) + }) + return _c +} + +func (_c *RepositoryMock_AddRuleSet_Call) Return(_a0 error) *RepositoryMock_AddRuleSet_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *RepositoryMock_AddRuleSet_Call) RunAndReturn(run func(string, []rule.Rule) error) *RepositoryMock_AddRuleSet_Call { + _c.Call.Return(run) + return _c +} + +// DeleteRuleSet provides a mock function with given fields: srcID +func (_m *RepositoryMock) DeleteRuleSet(srcID string) error { + ret := _m.Called(srcID) + + if len(ret) == 0 { + panic("no return value specified for DeleteRuleSet") + } + + var r0 error + if rf, ok := ret.Get(0).(func(string) error); ok { + r0 = rf(srcID) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// RepositoryMock_DeleteRuleSet_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteRuleSet' +type RepositoryMock_DeleteRuleSet_Call struct { + *mock.Call +} + +// DeleteRuleSet is a helper method to define mock.On call +// - srcID string +func (_e *RepositoryMock_Expecter) DeleteRuleSet(srcID interface{}) *RepositoryMock_DeleteRuleSet_Call { + return &RepositoryMock_DeleteRuleSet_Call{Call: _e.mock.On("DeleteRuleSet", srcID)} +} + +func (_c *RepositoryMock_DeleteRuleSet_Call) Run(run func(srcID string)) *RepositoryMock_DeleteRuleSet_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *RepositoryMock_DeleteRuleSet_Call) Return(_a0 error) *RepositoryMock_DeleteRuleSet_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *RepositoryMock_DeleteRuleSet_Call) RunAndReturn(run func(string) error) *RepositoryMock_DeleteRuleSet_Call { + _c.Call.Return(run) + return _c +} + +// FindRule provides a mock function with given fields: ctx +func (_m *RepositoryMock) FindRule(ctx heimdall.Context) (rule.Rule, error) { + ret := _m.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for FindRule") + } var r0 rule.Rule var r1 error - if rf, ok := ret.Get(0).(func(*url.URL) (rule.Rule, error)); ok { - return rf(_a0) + if rf, ok := ret.Get(0).(func(heimdall.Context) (rule.Rule, error)); ok { + return rf(ctx) } - if rf, ok := ret.Get(0).(func(*url.URL) rule.Rule); ok { - r0 = rf(_a0) + if rf, ok := ret.Get(0).(func(heimdall.Context) rule.Rule); ok { + r0 = rf(ctx) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(rule.Rule) } } - if rf, ok := ret.Get(1).(func(*url.URL) error); ok { - r1 = rf(_a0) + if rf, ok := ret.Get(1).(func(heimdall.Context) error); ok { + r1 = rf(ctx) } else { r1 = ret.Error(1) } @@ -54,14 +151,14 @@ type RepositoryMock_FindRule_Call struct { } // FindRule is a helper method to define mock.On call -// - _a0 *url.URL -func (_e *RepositoryMock_Expecter) FindRule(_a0 interface{}) *RepositoryMock_FindRule_Call { - return &RepositoryMock_FindRule_Call{Call: _e.mock.On("FindRule", _a0)} +// - ctx heimdall.Context +func (_e *RepositoryMock_Expecter) FindRule(ctx interface{}) *RepositoryMock_FindRule_Call { + return &RepositoryMock_FindRule_Call{Call: _e.mock.On("FindRule", ctx)} } -func (_c *RepositoryMock_FindRule_Call) Run(run func(_a0 *url.URL)) *RepositoryMock_FindRule_Call { +func (_c *RepositoryMock_FindRule_Call) Run(run func(ctx heimdall.Context)) *RepositoryMock_FindRule_Call { _c.Call.Run(func(args mock.Arguments) { - run(args[0].(*url.URL)) + run(args[0].(heimdall.Context)) }) return _c } @@ -71,18 +168,64 @@ func (_c *RepositoryMock_FindRule_Call) Return(_a0 rule.Rule, _a1 error) *Reposi return _c } -func (_c *RepositoryMock_FindRule_Call) RunAndReturn(run func(*url.URL) (rule.Rule, error)) *RepositoryMock_FindRule_Call { +func (_c *RepositoryMock_FindRule_Call) RunAndReturn(run func(heimdall.Context) (rule.Rule, error)) *RepositoryMock_FindRule_Call { _c.Call.Return(run) return _c } -type mockConstructorTestingTNewRepositoryMock interface { - mock.TestingT - Cleanup(func()) +// UpdateRuleSet provides a mock function with given fields: srcID, rules +func (_m *RepositoryMock) UpdateRuleSet(srcID string, rules []rule.Rule) error { + ret := _m.Called(srcID, rules) + + if len(ret) == 0 { + panic("no return value specified for UpdateRuleSet") + } + + var r0 error + if rf, ok := ret.Get(0).(func(string, []rule.Rule) error); ok { + r0 = rf(srcID, rules) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// RepositoryMock_UpdateRuleSet_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateRuleSet' +type RepositoryMock_UpdateRuleSet_Call struct { + *mock.Call +} + +// UpdateRuleSet is a helper method to define mock.On call +// - srcID string +// - rules []rule.Rule +func (_e *RepositoryMock_Expecter) UpdateRuleSet(srcID interface{}, rules interface{}) *RepositoryMock_UpdateRuleSet_Call { + return &RepositoryMock_UpdateRuleSet_Call{Call: _e.mock.On("UpdateRuleSet", srcID, rules)} +} + +func (_c *RepositoryMock_UpdateRuleSet_Call) Run(run func(srcID string, rules []rule.Rule)) *RepositoryMock_UpdateRuleSet_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string), args[1].([]rule.Rule)) + }) + return _c +} + +func (_c *RepositoryMock_UpdateRuleSet_Call) Return(_a0 error) *RepositoryMock_UpdateRuleSet_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *RepositoryMock_UpdateRuleSet_Call) RunAndReturn(run func(string, []rule.Rule) error) *RepositoryMock_UpdateRuleSet_Call { + _c.Call.Return(run) + return _c } // NewRepositoryMock creates a new instance of RepositoryMock. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. -func NewRepositoryMock(t mockConstructorTestingTNewRepositoryMock) *RepositoryMock { +// The first argument is typically a *testing.T value. +func NewRepositoryMock(t interface { + mock.TestingT + Cleanup(func()) +}) *RepositoryMock { mock := &RepositoryMock{} mock.Mock.Test(t) diff --git a/internal/rules/rule/mocks/rule.go b/internal/rules/rule/mocks/rule.go index 2c7dc6cb6..354cbfd71 100644 --- a/internal/rules/rule/mocks/rule.go +++ b/internal/rules/rule/mocks/rule.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.23.1. DO NOT EDIT. +// Code generated by mockery v2.42.1. DO NOT EDIT. package mocks @@ -7,8 +7,6 @@ import ( mock "github.com/stretchr/testify/mock" rule "github.com/dadrus/heimdall/internal/rules/rule" - - url "net/url" ) // RuleMock is an autogenerated mock type for the Rule type @@ -24,17 +22,66 @@ func (_m *RuleMock) EXPECT() *RuleMock_Expecter { return &RuleMock_Expecter{mock: &_m.Mock} } -// Execute provides a mock function with given fields: _a0 -func (_m *RuleMock) Execute(_a0 heimdall.Context) (rule.Backend, error) { - ret := _m.Called(_a0) +// BacktrackingEnabled provides a mock function with given fields: +func (_m *RuleMock) BacktrackingEnabled() bool { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for BacktrackingEnabled") + } + + var r0 bool + if rf, ok := ret.Get(0).(func() bool); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(bool) + } + + return r0 +} + +// RuleMock_BacktrackingEnabled_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'BacktrackingEnabled' +type RuleMock_BacktrackingEnabled_Call struct { + *mock.Call +} + +// BacktrackingEnabled is a helper method to define mock.On call +func (_e *RuleMock_Expecter) BacktrackingEnabled() *RuleMock_BacktrackingEnabled_Call { + return &RuleMock_BacktrackingEnabled_Call{Call: _e.mock.On("BacktrackingEnabled")} +} + +func (_c *RuleMock_BacktrackingEnabled_Call) Run(run func()) *RuleMock_BacktrackingEnabled_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *RuleMock_BacktrackingEnabled_Call) Return(_a0 bool) *RuleMock_BacktrackingEnabled_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *RuleMock_BacktrackingEnabled_Call) RunAndReturn(run func() bool) *RuleMock_BacktrackingEnabled_Call { + _c.Call.Return(run) + return _c +} + +// Execute provides a mock function with given fields: ctx +func (_m *RuleMock) Execute(ctx heimdall.Context) (rule.Backend, error) { + ret := _m.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for Execute") + } var r0 rule.Backend var r1 error if rf, ok := ret.Get(0).(func(heimdall.Context) (rule.Backend, error)); ok { - return rf(_a0) + return rf(ctx) } if rf, ok := ret.Get(0).(func(heimdall.Context) rule.Backend); ok { - r0 = rf(_a0) + r0 = rf(ctx) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(rule.Backend) @@ -42,7 +89,7 @@ func (_m *RuleMock) Execute(_a0 heimdall.Context) (rule.Backend, error) { } if rf, ok := ret.Get(1).(func(heimdall.Context) error); ok { - r1 = rf(_a0) + r1 = rf(ctx) } else { r1 = ret.Error(1) } @@ -56,12 +103,12 @@ type RuleMock_Execute_Call struct { } // Execute is a helper method to define mock.On call -// - _a0 heimdall.Context -func (_e *RuleMock_Expecter) Execute(_a0 interface{}) *RuleMock_Execute_Call { - return &RuleMock_Execute_Call{Call: _e.mock.On("Execute", _a0)} +// - ctx heimdall.Context +func (_e *RuleMock_Expecter) Execute(ctx interface{}) *RuleMock_Execute_Call { + return &RuleMock_Execute_Call{Call: _e.mock.On("Execute", ctx)} } -func (_c *RuleMock_Execute_Call) Run(run func(_a0 heimdall.Context)) *RuleMock_Execute_Call { +func (_c *RuleMock_Execute_Call) Run(run func(ctx heimdall.Context)) *RuleMock_Execute_Call { _c.Call.Run(func(args mock.Arguments) { run(args[0].(heimdall.Context)) }) @@ -82,6 +129,10 @@ func (_c *RuleMock_Execute_Call) RunAndReturn(run func(heimdall.Context) (rule.B func (_m *RuleMock) ID() string { ret := _m.Called() + if len(ret) == 0 { + panic("no return value specified for ID") + } + var r0 string if rf, ok := ret.Get(0).(func() string); ok { r0 = rf() @@ -119,13 +170,17 @@ func (_c *RuleMock_ID_Call) RunAndReturn(run func() string) *RuleMock_ID_Call { return _c } -// MatchesMethod provides a mock function with given fields: _a0 -func (_m *RuleMock) MatchesMethod(_a0 string) bool { - ret := _m.Called(_a0) +// Matches provides a mock function with given fields: ctx +func (_m *RuleMock) Matches(ctx heimdall.Context) bool { + ret := _m.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for Matches") + } var r0 bool - if rf, ok := ret.Get(0).(func(string) bool); ok { - r0 = rf(_a0) + if rf, ok := ret.Get(0).(func(heimdall.Context) bool); ok { + r0 = rf(ctx) } else { r0 = ret.Get(0).(bool) } @@ -133,41 +188,90 @@ func (_m *RuleMock) MatchesMethod(_a0 string) bool { return r0 } -// RuleMock_MatchesMethod_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'MatchesMethod' -type RuleMock_MatchesMethod_Call struct { +// RuleMock_Matches_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Matches' +type RuleMock_Matches_Call struct { *mock.Call } -// MatchesMethod is a helper method to define mock.On call -// - _a0 string -func (_e *RuleMock_Expecter) MatchesMethod(_a0 interface{}) *RuleMock_MatchesMethod_Call { - return &RuleMock_MatchesMethod_Call{Call: _e.mock.On("MatchesMethod", _a0)} +// Matches is a helper method to define mock.On call +// - ctx heimdall.Context +func (_e *RuleMock_Expecter) Matches(ctx interface{}) *RuleMock_Matches_Call { + return &RuleMock_Matches_Call{Call: _e.mock.On("Matches", ctx)} } -func (_c *RuleMock_MatchesMethod_Call) Run(run func(_a0 string)) *RuleMock_MatchesMethod_Call { +func (_c *RuleMock_Matches_Call) Run(run func(ctx heimdall.Context)) *RuleMock_Matches_Call { _c.Call.Run(func(args mock.Arguments) { - run(args[0].(string)) + run(args[0].(heimdall.Context)) }) return _c } -func (_c *RuleMock_MatchesMethod_Call) Return(_a0 bool) *RuleMock_MatchesMethod_Call { +func (_c *RuleMock_Matches_Call) Return(_a0 bool) *RuleMock_Matches_Call { _c.Call.Return(_a0) return _c } -func (_c *RuleMock_MatchesMethod_Call) RunAndReturn(run func(string) bool) *RuleMock_MatchesMethod_Call { +func (_c *RuleMock_Matches_Call) RunAndReturn(run func(heimdall.Context) bool) *RuleMock_Matches_Call { _c.Call.Return(run) return _c } -// MatchesURL provides a mock function with given fields: _a0 -func (_m *RuleMock) MatchesURL(_a0 *url.URL) bool { - ret := _m.Called(_a0) +// PathExpression provides a mock function with given fields: +func (_m *RuleMock) PathExpression() string { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for PathExpression") + } + + var r0 string + if rf, ok := ret.Get(0).(func() string); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(string) + } + + return r0 +} + +// RuleMock_PathExpression_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PathExpression' +type RuleMock_PathExpression_Call struct { + *mock.Call +} + +// PathExpression is a helper method to define mock.On call +func (_e *RuleMock_Expecter) PathExpression() *RuleMock_PathExpression_Call { + return &RuleMock_PathExpression_Call{Call: _e.mock.On("PathExpression")} +} + +func (_c *RuleMock_PathExpression_Call) Run(run func()) *RuleMock_PathExpression_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *RuleMock_PathExpression_Call) Return(_a0 string) *RuleMock_PathExpression_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *RuleMock_PathExpression_Call) RunAndReturn(run func() string) *RuleMock_PathExpression_Call { + _c.Call.Return(run) + return _c +} + +// SameAs provides a mock function with given fields: other +func (_m *RuleMock) SameAs(other rule.Rule) bool { + ret := _m.Called(other) + + if len(ret) == 0 { + panic("no return value specified for SameAs") + } var r0 bool - if rf, ok := ret.Get(0).(func(*url.URL) bool); ok { - r0 = rf(_a0) + if rf, ok := ret.Get(0).(func(rule.Rule) bool); ok { + r0 = rf(other) } else { r0 = ret.Get(0).(bool) } @@ -175,30 +279,30 @@ func (_m *RuleMock) MatchesURL(_a0 *url.URL) bool { return r0 } -// RuleMock_MatchesURL_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'MatchesURL' -type RuleMock_MatchesURL_Call struct { +// RuleMock_SameAs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SameAs' +type RuleMock_SameAs_Call struct { *mock.Call } -// MatchesURL is a helper method to define mock.On call -// - _a0 *url.URL -func (_e *RuleMock_Expecter) MatchesURL(_a0 interface{}) *RuleMock_MatchesURL_Call { - return &RuleMock_MatchesURL_Call{Call: _e.mock.On("MatchesURL", _a0)} +// SameAs is a helper method to define mock.On call +// - other rule.Rule +func (_e *RuleMock_Expecter) SameAs(other interface{}) *RuleMock_SameAs_Call { + return &RuleMock_SameAs_Call{Call: _e.mock.On("SameAs", other)} } -func (_c *RuleMock_MatchesURL_Call) Run(run func(_a0 *url.URL)) *RuleMock_MatchesURL_Call { +func (_c *RuleMock_SameAs_Call) Run(run func(other rule.Rule)) *RuleMock_SameAs_Call { _c.Call.Run(func(args mock.Arguments) { - run(args[0].(*url.URL)) + run(args[0].(rule.Rule)) }) return _c } -func (_c *RuleMock_MatchesURL_Call) Return(_a0 bool) *RuleMock_MatchesURL_Call { +func (_c *RuleMock_SameAs_Call) Return(_a0 bool) *RuleMock_SameAs_Call { _c.Call.Return(_a0) return _c } -func (_c *RuleMock_MatchesURL_Call) RunAndReturn(run func(*url.URL) bool) *RuleMock_MatchesURL_Call { +func (_c *RuleMock_SameAs_Call) RunAndReturn(run func(rule.Rule) bool) *RuleMock_SameAs_Call { _c.Call.Return(run) return _c } @@ -207,6 +311,10 @@ func (_c *RuleMock_MatchesURL_Call) RunAndReturn(run func(*url.URL) bool) *RuleM func (_m *RuleMock) SrcID() string { ret := _m.Called() + if len(ret) == 0 { + panic("no return value specified for SrcID") + } + var r0 string if rf, ok := ret.Get(0).(func() string); ok { r0 = rf() @@ -244,13 +352,12 @@ func (_c *RuleMock_SrcID_Call) RunAndReturn(run func() string) *RuleMock_SrcID_C return _c } -type mockConstructorTestingTNewRuleMock interface { +// NewRuleMock creates a new instance of RuleMock. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewRuleMock(t interface { mock.TestingT Cleanup(func()) -} - -// NewRuleMock creates a new instance of RuleMock. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. -func NewRuleMock(t mockConstructorTestingTNewRuleMock) *RuleMock { +}) *RuleMock { mock := &RuleMock{} mock.Mock.Test(t) diff --git a/internal/rules/rule/repository.go b/internal/rules/rule/repository.go index 34a0a187d..1108aa509 100644 --- a/internal/rules/rule/repository.go +++ b/internal/rules/rule/repository.go @@ -17,11 +17,15 @@ package rule import ( - "net/url" + "github.com/dadrus/heimdall/internal/heimdall" ) //go:generate mockery --name Repository --structname RepositoryMock type Repository interface { - FindRule(toMatch *url.URL) (Rule, error) + FindRule(ctx heimdall.Context) (Rule, error) + + AddRuleSet(srcID string, rules []Rule) error + UpdateRuleSet(srcID string, rules []Rule) error + DeleteRuleSet(srcID string) error } diff --git a/internal/rules/rule/rule.go b/internal/rules/rule/rule.go index d3d0aefa3..e7539c431 100644 --- a/internal/rules/rule/rule.go +++ b/internal/rules/rule/rule.go @@ -17,8 +17,6 @@ package rule import ( - "net/url" - "github.com/dadrus/heimdall/internal/heimdall" ) @@ -28,6 +26,8 @@ type Rule interface { ID() string SrcID() string Execute(ctx heimdall.Context) (Backend, error) - MatchesURL(match *url.URL) bool - MatchesMethod(method string) bool + Matches(ctx heimdall.Context) bool + PathExpression() string + BacktrackingEnabled() bool + SameAs(other Rule) bool } diff --git a/internal/rules/rule_executor_impl.go b/internal/rules/rule_executor_impl.go index 3dac1d8c4..5b27c8583 100644 --- a/internal/rules/rule_executor_impl.go +++ b/internal/rules/rule_executor_impl.go @@ -21,7 +21,6 @@ import ( "github.com/dadrus/heimdall/internal/heimdall" "github.com/dadrus/heimdall/internal/rules/rule" - "github.com/dadrus/heimdall/internal/x/errorchain" ) type ruleExecutor struct { @@ -33,24 +32,17 @@ func newRuleExecutor(repository rule.Repository) rule.Executor { } func (e *ruleExecutor) Execute(ctx heimdall.Context) (rule.Backend, error) { - req := ctx.Request() + request := ctx.Request() - //nolint:contextcheck zerolog.Ctx(ctx.AppContext()).Debug(). - Str("_method", req.Method). - Str("_url", req.URL.String()). + Str("_method", request.Method). + Str("_url", request.URL.String()). Msg("Analyzing request") - rul, err := e.r.FindRule(req.URL) + rul, err := e.r.FindRule(ctx) if err != nil { return nil, err } - method := ctx.Request().Method - if !rul.MatchesMethod(method) { - return nil, errorchain.NewWithMessagef(heimdall.ErrMethodNotAllowed, - "rule (id=%s, src=%s) doesn't match %s method", rul.ID(), rul.SrcID(), method) - } - return rul.Execute(ctx) } diff --git a/internal/rules/rule_executor_impl_test.go b/internal/rules/rule_executor_impl_test.go index 28e237688..e1f23ddd2 100644 --- a/internal/rules/rule_executor_impl_test.go +++ b/internal/rules/rule_executor_impl_test.go @@ -43,28 +43,16 @@ func TestRuleExecutorExecute(t *testing.T) { assertResponse func(t *testing.T, err error, response *http.Response) }{ { - uc: "no rules configured", + uc: "no matching rules", expErr: heimdall.ErrNoRuleFound, configureMocks: func(t *testing.T, ctx *mocks2.ContextMock, repo *mocks4.RepositoryMock, _ *mocks4.RuleMock) { t.Helper() - ctx.EXPECT().AppContext().Return(context.Background()) - ctx.EXPECT().Request().Return(&heimdall.Request{Method: http.MethodPost, URL: matchingURL}) - repo.EXPECT().FindRule(matchingURL).Return(nil, heimdall.ErrNoRuleFound) - }, - }, - { - uc: "rule doesn't match method", - expErr: heimdall.ErrMethodNotAllowed, - configureMocks: func(t *testing.T, ctx *mocks2.ContextMock, repo *mocks4.RepositoryMock, rule *mocks4.RuleMock) { - t.Helper() + req := &heimdall.Request{Method: http.MethodPost, URL: &heimdall.URL{URL: *matchingURL}} ctx.EXPECT().AppContext().Return(context.Background()) - ctx.EXPECT().Request().Return(&heimdall.Request{Method: http.MethodPost, URL: matchingURL}) - rule.EXPECT().MatchesMethod(http.MethodPost).Return(false) - rule.EXPECT().ID().Return("test_id") - rule.EXPECT().SrcID().Return("test_src") - repo.EXPECT().FindRule(matchingURL).Return(rule, nil) + ctx.EXPECT().Request().Return(req) + repo.EXPECT().FindRule(ctx).Return(nil, heimdall.ErrNoRuleFound) }, }, { @@ -73,11 +61,12 @@ func TestRuleExecutorExecute(t *testing.T) { configureMocks: func(t *testing.T, ctx *mocks2.ContextMock, repo *mocks4.RepositoryMock, rule *mocks4.RuleMock) { t.Helper() + req := &heimdall.Request{Method: http.MethodGet, URL: &heimdall.URL{URL: *matchingURL}} + ctx.EXPECT().AppContext().Return(context.Background()) - ctx.EXPECT().Request().Return(&heimdall.Request{Method: http.MethodGet, URL: matchingURL}) - rule.EXPECT().MatchesMethod(http.MethodGet).Return(true) + ctx.EXPECT().Request().Return(req) + repo.EXPECT().FindRule(ctx).Return(rule, nil) rule.EXPECT().Execute(ctx).Return(nil, heimdall.ErrAuthentication) - repo.EXPECT().FindRule(matchingURL).Return(rule, nil) }, }, { @@ -86,12 +75,12 @@ func TestRuleExecutorExecute(t *testing.T) { t.Helper() upstream := mocks4.NewBackendMock(t) + req := &heimdall.Request{Method: http.MethodGet, URL: &heimdall.URL{URL: *matchingURL}} ctx.EXPECT().AppContext().Return(context.Background()) - ctx.EXPECT().Request().Return(&heimdall.Request{Method: http.MethodGet, URL: matchingURL}) - rule.EXPECT().MatchesMethod(http.MethodGet).Return(true) + ctx.EXPECT().Request().Return(req) + repo.EXPECT().FindRule(ctx).Return(rule, nil) rule.EXPECT().Execute(ctx).Return(upstream, nil) - repo.EXPECT().FindRule(matchingURL).Return(rule, nil) }, }, } { diff --git a/internal/rules/rule_factory_impl.go b/internal/rules/rule_factory_impl.go index 30e0b2ee6..a0b4bf595 100644 --- a/internal/rules/rule_factory_impl.go +++ b/internal/rules/rule_factory_impl.go @@ -17,25 +17,18 @@ package rules import ( - "crypto" "errors" "fmt" - "net/http" - "slices" - "strings" - "github.com/goccy/go-json" "github.com/rs/zerolog" "github.com/dadrus/heimdall/internal/config" "github.com/dadrus/heimdall/internal/heimdall" config2 "github.com/dadrus/heimdall/internal/rules/config" "github.com/dadrus/heimdall/internal/rules/mechanisms" - "github.com/dadrus/heimdall/internal/rules/patternmatcher" "github.com/dadrus/heimdall/internal/rules/rule" "github.com/dadrus/heimdall/internal/x" "github.com/dadrus/heimdall/internal/x/errorchain" - "github.com/dadrus/heimdall/internal/x/slicex" ) func NewRuleFactory( @@ -154,27 +147,20 @@ func (f *ruleFactory) createExecutePipeline( func (f *ruleFactory) DefaultRule() rule.Rule { return f.defaultRule } func (f *ruleFactory) HasDefaultRule() bool { return f.hasDefaultRule } -//nolint:cyclop, funlen -func (f *ruleFactory) CreateRule(version, srcID string, ruleConfig config2.Rule) ( - rule.Rule, error, -) { - if len(ruleConfig.ID) == 0 { - return nil, errorchain.NewWithMessagef(heimdall.ErrConfiguration, - "no ID defined for rule ID=%s from %s", ruleConfig.ID, srcID) +func (f *ruleFactory) CreateRule(version, srcID string, ruleConfig config2.Rule) (rule.Rule, error) { + if f.mode == config.ProxyMode && ruleConfig.Backend == nil { + return nil, errorchain.NewWithMessage(heimdall.ErrConfiguration, "proxy mode requires forward_to definition") } - if f.mode == config.ProxyMode { - if err := checkProxyModeApplicability(srcID, ruleConfig); err != nil { - return nil, err - } - } + slashesHandling := x.IfThenElse( + len(ruleConfig.EncodedSlashesHandling) != 0, + ruleConfig.EncodedSlashesHandling, + config2.EncodedSlashesOff, + ) - matcher, err := patternmatcher.NewPatternMatcher( - ruleConfig.RuleMatcher.Strategy, ruleConfig.RuleMatcher.URL) + matcher, err := ruleConfig.Matcher.With.ToRequestMatcher(slashesHandling) if err != nil { - return nil, errorchain.NewWithMessagef(heimdall.ErrConfiguration, - "bad URL pattern for %s strategy defined for rule ID=%s from %s", - ruleConfig.RuleMatcher.Strategy, ruleConfig.ID, srcID).CausedBy(err) + return nil, err } authenticators, subHandlers, finalizers, err := f.createExecutePipeline(version, ruleConfig.Execute) @@ -187,95 +173,44 @@ func (f *ruleFactory) CreateRule(version, srcID string, ruleConfig config2.Rule) return nil, err } - methods, err := expandHTTPMethods(ruleConfig.Methods) - if err != nil { - return nil, errorchain.NewWithMessagef(heimdall.ErrConfiguration, - "failed to expand allowed HTTP methods for rule ID=%s from %s", ruleConfig.ID, srcID).CausedBy(err) - } + var defaultBacktracking bool if f.defaultRule != nil { authenticators = x.IfThenElse(len(authenticators) != 0, authenticators, f.defaultRule.sc) subHandlers = x.IfThenElse(len(subHandlers) != 0, subHandlers, f.defaultRule.sh) finalizers = x.IfThenElse(len(finalizers) != 0, finalizers, f.defaultRule.fi) errorHandlers = x.IfThenElse(len(errorHandlers) != 0, errorHandlers, f.defaultRule.eh) - methods = x.IfThenElse(len(methods) != 0, methods, f.defaultRule.methods) + defaultBacktracking = f.defaultRule.allowsBacktracking } if len(authenticators) == 0 { - return nil, errorchain.NewWithMessagef(heimdall.ErrConfiguration, - "no authenticator defined for rule ID=%s from %s", ruleConfig.ID, srcID) - } - - if len(methods) == 0 { - return nil, errorchain.NewWithMessagef(heimdall.ErrConfiguration, - "no methods defined for rule ID=%s from %s", ruleConfig.ID, srcID) - } - - hash, err := f.createHash(ruleConfig) - if err != nil { - return nil, errorchain.NewWithMessagef(heimdall.ErrConfiguration, - "failed to create hash for rule ID=%s from %s", ruleConfig.ID, srcID) - } - - return &ruleImpl{ - id: ruleConfig.ID, - encodedSlashesHandling: x.IfThenElse( - len(ruleConfig.EncodedSlashesHandling) != 0, - ruleConfig.EncodedSlashesHandling, - config2.EncodedSlashesOff, - ), - urlMatcher: matcher, - backend: ruleConfig.Backend, - methods: methods, - srcID: srcID, - isDefault: false, - hash: hash, - sc: authenticators, - sh: subHandlers, - fi: finalizers, - eh: errorHandlers, - }, nil -} - -func checkProxyModeApplicability(srcID string, ruleConfig config2.Rule) error { - if ruleConfig.Backend == nil { - return errorchain.NewWithMessagef(heimdall.ErrConfiguration, - "heimdall is operated in proxy mode, but no forward_to is defined in rule ID=%s from %s", - ruleConfig.ID, srcID) - } - - if len(ruleConfig.Backend.Host) == 0 { - return errorchain.NewWithMessagef(heimdall.ErrConfiguration, - "missing host definition in forward_to in rule ID=%s from %s", - ruleConfig.ID, srcID) + return nil, errorchain.NewWithMessage(heimdall.ErrConfiguration, "no authenticator defined") } - urlRewriter := ruleConfig.Backend.URLRewriter - if urlRewriter == nil { - return nil - } - - if len(urlRewriter.Scheme) == 0 && - len(urlRewriter.PathPrefixToAdd) == 0 && - len(urlRewriter.PathPrefixToCut) == 0 && - len(urlRewriter.QueryParamsToRemove) == 0 { - return errorchain.NewWithMessagef(heimdall.ErrConfiguration, - "rewrite is defined in forward_to in rule ID=%s from %s, but is empty", ruleConfig.ID, srcID) - } - - return nil -} - -func (f *ruleFactory) createHash(ruleConfig config2.Rule) ([]byte, error) { - rawRuleConfig, err := json.Marshal(ruleConfig) + hash, err := ruleConfig.Hash() if err != nil { return nil, err } - md := crypto.SHA256.New() - md.Write(rawRuleConfig) + allowsBacktracking := x.IfThenElseExec(ruleConfig.Matcher.BacktrackingEnabled != nil, + func() bool { return *ruleConfig.Matcher.BacktrackingEnabled }, + func() bool { return defaultBacktracking }) - return md.Sum(nil), nil + return &ruleImpl{ + id: ruleConfig.ID, + srcID: srcID, + isDefault: false, + allowsBacktracking: allowsBacktracking, + slashesHandling: slashesHandling, + matcher: matcher, + pathExpression: ruleConfig.Matcher.Path, + backend: ruleConfig.Backend, + hash: hash, + sc: authenticators, + sh: subHandlers, + fi: finalizers, + eh: errorHandlers, + }, nil } func (f *ruleFactory) createOnErrorPipeline( @@ -340,26 +275,16 @@ func (f *ruleFactory) initWithDefaultRule(ruleConfig *config.DefaultRule, logger return errorchain.NewWithMessage(heimdall.ErrConfiguration, "no authenticator defined for default rule") } - methods, err := expandHTTPMethods(ruleConfig.Methods) - if err != nil { - return errorchain.NewWithMessagef(heimdall.ErrConfiguration, "failed to expand allowed HTTP methods"). - CausedBy(err) - } - - if len(methods) == 0 { - return errorchain.NewWithMessagef(heimdall.ErrConfiguration, "no methods defined for default rule") - } - f.defaultRule = &ruleImpl{ - id: "default", - encodedSlashesHandling: config2.EncodedSlashesOff, - methods: methods, - srcID: "config", - isDefault: true, - sc: authenticators, - sh: subHandlers, - fi: finalizers, - eh: errorHandlers, + id: "default", + slashesHandling: config2.EncodedSlashesOff, + srcID: "config", + isDefault: true, + allowsBacktracking: ruleConfig.BacktrackingEnabled, + sc: authenticators, + sh: subHandlers, + fi: finalizers, + eh: errorHandlers, } f.hasDefaultRule = true @@ -367,30 +292,6 @@ func (f *ruleFactory) initWithDefaultRule(ruleConfig *config.DefaultRule, logger return nil } -func expandHTTPMethods(methods []string) ([]string, error) { - if slices.Contains(methods, "ALL") { - methods = slices.DeleteFunc(methods, func(method string) bool { return method == "ALL" }) - - methods = append(methods, - http.MethodGet, http.MethodHead, http.MethodPost, http.MethodPut, http.MethodPatch, - http.MethodDelete, http.MethodConnect, http.MethodOptions, http.MethodTrace) - } - - slices.SortFunc(methods, strings.Compare) - - methods = slices.Compact(methods) - if res := slicex.Filter(methods, func(s string) bool { return len(s) == 0 }); len(res) != 0 { - return nil, errorchain.NewWithMessage(heimdall.ErrConfiguration, - "methods list contains empty values. have you forgotten to put the corresponding value into braces?") - } - - tbr := slicex.Filter(methods, func(s string) bool { return strings.HasPrefix(s, "!") }) - methods = slicex.Subtract(methods, tbr) - tbr = slicex.Map[string, string](tbr, func(s string) string { return strings.TrimPrefix(s, "!") }) - - return slicex.Subtract(methods, tbr), nil -} - type CheckFunc func() error var errHandlerNotFound = errors.New("handler not found") diff --git a/internal/rules/rule_factory_impl_test.go b/internal/rules/rule_factory_impl_test.go index 21fad4011..610c88d80 100644 --- a/internal/rules/rule_factory_impl_test.go +++ b/internal/rules/rule_factory_impl_test.go @@ -17,7 +17,6 @@ package rules import ( - "net/http" "net/url" "testing" @@ -299,127 +298,6 @@ func TestRuleFactoryNew(t *testing.T) { require.ErrorContains(t, err, "no authenticator") }, }, - { - uc: "new factory with default rule, consisting of authenticator only", - config: &config.Configuration{ - Default: &config.DefaultRule{ - Execute: []config.MechanismConfig{ - {"authenticator": "bar"}, - }, - }, - }, - configureMocks: func(t *testing.T, mhf *mocks3.FactoryMock) { - t.Helper() - - mhf.EXPECT().CreateAuthenticator(mock.Anything, "bar", mock.Anything).Return(nil, nil) - }, - assert: func(t *testing.T, err error, _ *ruleFactory) { - t.Helper() - - require.Error(t, err) - require.ErrorIs(t, err, heimdall.ErrConfiguration) - require.ErrorContains(t, err, "no methods") - }, - }, - { - uc: "new factory with default rule, consisting of authorizer and contextualizer", - config: &config.Configuration{ - Default: &config.DefaultRule{ - Execute: []config.MechanismConfig{ - {"authenticator": "bar"}, - {"contextualizer": "baz"}, - }, - }, - }, - configureMocks: func(t *testing.T, mhf *mocks3.FactoryMock) { - t.Helper() - - mhf.EXPECT().CreateAuthenticator(mock.Anything, "bar", mock.Anything).Return(nil, nil) - mhf.EXPECT().CreateContextualizer(mock.Anything, "baz", mock.Anything).Return(nil, nil) - }, - assert: func(t *testing.T, err error, _ *ruleFactory) { - t.Helper() - - require.Error(t, err) - require.ErrorIs(t, err, heimdall.ErrConfiguration) - require.ErrorContains(t, err, "no methods") - }, - }, - { - uc: "new factory with default rule, consisting of authorizer, contextualizer and authorizer", - config: &config.Configuration{ - Default: &config.DefaultRule{ - Execute: []config.MechanismConfig{ - {"authenticator": "bar"}, - {"contextualizer": "baz"}, - {"authorizer": "zab"}, - }, - }, - }, - configureMocks: func(t *testing.T, mhf *mocks3.FactoryMock) { - t.Helper() - - mhf.EXPECT().CreateAuthenticator(mock.Anything, "bar", mock.Anything).Return(nil, nil) - mhf.EXPECT().CreateContextualizer(mock.Anything, "baz", mock.Anything).Return(nil, nil) - mhf.EXPECT().CreateAuthorizer(mock.Anything, "zab", mock.Anything).Return(nil, nil) - }, - assert: func(t *testing.T, err error, _ *ruleFactory) { - t.Helper() - - require.Error(t, err) - require.ErrorIs(t, err, heimdall.ErrConfiguration) - require.ErrorContains(t, err, "no methods") - }, - }, - { - uc: "new factory with default rule, consisting of authorizer and finalizer with error while expanding methods", - config: &config.Configuration{ - Default: &config.DefaultRule{ - Execute: []config.MechanismConfig{ - {"authenticator": "bar"}, - {"finalizer": "baz"}, - }, - Methods: []string{"FOO", ""}, - }, - }, - configureMocks: func(t *testing.T, mhf *mocks3.FactoryMock) { - t.Helper() - - mhf.EXPECT().CreateAuthenticator(mock.Anything, "bar", mock.Anything).Return(nil, nil) - mhf.EXPECT().CreateFinalizer(mock.Anything, "baz", mock.Anything).Return(nil, nil) - }, - assert: func(t *testing.T, err error, _ *ruleFactory) { - t.Helper() - - require.Error(t, err) - require.ErrorIs(t, err, heimdall.ErrConfiguration) - require.ErrorContains(t, err, "failed to expand") - }, - }, - { - uc: "new factory with default rule, consisting of authorizer and finalizer without methods defined", - config: &config.Configuration{ - Default: &config.DefaultRule{ - Execute: []config.MechanismConfig{ - {"authenticator": "bar"}, - {"finalizer": "baz"}, - }, - }, - }, - configureMocks: func(t *testing.T, mhf *mocks3.FactoryMock) { - t.Helper() - - mhf.EXPECT().CreateAuthenticator(mock.Anything, "bar", mock.Anything).Return(nil, nil) - mhf.EXPECT().CreateFinalizer(mock.Anything, "baz", mock.Anything).Return(nil, nil) - }, - assert: func(t *testing.T, err error, _ *ruleFactory) { - t.Helper() - - require.Error(t, err) - require.ErrorIs(t, err, heimdall.ErrConfiguration) - require.ErrorContains(t, err, "no methods defined") - }, - }, { uc: "new factory with default rule, configured with all required elements", config: &config.Configuration{ @@ -427,7 +305,6 @@ func TestRuleFactoryNew(t *testing.T) { Execute: []config.MechanismConfig{ {"authenticator": "bar"}, }, - Methods: []string{"FOO"}, }, }, configureMocks: func(t *testing.T, mhf *mocks3.FactoryMock) { @@ -447,8 +324,7 @@ func TestRuleFactoryNew(t *testing.T) { assert.True(t, defRule.isDefault) assert.Equal(t, "default", defRule.id) assert.Equal(t, "config", defRule.srcID) - assert.Equal(t, config2.EncodedSlashesOff, defRule.encodedSlashesHandling) - assert.ElementsMatch(t, defRule.methods, []string{"FOO"}) + assert.Equal(t, config2.EncodedSlashesOff, defRule.slashesHandling) assert.Len(t, defRule.sc, 1) assert.Empty(t, defRule.sh) assert.Empty(t, defRule.fi) @@ -469,7 +345,6 @@ func TestRuleFactoryNew(t *testing.T) { {"error_handler": "foobar"}, {"error_handler": "barfoo"}, }, - Methods: []string{"FOO", "BAR"}, }, }, configureMocks: func(t *testing.T, mhf *mocks3.FactoryMock) { @@ -494,8 +369,7 @@ func TestRuleFactoryNew(t *testing.T) { assert.True(t, defRule.isDefault) assert.Equal(t, "default", defRule.id) assert.Equal(t, "config", defRule.srcID) - assert.Equal(t, config2.EncodedSlashesOff, defRule.encodedSlashesHandling) - assert.ElementsMatch(t, defRule.methods, []string{"FOO", "BAR"}) + assert.Equal(t, config2.EncodedSlashesOff, defRule.slashesHandling) assert.Len(t, defRule.sc, 1) assert.Len(t, defRule.sh, 2) assert.Len(t, defRule.fi, 1) @@ -544,48 +418,27 @@ func TestRuleFactoryCreateRule(t *testing.T) { assert func(t *testing.T, err error, rul *ruleImpl) }{ { - uc: "without default rule and with missing id", - config: config2.Rule{}, - assert: func(t *testing.T, err error, _ *ruleImpl) { - t.Helper() - - require.Error(t, err) - require.ErrorIs(t, err, heimdall.ErrConfiguration) - assert.Contains(t, err.Error(), "no ID defined") - }, - }, - { - uc: "in proxy mode, with id, but missing forward_to definition", + uc: "in proxy mode without forward_to definition", opMode: config.ProxyMode, - config: config2.Rule{ID: "foobar"}, - assert: func(t *testing.T, err error, _ *ruleImpl) { - t.Helper() - - require.Error(t, err) - require.ErrorIs(t, err, heimdall.ErrConfiguration) - assert.Contains(t, err.Error(), "no forward_to") + config: config2.Rule{ + ID: "foobar", + Matcher: config2.Matcher{Path: "/foo/bar"}, }, - }, - { - uc: "in proxy mode, with id and empty forward_to definition", - opMode: config.ProxyMode, - config: config2.Rule{ID: "foobar", Backend: &config2.Backend{}}, assert: func(t *testing.T, err error, _ *ruleImpl) { t.Helper() require.Error(t, err) require.ErrorIs(t, err, heimdall.ErrConfiguration) - assert.Contains(t, err.Error(), "missing host") + assert.Contains(t, err.Error(), "requires forward_to") }, }, { - uc: "in proxy mode, with id and forward_to.host, but empty rewrite definition", - opMode: config.ProxyMode, + uc: "with error while creating request matcher", config: config2.Rule{ ID: "foobar", - Backend: &config2.Backend{ - Host: "foo.bar", - URLRewriter: &config2.URLRewriter{}, + Matcher: config2.Matcher{ + Path: "/foo/bar", + With: &config2.MatcherConstraints{HostRegex: "?>?<*??"}, }, }, assert: func(t *testing.T, err error, _ *ruleImpl) { @@ -593,37 +446,15 @@ func TestRuleFactoryCreateRule(t *testing.T) { require.Error(t, err) require.ErrorIs(t, err, heimdall.ErrConfiguration) - assert.Contains(t, err.Error(), "rewrite is defined") - }, - }, - { - uc: "without default rule, with id, but without url", - config: config2.Rule{ID: "foobar"}, - assert: func(t *testing.T, err error, _ *ruleImpl) { - t.Helper() - - require.Error(t, err) - require.ErrorIs(t, err, heimdall.ErrConfiguration) - assert.Contains(t, err.Error(), "bad URL pattern") - }, - }, - { - uc: "without default rule, with id, but bad url pattern", - config: config2.Rule{ID: "foobar", RuleMatcher: config2.Matcher{URL: "?>?<*??"}}, - assert: func(t *testing.T, err error, _ *ruleImpl) { - t.Helper() - - require.Error(t, err) - require.ErrorIs(t, err, heimdall.ErrConfiguration) - assert.Contains(t, err.Error(), "bad URL pattern") + assert.Contains(t, err.Error(), "filed to compile host expression") }, }, { uc: "with error while creating execute pipeline", config: config2.Rule{ - ID: "foobar", - RuleMatcher: config2.Matcher{URL: "http://foo.bar", Strategy: "regex"}, - Execute: []config.MechanismConfig{{"authenticator": "foo"}}, + ID: "foobar", + Matcher: config2.Matcher{Path: "/foo/bar"}, + Execute: []config.MechanismConfig{{"authenticator": "foo"}}, }, configureMocks: func(t *testing.T, mhf *mocks3.FactoryMock) { t.Helper() @@ -641,7 +472,7 @@ func TestRuleFactoryCreateRule(t *testing.T) { uc: "with error while creating on_error pipeline", config: config2.Rule{ ID: "foobar", - RuleMatcher: config2.Matcher{URL: "http://foo.bar", Strategy: "glob"}, + Matcher: config2.Matcher{Path: "/foo/bar"}, ErrorHandler: []config.MechanismConfig{{"error_handler": "foo"}}, }, configureMocks: func(t *testing.T, mhf *mocks3.FactoryMock) { @@ -659,8 +490,8 @@ func TestRuleFactoryCreateRule(t *testing.T) { { uc: "without default rule and without any execute configuration", config: config2.Rule{ - ID: "foobar", - RuleMatcher: config2.Matcher{URL: "http://foo.bar", Strategy: "regex"}, + ID: "foobar", + Matcher: config2.Matcher{Path: "/foo/bar"}, }, assert: func(t *testing.T, err error, _ *ruleImpl) { t.Helper() @@ -671,137 +502,17 @@ func TestRuleFactoryCreateRule(t *testing.T) { }, }, { - uc: "without default rule and with only authenticator configured", + uc: "without default rule and minimum required configuration in decision mode", config: config2.Rule{ - ID: "foobar", - RuleMatcher: config2.Matcher{URL: "http://foo.bar", Strategy: "glob"}, - Execute: []config.MechanismConfig{{"authenticator": "foo"}}, - }, - configureMocks: func(t *testing.T, mhf *mocks3.FactoryMock) { - t.Helper() - - mhf.EXPECT().CreateAuthenticator("test", "foo", mock.Anything).Return(&mocks2.AuthenticatorMock{}, nil) - }, - assert: func(t *testing.T, err error, _ *ruleImpl) { - t.Helper() - - require.Error(t, err) - require.ErrorIs(t, err, heimdall.ErrConfiguration) - require.ErrorContains(t, err, "no methods defined") - }, - }, - { - uc: "without default rule and with only authenticator and contextualizer configured", - config: config2.Rule{ - ID: "foobar", - RuleMatcher: config2.Matcher{URL: "http://foo.bar", Strategy: "glob"}, + ID: "foobar", + Matcher: config2.Matcher{Path: "/foo/bar"}, Execute: []config.MechanismConfig{ {"authenticator": "foo"}, - {"contextualizer": "bar"}, }, }, configureMocks: func(t *testing.T, mhf *mocks3.FactoryMock) { t.Helper() - mhf.EXPECT().CreateAuthenticator("test", "foo", mock.Anything).Return(&mocks2.AuthenticatorMock{}, nil) - mhf.EXPECT().CreateContextualizer("test", "bar", mock.Anything).Return(&mocks5.ContextualizerMock{}, nil) - }, - assert: func(t *testing.T, err error, _ *ruleImpl) { - t.Helper() - - require.Error(t, err) - require.ErrorIs(t, err, heimdall.ErrConfiguration) - require.ErrorContains(t, err, "no methods defined") - }, - }, - { - uc: "without default rule and with only authenticator, contextualizer and authorizer configured", - config: config2.Rule{ - ID: "foobar", - RuleMatcher: config2.Matcher{URL: "http://foo.bar", Strategy: "regex"}, - Execute: []config.MechanismConfig{ - {"authenticator": "foo"}, - {"contextualizer": "bar"}, - {"authorizer": "baz"}, - }, - }, - configureMocks: func(t *testing.T, mhf *mocks3.FactoryMock) { - t.Helper() - - mhf.EXPECT().CreateAuthenticator("test", "foo", mock.Anything).Return(&mocks2.AuthenticatorMock{}, nil) - mhf.EXPECT().CreateContextualizer("test", "bar", mock.Anything).Return(&mocks5.ContextualizerMock{}, nil) - mhf.EXPECT().CreateAuthorizer("test", "baz", mock.Anything).Return(&mocks4.AuthorizerMock{}, nil) - }, - assert: func(t *testing.T, err error, _ *ruleImpl) { - t.Helper() - - require.Error(t, err) - require.ErrorIs(t, err, heimdall.ErrConfiguration) - require.ErrorContains(t, err, "no methods defined") - }, - }, - { - uc: "without default rule and with authenticator and finalizer configured, but with error while expanding methods", - config: config2.Rule{ - ID: "foobar", - RuleMatcher: config2.Matcher{URL: "http://foo.bar", Strategy: "glob"}, - Execute: []config.MechanismConfig{ - {"authenticator": "foo"}, - {"finalizer": "bar"}, - }, - Methods: []string{"FOO", ""}, - }, - configureMocks: func(t *testing.T, mhf *mocks3.FactoryMock) { - t.Helper() - - mhf.EXPECT().CreateAuthenticator("test", "foo", mock.Anything).Return(&mocks2.AuthenticatorMock{}, nil) - mhf.EXPECT().CreateFinalizer("test", "bar", mock.Anything).Return(&mocks7.FinalizerMock{}, nil) - }, - assert: func(t *testing.T, err error, _ *ruleImpl) { - t.Helper() - - require.Error(t, err) - require.ErrorIs(t, err, heimdall.ErrConfiguration) - require.ErrorContains(t, err, "failed to expand") - }, - }, - { - uc: "without default rule and with authenticator and finalizer configured, but without methods", - config: config2.Rule{ - ID: "foobar", - RuleMatcher: config2.Matcher{URL: "http://foo.bar", Strategy: "glob"}, - Execute: []config.MechanismConfig{ - {"authenticator": "foo"}, - {"finalizer": "bar"}, - }, - }, - configureMocks: func(t *testing.T, mhf *mocks3.FactoryMock) { - t.Helper() - - mhf.EXPECT().CreateAuthenticator("test", "foo", mock.Anything).Return(&mocks2.AuthenticatorMock{}, nil) - mhf.EXPECT().CreateFinalizer("test", "bar", mock.Anything).Return(&mocks7.FinalizerMock{}, nil) - }, - assert: func(t *testing.T, err error, _ *ruleImpl) { - t.Helper() - - require.Error(t, err) - require.ErrorIs(t, err, heimdall.ErrConfiguration) - require.ErrorContains(t, err, "no methods defined") - }, - }, - { - uc: "without default rule but with minimum required configuration in decision mode", - config: config2.Rule{ - ID: "foobar", - RuleMatcher: config2.Matcher{URL: "http://foo.bar", Strategy: "glob"}, - Execute: []config.MechanismConfig{ - {"authenticator": "foo"}, - }, - Methods: []string{"FOO", "BAR"}, - }, - configureMocks: func(t *testing.T, mhf *mocks3.FactoryMock) { - t.Helper() - mhf.EXPECT().CreateAuthenticator("test", "foo", mock.Anything).Return(&mocks2.AuthenticatorMock{}, nil) }, assert: func(t *testing.T, err error, rul *ruleImpl) { @@ -813,9 +524,9 @@ func TestRuleFactoryCreateRule(t *testing.T) { assert.Equal(t, "test", rul.srcID) assert.False(t, rul.isDefault) assert.Equal(t, "foobar", rul.id) - assert.Equal(t, config2.EncodedSlashesOff, rul.encodedSlashesHandling) - assert.NotNil(t, rul.urlMatcher) - assert.ElementsMatch(t, rul.methods, []string{"FOO", "BAR"}) + assert.Equal(t, config2.EncodedSlashesOff, rul.slashesHandling) + assert.NotNil(t, rul.matcher) + assert.Equal(t, "/foo/bar", rul.PathExpression()) assert.Len(t, rul.sc, 1) assert.Empty(t, rul.sh) assert.Empty(t, rul.fi) @@ -823,16 +534,15 @@ func TestRuleFactoryCreateRule(t *testing.T) { }, }, { - uc: "without default rule but with minimum required configuration in proxy mode", + uc: "without default rule and minimum required configuration in proxy mode", opMode: config.ProxyMode, config: config2.Rule{ - ID: "foobar", - Backend: &config2.Backend{Host: "foo.bar"}, - RuleMatcher: config2.Matcher{URL: "http://foo.bar", Strategy: "glob"}, + ID: "foobar", + Backend: &config2.Backend{Host: "foo.bar"}, + Matcher: config2.Matcher{Path: "/foo/bar"}, Execute: []config.MechanismConfig{ {"authenticator": "foo"}, }, - Methods: []string{"FOO", "BAR"}, }, configureMocks: func(t *testing.T, mhf *mocks3.FactoryMock) { t.Helper() @@ -848,9 +558,9 @@ func TestRuleFactoryCreateRule(t *testing.T) { assert.Equal(t, "test", rul.srcID) assert.False(t, rul.isDefault) assert.Equal(t, "foobar", rul.id) - assert.Equal(t, config2.EncodedSlashesOff, rul.encodedSlashesHandling) - assert.NotNil(t, rul.urlMatcher) - assert.ElementsMatch(t, rul.methods, []string{"FOO", "BAR"}) + assert.Equal(t, config2.EncodedSlashesOff, rul.slashesHandling) + assert.NotNil(t, rul.matcher) + assert.Equal(t, "/foo/bar", rul.PathExpression()) assert.Len(t, rul.sc, 1) assert.Empty(t, rul.sh) assert.Empty(t, rul.fi) @@ -859,17 +569,16 @@ func TestRuleFactoryCreateRule(t *testing.T) { }, }, { - uc: "with default rule and with id and url only", + uc: "with default rule and with id and path expression only", config: config2.Rule{ - ID: "foobar", - RuleMatcher: config2.Matcher{URL: "http://foo.bar", Strategy: "glob"}, + ID: "foobar", + Matcher: config2.Matcher{Path: "/foo/bar"}, }, defaultRule: &ruleImpl{ - methods: []string{"FOO"}, - sc: compositeSubjectCreator{&mocks.SubjectCreatorMock{}}, - sh: compositeSubjectHandler{&mocks.SubjectHandlerMock{}}, - fi: compositeSubjectHandler{&mocks.SubjectHandlerMock{}}, - eh: compositeErrorHandler{&mocks.ErrorHandlerMock{}}, + sc: compositeSubjectCreator{&mocks.SubjectCreatorMock{}}, + sh: compositeSubjectHandler{&mocks.SubjectHandlerMock{}}, + fi: compositeSubjectHandler{&mocks.SubjectHandlerMock{}}, + eh: compositeErrorHandler{&mocks.ErrorHandlerMock{}}, }, assert: func(t *testing.T, err error, rul *ruleImpl) { t.Helper() @@ -880,8 +589,8 @@ func TestRuleFactoryCreateRule(t *testing.T) { assert.Equal(t, "test", rul.srcID) assert.False(t, rul.isDefault) assert.Equal(t, "foobar", rul.id) - assert.NotNil(t, rul.urlMatcher) - assert.ElementsMatch(t, rul.methods, []string{"FOO"}) + assert.NotNil(t, rul.matcher) + assert.Equal(t, "/foo/bar", rul.PathExpression()) assert.Len(t, rul.sc, 1) assert.Len(t, rul.sh, 1) assert.Len(t, rul.fi, 1) @@ -891,9 +600,17 @@ func TestRuleFactoryCreateRule(t *testing.T) { { uc: "with default rule and with all attributes defined by the rule itself in decision mode", config: config2.Rule{ - ID: "foobar", - RuleMatcher: config2.Matcher{URL: "http://foo.bar", Strategy: "glob"}, - EncodedSlashesHandling: config2.EncodedSlashesNoDecode, + ID: "foobar", + Matcher: config2.Matcher{ + Path: "/foo/:resource", + With: &config2.MatcherConstraints{ + Scheme: "https", + HostGlob: "**.example.com", + PathRegex: "^/foo/(bar|baz)", + Methods: []string{"BAR", "BAZ"}, + }, + }, + EncodedSlashesHandling: config2.EncodedSlashesOnNoDecode, Execute: []config.MechanismConfig{ {"authenticator": "foo"}, {"contextualizer": "bar"}, @@ -903,14 +620,12 @@ func TestRuleFactoryCreateRule(t *testing.T) { ErrorHandler: []config.MechanismConfig{ {"error_handler": "foo"}, }, - Methods: []string{"BAR", "BAZ"}, }, defaultRule: &ruleImpl{ - methods: []string{"FOO"}, - sc: compositeSubjectCreator{&mocks.SubjectCreatorMock{}}, - sh: compositeSubjectHandler{&mocks.SubjectHandlerMock{}}, - fi: compositeSubjectHandler{&mocks.SubjectHandlerMock{}}, - eh: compositeErrorHandler{&mocks.ErrorHandlerMock{}}, + sc: compositeSubjectCreator{&mocks.SubjectCreatorMock{}}, + sh: compositeSubjectHandler{&mocks.SubjectHandlerMock{}}, + fi: compositeSubjectHandler{&mocks.SubjectHandlerMock{}}, + eh: compositeErrorHandler{&mocks.ErrorHandlerMock{}}, }, configureMocks: func(t *testing.T, mhf *mocks3.FactoryMock) { t.Helper() @@ -935,9 +650,9 @@ func TestRuleFactoryCreateRule(t *testing.T) { assert.Equal(t, "test", rul.srcID) assert.False(t, rul.isDefault) assert.Equal(t, "foobar", rul.id) - assert.Equal(t, config2.EncodedSlashesNoDecode, rul.encodedSlashesHandling) - assert.NotNil(t, rul.urlMatcher) - assert.ElementsMatch(t, rul.methods, []string{"BAR", "BAZ"}) + assert.Equal(t, config2.EncodedSlashesOnNoDecode, rul.slashesHandling) + assert.Equal(t, "/foo/:resource", rul.PathExpression()) + assert.NotNil(t, rul.matcher) // nil checks above mean the responses from the mockHandlerFactory are used // and not the values from the default rule @@ -956,8 +671,16 @@ func TestRuleFactoryCreateRule(t *testing.T) { uc: "with default rule and with all attributes defined by the rule itself in proxy mode", opMode: config.ProxyMode, config: config2.Rule{ - ID: "foobar", - RuleMatcher: config2.Matcher{URL: "http://foo.bar", Strategy: "glob"}, + ID: "foobar", + Matcher: config2.Matcher{ + Path: "/foo/:resource", + With: &config2.MatcherConstraints{ + Scheme: "https", + HostGlob: "**.example.com", + PathRegex: "^/foo/(bar|baz)", + Methods: []string{"BAR", "BAZ"}, + }, + }, EncodedSlashesHandling: config2.EncodedSlashesOn, Backend: &config2.Backend{ Host: "bar.foo", @@ -977,14 +700,12 @@ func TestRuleFactoryCreateRule(t *testing.T) { ErrorHandler: []config.MechanismConfig{ {"error_handler": "foo"}, }, - Methods: []string{"BAR", "BAZ"}, }, defaultRule: &ruleImpl{ - methods: []string{"FOO"}, - sc: compositeSubjectCreator{&mocks.SubjectCreatorMock{}}, - sh: compositeSubjectHandler{&mocks.SubjectHandlerMock{}}, - fi: compositeSubjectHandler{&mocks.SubjectHandlerMock{}}, - eh: compositeErrorHandler{&mocks.ErrorHandlerMock{}}, + sc: compositeSubjectCreator{&mocks.SubjectCreatorMock{}}, + sh: compositeSubjectHandler{&mocks.SubjectHandlerMock{}}, + fi: compositeSubjectHandler{&mocks.SubjectHandlerMock{}}, + eh: compositeErrorHandler{&mocks.ErrorHandlerMock{}}, }, configureMocks: func(t *testing.T, mhf *mocks3.FactoryMock) { t.Helper() @@ -1009,9 +730,9 @@ func TestRuleFactoryCreateRule(t *testing.T) { assert.Equal(t, "test", rul.srcID) assert.False(t, rul.isDefault) assert.Equal(t, "foobar", rul.id) - assert.Equal(t, config2.EncodedSlashesOn, rul.encodedSlashesHandling) - assert.NotNil(t, rul.urlMatcher) - assert.ElementsMatch(t, rul.methods, []string{"BAR", "BAZ"}) + assert.Equal(t, config2.EncodedSlashesOn, rul.slashesHandling) + assert.Equal(t, "/foo/:resource", rul.PathExpression()) + assert.NotNil(t, rul.matcher) assert.Equal(t, "https://bar.foo/baz/bar?foo=bar", rul.backend.CreateURL(&url.URL{ Scheme: "http", Host: "foo.bar:8888", @@ -1036,13 +757,12 @@ func TestRuleFactoryCreateRule(t *testing.T) { { uc: "with conditional execution configuration type error", config: config2.Rule{ - ID: "foobar", - RuleMatcher: config2.Matcher{URL: "http://foo.bar", Strategy: "glob"}, + ID: "foobar", + Matcher: config2.Matcher{Path: "/foo/bar"}, Execute: []config.MechanismConfig{ {"authenticator": "foo"}, {"finalizer": "bar", "if": 1}, }, - Methods: []string{"FOO"}, }, configureMocks: func(t *testing.T, mhf *mocks3.FactoryMock) { t.Helper() @@ -1060,13 +780,12 @@ func TestRuleFactoryCreateRule(t *testing.T) { { uc: "with empty conditional execution configuration", config: config2.Rule{ - ID: "foobar", - RuleMatcher: config2.Matcher{URL: "http://foo.bar", Strategy: "glob"}, + ID: "foobar", + Matcher: config2.Matcher{Path: "/foo/bar"}, Execute: []config.MechanismConfig{ {"authenticator": "foo"}, {"finalizer": "bar", "if": ""}, }, - Methods: []string{"FOO"}, }, configureMocks: func(t *testing.T, mhf *mocks3.FactoryMock) { t.Helper() @@ -1084,8 +803,8 @@ func TestRuleFactoryCreateRule(t *testing.T) { { uc: "with conditional execution for some mechanisms", config: config2.Rule{ - ID: "foobar", - RuleMatcher: config2.Matcher{URL: "http://foo.bar", Strategy: "glob"}, + ID: "foobar", + Matcher: config2.Matcher{Path: "/foo/bar"}, Execute: []config.MechanismConfig{ {"authenticator": "foo"}, {"authorizer": "bar", "if": "false"}, @@ -1093,7 +812,6 @@ func TestRuleFactoryCreateRule(t *testing.T) { {"authorizer": "baz"}, {"finalizer": "bar", "if": "true"}, }, - Methods: []string{"FOO"}, }, configureMocks: func(t *testing.T, mhf *mocks3.FactoryMock) { t.Helper() @@ -1116,8 +834,8 @@ func TestRuleFactoryCreateRule(t *testing.T) { assert.Equal(t, "test", rul.srcID) assert.False(t, rul.isDefault) assert.Equal(t, "foobar", rul.id) - assert.NotNil(t, rul.urlMatcher) - assert.ElementsMatch(t, rul.methods, []string{"FOO"}) + assert.Equal(t, "/foo/bar", rul.PathExpression()) + assert.NotNil(t, rul.matcher) require.Len(t, rul.sc, 1) assert.NotNil(t, rul.sc[0]) @@ -1150,8 +868,8 @@ func TestRuleFactoryCreateRule(t *testing.T) { { uc: "with conditional execution for error handler", config: config2.Rule{ - ID: "foobar", - RuleMatcher: config2.Matcher{URL: "http://foo.bar", Strategy: "glob"}, + ID: "foobar", + Matcher: config2.Matcher{Path: "/foo/bar"}, Execute: []config.MechanismConfig{ {"authenticator": "foo"}, {"authorizer": "bar"}, @@ -1161,7 +879,6 @@ func TestRuleFactoryCreateRule(t *testing.T) { {"error_handler": "foo", "if": "true", "config": map[string]any{}}, {"error_handler": "bar", "if": "false", "config": map[string]any{}}, }, - Methods: []string{"FOO"}, }, configureMocks: func(t *testing.T, mhf *mocks3.FactoryMock) { t.Helper() @@ -1188,8 +905,8 @@ func TestRuleFactoryCreateRule(t *testing.T) { assert.Equal(t, "test", rul.srcID) assert.False(t, rul.isDefault) assert.Equal(t, "foobar", rul.id) - assert.NotNil(t, rul.urlMatcher) - assert.ElementsMatch(t, rul.methods, []string{"FOO"}) + assert.Equal(t, "/foo/bar", rul.PathExpression()) + assert.NotNil(t, rul.matcher) require.Len(t, rul.sc, 1) assert.NotNil(t, rul.sc[0]) @@ -1284,157 +1001,3 @@ func TestRuleFactoryConfigExtraction(t *testing.T) { }) } } - -func TestRuleFactoryProxyModeApplicability(t *testing.T) { - t.Parallel() - - for _, tc := range []struct { - uc string - ruleConfig config2.Rule - shouldError bool - }{ - { - uc: "no upstream url factory", - ruleConfig: config2.Rule{}, - shouldError: true, - }, - { - uc: "no host defined", - ruleConfig: config2.Rule{Backend: &config2.Backend{}}, - shouldError: true, - }, - { - uc: "with host but no rewrite options", - ruleConfig: config2.Rule{Backend: &config2.Backend{Host: "foo.bar"}}, - }, - { - uc: "with host and empty rewrite option", - ruleConfig: config2.Rule{ - Backend: &config2.Backend{ - Host: "foo.bar", - URLRewriter: &config2.URLRewriter{}, - }, - }, - shouldError: true, - }, - { - uc: "with host and scheme rewrite option", - ruleConfig: config2.Rule{ - Backend: &config2.Backend{ - Host: "foo.bar", - URLRewriter: &config2.URLRewriter{Scheme: "https"}, - }, - }, - }, - { - uc: "with host and strip path prefix rewrite option", - ruleConfig: config2.Rule{ - Backend: &config2.Backend{ - Host: "foo.bar", - URLRewriter: &config2.URLRewriter{PathPrefixToCut: "/foo"}, - }, - }, - }, - { - uc: "with host and add path prefix rewrite option", - ruleConfig: config2.Rule{ - Backend: &config2.Backend{ - Host: "foo.bar", - URLRewriter: &config2.URLRewriter{PathPrefixToAdd: "/foo"}, - }, - }, - }, - { - uc: "with host and empty strip query parameter rewrite option", - ruleConfig: config2.Rule{ - Backend: &config2.Backend{ - Host: "foo.bar", - URLRewriter: &config2.URLRewriter{QueryParamsToRemove: []string{}}, - }, - }, - shouldError: true, - }, - { - uc: "with host and strip query parameter rewrite option", - ruleConfig: config2.Rule{ - Backend: &config2.Backend{ - Host: "foo.bar", - URLRewriter: &config2.URLRewriter{QueryParamsToRemove: []string{"foo"}}, - }, - }, - }, - } { - t.Run(tc.uc, func(t *testing.T) { - // WHEN - err := checkProxyModeApplicability("test", tc.ruleConfig) - - // THEN - if tc.shouldError { - require.Error(t, err) - } else { - require.NoError(t, err) - } - }) - } -} - -func TestExpandHTTPMethods(t *testing.T) { - t.Parallel() - - for _, tc := range []struct { - uc string - configured []string - expected []string - shouldError bool - }{ - { - uc: "empty configuration", - }, - { - uc: "empty method in list", - configured: []string{"FOO", ""}, - shouldError: true, - }, - { - uc: "duplicates should be removed", - configured: []string{"BAR", "BAZ", "BAZ", "FOO", "FOO", "ZAB"}, - expected: []string{"BAR", "BAZ", "FOO", "ZAB"}, - }, - { - uc: "only ALL configured", - configured: []string{"ALL"}, - expected: []string{ - http.MethodConnect, http.MethodDelete, http.MethodGet, http.MethodHead, http.MethodOptions, - http.MethodPatch, http.MethodPost, http.MethodPut, http.MethodTrace, - }, - }, - { - uc: "ALL without POST and TRACE", - configured: []string{"ALL", "!POST", "!TRACE"}, - expected: []string{ - http.MethodConnect, http.MethodDelete, http.MethodGet, http.MethodHead, - http.MethodOptions, http.MethodPatch, http.MethodPut, - }, - }, - { - uc: "ALL with duplicates and without POST and TRACE", - configured: []string{"ALL", "GET", "!POST", "!TRACE", "!TRACE"}, - expected: []string{ - http.MethodConnect, http.MethodDelete, http.MethodGet, http.MethodHead, - http.MethodOptions, http.MethodPatch, http.MethodPut, - }, - }, - } { - t.Run(tc.uc, func(t *testing.T) { - // WHEN - res, err := expandHTTPMethods(tc.configured) - - // THEN - if tc.shouldError { - require.Error(t, err) - } else { - require.Equal(t, tc.expected, res) - } - }) - } -} diff --git a/internal/rules/rule_impl.go b/internal/rules/rule_impl.go index 7e882d072..146d6bb65 100644 --- a/internal/rules/rule_impl.go +++ b/internal/rules/rule_impl.go @@ -17,32 +17,31 @@ package rules import ( - "fmt" "net/url" - "slices" "strings" "github.com/rs/zerolog" "github.com/dadrus/heimdall/internal/heimdall" "github.com/dadrus/heimdall/internal/rules/config" - "github.com/dadrus/heimdall/internal/rules/patternmatcher" "github.com/dadrus/heimdall/internal/rules/rule" + "github.com/dadrus/heimdall/internal/x/errorchain" ) type ruleImpl struct { - id string - encodedSlashesHandling config.EncodedSlashesHandling - urlMatcher patternmatcher.PatternMatcher - backend *config.Backend - methods []string - srcID string - isDefault bool - hash []byte - sc compositeSubjectCreator - sh compositeSubjectHandler - fi compositeSubjectHandler - eh compositeErrorHandler + id string + srcID string + isDefault bool + hash []byte + pathExpression string + matcher config.RequestMatcher + allowsBacktracking bool + slashesHandling config.EncodedSlashesHandling + backend *config.Backend + sc compositeSubjectCreator + sh compositeSubjectHandler + fi compositeSubjectHandler + eh compositeErrorHandler } func (r *ruleImpl) Execute(ctx heimdall.Context) (rule.Backend, error) { @@ -54,6 +53,25 @@ func (r *ruleImpl) Execute(ctx heimdall.Context) (rule.Backend, error) { logger.Info().Str("_src", r.srcID).Str("_id", r.id).Msg("Executing rule") } + request := ctx.Request() + + switch r.slashesHandling { //nolint:exhaustive + case config.EncodedSlashesOn: + // unescape path + request.URL.RawPath = "" + case config.EncodedSlashesOff: + if strings.Contains(request.URL.RawPath, "%2F") { + return nil, errorchain.NewWithMessage(heimdall.ErrArgument, + "path contains encoded slash, which is not allowed") + } + } + + // unescape captures + captures := request.URL.Captures + for k, v := range captures { + captures[k] = unescape(v, r.slashesHandling) + } + // authenticators sub, err := r.sc.Execute(ctx) if err != nil { @@ -73,54 +91,57 @@ func (r *ruleImpl) Execute(ctx heimdall.Context) (rule.Backend, error) { var upstream rule.Backend if r.backend != nil { - targetURL := *ctx.Request().URL - if r.encodedSlashesHandling == config.EncodedSlashesOn && len(targetURL.RawPath) != 0 { - targetURL.RawPath = "" - } - upstream = &backend{ - targetURL: r.backend.CreateURL(&targetURL), + targetURL: r.backend.CreateURL(&request.URL.URL), } } return upstream, nil } -func (r *ruleImpl) MatchesURL(requestURL *url.URL) bool { - var path string - - switch r.encodedSlashesHandling { - case config.EncodedSlashesOff: - if strings.Contains(requestURL.RawPath, "%2F") { - return false - } +func (r *ruleImpl) Matches(ctx heimdall.Context) bool { + request := ctx.Request() + logger := zerolog.Ctx(ctx.AppContext()).With().Str("_source", r.srcID).Str("_id", r.id).Logger() - path = requestURL.Path - case config.EncodedSlashesNoDecode: - if len(requestURL.RawPath) != 0 { - path = strings.ReplaceAll(requestURL.RawPath, "%2F", "$$$escaped-slash$$$") - path, _ = url.PathUnescape(path) - path = strings.ReplaceAll(path, "$$$escaped-slash$$$", "%2F") + logger.Debug().Msg("Matching rule") - break - } + if err := r.matcher.Matches(request); err != nil { + logger.Debug().Err(err).Msg("Request does not satisfy matching conditions") - fallthrough - default: - path = requestURL.Path + return false } - return r.urlMatcher.Match(fmt.Sprintf("%s://%s%s", requestURL.Scheme, requestURL.Host, path)) -} + logger.Debug().Msg("Rule matched") -func (r *ruleImpl) MatchesMethod(method string) bool { return slices.Contains(r.methods, method) } + return true +} func (r *ruleImpl) ID() string { return r.id } func (r *ruleImpl) SrcID() string { return r.srcID } +func (r *ruleImpl) PathExpression() string { return r.pathExpression } + +func (r *ruleImpl) BacktrackingEnabled() bool { return r.allowsBacktracking } + +func (r *ruleImpl) SameAs(other rule.Rule) bool { + return r.ID() == other.ID() && r.SrcID() == other.SrcID() +} + type backend struct { targetURL *url.URL } func (b *backend) URL() *url.URL { return b.targetURL } + +func unescape(value string, handling config.EncodedSlashesHandling) string { + if handling == config.EncodedSlashesOn { + unescaped, _ := url.PathUnescape(value) + + return unescaped + } + + unescaped, _ := url.PathUnescape(strings.ReplaceAll(value, "%2F", "$$$escaped-slash$$$")) + + return strings.ReplaceAll(unescaped, "$$$escaped-slash$$$", "%2F") +} diff --git a/internal/rules/rule_impl_test.go b/internal/rules/rule_impl_test.go index 4374fcb81..1202c9faa 100644 --- a/internal/rules/rule_impl_test.go +++ b/internal/rules/rule_impl_test.go @@ -18,213 +18,72 @@ package rules import ( "context" + "errors" + "net/http" "net/url" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "github.com/dadrus/heimdall/internal/heimdall" heimdallmocks "github.com/dadrus/heimdall/internal/heimdall/mocks" "github.com/dadrus/heimdall/internal/rules/config" + mocks2 "github.com/dadrus/heimdall/internal/rules/config/mocks" "github.com/dadrus/heimdall/internal/rules/mechanisms/subject" "github.com/dadrus/heimdall/internal/rules/mocks" - "github.com/dadrus/heimdall/internal/rules/patternmatcher" "github.com/dadrus/heimdall/internal/rules/rule" "github.com/dadrus/heimdall/internal/x" "github.com/dadrus/heimdall/internal/x/testsupport" ) -func TestRuleMatchMethod(t *testing.T) { +func TestRuleMatches(t *testing.T) { t.Parallel() for _, tc := range []struct { - uc string - methods []string - toBeMatched string - assert func(t *testing.T, matched bool) + uc string + rule *ruleImpl + toMatch *heimdall.Request + matches bool }{ - { - uc: "matches", - methods: []string{"FOO", "BAR"}, - toBeMatched: "BAR", - assert: func(t *testing.T, matched bool) { - t.Helper() - - assert.True(t, matched) - }, - }, - { - uc: "doesn't match", - methods: []string{"FOO", "BAR"}, - toBeMatched: "BAZ", - assert: func(t *testing.T, matched bool) { - t.Helper() - - assert.False(t, matched) - }, - }, - } { - t.Run("case="+tc.uc, func(t *testing.T) { - // GIVEN - rul := &ruleImpl{methods: tc.methods} - - // WHEN - matched := rul.MatchesMethod(tc.toBeMatched) - - // THEN - tc.assert(t, matched) - }) - } -} - -func TestRuleMatchURL(t *testing.T) { - t.Parallel() - - for _, tc := range []struct { - uc string - slashHandling config.EncodedSlashesHandling - matcher func(t *testing.T) patternmatcher.PatternMatcher - toBeMatched string - assert func(t *testing.T, matched bool) - }{ - { - uc: "matches", - matcher: func(t *testing.T) patternmatcher.PatternMatcher { - t.Helper() - - matcher, err := patternmatcher.NewPatternMatcher("glob", "http://foo.bar/baz") - require.NoError(t, err) - - return matcher - }, - toBeMatched: "http://foo.bar/baz", - assert: func(t *testing.T, matched bool) { - t.Helper() - - assert.True(t, matched) - }, - }, - { - uc: "matches with urlencoded path fragments", - matcher: func(t *testing.T) patternmatcher.PatternMatcher { - t.Helper() - - matcher, err := patternmatcher.NewPatternMatcher("glob", "http://foo.bar/[id]/baz") - require.NoError(t, err) - - return matcher - }, - toBeMatched: "http://foo.bar/%5Bid%5D/baz", - assert: func(t *testing.T, matched bool) { - t.Helper() - - assert.True(t, matched) - }, - }, - { - uc: "doesn't match with urlencoded slash in path", - matcher: func(t *testing.T) patternmatcher.PatternMatcher { - t.Helper() - - matcher, err := patternmatcher.NewPatternMatcher("glob", "http://foo.bar/foo%2Fbaz") - require.NoError(t, err) - - return matcher - }, - toBeMatched: "http://foo.bar/foo%2Fbaz", - assert: func(t *testing.T, matched bool) { - t.Helper() - - assert.False(t, matched) - }, - }, - { - uc: "matches with urlencoded slash in path if allowed with decoding", - slashHandling: config.EncodedSlashesOn, - matcher: func(t *testing.T) patternmatcher.PatternMatcher { - t.Helper() - - matcher, err := patternmatcher.NewPatternMatcher("glob", "http://foo.bar/foo/baz/[id]") - require.NoError(t, err) - - return matcher - }, - toBeMatched: "http://foo.bar/foo%2Fbaz/%5Bid%5D", - assert: func(t *testing.T, matched bool) { - t.Helper() - - assert.True(t, matched) - }, - }, - { - uc: "matches with urlencoded slash in path if allowed without decoding", - slashHandling: config.EncodedSlashesNoDecode, - matcher: func(t *testing.T) patternmatcher.PatternMatcher { - t.Helper() - - matcher, err := patternmatcher.NewPatternMatcher("glob", "http://foo.bar/foo%2Fbaz/[id]") - require.NoError(t, err) - - return matcher - }, - toBeMatched: "http://foo.bar/foo%2Fbaz/%5Bid%5D", - assert: func(t *testing.T, matched bool) { - t.Helper() - - assert.True(t, matched) - }, - }, { uc: "doesn't match", - matcher: func(t *testing.T) patternmatcher.PatternMatcher { - t.Helper() - - matcher, err := patternmatcher.NewPatternMatcher("glob", "http://foo.bar/baz") - require.NoError(t, err) - - return matcher - }, - toBeMatched: "https://foo.bar/baz", - assert: func(t *testing.T, matched bool) { - t.Helper() + rule: &ruleImpl{ + matcher: func() config.RequestMatcher { + rm := mocks2.NewRequestMatcherMock(t) + rm.EXPECT().Matches(mock.Anything).Return(errors.New("test error")) - assert.False(t, matched) + return rm + }(), }, + toMatch: &heimdall.Request{Method: http.MethodGet, URL: &heimdall.URL{}}, + matches: false, }, { - uc: "query params are ignored while matching", - matcher: func(t *testing.T) patternmatcher.PatternMatcher { - t.Helper() - - matcher, err := patternmatcher.NewPatternMatcher("glob", "http://foo.bar/baz") - require.NoError(t, err) - - return matcher - }, - toBeMatched: "https://foo.bar/baz?foo=bar", - assert: func(t *testing.T, matched bool) { - t.Helper() + uc: "matches", + rule: &ruleImpl{ + matcher: func() config.RequestMatcher { + rm := mocks2.NewRequestMatcherMock(t) + rm.EXPECT().Matches(mock.Anything).Return(nil) - assert.False(t, matched) + return rm + }(), }, + toMatch: &heimdall.Request{Method: http.MethodPost, URL: &heimdall.URL{}}, + matches: true, }, } { t.Run("case="+tc.uc, func(t *testing.T) { - // GIVEN - rul := &ruleImpl{ - urlMatcher: tc.matcher(t), - encodedSlashesHandling: x.IfThenElse(len(tc.slashHandling) != 0, tc.slashHandling, config.EncodedSlashesOff), - } - - tbmu, err := url.Parse(tc.toBeMatched) - require.NoError(t, err) + ctx := heimdallmocks.NewContextMock(t) + ctx.EXPECT().AppContext().Return(context.TODO()) + ctx.EXPECT().Request().Return(tc.toMatch) // WHEN - matched := rul.MatchesURL(tbmu) + matched := tc.rule.Matches(ctx) // THEN - tc.assert(t, matched) + assert.Equal(t, tc.matches, matched) }) } } @@ -244,7 +103,7 @@ func TestRuleExecute(t *testing.T) { finalizer *mocks.SubjectHandlerMock, errHandler *mocks.ErrorHandlerMock, ) - assert func(t *testing.T, err error, backend rule.Backend) + assert func(t *testing.T, err error, backend rule.Backend, captures map[string]string) }{ { uc: "authenticator fails, but error handler succeeds", @@ -254,12 +113,14 @@ func TestRuleExecute(t *testing.T) { ) { t.Helper() + ctx.EXPECT().Request().Return(&heimdall.Request{URL: &heimdall.URL{}}) + authenticator.EXPECT().Execute(ctx).Return(nil, testsupport.ErrTestPurpose) authenticator.EXPECT().IsFallbackOnErrorAllowed().Return(false) errHandler.EXPECT().CanExecute(ctx, testsupport.ErrTestPurpose).Return(true) errHandler.EXPECT().Execute(ctx, testsupport.ErrTestPurpose).Return(nil) }, - assert: func(t *testing.T, err error, backend rule.Backend) { + assert: func(t *testing.T, err error, backend rule.Backend, _ map[string]string) { t.Helper() require.NoError(t, err) @@ -274,12 +135,14 @@ func TestRuleExecute(t *testing.T) { ) { t.Helper() + ctx.EXPECT().Request().Return(&heimdall.Request{URL: &heimdall.URL{}}) + authenticator.EXPECT().Execute(ctx).Return(nil, testsupport.ErrTestPurpose) authenticator.EXPECT().IsFallbackOnErrorAllowed().Return(false) errHandler.EXPECT().CanExecute(ctx, testsupport.ErrTestPurpose).Return(true) errHandler.EXPECT().Execute(ctx, testsupport.ErrTestPurpose).Return(testsupport.ErrTestPurpose2) }, - assert: func(t *testing.T, err error, backend rule.Backend) { + assert: func(t *testing.T, err error, backend rule.Backend, _ map[string]string) { t.Helper() require.Error(t, err) @@ -295,6 +158,8 @@ func TestRuleExecute(t *testing.T) { ) { t.Helper() + ctx.EXPECT().Request().Return(&heimdall.Request{URL: &heimdall.URL{}}) + sub := &subject.Subject{ID: "Foo"} authenticator.EXPECT().Execute(ctx).Return(sub, nil) @@ -303,7 +168,7 @@ func TestRuleExecute(t *testing.T) { errHandler.EXPECT().CanExecute(ctx, testsupport.ErrTestPurpose).Return(true) errHandler.EXPECT().Execute(ctx, testsupport.ErrTestPurpose).Return(nil) }, - assert: func(t *testing.T, err error, backend rule.Backend) { + assert: func(t *testing.T, err error, backend rule.Backend, _ map[string]string) { t.Helper() require.NoError(t, err) @@ -318,6 +183,8 @@ func TestRuleExecute(t *testing.T) { ) { t.Helper() + ctx.EXPECT().Request().Return(&heimdall.Request{URL: &heimdall.URL{}}) + sub := &subject.Subject{ID: "Foo"} authenticator.EXPECT().Execute(ctx).Return(sub, nil) @@ -326,7 +193,7 @@ func TestRuleExecute(t *testing.T) { errHandler.EXPECT().CanExecute(ctx, testsupport.ErrTestPurpose).Return(true) errHandler.EXPECT().Execute(ctx, testsupport.ErrTestPurpose).Return(testsupport.ErrTestPurpose2) }, - assert: func(t *testing.T, err error, backend rule.Backend) { + assert: func(t *testing.T, err error, backend rule.Backend, _ map[string]string) { t.Helper() require.Error(t, err) @@ -342,6 +209,8 @@ func TestRuleExecute(t *testing.T) { ) { t.Helper() + ctx.EXPECT().Request().Return(&heimdall.Request{URL: &heimdall.URL{}}) + sub := &subject.Subject{ID: "Foo"} authenticator.EXPECT().Execute(ctx).Return(sub, nil) @@ -351,7 +220,7 @@ func TestRuleExecute(t *testing.T) { errHandler.EXPECT().CanExecute(ctx, testsupport.ErrTestPurpose).Return(true) errHandler.EXPECT().Execute(ctx, testsupport.ErrTestPurpose).Return(nil) }, - assert: func(t *testing.T, err error, backend rule.Backend) { + assert: func(t *testing.T, err error, backend rule.Backend, _ map[string]string) { t.Helper() require.NoError(t, err) @@ -366,6 +235,8 @@ func TestRuleExecute(t *testing.T) { ) { t.Helper() + ctx.EXPECT().Request().Return(&heimdall.Request{URL: &heimdall.URL{}}) + sub := &subject.Subject{ID: "Foo"} authenticator.EXPECT().Execute(ctx).Return(sub, nil) @@ -375,7 +246,7 @@ func TestRuleExecute(t *testing.T) { errHandler.EXPECT().CanExecute(ctx, testsupport.ErrTestPurpose).Return(true) errHandler.EXPECT().Execute(ctx, testsupport.ErrTestPurpose).Return(testsupport.ErrTestPurpose2) }, - assert: func(t *testing.T, err error, backend rule.Backend) { + assert: func(t *testing.T, err error, backend rule.Backend, _ map[string]string) { t.Helper() require.Error(t, err) @@ -384,32 +255,30 @@ func TestRuleExecute(t *testing.T) { }, }, { - uc: "all handler succeed with disallowed urlencoded slashes", + uc: "all handler succeed with disallowed urlencoded slashes", + slashHandling: config.EncodedSlashesOff, backend: &config.Backend{ Host: "foo.bar", }, - configureMocks: func(t *testing.T, ctx *heimdallmocks.ContextMock, authenticator *mocks.SubjectCreatorMock, - authorizer *mocks.SubjectHandlerMock, finalizer *mocks.SubjectHandlerMock, - _ *mocks.ErrorHandlerMock, + configureMocks: func(t *testing.T, ctx *heimdallmocks.ContextMock, _ *mocks.SubjectCreatorMock, + _ *mocks.SubjectHandlerMock, _ *mocks.SubjectHandlerMock, _ *mocks.ErrorHandlerMock, ) { t.Helper() - sub := &subject.Subject{ID: "Foo"} - - authenticator.EXPECT().Execute(ctx).Return(sub, nil) - authorizer.EXPECT().Execute(ctx, sub).Return(nil) - finalizer.EXPECT().Execute(ctx, sub).Return(nil) - - targetURL, _ := url.Parse("http://foo.local/api/v1/foo%5Bid%5D") - ctx.EXPECT().Request().Return(&heimdall.Request{URL: targetURL}) + targetURL, _ := url.Parse("http://foo.local/api%2Fv1/foo%5Bid%5D") + ctx.EXPECT().Request().Return(&heimdall.Request{ + URL: &heimdall.URL{ + URL: *targetURL, + Captures: map[string]string{"first": "api%2Fv1", "second": "foo%5Bid%5D"}, + }, + }) }, - assert: func(t *testing.T, err error, backend rule.Backend) { + assert: func(t *testing.T, err error, _ rule.Backend, _ map[string]string) { t.Helper() - require.NoError(t, err) - - expectedURL, _ := url.Parse("http://foo.bar/api/v1/foo%5Bid%5D") - assert.Equal(t, expectedURL, backend.URL()) + require.Error(t, err) + require.ErrorIs(t, err, heimdall.ErrArgument) + require.ErrorContains(t, err, "path contains encoded slash") }, }, { @@ -431,15 +300,24 @@ func TestRuleExecute(t *testing.T) { finalizer.EXPECT().Execute(ctx, sub).Return(nil) targetURL, _ := url.Parse("http://foo.local/api/v1/foo%5Bid%5D") - ctx.EXPECT().Request().Return(&heimdall.Request{URL: targetURL}) + ctx.EXPECT().Request().Return(&heimdall.Request{ + URL: &heimdall.URL{ + URL: *targetURL, + Captures: map[string]string{"first": "api", "second": "v1", "third": "foo%5Bid%5D"}, + }, + }) }, - assert: func(t *testing.T, err error, backend rule.Backend) { + assert: func(t *testing.T, err error, backend rule.Backend, captures map[string]string) { t.Helper() require.NoError(t, err) expectedURL, _ := url.Parse("http://foo.bar/api/v1/foo%5Bid%5D") assert.Equal(t, expectedURL, backend.URL()) + + assert.Equal(t, "api", captures["first"]) + assert.Equal(t, "v1", captures["second"]) + assert.Equal(t, "foo[id]", captures["third"]) }, }, { @@ -461,20 +339,28 @@ func TestRuleExecute(t *testing.T) { finalizer.EXPECT().Execute(ctx, sub).Return(nil) targetURL, _ := url.Parse("http://foo.local/api%2Fv1/foo%5Bid%5D") - ctx.EXPECT().Request().Return(&heimdall.Request{URL: targetURL}) + ctx.EXPECT().Request().Return(&heimdall.Request{ + URL: &heimdall.URL{ + URL: *targetURL, + Captures: map[string]string{"first": "api%2Fv1", "second": "foo%5Bid%5D"}, + }, + }) }, - assert: func(t *testing.T, err error, backend rule.Backend) { + assert: func(t *testing.T, err error, backend rule.Backend, captures map[string]string) { t.Helper() require.NoError(t, err) expectedURL, _ := url.Parse("http://foo.bar/api/v1/foo%5Bid%5D") assert.Equal(t, expectedURL, backend.URL()) + + assert.Equal(t, "api/v1", captures["first"]) + assert.Equal(t, "foo[id]", captures["second"]) }, }, { uc: "all handler succeed with urlencoded slashes on with urlencoded slash but without decoding it", - slashHandling: config.EncodedSlashesNoDecode, + slashHandling: config.EncodedSlashesOnNoDecode, backend: &config.Backend{ Host: "foo.bar", }, @@ -491,15 +377,23 @@ func TestRuleExecute(t *testing.T) { finalizer.EXPECT().Execute(ctx, sub).Return(nil) targetURL, _ := url.Parse("http://foo.local/api%2Fv1/foo%5Bid%5D") - ctx.EXPECT().Request().Return(&heimdall.Request{URL: targetURL}) + ctx.EXPECT().Request().Return(&heimdall.Request{ + URL: &heimdall.URL{ + URL: *targetURL, + Captures: map[string]string{"first": "api%2Fv1", "second": "foo%5Bid%5D"}, + }, + }) }, - assert: func(t *testing.T, err error, backend rule.Backend) { + assert: func(t *testing.T, err error, backend rule.Backend, captures map[string]string) { t.Helper() require.NoError(t, err) expectedURL, _ := url.Parse("http://foo.bar/api%2Fv1/foo%5Bid%5D") assert.Equal(t, expectedURL, backend.URL()) + + assert.Equal(t, "api%2Fv1", captures["first"]) + assert.Equal(t, "foo[id]", captures["second"]) }, }, { @@ -521,9 +415,9 @@ func TestRuleExecute(t *testing.T) { finalizer.EXPECT().Execute(ctx, sub).Return(nil) targetURL, _ := url.Parse("http://foo.local/api/v1/foo") - ctx.EXPECT().Request().Return(&heimdall.Request{URL: targetURL}) + ctx.EXPECT().Request().Return(&heimdall.Request{URL: &heimdall.URL{URL: *targetURL}}) }, - assert: func(t *testing.T, err error, backend rule.Backend) { + assert: func(t *testing.T, err error, backend rule.Backend, _ map[string]string) { t.Helper() require.NoError(t, err) @@ -544,12 +438,12 @@ func TestRuleExecute(t *testing.T) { errHandler := mocks.NewErrorHandlerMock(t) rul := &ruleImpl{ - backend: tc.backend, - encodedSlashesHandling: x.IfThenElse(len(tc.slashHandling) != 0, tc.slashHandling, config.EncodedSlashesOff), - sc: compositeSubjectCreator{authenticator}, - sh: compositeSubjectHandler{authorizer}, - fi: compositeSubjectHandler{finalizer}, - eh: compositeErrorHandler{errHandler}, + backend: tc.backend, + slashesHandling: x.IfThenElse(len(tc.slashHandling) != 0, tc.slashHandling, config.EncodedSlashesOff), + sc: compositeSubjectCreator{authenticator}, + sh: compositeSubjectHandler{authorizer}, + fi: compositeSubjectHandler{finalizer}, + eh: compositeErrorHandler{errHandler}, } tc.configureMocks(t, ctx, authenticator, authorizer, finalizer, errHandler) @@ -558,7 +452,7 @@ func TestRuleExecute(t *testing.T) { upstream, err := rul.Execute(ctx) // THEN - tc.assert(t, err, upstream) + tc.assert(t, err, upstream, ctx.Request().URL.Captures) }) } } diff --git a/internal/rules/ruleset_processor_impl.go b/internal/rules/ruleset_processor_impl.go index 21462d1c7..2b409eba6 100644 --- a/internal/rules/ruleset_processor_impl.go +++ b/internal/rules/ruleset_processor_impl.go @@ -19,11 +19,8 @@ package rules import ( "errors" - "github.com/rs/zerolog" - "github.com/dadrus/heimdall/internal/heimdall" "github.com/dadrus/heimdall/internal/rules/config" - "github.com/dadrus/heimdall/internal/rules/event" "github.com/dadrus/heimdall/internal/rules/rule" "github.com/dadrus/heimdall/internal/x/errorchain" ) @@ -31,18 +28,14 @@ import ( var ErrUnsupportedRuleSetVersion = errors.New("unsupported rule set version") type ruleSetProcessor struct { - q event.RuleSetChangedEventQueue + r rule.Repository f rule.Factory - l zerolog.Logger } -func NewRuleSetProcessor( - queue event.RuleSetChangedEventQueue, factory rule.Factory, logger zerolog.Logger, -) rule.SetProcessor { +func NewRuleSetProcessor(repository rule.Repository, factory rule.Factory) rule.SetProcessor { return &ruleSetProcessor{ - q: queue, + r: repository, f: factory, - l: logger, } } @@ -56,7 +49,8 @@ func (p *ruleSetProcessor) loadRules(ruleSet *config.RuleSet) ([]rule.Rule, erro for idx, rc := range ruleSet.Rules { rul, err := p.f.CreateRule(ruleSet.Version, ruleSet.Source, rc) if err != nil { - return nil, errorchain.NewWithMessage(heimdall.ErrInternal, "failed loading rule").CausedBy(err) + return nil, errorchain.NewWithMessagef(heimdall.ErrInternal, + "loading rule ID='%s' failed", rc.ID).CausedBy(err) } rules[idx] = rul @@ -75,16 +69,7 @@ func (p *ruleSetProcessor) OnCreated(ruleSet *config.RuleSet) error { return err } - evt := event.RuleSetChanged{ - Source: ruleSet.Source, - Name: ruleSet.Name, - Rules: rules, - ChangeType: event.Create, - } - - p.sendEvent(evt) - - return nil + return p.r.AddRuleSet(ruleSet.Source, rules) } func (p *ruleSetProcessor) OnUpdated(ruleSet *config.RuleSet) error { @@ -97,34 +82,9 @@ func (p *ruleSetProcessor) OnUpdated(ruleSet *config.RuleSet) error { return err } - evt := event.RuleSetChanged{ - Source: ruleSet.Source, - Name: ruleSet.Name, - Rules: rules, - ChangeType: event.Update, - } - - p.sendEvent(evt) - - return nil + return p.r.UpdateRuleSet(ruleSet.Source, rules) } func (p *ruleSetProcessor) OnDeleted(ruleSet *config.RuleSet) error { - evt := event.RuleSetChanged{ - Source: ruleSet.Source, - Name: ruleSet.Name, - ChangeType: event.Remove, - } - - p.sendEvent(evt) - - return nil -} - -func (p *ruleSetProcessor) sendEvent(evt event.RuleSetChanged) { - p.l.Info(). - Str("_src", evt.Source). - Str("_type", evt.ChangeType.String()). - Msg("Rule set changed") - p.q <- evt + return p.r.DeleteRuleSet(ruleSet.Source) } diff --git a/internal/rules/ruleset_processor_test.go b/internal/rules/ruleset_processor_test.go index a185877c6..839eb67e9 100644 --- a/internal/rules/ruleset_processor_test.go +++ b/internal/rules/ruleset_processor_test.go @@ -17,33 +17,32 @@ package rules import ( + "errors" "testing" - "github.com/rs/zerolog/log" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "github.com/dadrus/heimdall/internal/rules/config" - "github.com/dadrus/heimdall/internal/rules/event" + "github.com/dadrus/heimdall/internal/rules/rule" "github.com/dadrus/heimdall/internal/rules/rule/mocks" - "github.com/dadrus/heimdall/internal/x" - "github.com/dadrus/heimdall/internal/x/testsupport" ) func TestRuleSetProcessorOnCreated(t *testing.T) { t.Parallel() for _, tc := range []struct { - uc string - ruleset *config.RuleSet - configureFactory func(t *testing.T, mhf *mocks.FactoryMock) - assert func(t *testing.T, err error, queue event.RuleSetChangedEventQueue) + uc string + ruleset *config.RuleSet + configure func(t *testing.T, mhf *mocks.FactoryMock, repo *mocks.RepositoryMock) + assert func(t *testing.T, err error) }{ { - uc: "unsupported version", - ruleset: &config.RuleSet{Version: "foo"}, - assert: func(t *testing.T, err error, _ event.RuleSetChangedEventQueue) { + uc: "unsupported version", + ruleset: &config.RuleSet{Version: "foo"}, + configure: func(t *testing.T, _ *mocks.FactoryMock, _ *mocks.RepositoryMock) { t.Helper() }, + assert: func(t *testing.T, err error) { t.Helper() require.Error(t, err) @@ -53,18 +52,32 @@ func TestRuleSetProcessorOnCreated(t *testing.T) { { uc: "error while loading rule set", ruleset: &config.RuleSet{Version: config.CurrentRuleSetVersion, Rules: []config.Rule{{ID: "foo"}}}, - configureFactory: func(t *testing.T, mhf *mocks.FactoryMock) { + configure: func(t *testing.T, mhf *mocks.FactoryMock, _ *mocks.RepositoryMock) { t.Helper() - mhf.EXPECT().CreateRule(mock.Anything, mock.Anything, mock.Anything). - Return(nil, testsupport.ErrTestPurpose) + mhf.EXPECT().CreateRule(mock.Anything, mock.Anything, mock.Anything).Return(nil, errors.New("test error")) }, - assert: func(t *testing.T, err error, _ event.RuleSetChangedEventQueue) { + assert: func(t *testing.T, err error) { t.Helper() require.Error(t, err) - require.ErrorIs(t, err, testsupport.ErrTestPurpose) - assert.Contains(t, err.Error(), "failed loading") + assert.Contains(t, err.Error(), "loading rule ID='foo' failed") + }, + }, + { + uc: "error while adding rule set", + ruleset: &config.RuleSet{Version: config.CurrentRuleSetVersion, Rules: []config.Rule{{ID: "foo"}}}, + configure: func(t *testing.T, mhf *mocks.FactoryMock, repo *mocks.RepositoryMock) { + t.Helper() + + mhf.EXPECT().CreateRule(config.CurrentRuleSetVersion, mock.Anything, mock.Anything).Return(&mocks.RuleMock{}, nil) + repo.EXPECT().AddRuleSet(mock.Anything, mock.Anything).Return(errors.New("test error")) + }, + assert: func(t *testing.T, err error) { + t.Helper() + + require.Error(t, err) + assert.Contains(t, err.Error(), "test error") }, }, { @@ -75,45 +88,37 @@ func TestRuleSetProcessorOnCreated(t *testing.T) { Name: "foobar", Rules: []config.Rule{{ID: "foo"}}, }, - configureFactory: func(t *testing.T, mhf *mocks.FactoryMock) { + configure: func(t *testing.T, mhf *mocks.FactoryMock, repo *mocks.RepositoryMock) { t.Helper() - mhf.EXPECT().CreateRule(config.CurrentRuleSetVersion, mock.Anything, mock.Anything).Return(&mocks.RuleMock{}, nil) + rul := &mocks.RuleMock{} + + mhf.EXPECT().CreateRule(config.CurrentRuleSetVersion, mock.Anything, mock.Anything).Return(rul, nil) + repo.EXPECT().AddRuleSet("test", mock.MatchedBy(func(rules []rule.Rule) bool { + return len(rules) == 1 && rules[0] == rul + })).Return(nil) }, - assert: func(t *testing.T, err error, queue event.RuleSetChangedEventQueue) { + assert: func(t *testing.T, err error) { t.Helper() require.NoError(t, err) - require.Len(t, queue, 1) - - evt := <-queue - require.Len(t, evt.Rules, 1) - assert.Equal(t, event.Create, evt.ChangeType) - assert.Equal(t, "test", evt.Source) - assert.Equal(t, "foobar", evt.Name) - - assert.Equal(t, &mocks.RuleMock{}, evt.Rules[0]) }, }, } { t.Run(tc.uc, func(t *testing.T) { - // GIVEM - configureFactory := x.IfThenElse(tc.configureFactory != nil, - tc.configureFactory, - func(t *testing.T, _ *mocks.FactoryMock) { t.Helper() }) - - queue := make(event.RuleSetChangedEventQueue, 10) - + // GIVEN factory := mocks.NewFactoryMock(t) - configureFactory(t, factory) + repo := mocks.NewRepositoryMock(t) + + tc.configure(t, factory, repo) - processor := NewRuleSetProcessor(queue, factory, log.Logger) + processor := NewRuleSetProcessor(repo, factory) // WHEN err := processor.OnCreated(tc.ruleset) // THEN - tc.assert(t, err, queue) + tc.assert(t, err) }) } } @@ -122,15 +127,18 @@ func TestRuleSetProcessorOnUpdated(t *testing.T) { t.Parallel() for _, tc := range []struct { - uc string - ruleset *config.RuleSet - configureFactory func(t *testing.T, mhf *mocks.FactoryMock) - assert func(t *testing.T, err error, queue event.RuleSetChangedEventQueue) + uc string + ruleset *config.RuleSet + configure func(t *testing.T, mhf *mocks.FactoryMock, repo *mocks.RepositoryMock) + assert func(t *testing.T, err error) }{ { uc: "unsupported version", ruleset: &config.RuleSet{Version: "foo"}, - assert: func(t *testing.T, err error, _ event.RuleSetChangedEventQueue) { + configure: func(t *testing.T, _ *mocks.FactoryMock, _ *mocks.RepositoryMock) { + t.Helper() + }, + assert: func(t *testing.T, err error) { t.Helper() require.Error(t, err) @@ -140,18 +148,32 @@ func TestRuleSetProcessorOnUpdated(t *testing.T) { { uc: "error while loading rule set", ruleset: &config.RuleSet{Version: config.CurrentRuleSetVersion, Rules: []config.Rule{{ID: "foo"}}}, - configureFactory: func(t *testing.T, mhf *mocks.FactoryMock) { + configure: func(t *testing.T, mhf *mocks.FactoryMock, _ *mocks.RepositoryMock) { + t.Helper() + + mhf.EXPECT().CreateRule(mock.Anything, mock.Anything, mock.Anything).Return(nil, errors.New("test error")) + }, + assert: func(t *testing.T, err error) { + t.Helper() + + require.Error(t, err) + assert.Contains(t, err.Error(), "loading rule ID='foo' failed") + }, + }, + { + uc: "error while updating rule set", + ruleset: &config.RuleSet{Version: config.CurrentRuleSetVersion, Rules: []config.Rule{{ID: "foo"}}}, + configure: func(t *testing.T, mhf *mocks.FactoryMock, repo *mocks.RepositoryMock) { t.Helper() - mhf.EXPECT().CreateRule(mock.Anything, mock.Anything, mock.Anything). - Return(nil, testsupport.ErrTestPurpose) + mhf.EXPECT().CreateRule(mock.Anything, mock.Anything, mock.Anything).Return(&mocks.RuleMock{}, nil) + repo.EXPECT().UpdateRuleSet(mock.Anything, mock.Anything).Return(errors.New("test error")) }, - assert: func(t *testing.T, err error, _ event.RuleSetChangedEventQueue) { + assert: func(t *testing.T, err error) { t.Helper() require.Error(t, err) - require.ErrorIs(t, err, testsupport.ErrTestPurpose) - assert.Contains(t, err.Error(), "failed loading") + assert.Contains(t, err.Error(), "test error") }, }, { @@ -162,46 +184,37 @@ func TestRuleSetProcessorOnUpdated(t *testing.T) { Name: "foobar", Rules: []config.Rule{{ID: "foo"}}, }, - configureFactory: func(t *testing.T, mhf *mocks.FactoryMock) { + configure: func(t *testing.T, mhf *mocks.FactoryMock, repo *mocks.RepositoryMock) { t.Helper() - mhf.EXPECT().CreateRule(config.CurrentRuleSetVersion, mock.Anything, mock.Anything). - Return(&mocks.RuleMock{}, nil) + rul := &mocks.RuleMock{} + + mhf.EXPECT().CreateRule(config.CurrentRuleSetVersion, mock.Anything, mock.Anything).Return(rul, nil) + repo.EXPECT().UpdateRuleSet("test", mock.MatchedBy(func(rules []rule.Rule) bool { + return len(rules) == 1 && rules[0] == rul + })).Return(nil) }, - assert: func(t *testing.T, err error, queue event.RuleSetChangedEventQueue) { + assert: func(t *testing.T, err error) { t.Helper() require.NoError(t, err) - require.Len(t, queue, 1) - - evt := <-queue - require.Len(t, evt.Rules, 1) - assert.Equal(t, event.Update, evt.ChangeType) - assert.Equal(t, "test", evt.Source) - assert.Equal(t, "foobar", evt.Name) - - assert.Equal(t, &mocks.RuleMock{}, evt.Rules[0]) }, }, } { t.Run(tc.uc, func(t *testing.T) { // GIVEM - configureFactory := x.IfThenElse(tc.configureFactory != nil, - tc.configureFactory, - func(t *testing.T, _ *mocks.FactoryMock) { t.Helper() }) - - queue := make(event.RuleSetChangedEventQueue, 10) - factory := mocks.NewFactoryMock(t) - configureFactory(t, factory) + repo := mocks.NewRepositoryMock(t) + + tc.configure(t, factory, repo) - processor := NewRuleSetProcessor(queue, factory, log.Logger) + processor := NewRuleSetProcessor(repo, factory) // WHEN err := processor.OnUpdated(tc.ruleset) // THEN - tc.assert(t, err, queue) + tc.assert(t, err) }) } } @@ -210,10 +223,30 @@ func TestRuleSetProcessorOnDeleted(t *testing.T) { t.Parallel() for _, tc := range []struct { - uc string - ruleset *config.RuleSet - assert func(t *testing.T, err error, queue event.RuleSetChangedEventQueue) + uc string + ruleset *config.RuleSet + configure func(t *testing.T, repo *mocks.RepositoryMock) + assert func(t *testing.T, err error) }{ + { + uc: "failed removing rule set", + ruleset: &config.RuleSet{ + MetaData: config.MetaData{Source: "test"}, + Version: config.CurrentRuleSetVersion, + Name: "foobar", + }, + configure: func(t *testing.T, repo *mocks.RepositoryMock) { + t.Helper() + + repo.EXPECT().DeleteRuleSet("test").Return(errors.New("test error")) + }, + assert: func(t *testing.T, err error) { + t.Helper() + + require.Error(t, err) + require.ErrorContains(t, err, "test error") + }, + }, { uc: "successful", ruleset: &config.RuleSet{ @@ -221,29 +254,30 @@ func TestRuleSetProcessorOnDeleted(t *testing.T) { Version: config.CurrentRuleSetVersion, Name: "foobar", }, - assert: func(t *testing.T, err error, queue event.RuleSetChangedEventQueue) { + configure: func(t *testing.T, repo *mocks.RepositoryMock) { t.Helper() - require.NoError(t, err) - require.Len(t, queue, 1) + repo.EXPECT().DeleteRuleSet("test").Return(nil) + }, + assert: func(t *testing.T, err error) { + t.Helper() - evt := <-queue - assert.Equal(t, event.Remove, evt.ChangeType) - assert.Equal(t, "test", evt.Source) - assert.Equal(t, "foobar", evt.Name) + require.NoError(t, err) }, }, } { t.Run(tc.uc, func(t *testing.T) { // GIVEM - queue := make(event.RuleSetChangedEventQueue, 10) - processor := NewRuleSetProcessor(queue, mocks.NewFactoryMock(t), log.Logger) + repo := mocks.NewRepositoryMock(t) + tc.configure(t, repo) + + processor := NewRuleSetProcessor(repo, mocks.NewFactoryMock(t)) // WHEN err := processor.OnDeleted(tc.ruleset) // THEN - tc.assert(t, err, queue) + tc.assert(t, err) }) } } diff --git a/internal/validation/validation.go b/internal/validation/validation.go index 8efc9000f..e8195f491 100644 --- a/internal/validation/validation.go +++ b/internal/validation/validation.go @@ -46,8 +46,19 @@ func init() { panic(err) } + getTagValue := func(tag reflect.StructTag) string { + for _, tagName := range []string{"mapstructure", "json", "yaml"} { + val := tag.Get(tagName) + if len(val) != 0 { + return val + } + } + + return "" + } + validate.RegisterTagNameFunc(func(fld reflect.StructField) string { - return "'" + strings.SplitN(fld.Tag.Get("mapstructure"), ",", 2)[0] + "'" // nolint: gomnd + return "'" + strings.SplitN(getTagValue(fld.Tag), ",", 2)[0] + "'" // nolint: gomnd }) } diff --git a/internal/x/radixtree/matcher.go b/internal/x/radixtree/matcher.go new file mode 100644 index 000000000..7a6f84249 --- /dev/null +++ b/internal/x/radixtree/matcher.go @@ -0,0 +1,18 @@ +package radixtree + +// Matcher is used for additional checks while performing the lookup in the spanned tree. +type Matcher[V any] interface { + // Match should return true if the value should be returned by the lookup. If it returns false, it + // instructs the lookup to continue with backtracking from the current tree position. + Match(value V) bool +} + +// The MatcherFunc type is an adapter to allow the use of ordinary functions as match functions. +// If f is a function with the appropriate signature, MatcherFunc(f) is a [Matcher] +// that calls f. +type MatcherFunc[V any] func(value V) bool + +// Match calls f(value). +func (f MatcherFunc[V]) Match(value V) bool { + return f(value) +} diff --git a/internal/x/radixtree/options.go b/internal/x/radixtree/options.go new file mode 100644 index 000000000..b3f85ba77 --- /dev/null +++ b/internal/x/radixtree/options.go @@ -0,0 +1,19 @@ +package radixtree + +type Option[V any] func(n *Tree[V]) + +func WithValuesConstraints[V any](constraints ConstraintsFunc[V]) Option[V] { + return func(n *Tree[V]) { + if constraints != nil { + n.canAdd = constraints + } + } +} + +type AddOption[V any] func(n *Tree[V]) + +func WithBacktracking[V any](flag bool) AddOption[V] { + return func(n *Tree[V]) { + n.backtrackingEnabled = flag + } +} diff --git a/internal/x/radixtree/options_test.go b/internal/x/radixtree/options_test.go new file mode 100644 index 000000000..9c3289f2a --- /dev/null +++ b/internal/x/radixtree/options_test.go @@ -0,0 +1,32 @@ +package radixtree + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestValuesConstrainedTree(t *testing.T) { + t.Parallel() + + // GIVEN + tree1 := New[string](WithValuesConstraints[string](func(oldValues []string, _ string) bool { + return len(oldValues) == 0 + })) + + tree2 := New[string]() + + err := tree1.Add("/foo", "bar") + require.NoError(t, err) + + err = tree2.Add("/foo", "bar") + require.NoError(t, err) + + // WHEN + err1 := tree1.Add("/foo", "bar") + err2 := tree2.Add("/foo", "bar") + + // THEN + require.Error(t, err1) + require.NoError(t, err2) +} diff --git a/internal/x/radixtree/package.go b/internal/x/radixtree/package.go new file mode 100644 index 000000000..20b63b6f0 --- /dev/null +++ b/internal/x/radixtree/package.go @@ -0,0 +1,7 @@ +/* +Package radixtree implements a tree lookup for values associated to +paths. + +This package is a fork of https://github.com/dimfeld/httptreemux. +*/ +package radixtree diff --git a/internal/x/radixtree/tree.go b/internal/x/radixtree/tree.go new file mode 100644 index 000000000..a3355b0ba --- /dev/null +++ b/internal/x/radixtree/tree.go @@ -0,0 +1,548 @@ +package radixtree + +import ( + "errors" + "fmt" + "slices" + "strings" +) + +var ( + ErrInvalidPath = errors.New("invalid path") + ErrNotFound = errors.New("not found") + ErrFailedToDelete = errors.New("failed to delete") + ErrConstraintsViolation = errors.New("constraints violation") +) + +type ( + ConstraintsFunc[V any] func(oldValues []V, newValue V) bool + + Entry[V any] struct { + Value V + Parameters map[string]string + } + + Tree[V any] struct { + path string + + priority int + + // The list of static children to check. + staticIndices []byte + staticChildren []*Tree[V] + + // If none of the above match, check the wildcard children + wildcardChild *Tree[V] + + // If none of the above match, then we use the catch-all, if applicable. + catchAllChild *Tree[V] + + isCatchAll bool + isWildcard bool + + values []V + wildcardKeys []string + + // global options + canAdd ConstraintsFunc[V] + + // node local options + backtrackingEnabled bool + } +) + +func New[V any](opts ...Option[V]) *Tree[V] { + root := &Tree[V]{ + canAdd: func(_ []V, _ V) bool { return true }, + } + + for _, opt := range opts { + opt(root) + } + + return root +} + +func (n *Tree[V]) sortStaticChildren(i int) { + for i > 0 && n.staticChildren[i].priority > n.staticChildren[i-1].priority { + n.staticChildren[i], n.staticChildren[i-1] = n.staticChildren[i-1], n.staticChildren[i] + n.staticIndices[i], n.staticIndices[i-1] = n.staticIndices[i-1], n.staticIndices[i] + + i-- + } +} + +func (n *Tree[V]) nextSeparator(path string) int { + if idx := strings.IndexByte(path, '/'); idx != -1 { + return idx + } + + return len(path) +} + +//nolint:funlen,gocognit,cyclop +func (n *Tree[V]) addNode(path string, wildcardKeys []string, inStaticToken bool) (*Tree[V], error) { + if len(path) == 0 { + // we have a leaf node + if len(wildcardKeys) != 0 { + // Ensure the current wildcard keys are the same as the old ones. + if len(n.wildcardKeys) != 0 && !slices.Equal(n.wildcardKeys, wildcardKeys) { + return nil, fmt.Errorf("%w: %s is ambigous - wildcard keys differ", ErrInvalidPath, path) + } + + n.wildcardKeys = wildcardKeys + } + + return n, nil + } + + token := path[0] + nextSlash := strings.IndexByte(path, '/') + + var ( + thisToken string + tokenEnd int + unescaped bool + ) + + switch { + case token == '/': + thisToken = "/" + tokenEnd = 1 + case nextSlash == -1: + thisToken = path + tokenEnd = len(path) + default: + thisToken = path[0:nextSlash] + tokenEnd = nextSlash + } + + remainingPath := path[tokenEnd:] + + if !inStaticToken { //nolint:nestif + switch token { + case '*': + thisToken = thisToken[1:] + + if nextSlash != -1 { + return nil, fmt.Errorf("%w: %s has '/' after a free wildcard", ErrInvalidPath, path) + } + + if n.catchAllChild == nil { + n.catchAllChild = &Tree[V]{ + path: thisToken, + isCatchAll: true, + } + + if len(n.values) == 0 { + n.backtrackingEnabled = true + } + } + + if path[1:] != n.catchAllChild.path { + return nil, fmt.Errorf("%w: free wildcard name in %s doesn't match %s", + ErrInvalidPath, path, n.catchAllChild.path) + } + + wildcardKeys = append(wildcardKeys, thisToken) + n.catchAllChild.wildcardKeys = wildcardKeys + + return n.catchAllChild, nil + case ':': + if n.wildcardChild == nil { + n.wildcardChild = &Tree[V]{path: "wildcard", isWildcard: true} + + if len(n.values) == 0 { + n.backtrackingEnabled = true + } + } + + return n.wildcardChild.addNode(remainingPath, append(wildcardKeys, thisToken[1:]), false) + } + } + + if !inStaticToken && + len(thisToken) >= 2 && + thisToken[0] == '\\' && + (thisToken[1] == '*' || thisToken[1] == ':' || thisToken[1] == '\\') { + // The token starts with a character escaped by a backslash. Drop the backslash. + token = thisToken[1] + thisToken = thisToken[1:] + unescaped = true + } + + for i, index := range n.staticIndices { + if token == index { + // Yes. Split it based on the common prefix of the existing + // node and the new one. + child, prefixSplit := n.splitCommonPrefix(i, thisToken) + child.priority++ + + n.sortStaticChildren(i) + + if unescaped { + // Account for the removed backslash. + prefixSplit++ + } + + // Ensure that the rest of this token is not mistaken for a wildcard + // if a prefix split occurs at a '*' or ':'. + return child.addNode(path[prefixSplit:], wildcardKeys, token != '/') + } + } + + child := &Tree[V]{path: thisToken} + + n.staticIndices = append(n.staticIndices, token) + n.staticChildren = append(n.staticChildren, child) + + if len(n.values) == 0 { + n.backtrackingEnabled = true + } + + // Ensure that the rest of this token is not mistaken for a wildcard + // if a prefix split occurs at a '*' or ':'. + return child.addNode(remainingPath, wildcardKeys, token != '/') +} + +//nolint:cyclop,funlen +func (n *Tree[V]) delNode(path string, matcher Matcher[V]) bool { + pathLen := len(path) + if pathLen == 0 { + if len(n.values) == 0 { + return false + } + + oldSize := len(n.values) + n.values = slices.DeleteFunc(n.values, matcher.Match) + newSize := len(n.values) + + if newSize == 0 { + n.backtrackingEnabled = true + } + + return oldSize != newSize + } + + var ( + nextPath string + child *Tree[V] + ) + + token := path[0] + + switch token { + case ':': + if n.wildcardChild == nil { + return false + } + + child = n.wildcardChild + nextSeparator := n.nextSeparator(path) + nextPath = path[nextSeparator:] + case '*': + if n.catchAllChild == nil { + return false + } + + child = n.catchAllChild + nextPath = "" + } + + if child != nil { + if child.delNode(nextPath, matcher) { + if len(child.values) == 0 { + n.deleteChild(child, token) + } + + return true + } + + return false + } + + if len(path) >= 2 && + path[0] == '\\' && + (path[1] == '*' || path[1] == ':' || path[1] == '\\') { + // The token starts with a character escaped by a backslash. Drop the backslash. + token = path[1] + path = path[1:] + } + + for i, staticIndex := range n.staticIndices { + if token == staticIndex { + child = n.staticChildren[i] + childPathLen := len(child.path) + + if pathLen >= childPathLen && child.path == path[:childPathLen] && + child.delNode(path[childPathLen:], matcher) { + if len(child.values) == 0 { + n.deleteChild(child, token) + } + + return true + } + + break + } + } + + return false +} + +//nolint:cyclop +func (n *Tree[V]) deleteChild(child *Tree[V], token uint8) { + if len(child.staticIndices) == 1 && child.staticIndices[0] != '/' && child.path != "/" { + if len(child.staticChildren) == 1 { + grandChild := child.staticChildren[0] + grandChild.path = child.path + grandChild.path + *child = *grandChild + } + + // new leaf created + if len(child.values) != 0 { + return + } + } + + // Delete the child from the parent only if the child has no children + if len(child.staticIndices) == 0 && child.wildcardChild == nil && child.catchAllChild == nil { + switch { + case child.isWildcard: + n.wildcardChild = nil + case child.isCatchAll: + n.catchAllChild = nil + default: + n.delEdge(token) + } + } +} + +func (n *Tree[V]) delEdge(token byte) { + for i, index := range n.staticIndices { + if token == index { + n.staticChildren = append(n.staticChildren[:i], n.staticChildren[i+1:]...) + n.staticIndices = append(n.staticIndices[:i], n.staticIndices[i+1:]...) + + return + } + } +} + +//nolint:funlen,gocognit,cyclop +func (n *Tree[V]) findNode(path string, matcher Matcher[V]) (*Tree[V], int, []string, bool) { + var ( + found *Tree[V] + params []string + idx int + value V + ) + + backtrack := true + + pathLen := len(path) + if pathLen == 0 { + if len(n.values) == 0 { + return nil, 0, nil, true + } + + for idx, value = range n.values { + if match := matcher.Match(value); match { + return n, idx, nil, false + } + } + + return nil, 0, nil, n.backtrackingEnabled + } + + // First see if this matches a static token. + firstChar := path[0] + for i, staticIndex := range n.staticIndices { + if staticIndex == firstChar { + child := n.staticChildren[i] + childPathLen := len(child.path) + + if pathLen >= childPathLen && child.path == path[:childPathLen] { + nextPath := path[childPathLen:] + found, idx, params, backtrack = child.findNode(nextPath, matcher) + } + + break + } + } + + if found != nil || !backtrack { + return found, idx, params, backtrack + } + + if n.wildcardChild != nil { //nolint:nestif + // Didn't find a static token, so check for a wildcard. + nextSeparator := n.nextSeparator(path) + thisToken := path[0:nextSeparator] + nextToken := path[nextSeparator:] + + if len(thisToken) > 0 { // Don't match on empty tokens. + found, idx, params, backtrack = n.wildcardChild.findNode(nextToken, matcher) + if found != nil { + if params == nil { + // we don't expect more than 3 parameters to be defined for a path + // even 3 is already too much + params = make([]string, 0, 3) //nolint:gomnd + } + + return found, idx, append(params, thisToken), backtrack + } else if !backtrack { + return nil, 0, nil, false + } + } + } + + if n.catchAllChild != nil { + // Hit the catchall, so just assign the whole remaining path. + for idx, value = range n.catchAllChild.values { + if match := matcher.Match(value); match { + // we don't expect more than 3 parameters to be defined for a path + // even 3 is already too much + params = make([]string, 1, 3) //nolint:gomnd + params[0] = path + + return n.catchAllChild, idx, params, false + } + } + + return nil, 0, nil, n.backtrackingEnabled + } + + return nil, 0, nil, true +} + +func (n *Tree[V]) splitCommonPrefix(existingNodeIndex int, path string) (*Tree[V], int) { + childNode := n.staticChildren[existingNodeIndex] + + if strings.HasPrefix(path, childNode.path) { + // No split needs to be done. Rather, the new path shares the entire + // prefix with the existing node, so the new node is just a child of + // the existing one. Or the new path is the same as the existing path, + // which means that we just move on to the next token. Either way, + // this return accomplishes that + return childNode, len(childNode.path) + } + + // Find the length of the common prefix of the child node and the new path. + i := commonPrefixLen(childNode.path, path) + + commonPrefix := path[0:i] + childNode.path = childNode.path[i:] + + // Create a new intermediary node in the place of the existing node, with + // the existing node as a child. + newNode := &Tree[V]{ + path: commonPrefix, + priority: childNode.priority, + // Index is the first byte of the non-common part of the path. + staticIndices: []byte{childNode.path[0]}, + staticChildren: []*Tree[V]{childNode}, + } + n.staticChildren[existingNodeIndex] = newNode + + return newNode, i +} + +func (n *Tree[V]) Find(path string, matcher Matcher[V]) (*Entry[V], error) { + found, idx, params, _ := n.findNode(path, matcher) + if found == nil { + return nil, fmt.Errorf("%w: %s", ErrNotFound, path) + } + + entry := &Entry[V]{ + Value: found.values[idx], + } + + if len(params) == 0 { + return entry, nil + } + + entry.Parameters = make(map[string]string, len(params)) + + for i, param := range params { + key := found.wildcardKeys[len(params)-1-i] + if key != "*" { + entry.Parameters[key] = param + } + } + + return entry, nil +} + +func (n *Tree[V]) Add(path string, value V, opts ...AddOption[V]) error { + node, err := n.addNode(path, nil, false) + if err != nil { + return err + } + + if !n.canAdd(node.values, value) { + return fmt.Errorf("%w: %s", ErrConstraintsViolation, path) + } + + for _, apply := range opts { + apply(node) + } + + node.values = append(node.values, value) + + return nil +} + +func (n *Tree[V]) Delete(path string, matcher Matcher[V]) error { + if !n.delNode(path, matcher) { + return fmt.Errorf("%w: %s", ErrFailedToDelete, path) + } + + return nil +} + +func (n *Tree[V]) Empty() bool { + return len(n.values) == 0 && len(n.staticChildren) == 0 && n.wildcardChild == nil && n.catchAllChild == nil +} + +func (n *Tree[V]) Clone() *Tree[V] { + root := &Tree[V]{} + + n.cloneInto(root) + + return root +} + +func (n *Tree[V]) cloneInto(out *Tree[V]) { + *out = *n + + if len(n.wildcardKeys) != 0 { + out.wildcardKeys = slices.Clone(n.wildcardKeys) + } + + if len(n.values) != 0 { + out.values = slices.Clone(n.values) + } + + if n.catchAllChild != nil { + out.catchAllChild = &Tree[V]{} + n.catchAllChild.cloneInto(out.catchAllChild) + } + + if n.wildcardChild != nil { + out.wildcardChild = &Tree[V]{} + n.wildcardChild.cloneInto(out.wildcardChild) + } + + if len(n.staticChildren) != 0 { + out.staticIndices = slices.Clone(n.staticIndices) + out.staticChildren = make([]*Tree[V], len(n.staticChildren)) + + for idx, child := range n.staticChildren { + newChild := &Tree[V]{} + + child.cloneInto(newChild) + out.staticChildren[idx] = newChild + } + } +} diff --git a/internal/x/radixtree/tree_benchmark_test.go b/internal/x/radixtree/tree_benchmark_test.go new file mode 100644 index 000000000..93556eb3e --- /dev/null +++ b/internal/x/radixtree/tree_benchmark_test.go @@ -0,0 +1,122 @@ +package radixtree + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func BenchmarkNodeSearchNoPaths(b *testing.B) { + tm := testMatcher[string](true) + tree := &Tree[string]{ + path: "/", + canAdd: func(_ []string, _ string) bool { return true }, + } + + b.ReportAllocs() + b.ResetTimer() + + for range b.N { + tree.findNode("", tm) + } +} + +func BenchmarkNodeSearchRoot(b *testing.B) { + tm := testMatcher[string](true) + tree := &Tree[string]{ + path: "/", + canAdd: func(_ []string, _ string) bool { return true }, + } + + b.ReportAllocs() + b.ResetTimer() + + for range b.N { + tree.findNode("/", tm) + } +} + +func BenchmarkNodeSearchOneStaticPath(b *testing.B) { + tm := testMatcher[string](true) + tree := &Tree[string]{ + path: "/", + canAdd: func(_ []string, _ string) bool { return true }, + } + + tree.Add("abc", "foo") + + b.ReportAllocs() + b.ResetTimer() + + for range b.N { + tree.findNode("abc", tm) + } +} + +func BenchmarkNodeSearchOneLongStaticPath(b *testing.B) { + tm := testMatcher[string](true) + tree := &Tree[string]{ + path: "/", + canAdd: func(_ []string, _ string) bool { return true }, + } + + tree.Add("foo/bar/baz", "foo") + + b.ReportAllocs() + b.ResetTimer() + + for range b.N { + tree.findNode("foo/bar/baz", tm) + } +} + +func BenchmarkNodeSearchOneWildcardPath(b *testing.B) { + tm := testMatcher[string](true) + tree := &Tree[string]{ + path: "/", + canAdd: func(_ []string, _ string) bool { return true }, + } + + require.NoError(b, tree.Add(":abc", "foo")) + + b.ReportAllocs() + b.ResetTimer() + + for range b.N { + tree.findNode("abc", tm) + } +} + +func BenchmarkNodeSearchOneLongWildcards(b *testing.B) { + tm := testMatcher[string](true) + tree := &Tree[string]{ + path: "/", + canAdd: func(_ []string, _ string) bool { return true }, + } + + tree.Add(":abc/:def/:ghi", "foo") + + b.ReportAllocs() + b.ResetTimer() + + for range b.N { + tree.findNode("abcdefghijklmnop/aaaabbbbccccddddeeeeffffgggg/hijkl", tm) + } +} + +func BenchmarkNodeSearchOneFreeWildcard(b *testing.B) { + tm := testMatcher[string](true) + tree := &Tree[string]{ + path: "/", + canAdd: func(_ []string, _ string) bool { return true }, + } + + require.NoError(b, tree.Add("*abc", "foo")) + + b.ReportAllocs() + b.ResetTimer() + + for range b.N { + tree.findNode("foo", tm) + } +} diff --git a/internal/x/radixtree/tree_test.go b/internal/x/radixtree/tree_test.go new file mode 100644 index 000000000..283d207e0 --- /dev/null +++ b/internal/x/radixtree/tree_test.go @@ -0,0 +1,409 @@ +package radixtree + +import ( + "slices" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/exp/maps" +) + +func testMatcher[V any](matches bool) MatcherFunc[V] { return func(_ V) bool { return matches } } + +func TestTreeSearch(t *testing.T) { + t.Parallel() + + // Setup & populate tree + tree := New[string]() + + for _, path := range []string{ + "/", + "/i", + "/i/:aaa", + "/images", + "/images/abc.jpg", + "/images/:imgname", + "/images/*path", + "/ima", + "/ima/:par", + "/images1", + "/images2", + "/apples", + "/app/les", + "/apples1", + "/appeasement", + "/appealing", + "/date/:year/:month", + "/date/:year/month", + "/date/:year/:month/abc", + "/date/:year/:month/:post", + "/date/:year/:month/*post", + "/:page", + "/:page/:index", + "/post/:post/page/:page", + "/plaster", + "/users/:pk/:related", + "/users/:id/updatePassword", + "/:something/abc", + "/:something/def", + "/something/**", + "/images/\\*path", + "/images/\\*patch", + "/date/\\:year/\\:month", + "/apples/ab:cde/:fg/*hi", + "/apples/ab*cde/:fg/*hi", + "/apples/ab\\*cde/:fg/*hi", + "/apples/ab*dde", + "/マ", + "/カ", + } { + err := tree.Add(path, path) + require.NoError(t, err) + } + + trueMatcher := testMatcher[string](true) + falseMatcher := testMatcher[string](false) + + for _, tc := range []struct { + path string + expPath string + expErr error + expParams map[string]string + matcher Matcher[string] + }{ + {path: "/users/abc/updatePassword", expPath: "/users/:id/updatePassword", expParams: map[string]string{"id": "abc"}}, + {path: "/users/all/something", expPath: "/users/:pk/:related", expParams: map[string]string{"pk": "all", "related": "something"}}, + {path: "/aaa/abc", expPath: "/:something/abc", expParams: map[string]string{"something": "aaa"}}, + {path: "/aaa/def", expPath: "/:something/def", expParams: map[string]string{"something": "aaa"}}, + {path: "/paper", expPath: "/:page", expParams: map[string]string{"page": "paper"}}, + {path: "/", expPath: "/"}, + {path: "/i", expPath: "/i"}, + {path: "/images", expPath: "/images"}, + {path: "/images/abc.jpg", expPath: "/images/abc.jpg"}, + {path: "/images/something", expPath: "/images/:imgname", expParams: map[string]string{"imgname": "something"}}, + {path: "/images/long/path", expPath: "/images/*path", expParams: map[string]string{"path": "long/path"}}, + {path: "/images/long/path", matcher: falseMatcher, expErr: ErrNotFound}, + {path: "/images/even/longer/path", expPath: "/images/*path", expParams: map[string]string{"path": "even/longer/path"}}, + {path: "/ima", expPath: "/ima"}, + {path: "/apples", expPath: "/apples"}, + {path: "/app/les", expPath: "/app/les"}, + {path: "/abc", expPath: "/:page", expParams: map[string]string{"page": "abc"}}, + {path: "/abc/100", expPath: "/:page/:index", expParams: map[string]string{"page": "abc", "index": "100"}}, + {path: "/post/a/page/2", expPath: "/post/:post/page/:page", expParams: map[string]string{"post": "a", "page": "2"}}, + {path: "/date/2014/5", expPath: "/date/:year/:month", expParams: map[string]string{"year": "2014", "month": "5"}}, + {path: "/date/2014/month", expPath: "/date/:year/month", expParams: map[string]string{"year": "2014"}}, + {path: "/date/2014/5/abc", expPath: "/date/:year/:month/abc", expParams: map[string]string{"year": "2014", "month": "5"}}, + {path: "/date/2014/5/def", expPath: "/date/:year/:month/:post", expParams: map[string]string{"year": "2014", "month": "5", "post": "def"}}, + {path: "/date/2014/5/def/hij", expPath: "/date/:year/:month/*post", expParams: map[string]string{"year": "2014", "month": "5", "post": "def/hij"}}, + {path: "/date/2014/5/def/hij/", expPath: "/date/:year/:month/*post", expParams: map[string]string{"year": "2014", "month": "5", "post": "def/hij/"}}, + {path: "/date/2014/ab%2f", expPath: "/date/:year/:month", expParams: map[string]string{"year": "2014", "month": "ab%2f"}}, + {path: "/post/ab%2fdef/page/2%2f", expPath: "/post/:post/page/:page", expParams: map[string]string{"post": "ab%2fdef", "page": "2%2f"}}, + {path: "/ima/bcd/fgh", expErr: ErrNotFound}, + {path: "/date/2014//month", expErr: ErrNotFound}, + {path: "/date/2014/05/", expErr: ErrNotFound}, // Empty catchall should not match + {path: "/post//abc/page/2", expErr: ErrNotFound}, + {path: "/post/abc//page/2", expErr: ErrNotFound}, + {path: "/post/abc/page//2", expErr: ErrNotFound}, + {path: "//post/abc/page/2", expErr: ErrNotFound}, + {path: "//post//abc//page//2", expErr: ErrNotFound}, + {path: "/something/foo/bar", expPath: "/something/**", expParams: map[string]string{}}, + {path: "/images/*path", expPath: "/images/\\*path"}, + {path: "/images/*patch", expPath: "/images/\\*patch"}, + {path: "/date/:year/:month", expPath: "/date/\\:year/\\:month"}, + {path: "/apples/ab*cde/lala/baba/dada", expPath: "/apples/ab*cde/:fg/*hi", expParams: map[string]string{"fg": "lala", "hi": "baba/dada"}}, + {path: "/apples/ab\\*cde/lala/baba/dada", expPath: "/apples/ab\\*cde/:fg/*hi", expParams: map[string]string{"fg": "lala", "hi": "baba/dada"}}, + {path: "/apples/ab:cde/:fg/*hi", expPath: "/apples/ab:cde/:fg/*hi", expParams: map[string]string{"fg": ":fg", "hi": "*hi"}}, + {path: "/apples/ab*cde/:fg/*hi", expPath: "/apples/ab*cde/:fg/*hi", expParams: map[string]string{"fg": ":fg", "hi": "*hi"}}, + {path: "/apples/ab*cde/one/two/three", expPath: "/apples/ab*cde/:fg/*hi", expParams: map[string]string{"fg": "one", "hi": "two/three"}}, + {path: "/apples/ab*dde", expPath: "/apples/ab*dde"}, + {path: "/マ", expPath: "/マ"}, + {path: "/カ", expPath: "/カ"}, + } { + t.Run(tc.path, func(t *testing.T) { + var matcher Matcher[string] + if tc.matcher == nil { + matcher = trueMatcher + } else { + matcher = tc.matcher + } + + entry, err := tree.Find(tc.path, matcher) + if tc.expErr != nil { + require.Error(t, err) + require.ErrorIs(t, err, tc.expErr) + + return + } + + require.NoError(t, err) + assert.Equalf(t, tc.expPath, entry.Value, "Path %s matched %s, expected %s", tc.path, entry.Value, tc.expPath) + assert.Equal(t, tc.expParams, entry.Parameters, "Path %s expected parameters are %v, saw %v", tc.path, tc.expParams, entry.Parameters) + }) + } +} + +func TestTreeSearchWithBacktracking(t *testing.T) { + t.Parallel() + + // GIVEN + tree := New[string]() + + err := tree.Add("/date/:year/abc", "first", WithBacktracking[string](true)) + require.NoError(t, err) + + err = tree.Add("/date/**", "second") + require.NoError(t, err) + + // WHEN + entry, err := tree.Find("/date/2024/abc", MatcherFunc[string](func(value string) bool { return value != "first" })) + + // THEN + require.NoError(t, err) + assert.Equal(t, "second", entry.Value) +} + +func TestTreeSearchWithoutBacktracking(t *testing.T) { + t.Parallel() + + // GIVEN + tree := New[string]() + + err := tree.Add("/date/:year/abc", "first") + require.NoError(t, err) + + err = tree.Add("/date/**", "second") + require.NoError(t, err) + + // WHEN + entry, err := tree.Find("/date/2024/abc", MatcherFunc[string](func(value string) bool { + return value != "first" + })) + + // THEN + require.Error(t, err) + require.ErrorIs(t, err, ErrNotFound) + require.Nil(t, entry) +} + +func TestTreeAddPathDuplicates(t *testing.T) { + t.Parallel() + + tree := New[string]() + path := "/date/:year/:month/abc" + + err := tree.Add(path, "first") + require.NoError(t, err) + + err = tree.Add(path, "second") + require.NoError(t, err) + + entry, err := tree.Find("/date/2024/04/abc", MatcherFunc[string](func(value string) bool { + return value == "first" + })) + require.NoError(t, err) + assert.Equal(t, "first", entry.Value) + assert.Equal(t, map[string]string{"year": "2024", "month": "04"}, entry.Parameters) + + entry, err = tree.Find("/date/2024/04/abc", MatcherFunc[string](func(value string) bool { + return value == "second" + })) + require.NoError(t, err) + assert.Equal(t, "second", entry.Value) + assert.Equal(t, map[string]string{"year": "2024", "month": "04"}, entry.Parameters) +} + +func TestTreeAddPath(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + uc string + paths []string + shouldFail bool + }{ + {"slash after catch-all", []string{"/abc/*path/"}, true}, + {"path segment after catch-all", []string{"/abc/*path/def"}, true}, + {"conflicting catch-alls", []string{"/abc/*path", "/abc/*paths"}, true}, + {"ambiguous wildcards", []string{"/abc/:foo/:bar", "/abc/:oof/:rab"}, true}, + {"multiple path segments without wildcard", []string{"/", "/i", "/images", "/images/abc.jpg"}, false}, + {"multiple path segments with wildcard", []string{"/i", "/i/:aaa", "/images/:imgname", "/:images/*path", "/ima", "/ima/:par", "/images1"}, false}, + {"multiple wildcards", []string{"/date/:year/:month", "/date/:year/month", "/date/:year/:month/:post"}, false}, + {"escaped : at the beginning of path segment", []string{"/abc/\\:cd"}, false}, + {"escaped * at the beginning of path segment", []string{"/abc/\\*cd"}, false}, + {": in middle of path segment", []string{"/abc/ab:cd"}, false}, + {": in middle of path segment with existing path", []string{"/abc/ab", "/abc/ab:cd"}, false}, + {"* in middle of path segment", []string{"/abc/ab*cd"}, false}, + {"* in middle of path segment with existing path", []string{"/abc/ab", "/abc/ab*cd"}, false}, + {"katakana /マ", []string{"/マ"}, false}, + {"katakana /カ", []string{"/カ"}, false}, + } { + t.Run(tc.uc, func(t *testing.T) { + tree := New[string]() + + var err error + + for _, path := range tc.paths { + err = tree.Add(path, path) + if err != nil { + break + } + } + + if tc.shouldFail { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestTreeDeleteStaticPaths(t *testing.T) { + t.Parallel() + + paths := []string{ + "/apples", + "/app/les", + "/abc", + "/abc/100", + "/aaa/abc", + "/aaa/def", + "/args", + "/app/les/and/bananas", + "/app/les/or/bananas", + } + + tree := New[int]() + + for idx, path := range paths { + err := tree.Add(path, idx) + require.NoError(t, err) + } + + for i := len(paths) - 1; i >= 0; i-- { + err := tree.Delete(paths[i], testMatcher[int](true)) + require.NoError(t, err) + + err = tree.Delete(paths[i], testMatcher[int](true)) + require.Error(t, err) + } +} + +func TestTreeDeleteStaticAndWildcardPaths(t *testing.T) { + t.Parallel() + + paths := []string{ + "/:foo/bar", + "/:foo/:bar/baz", + "/apples", + "/app/awesome/:id", + "/app/:name/:id", + "/app/awesome", + "/abc", + "/abc/:les", + "/abc/:les/bananas", + } + + tree := New[int]() + + for idx, path := range paths { + err := tree.Add(path, idx+1) + require.NoError(t, err) + } + + var deletedPaths []string + + for i := len(paths) - 1; i >= 0; i-- { + tbdPath := paths[i] + + err := tree.Delete(tbdPath, testMatcher[int](true)) + require.NoErrorf(t, err, "Should be able to delete %s", paths[i]) + + err = tree.Delete(tbdPath, testMatcher[int](true)) + require.Errorf(t, err, "Should not be able to delete %s", paths[i]) + + deletedPaths = append(deletedPaths, tbdPath) + + for idx, path := range paths { + entry, err := tree.Find(path, testMatcher[int](true)) + + if slices.Contains(deletedPaths, path) { + require.Errorf(t, err, "Should not be able to find %s after deleting %s", path, tbdPath) + } else { + require.NoErrorf(t, err, "Should be able to find %s after deleting %s", path, tbdPath) + assert.Equal(t, idx+1, entry.Value) + } + } + } +} + +func TestTreeDeleteMixedPaths(t *testing.T) { + t.Parallel() + + paths := []string{ + "/foo/*bar", + "/:foo/:bar/baz", + "/apples", + "/app/awesome/:id", + "/app/:name/:id", + "/app/*awesome", + "/abc/cba", + "/abc/:les", + "/abc/les/bananas", + "/abc/\\:les/bananas", + "/abc/:les/bananas", + "/abc/:les/\\*all", + "/abc/:les/*all", + "/abb/\\:ba/*all", + "/abb/:ba/*all", + "/abb/\\*all", + "/abb/*all", + } + + tree := New[int]() + + for idx, path := range paths { + err := tree.Add(path, idx+1) + require.NoError(t, err) + } + + for i := len(paths) - 1; i >= 0; i-- { + tbdPath := paths[i] + + err := tree.Delete(tbdPath, testMatcher[int](true)) + require.NoErrorf(t, err, "Should be able to delete %s", paths[i]) + + err = tree.Delete(tbdPath, testMatcher[int](true)) + require.Errorf(t, err, "Should not be able to delete %s", paths[i]) + } + + require.True(t, tree.Empty()) +} + +func TestTreeClone(t *testing.T) { + t.Parallel() + + tree := New[string]() + paths := map[string]string{ + "/abc/bca/bbb": "/abc/bca/bbb", + "/abb/abc/bbb": "/abb/abc/bbb", + "/**": "/foo", + "/abc/*foo": "/abc/bar/baz", + "/:foo/abc": "/bar/abc", + "/:foo/:bar/**": "/bar/baz/foo", + "/:foo/:bar/abc": "/bar/baz/abc", + } + + for expr, path := range paths { + require.NoError(t, tree.Add(expr, path)) + } + + clone := tree.Clone() + + for _, path := range maps.Values(paths) { + entry, err := clone.Find(path, MatcherFunc[string](func(_ string) bool { return true })) + + require.NoError(t, err) + assert.Equal(t, path, entry.Value) + } +} diff --git a/internal/x/radixtree/utils.go b/internal/x/radixtree/utils.go new file mode 100644 index 000000000..00475af37 --- /dev/null +++ b/internal/x/radixtree/utils.go @@ -0,0 +1,10 @@ +package radixtree + +func commonPrefixLen(a, b string) int { + n := 0 + for n < len(a) && n < len(b) && a[n] == b[n] { + n++ + } + + return n +} diff --git a/schema/config.schema.json b/schema/config.schema.json index c7e08b0e9..3c868ec62 100644 --- a/schema/config.schema.json +++ b/schema/config.schema.json @@ -551,9 +551,6 @@ "authorization_error": { "$ref": "#/definitions/responseOverride" }, - "method_error": { - "$ref": "#/definitions/responseOverride" - }, "communication_error": { "$ref": "#/definitions/responseOverride" }, @@ -716,80 +713,6 @@ } } }, - "ruleSetEndpointConfiguration": { - "description": "Endpoint to load rule sets from", - "type": "object", - "additionalProperties": false, - "required": [ - "url" - ], - "properties": { - "url": { - "description": "The URL to communicate to.", - "type": "string", - "format": "uri", - "examples": [ - "https://session-store-host" - ] - }, - "headers": { - "description": "The HTTP headers to be send to the endpoint", - "type": "object", - "additionalProperties": { - "type": "string" - }, - "minLength": 0, - "uniqueItems": true, - "default": [] - }, - "retry": { - "description": "How the implementation should behave when trying to access the configured endpoint", - "type": "object", - "additionalProperties": false, - "properties": { - "give_up_after": { - "description": "When the implementation should finally give up, if the endpoint is not answering.", - "type": "string", - "default": "1s", - "pattern": "^[0-9]+(ns|us|ms|s|m|h)$" - }, - "max_delay": { - "description": "How long the implementation should wait between the attempts", - "type": "string", - "pattern": "^[0-9]+(ns|us|ms|s|m|h)$", - "default": "100ms" - } - } - }, - "auth": { - "description": "How to authenticate against the endpoint", - "type": "object", - "oneOf": [ - { - "$ref": "#/definitions/endpointAuthApiKeyProperties" - }, - { - "$ref": "#/definitions/endpointAuthBasicAuthProperties" - }, - { - "$ref": "#/definitions/endpointAuth2ClientCredentialsProperties" - } - ] - }, - "rule_path_match_prefix": { - "description": "The path prefix to be checked in each rule retrieved from the endpoint", - "type": "string", - "examples": [ - "/foo/bar" - ] - }, - "enable_http_cache": { - "description": "Enables or disables http cache usage according to RFC 7234", - "type": "boolean", - "default": true - } - } - }, "endpointConfiguration": { "description": "Endpoint to to communicate to", "anyOf": [ @@ -2027,7 +1950,7 @@ "type": "array", "additionalItems": false, "items": { - "$ref": "#/definitions/ruleSetEndpointConfiguration" + "$ref": "#/definitions/endpointConfiguration" } }, "watch_interval": { @@ -2078,13 +2001,6 @@ "prefix": { "description": "Indicates that only blobs with a key starting with this prefix should be retrieved", "type": "string" - }, - "rule_path_match_prefix": { - "description": "The path prefix to be checked in each url pattern of each rule retrieved from the bucket", - "type": "string", - "examples": [ - "/foo/bar" - ] } } } @@ -2523,18 +2439,10 @@ "type": "object", "additionalProperties": false, "properties": { - "methods": { - "description": "Allowed HTTP methods for any endpoint", - "type": "array", - "additionalItems": false, - "uniqueItems": true, - "items": { - "type": "string" - }, - "examples": [ - "GET", - "POST" - ] + "backtracking_enabled": { + "description": "Enables or disables backtracking while matching the rules globally. Defaults to false.", + "type": "boolean", + "default": false }, "execute": { "description": "The mechanisms to execute (authenticators, authorizers, etc)",