diff --git a/.gitignore b/.gitignore index 73b753d1fc..b281e6b866 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ vendor _book node_modules/ LICENSE.txt -*-packr.go \ No newline at end of file +*-packr.go +dev \ No newline at end of file diff --git a/.schemas/config.schema.json b/.schemas/config.schema.json index da022c0061..3e16905e4b 100644 --- a/.schemas/config.schema.json +++ b/.schemas/config.schema.json @@ -982,6 +982,16 @@ "examples": [ "[\"file://path/to/rules.json\",\"inline://W3siaWQiOiJmb28tcnVsZSIsImF1dGhlbnRpY2F0b3JzIjpbXX1d\",\"https://path-to-my-rules/rules.json\"]" ] + }, + "matching_strategy": { + "title": "Matching strategy", + "description": "This an optional field describing matching strategy. Currently supported values are 'glob' and 'regexp'.", + "type": "string", + "default": "regexp", + "enum": ["glob", "regexp"], + "examples": [ + "glob", "regexp" + ] } } }, diff --git a/README.md b/README.md index 6fa6932ba1..3e8a233bd2 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,8 @@ Chat | Forums | Newsletter

- Guide | API Docs | + Guide | Code Docs

Support this project! diff --git a/api/decision_test.go b/api/decision_test.go index 334909491e..6078c6f71e 100644 --- a/api/decision_test.go +++ b/api/decision_test.go @@ -21,6 +21,7 @@ package api_test import ( + "context" "fmt" "net/http" "net/http/httptest" @@ -74,42 +75,62 @@ func TestDecisionAPI(t *testing.T) { Upstream: rule.Upstream{URL: "", StripPath: "/strip-path/", PreserveHost: true}, } + ruleNoOpAuthenticatorGLOB := rule.Rule{ + Match: &rule.Match{Methods: []string{"GET"}, URL: ts.URL + "/authn-noop/<[0-9]*>"}, + Authenticators: []rule.Handler{{Handler: "noop"}}, + Authorizer: rule.Handler{Handler: "allow"}, + Mutators: []rule.Handler{{Handler: "noop"}}, + Upstream: rule.Upstream{URL: ""}, + } + ruleNoOpAuthenticatorModifyUpstreamGLOB := rule.Rule{ + Match: &rule.Match{Methods: []string{"GET"}, URL: ts.URL + "/strip-path/authn-noop/<[0-9]*>"}, + Authenticators: []rule.Handler{{Handler: "noop"}}, + Authorizer: rule.Handler{Handler: "allow"}, + Mutators: []rule.Handler{{Handler: "noop"}}, + Upstream: rule.Upstream{URL: "", StripPath: "/strip-path/", PreserveHost: true}, + } + for k, tc := range []struct { - url string - code int - messages []string - rules []rule.Rule - transform func(r *http.Request) - authz string - d string + url string + code int + messages []string + rulesRegexp []rule.Rule + rulesGlob []rule.Rule + transform func(r *http.Request) + authz string + d string }{ { - d: "should fail because url does not exist in rule set", - url: ts.URL + "/decisions" + "/invalid", - rules: []rule.Rule{}, - code: http.StatusNotFound, + d: "should fail because url does not exist in rule set", + url: ts.URL + "/decisions" + "/invalid", + rulesRegexp: []rule.Rule{}, + rulesGlob: []rule.Rule{}, + code: http.StatusNotFound, }, { - d: "should fail because url does exist but is matched by two rules", - url: ts.URL + "/decisions" + "/authn-noop/1234", - rules: []rule.Rule{ruleNoOpAuthenticator, ruleNoOpAuthenticator}, - code: http.StatusInternalServerError, + d: "should fail because url does exist but is matched by two rulesRegexp", + url: ts.URL + "/decisions" + "/authn-noop/1234", + rulesRegexp: []rule.Rule{ruleNoOpAuthenticator, ruleNoOpAuthenticator}, + rulesGlob: []rule.Rule{ruleNoOpAuthenticatorGLOB, ruleNoOpAuthenticatorGLOB}, + code: http.StatusInternalServerError, }, { - d: "should pass", - url: ts.URL + "/decisions" + "/authn-noop/1234", - rules: []rule.Rule{ruleNoOpAuthenticator}, - code: http.StatusOK, + d: "should pass", + url: ts.URL + "/decisions" + "/authn-noop/1234", + rulesRegexp: []rule.Rule{ruleNoOpAuthenticator}, + rulesGlob: []rule.Rule{ruleNoOpAuthenticatorGLOB}, + code: http.StatusOK, transform: func(r *http.Request) { r.Header.Add("Authorization", "bearer token") }, authz: "bearer token", }, { - d: "should pass", - url: ts.URL + "/decisions" + "/strip-path/authn-noop/1234", - rules: []rule.Rule{ruleNoOpAuthenticatorModifyUpstream}, - code: http.StatusOK, + d: "should pass", + url: ts.URL + "/decisions" + "/strip-path/authn-noop/1234", + rulesRegexp: []rule.Rule{ruleNoOpAuthenticatorModifyUpstream}, + rulesGlob: []rule.Rule{ruleNoOpAuthenticatorModifyUpstreamGLOB}, + code: http.StatusOK, transform: func(r *http.Request) { r.Header.Add("Authorization", "bearer token") }, @@ -118,11 +139,16 @@ func TestDecisionAPI(t *testing.T) { { d: "should fail because no authorizer was configured", url: ts.URL + "/decisions" + "/authn-anon/authz-none/cred-none/1234", - rules: []rule.Rule{{ + rulesRegexp: []rule.Rule{{ Match: &rule.Match{Methods: []string{"GET"}, URL: ts.URL + "/authn-anon/authz-none/cred-none/<[0-9]+>"}, Authenticators: []rule.Handler{{Handler: "anonymous"}}, Upstream: rule.Upstream{URL: ""}, }}, + rulesGlob: []rule.Rule{{ + Match: &rule.Match{Methods: []string{"GET"}, URL: ts.URL + "/authn-anon/authz-none/cred-none/<[0-9]*>"}, + Authenticators: []rule.Handler{{Handler: "anonymous"}}, + Upstream: rule.Upstream{URL: ""}, + }}, transform: func(r *http.Request) { r.Header.Add("Authorization", "bearer token") }, @@ -131,77 +157,116 @@ func TestDecisionAPI(t *testing.T) { { d: "should fail because no mutator was configured", url: ts.URL + "/decisions" + "/authn-anon/authz-allow/cred-none/1234", - rules: []rule.Rule{{ + rulesRegexp: []rule.Rule{{ Match: &rule.Match{Methods: []string{"GET"}, URL: ts.URL + "/authn-anon/authz-allow/cred-none/<[0-9]+>"}, Authenticators: []rule.Handler{{Handler: "anonymous"}}, Authorizer: rule.Handler{Handler: "allow"}, Upstream: rule.Upstream{URL: ""}, }}, + rulesGlob: []rule.Rule{{ + Match: &rule.Match{Methods: []string{"GET"}, URL: ts.URL + "/authn-anon/authz-allow/cred-none/<[0-9]*>"}, + Authenticators: []rule.Handler{{Handler: "anonymous"}}, + Authorizer: rule.Handler{Handler: "allow"}, + Upstream: rule.Upstream{URL: ""}, + }}, code: http.StatusInternalServerError, }, { d: "should pass with anonymous and everything else set to noop", url: ts.URL + "/decisions" + "/authn-anon/authz-allow/cred-noop/1234", - rules: []rule.Rule{{ + rulesRegexp: []rule.Rule{{ Match: &rule.Match{Methods: []string{"GET"}, URL: ts.URL + "/authn-anon/authz-allow/cred-noop/<[0-9]+>"}, Authenticators: []rule.Handler{{Handler: "anonymous"}}, Authorizer: rule.Handler{Handler: "allow"}, Mutators: []rule.Handler{{Handler: "noop"}}, Upstream: rule.Upstream{URL: ""}, }}, + rulesGlob: []rule.Rule{{ + Match: &rule.Match{Methods: []string{"GET"}, URL: ts.URL + "/authn-anon/authz-allow/cred-noop/<[0-9]*>"}, + Authenticators: []rule.Handler{{Handler: "anonymous"}}, + Authorizer: rule.Handler{Handler: "allow"}, + Mutators: []rule.Handler{{Handler: "noop"}}, + Upstream: rule.Upstream{URL: ""}, + }}, code: http.StatusOK, authz: "", }, { d: "should fail when authorizer fails", url: ts.URL + "/decisions" + "/authn-anon/authz-deny/cred-noop/1234", - rules: []rule.Rule{{ + rulesRegexp: []rule.Rule{{ Match: &rule.Match{Methods: []string{"GET"}, URL: ts.URL + "/authn-anon/authz-deny/cred-noop/<[0-9]+>"}, Authenticators: []rule.Handler{{Handler: "anonymous"}}, Authorizer: rule.Handler{Handler: "deny"}, Mutators: []rule.Handler{{Handler: "noop"}}, Upstream: rule.Upstream{URL: ""}, }}, + rulesGlob: []rule.Rule{{ + Match: &rule.Match{Methods: []string{"GET"}, URL: ts.URL + "/authn-anon/authz-deny/cred-noop/<[0-9]*>"}, + Authenticators: []rule.Handler{{Handler: "anonymous"}}, + Authorizer: rule.Handler{Handler: "deny"}, + Mutators: []rule.Handler{{Handler: "noop"}}, + Upstream: rule.Upstream{URL: ""}, + }}, code: http.StatusForbidden, }, { d: "should fail when authenticator fails", url: ts.URL + "/decisions" + "/authn-broken/authz-none/cred-none/1234", - rules: []rule.Rule{{ + rulesRegexp: []rule.Rule{{ Match: &rule.Match{Methods: []string{"GET"}, URL: ts.URL + "/authn-broken/authz-none/cred-none/<[0-9]+>"}, Authenticators: []rule.Handler{{Handler: "unauthorized"}}, Upstream: rule.Upstream{URL: ""}, }}, + rulesGlob: []rule.Rule{{ + Match: &rule.Match{Methods: []string{"GET"}, URL: ts.URL + "/authn-broken/authz-none/cred-none/<[0-9]*>"}, + Authenticators: []rule.Handler{{Handler: "unauthorized"}}, + Upstream: rule.Upstream{URL: ""}, + }}, code: http.StatusUnauthorized, }, { d: "should fail when mutator fails", url: ts.URL + "/decisions" + "/authn-anonymous/authz-allow/cred-broken/1234", - rules: []rule.Rule{{ + rulesRegexp: []rule.Rule{{ Match: &rule.Match{Methods: []string{"GET"}, URL: ts.URL + "/authn-anonymous/authz-allow/cred-broken/<[0-9]+>"}, Authenticators: []rule.Handler{{Handler: "anonymous"}}, Authorizer: rule.Handler{Handler: "allow"}, Mutators: []rule.Handler{{Handler: "broken"}}, Upstream: rule.Upstream{URL: ""}, }}, + rulesGlob: []rule.Rule{{ + Match: &rule.Match{Methods: []string{"GET"}, URL: ts.URL + "/authn-anonymous/authz-allow/cred-broken/<[0-9]*>"}, + Authenticators: []rule.Handler{{Handler: "anonymous"}}, + Authorizer: rule.Handler{Handler: "allow"}, + Mutators: []rule.Handler{{Handler: "broken"}}, + Upstream: rule.Upstream{URL: ""}, + }}, code: http.StatusInternalServerError, }, { d: "should fail when one of the mutators fails", url: ts.URL + "/decisions" + "/authn-anonymous/authz-allow/cred-broken/1234", - rules: []rule.Rule{{ + rulesRegexp: []rule.Rule{{ Match: &rule.Match{Methods: []string{"GET"}, URL: ts.URL + "/authn-anonymous/authz-allow/cred-broken/<[0-9]+>"}, Authenticators: []rule.Handler{{Handler: "anonymous"}}, Authorizer: rule.Handler{Handler: "allow"}, Mutators: []rule.Handler{{Handler: "noop"}, {Handler: "broken"}}, Upstream: rule.Upstream{URL: ""}, }}, + rulesGlob: []rule.Rule{{ + Match: &rule.Match{Methods: []string{"GET"}, URL: ts.URL + "/authn-anonymous/authz-allow/cred-broken/<[0-9]*>"}, + Authenticators: []rule.Handler{{Handler: "anonymous"}}, + Authorizer: rule.Handler{Handler: "allow"}, + Mutators: []rule.Handler{{Handler: "noop"}, {Handler: "broken"}}, + Upstream: rule.Upstream{URL: ""}, + }}, code: http.StatusInternalServerError, }, { d: "should fail when authorizer fails and send www_authenticate as defined in the rule", url: ts.URL + "/decisions" + "/authn-anon/authz-deny/cred-noop/1234", - rules: []rule.Rule{{ + rulesRegexp: []rule.Rule{{ Match: &rule.Match{Methods: []string{"GET"}, URL: ts.URL + "/authn-anon/authz-deny/cred-noop/<[0-9]+>"}, Authenticators: []rule.Handler{{Handler: "anonymous"}}, Authorizer: rule.Handler{Handler: "deny"}, @@ -209,24 +274,41 @@ func TestDecisionAPI(t *testing.T) { Upstream: rule.Upstream{URL: ""}, Errors: []rule.ErrorHandler{{Handler: "www_authenticate"}}, }}, + rulesGlob: []rule.Rule{{ + Match: &rule.Match{Methods: []string{"GET"}, URL: ts.URL + "/authn-anon/authz-deny/cred-noop/<[0-9]*>"}, + Authenticators: []rule.Handler{{Handler: "anonymous"}}, + Authorizer: rule.Handler{Handler: "deny"}, + Mutators: []rule.Handler{{Handler: "noop"}}, + Upstream: rule.Upstream{URL: ""}, + Errors: []rule.ErrorHandler{{Handler: "www_authenticate"}}, + }}, code: http.StatusUnauthorized, }, } { t.Run(fmt.Sprintf("case=%d/description=%s", k, tc.d), func(t *testing.T) { - reg.RuleRepository().(*rule.RepositoryMemory).WithRules(tc.rules) + testFunc := func(strategy configuration.MatchingStrategy) { + require.NoError(t, reg.RuleRepository().SetMatchingStrategy(context.Background(), strategy)) + req, err := http.NewRequest("GET", tc.url, nil) + require.NoError(t, err) + if tc.transform != nil { + tc.transform(req) + } - req, err := http.NewRequest("GET", tc.url, nil) - require.NoError(t, err) - if tc.transform != nil { - tc.transform(req) - } - - res, err := http.DefaultClient.Do(req) - require.NoError(t, err) - defer res.Body.Close() + res, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer res.Body.Close() - assert.Equal(t, tc.authz, res.Header.Get("Authorization")) - assert.Equal(t, tc.code, res.StatusCode) + assert.Equal(t, tc.authz, res.Header.Get("Authorization")) + assert.Equal(t, tc.code, res.StatusCode) + } + t.Run("regexp", func(t *testing.T) { + reg.RuleRepository().(*rule.RepositoryMemory).WithRules(tc.rulesRegexp) + testFunc(configuration.Regexp) + }) + t.Run("glob", func(t *testing.T) { + reg.RuleRepository().(*rule.RepositoryMemory).WithRules(tc.rulesRegexp) + testFunc(configuration.Glob) + }) }) } } diff --git a/api/rule_test.go b/api/rule_test.go index abb4ca0571..4e7a2559b3 100644 --- a/api/rule_test.go +++ b/api/rule_test.go @@ -22,18 +22,21 @@ package api_test import ( "bytes" + "context" "encoding/json" "net/http/httptest" "net/url" "testing" + "github.com/ory/oathkeeper/driver/configuration" "github.com/ory/oathkeeper/x" + "github.com/ory/x/pointerx" + "github.com/ory/oathkeeper/internal" "github.com/ory/oathkeeper/internal/httpclient/client" sdkrule "github.com/ory/oathkeeper/internal/httpclient/client/api" "github.com/ory/oathkeeper/rule" - "github.com/ory/x/pointerx" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -56,7 +59,7 @@ func TestHandler(t *testing.T) { Schemes: []string{u.Scheme}, }) - rules := []rule.Rule{ + rulesRegexp := []rule.Rule{ { ID: "foo1", Match: &rule.Match{ @@ -90,23 +93,66 @@ func TestHandler(t *testing.T) { }, }, } - - reg.RuleRepository().(*rule.RepositoryMemory).WithRules(rules) + rulesGlob := []rule.Rule{ + { + ID: "foo1", + Match: &rule.Match{ + URL: "https://localhost:1234/<{foo*,bar*}>>", + Methods: []string{"POST"}, + }, + Description: "Create users rule", + Authorizer: rule.Handler{Handler: "allow", Config: json.RawMessage(`{"type":"any"}`)}, + Authenticators: []rule.Handler{{Handler: "anonymous", Config: json.RawMessage(`{"name":"anonymous1"}`)}}, + Mutators: []rule.Handler{{Handler: "id_token", Config: json.RawMessage(`{"issuer":"anything"}`)}}, + Upstream: rule.Upstream{ + URL: "http://localhost:1235/", + StripPath: "/bar", + PreserveHost: true, + }, + }, + { + ID: "foo2", + Match: &rule.Match{ + URL: "https://localhost:34/<{baz*,bar*}>", + Methods: []string{"GET"}, + }, + Description: "Get users rule", + Authorizer: rule.Handler{Handler: "deny", Config: json.RawMessage(`{"type":"any"}`)}, + Authenticators: []rule.Handler{{Handler: "oauth2_introspection", Config: json.RawMessage(`{"name":"anonymous1"}`)}}, + Mutators: []rule.Handler{{Handler: "id_token", Config: json.RawMessage(`{"issuer":"anything"}`)}, {Handler: "headers", Config: json.RawMessage(`{"headers":{"X-User":"user"}}`)}}, + Upstream: rule.Upstream{ + URL: "http://localhost:333/", + StripPath: "/foo", + PreserveHost: false, + }, + }, + } t.Run("case=create a new rule", func(t *testing.T) { - results, err := cl.API.ListRules(sdkrule.NewListRulesParams().WithLimit(pointerx.Int64(10))) - require.NoError(t, err) - require.Len(t, results.Payload, 2) - assert.True(t, results.Payload[0].ID != results.Payload[1].ID) + testFunc := func(strategy configuration.MatchingStrategy, rules []rule.Rule) { + reg.RuleRepository().(*rule.RepositoryMemory).WithRules(rules) + require.NoError(t, reg.RuleRepository().SetMatchingStrategy(context.Background(), strategy)) + results, err := cl.API.ListRules(sdkrule.NewListRulesParams().WithLimit(pointerx.Int64(10))) + require.NoError(t, err) + require.Len(t, results.Payload, 2) + assert.True(t, results.Payload[0].ID != results.Payload[1].ID) + + result, err := cl.API.GetRule(sdkrule.NewGetRuleParams().WithID(rules[1].ID)) + require.NoError(t, err) - result, err := cl.API.GetRule(sdkrule.NewGetRuleParams().WithID(rules[1].ID)) - require.NoError(t, err) + var b bytes.Buffer + var ruleResult rule.Rule + require.NoError(t, json.NewEncoder(&b).Encode(result.Payload)) + require.NoError(t, json.NewDecoder(&b).Decode(&ruleResult)) - var b bytes.Buffer - var ruleResult rule.Rule - require.NoError(t, json.NewEncoder(&b).Encode(result.Payload)) - require.NoError(t, json.NewDecoder(&b).Decode(&ruleResult)) + assert.EqualValues(t, rules[1], ruleResult) + } + t.Run("regexp", func(t *testing.T) { + testFunc(configuration.Regexp, rulesRegexp) + }) + t.Run("glob", func(t *testing.T) { + testFunc(configuration.Glob, rulesGlob) + }) - assert.EqualValues(t, rules[1], ruleResult) }) } diff --git a/docs/.oathkeeper.yaml b/docs/.oathkeeper.yaml index f6518a8e1c..9246af46ff 100644 --- a/docs/.oathkeeper.yaml +++ b/docs/.oathkeeper.yaml @@ -87,6 +87,8 @@ access_rules: # If the URL Scheme is `http://` or `https://`, the access rules (an array of access rules is expected) will be # fetched from the provided HTTP(s) location. - https://path-to-my-rules/rules.json + # Optional fields describing matching strategy, defaults to "regexp". + matching_strategy: glob errors: fallback: diff --git a/driver/configuration/provider.go b/driver/configuration/provider.go index 4bf1870517..9400bc6411 100644 --- a/driver/configuration/provider.go +++ b/driver/configuration/provider.go @@ -19,6 +19,16 @@ const ( ForbiddenStrategyErrorType = "forbidden" ) +// MatchingStrategy defines matching strategy such as Regexp or Glob. +// Empty string defaults to "regexp". +type MatchingStrategy string + +// Possible matching strategies. +const ( + Regexp MatchingStrategy = "regexp" + Glob MatchingStrategy = "glob" +) + type Provider interface { CORSEnabled(iface string) bool CORSOptions(iface string) cors.Options @@ -33,6 +43,7 @@ type Provider interface { ProxyIdleTimeout() time.Duration AccessRuleRepositories() []url.URL + AccessRuleMatchingStrategy() MatchingStrategy ProxyServeAddress() string APIServeAddress() string diff --git a/driver/configuration/provider_viper.go b/driver/configuration/provider_viper.go index c6dd0bad81..42b62d063f 100644 --- a/driver/configuration/provider_viper.go +++ b/driver/configuration/provider_viper.go @@ -37,14 +37,15 @@ func init() { } const ( - ViperKeyProxyReadTimeout = "serve.proxy.timeout.read" - ViperKeyProxyWriteTimeout = "serve.proxy.timeout.write" - ViperKeyProxyIdleTimeout = "serve.proxy.timeout.idle" - ViperKeyProxyServeAddressHost = "serve.proxy.host" - ViperKeyProxyServeAddressPort = "serve.proxy.port" - ViperKeyAPIServeAddressHost = "serve.api.host" - ViperKeyAPIServeAddressPort = "serve.api.port" - ViperKeyAccessRuleRepositories = "access_rules.repositories" + ViperKeyProxyReadTimeout = "serve.proxy.timeout.read" + ViperKeyProxyWriteTimeout = "serve.proxy.timeout.write" + ViperKeyProxyIdleTimeout = "serve.proxy.timeout.idle" + ViperKeyProxyServeAddressHost = "serve.proxy.host" + ViperKeyProxyServeAddressPort = "serve.proxy.port" + ViperKeyAPIServeAddressHost = "serve.api.host" + ViperKeyAPIServeAddressPort = "serve.api.port" + ViperKeyAccessRuleRepositories = "access_rules.repositories" + ViperKeyAccessRuleMatchingStrategy = "access_rules.matching_strategy" ) // Authorizers @@ -131,6 +132,11 @@ func (v *ViperProvider) AccessRuleRepositories() []url.URL { return repositories } +// AccessRuleMatchingStrategy returns current MatchingStrategy. +func (v *ViperProvider) AccessRuleMatchingStrategy() MatchingStrategy { + return MatchingStrategy(viperx.GetString(v.l, ViperKeyAccessRuleMatchingStrategy, "")) +} + func (v *ViperProvider) CORSEnabled(iface string) bool { return corsx.IsEnabled(v.l, "serve."+iface) } diff --git a/go.mod b/go.mod index 3d0f178126..9716e0c660 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/blang/semver v3.5.1+incompatible github.com/bxcodec/faker v2.0.1+incompatible github.com/dgrijalva/jwt-go v3.2.0+incompatible + github.com/dlclark/regexp2 v1.2.0 github.com/fsnotify/fsnotify v1.4.7 github.com/ghodss/yaml v1.0.0 github.com/go-openapi/errors v0.19.2 @@ -19,6 +20,7 @@ require ( github.com/go-swagger/go-swagger v0.21.1-0.20200107003254-1c98855b472d github.com/gobuffalo/httptest v1.0.2 github.com/gobuffalo/packr/v2 v2.0.0-rc.15 + github.com/gobwas/glob v0.2.3 github.com/golang/gddo v0.0.0-20190904175337-72a348e765d2 github.com/golang/mock v1.3.1 github.com/google/uuid v1.1.1 @@ -34,7 +36,7 @@ require ( github.com/ory/gojsonschema v1.2.0 github.com/ory/graceful v0.1.1 github.com/ory/herodot v0.6.2 - github.com/ory/ladon v1.0.1 + github.com/ory/ladon v1.1.0 github.com/ory/sdk/swagutil v0.0.0-20200202121523-307941feee4b github.com/ory/viper v1.5.7 github.com/ory/x v0.0.93 diff --git a/go.sum b/go.sum index ce7633dd33..89f36af130 100644 --- a/go.sum +++ b/go.sum @@ -84,6 +84,8 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= +github.com/dlclark/regexp2 v1.2.0 h1:8sAhBGEM0dRWogWqWyQeIJnxjWO6oIjl8FKqREDsGfk= +github.com/dlclark/regexp2 v1.2.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ= github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec= github.com/docker/go-units v0.3.3/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= @@ -349,6 +351,8 @@ github.com/gobuffalo/uuid v2.0.5+incompatible/go.mod h1:ErhIzkRhm0FtRuiE/PeORqcw github.com/gobuffalo/validate v2.0.3+incompatible/go.mod h1:N+EtDe0J8252BgfzQUChBgfd6L93m9weay53EWFVsMM= github.com/gobuffalo/x v0.0.0-20181003152136-452098b06085/go.mod h1:WevpGD+5YOreDJznWevcn8NTmQEW5STSBgIkpkjzqXc= github.com/gobuffalo/x v0.0.0-20181007152206-913e47c59ca7/go.mod h1:9rDPXaB3kXdKWzMc4odGQQdG2e2DIEmANy5aSJ9yesY= +github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= +github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= github.com/gofrs/uuid v3.1.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= @@ -552,8 +556,9 @@ github.com/ory/graceful v0.1.1 h1:zx+8tDObLPrG+7Tc8jKYlXsqWnLtOQA1IZ/FAAKHMXU= github.com/ory/graceful v0.1.1/go.mod h1:zqu70l95WrKHF4AZ6tXHvAqAvpY6M7g6ttaAVcMm7KU= github.com/ory/herodot v0.6.2 h1:zOb5MsuMn7AH9/Ewc/EK83yqcNViK1m1l3C2UuP3RcA= github.com/ory/herodot v0.6.2/go.mod h1:3BOneqcyBsVybCPAJoi92KN2BpJHcmDqAMcAAaJiJow= -github.com/ory/ladon v1.0.1 h1:zCEfqnv8Ro62id0Z9cVPRPv4ejTu6HHtuPbXmTWBxks= github.com/ory/ladon v1.0.1/go.mod h1:1VhCA2mBtaMhRUS6VS0d9qrNVDQnFXqSRb5D0NvQUPY= +github.com/ory/ladon v1.1.0 h1:6tgazU2J3Z3odPs1f0qn729kRXCAtlJROliuWUHedV0= +github.com/ory/ladon v1.1.0/go.mod h1:25bNc/Glx/8xCH7MbItDxjvviAmFQ+aYxb1V1SE5wlg= github.com/ory/pagination v0.0.1/go.mod h1:d1ToRROAUleriPhmb2dYbhANhhLwZ8s395m2yJCDFh8= github.com/ory/sdk/swagutil v0.0.0-20200123152503-0d50960e70bd h1:QrEYSnaOX6kpyBcQGUlExcI4RwCq2S/Wta/zbgT74Kk= github.com/ory/sdk/swagutil v0.0.0-20200123152503-0d50960e70bd/go.mod h1:Ufg1eAyz+Zt3+oweSZVThG13ewewWCKwBmoNmK8Z0co= @@ -564,6 +569,7 @@ github.com/ory/sdk/swagutil v0.0.0-20200202121523-307941feee4b/go.mod h1:Ufg1eAy github.com/ory/viper v1.5.6/go.mod h1:TYmpFpKLxjQwvT4f0QPpkOn4sDXU1kDgAwJpgLYiQ28= github.com/ory/viper v1.5.7 h1:VeXfcBgTG3SCMlw4hmXkazXPZbyvBTdUjS7Dxm3gOjQ= github.com/ory/viper v1.5.7/go.mod h1:+Mfm2gCDqtYRn5gVaLsBtXACO59zITETZQ/jQwc9SZo= +github.com/ory/x v0.0.87/go.mod h1:wrnJRjIfYXFY/AUiuUlcIUpLBDxFtWc+8x6toAeLZXU= github.com/ory/x v0.0.88/go.mod h1:wrnJRjIfYXFY/AUiuUlcIUpLBDxFtWc+8x6toAeLZXU= github.com/ory/x v0.0.91 h1:4sySRGI1dExt3FpvXcnenpagoM6oQeEvboQ53/tcY9g= github.com/ory/x v0.0.91/go.mod h1:lfcTaGXpTZs7IEQAW00r9EtTCOxD//SiP5uWtNiz31g= @@ -879,8 +885,10 @@ golang.org/x/tools v0.0.0-20190614205625-5aca471b1d59/go.mod h1:/rFqwRUd4F7ZHNgw golang.org/x/tools v0.0.0-20190617190820-da514acc4774/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190711191110-9a621aea19f8/go.mod h1:jcCCGcm9btYwXyDqrUWc6MKQKKGJCWEQ3AfLSRIbEuI= +golang.org/x/tools v0.0.0-20191026034945-b2104f82a97d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191224055732-dd894d0a8a40 h1:UyP2XDSgSc8ldYCxAK735zQxeH3Gd81sK7Iy7AoaVxk= golang.org/x/tools v0.0.0-20191224055732-dd894d0a8a40/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= @@ -921,6 +929,7 @@ gopkg.in/mail.v2 v2.0.0-20180731213649-a0242b2233b4/go.mod h1:htwXN1Qh09vZJ1NVKx gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= gopkg.in/square/go-jose.v2 v2.1.9/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= gopkg.in/square/go-jose.v2 v2.2.2/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= +gopkg.in/square/go-jose.v2 v2.3.0/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= gopkg.in/square/go-jose.v2 v2.3.1 h1:SK5KegNXmKmqE342YYN2qPHEnUYeoMiXXl1poUlI+o4= gopkg.in/square/go-jose.v2 v2.3.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= diff --git a/helper/errors.go b/helper/errors.go index 3ba6b42d58..34bffbf820 100644 --- a/helper/errors.go +++ b/helper/errors.go @@ -47,6 +47,12 @@ var ( CodeField: http.StatusInternalServerError, StatusField: http.StatusText(http.StatusInternalServerError), } + // TODO: discuss the text and status code + ErrNonRegexpMatchingStrategy = &herodot.DefaultError{ + ErrorField: "The matched handler uses Regexp MatchingStrategy which is not selected in the configuration", + CodeField: http.StatusInternalServerError, + StatusField: http.StatusText(http.StatusInternalServerError), + } ErrMatchesNoRule = &herodot.DefaultError{ ErrorField: "Requested url does not match any rules", CodeField: http.StatusNotFound, diff --git a/pipeline/authz/keto_engine_acp_ory.go b/pipeline/authz/keto_engine_acp_ory.go index 26ef6608ec..b81945511a 100644 --- a/pipeline/authz/keto_engine_acp_ory.go +++ b/pipeline/authz/keto_engine_acp_ory.go @@ -94,9 +94,9 @@ func (a *AuthorizerKetoEngineACPORY) Authorize(r *http.Request, session *authn.A return err } - compiled, err := rule.CompileURL() - if err != nil { - return errors.WithStack(err) + // only Regexp matching strategy is supported for now. + if !(a.c.AccessRuleMatchingStrategy() == "" || a.c.AccessRuleMatchingStrategy() == configuration.Regexp) { + return helper.ErrNonRegexpMatchingStrategy } subject := session.Subject @@ -115,9 +115,19 @@ func (a *AuthorizerKetoEngineACPORY) Authorize(r *http.Request, session *authn.A var b bytes.Buffer u := fmt.Sprintf("%s://%s%s", r.URL.Scheme, r.URL.Host, r.URL.Path) + + action, err := rule.ReplaceAllString(a.c.AccessRuleMatchingStrategy(), u, cf.RequiredAction) + if err != nil { + return errors.WithStack(err) + } + resource, err := rule.ReplaceAllString(a.c.AccessRuleMatchingStrategy(), u, cf.RequiredResource) + if err != nil { + return errors.WithStack(err) + } + if err := json.NewEncoder(&b).Encode(&AuthorizerKetoEngineACPORYRequestBody{ - Action: compiled.ReplaceAllString(u, cf.RequiredAction), - Resource: compiled.ReplaceAllString(u, cf.RequiredResource), + Action: action, + Resource: resource, Context: a.contextCreator(r), Subject: subject, }); err != nil { @@ -130,10 +140,10 @@ func (a *AuthorizerKetoEngineACPORY) Authorize(r *http.Request, session *authn.A } req, err := http.NewRequest("POST", urlx.AppendPaths(baseURL, "/engines/acp/ory", flavor, "/allowed").String(), &b) - req.Header.Add("Content-Type", "application/json") if err != nil { return errors.WithStack(err) } + req.Header.Add("Content-Type", "application/json") res, err := a.client.Do(req) if err != nil { diff --git a/pipeline/rule.go b/pipeline/rule.go index 469282a0b4..56360d0fe1 100644 --- a/pipeline/rule.go +++ b/pipeline/rule.go @@ -1,8 +1,12 @@ package pipeline -import "regexp" +import ( + "github.com/ory/oathkeeper/driver/configuration" +) type Rule interface { GetID() string - CompileURL() (*regexp.Regexp, error) + // ReplaceAllString searches the input string and replaces each match (with the rule's pattern) + // found with the replacement text. + ReplaceAllString(strategy configuration.MatchingStrategy, input, replacement string) (string, error) } diff --git a/proxy/proxy_test.go b/proxy/proxy_test.go index 3f467fcbc1..eab4cb8887 100644 --- a/proxy/proxy_test.go +++ b/proxy/proxy_test.go @@ -21,6 +21,7 @@ package proxy_test import ( + "context" "fmt" "io/ioutil" "net/http" @@ -82,6 +83,20 @@ func TestProxy(t *testing.T) { Mutators: []rule.Handler{{Handler: "noop"}}, Upstream: rule.Upstream{URL: backend.URL, StripPath: "/strip-path/", PreserveHost: true}, } + ruleNoOpAuthenticatorGlob := rule.Rule{ + Match: &rule.Match{Methods: []string{"GET"}, URL: ts.URL + "/authn-noop/<[0-9]*>"}, + Authenticators: []rule.Handler{{Handler: "noop"}}, + Authorizer: rule.Handler{Handler: "allow"}, + Mutators: []rule.Handler{{Handler: "noop"}}, + Upstream: rule.Upstream{URL: backend.URL}, + } + ruleNoOpAuthenticatorModifyUpstreamGlob := rule.Rule{ + Match: &rule.Match{Methods: []string{"GET"}, URL: ts.URL + "/strip-path/authn-noop/<[0-9]*>"}, + Authenticators: []rule.Handler{{Handler: "noop"}}, + Authorizer: rule.Handler{Handler: "allow"}, + Mutators: []rule.Handler{{Handler: "noop"}}, + Upstream: rule.Upstream{URL: backend.URL, StripPath: "/strip-path/", PreserveHost: true}, + } // acceptRuleStripHost := rule.Rule{MatchesMethods: []string{"GET"}, MatchesURLCompiled: mustCompileRegex(t, proxy.URL+"/users/<[0-9]+>"), Mode: "pass_through_accept", Upstream: rule.Upstream{URLParsed: u, StripPath: "/users/", PreserveHost: true}} // acceptRuleStripHostWithoutTrailing := rule.Rule{MatchesMethods: []string{"GET"}, MatchesURLCompiled: mustCompileRegex(t, proxy.URL+"/users/<[0-9]+>"), Mode: "pass_through_accept", Upstream: rule.Upstream{URLParsed: u, StripPath: "/users", PreserveHost: true}} @@ -89,30 +104,34 @@ func TestProxy(t *testing.T) { // denyRule := rule.Rule{MatchesMethods: []string{"GET"}, MatchesURLCompiled: mustCompileRegex(t, proxy.URL+"/users/<[0-9]+>"), Mode: "pass_through_deny", Upstream: rule.Upstream{URLParsed: u}} for k, tc := range []struct { - url string - code int - messages []string - rules []rule.Rule - transform func(r *http.Request) - d string + url string + code int + messages []string + rulesRegexp []rule.Rule + rulesGlob []rule.Rule + transform func(r *http.Request) + d string }{ { - d: "should fail because url does not exist in rule set", - url: ts.URL + "/invalid", - rules: []rule.Rule{}, - code: http.StatusNotFound, + d: "should fail because url does not exist in rule set", + url: ts.URL + "/invalid", + rulesRegexp: []rule.Rule{}, + rulesGlob: []rule.Rule{}, + code: http.StatusNotFound, }, { - d: "should fail because url does exist but is matched by two rules", - url: ts.URL + "/authn-noop/1234", - rules: []rule.Rule{ruleNoOpAuthenticator, ruleNoOpAuthenticator}, - code: http.StatusInternalServerError, + d: "should fail because url does exist but is matched by two rules", + url: ts.URL + "/authn-noop/1234", + rulesRegexp: []rule.Rule{ruleNoOpAuthenticator, ruleNoOpAuthenticator}, + rulesGlob: []rule.Rule{ruleNoOpAuthenticatorGlob, ruleNoOpAuthenticatorGlob}, + code: http.StatusInternalServerError, }, { - d: "should pass", - url: ts.URL + "/authn-noop/1234", - rules: []rule.Rule{ruleNoOpAuthenticator}, - code: http.StatusOK, + d: "should pass", + url: ts.URL + "/authn-noop/1234", + rulesRegexp: []rule.Rule{ruleNoOpAuthenticator}, + rulesGlob: []rule.Rule{ruleNoOpAuthenticatorGlob}, + code: http.StatusOK, transform: func(r *http.Request) { r.Header.Add("Authorization", "bearer token") }, @@ -123,10 +142,11 @@ func TestProxy(t *testing.T) { }, }, { - d: "should pass", - url: ts.URL + "/strip-path/authn-noop/1234", - rules: []rule.Rule{ruleNoOpAuthenticatorModifyUpstream}, - code: http.StatusOK, + d: "should pass", + url: ts.URL + "/strip-path/authn-noop/1234", + rulesRegexp: []rule.Rule{ruleNoOpAuthenticatorModifyUpstream}, + rulesGlob: []rule.Rule{ruleNoOpAuthenticatorModifyUpstreamGlob}, + code: http.StatusOK, transform: func(r *http.Request) { r.Header.Add("Authorization", "bearer token") }, @@ -139,11 +159,16 @@ func TestProxy(t *testing.T) { { d: "should fail because no authorizer was configured", url: ts.URL + "/authn-anon/authz-none/cred-none/1234", - rules: []rule.Rule{{ + rulesRegexp: []rule.Rule{{ Match: &rule.Match{Methods: []string{"GET"}, URL: ts.URL + "/authn-anon/authz-none/cred-none/<[0-9]+>"}, Authenticators: []rule.Handler{{Handler: "anonymous"}}, Upstream: rule.Upstream{URL: backend.URL}, }}, + rulesGlob: []rule.Rule{{ + Match: &rule.Match{Methods: []string{"GET"}, URL: ts.URL + "/authn-anon/authz-none/cred-none/<[0-9]*>"}, + Authenticators: []rule.Handler{{Handler: "anonymous"}}, + Upstream: rule.Upstream{URL: backend.URL}, + }}, transform: func(r *http.Request) { r.Header.Add("Authorization", "bearer token") }, @@ -152,24 +177,37 @@ func TestProxy(t *testing.T) { { d: "should fail because no credentials issuer was configured", url: ts.URL + "/authn-anon/authz-allow/cred-none/1234", - rules: []rule.Rule{{ + rulesRegexp: []rule.Rule{{ Match: &rule.Match{Methods: []string{"GET"}, URL: ts.URL + "/authn-anon/authz-allow/cred-none/<[0-9]+>"}, Authenticators: []rule.Handler{{Handler: "anonymous"}}, Authorizer: rule.Handler{Handler: "allow"}, Upstream: rule.Upstream{URL: backend.URL}, }}, + rulesGlob: []rule.Rule{{ + Match: &rule.Match{Methods: []string{"GET"}, URL: ts.URL + "/authn-anon/authz-allow/cred-none/<[0-9]*>"}, + Authenticators: []rule.Handler{{Handler: "anonymous"}}, + Authorizer: rule.Handler{Handler: "allow"}, + Upstream: rule.Upstream{URL: backend.URL}, + }}, code: http.StatusInternalServerError, }, { d: "should pass with anonymous and everything else set to noop", url: ts.URL + "/authn-anon/authz-allow/cred-noop/1234", - rules: []rule.Rule{{ + rulesRegexp: []rule.Rule{{ Match: &rule.Match{Methods: []string{"GET"}, URL: ts.URL + "/authn-anon/authz-allow/cred-noop/<[0-9]+>"}, Authenticators: []rule.Handler{{Handler: "anonymous"}}, Authorizer: rule.Handler{Handler: "allow"}, Mutators: []rule.Handler{{Handler: "noop"}}, Upstream: rule.Upstream{URL: backend.URL}, }}, + rulesGlob: []rule.Rule{{ + Match: &rule.Match{Methods: []string{"GET"}, URL: ts.URL + "/authn-anon/authz-allow/cred-noop/<[0-9]*>"}, + Authenticators: []rule.Handler{{Handler: "anonymous"}}, + Authorizer: rule.Handler{Handler: "allow"}, + Mutators: []rule.Handler{{Handler: "noop"}}, + Upstream: rule.Upstream{URL: backend.URL}, + }}, code: http.StatusOK, messages: []string{ "authorization=", @@ -180,19 +218,26 @@ func TestProxy(t *testing.T) { { d: "should fail when authorizer fails", url: ts.URL + "/authn-anon/authz-deny/cred-noop/1234", - rules: []rule.Rule{{ + rulesRegexp: []rule.Rule{{ Match: &rule.Match{Methods: []string{"GET"}, URL: ts.URL + "/authn-anon/authz-deny/cred-noop/<[0-9]+>"}, Authenticators: []rule.Handler{{Handler: "anonymous"}}, Authorizer: rule.Handler{Handler: "deny"}, Mutators: []rule.Handler{{Handler: "noop"}}, Upstream: rule.Upstream{URL: backend.URL}, }}, + rulesGlob: []rule.Rule{{ + Match: &rule.Match{Methods: []string{"GET"}, URL: ts.URL + "/authn-anon/authz-deny/cred-noop/<[0-9]*>"}, + Authenticators: []rule.Handler{{Handler: "anonymous"}}, + Authorizer: rule.Handler{Handler: "deny"}, + Mutators: []rule.Handler{{Handler: "noop"}}, + Upstream: rule.Upstream{URL: backend.URL}, + }}, code: http.StatusForbidden, }, { d: "should fail when authorizer fails and send www_authenticate as defined in the rule", url: ts.URL + "/authn-anon/authz-deny/cred-noop/1234", - rules: []rule.Rule{{ + rulesRegexp: []rule.Rule{{ Match: &rule.Match{Methods: []string{"GET"}, URL: ts.URL + "/authn-anon/authz-deny/cred-noop/<[0-9]+>"}, Authenticators: []rule.Handler{{Handler: "anonymous"}}, Authorizer: rule.Handler{Handler: "deny"}, @@ -200,78 +245,123 @@ func TestProxy(t *testing.T) { Upstream: rule.Upstream{URL: backend.URL}, Errors: []rule.ErrorHandler{{Handler: "www_authenticate"}}, }}, + rulesGlob: []rule.Rule{{ + Match: &rule.Match{Methods: []string{"GET"}, URL: ts.URL + "/authn-anon/authz-deny/cred-noop/<[0-9]*>"}, + Authenticators: []rule.Handler{{Handler: "anonymous"}}, + Authorizer: rule.Handler{Handler: "deny"}, + Mutators: []rule.Handler{{Handler: "noop"}}, + Upstream: rule.Upstream{URL: backend.URL}, + Errors: []rule.ErrorHandler{{Handler: "www_authenticate"}}, + }}, code: http.StatusUnauthorized, }, { d: "should fail when authenticator fails", url: ts.URL + "/authn-broken/authz-none/cred-none/1234", - rules: []rule.Rule{{ + rulesRegexp: []rule.Rule{{ Match: &rule.Match{Methods: []string{"GET"}, URL: ts.URL + "/authn-broken/authz-none/cred-none/<[0-9]+>"}, Authenticators: []rule.Handler{{Handler: "unauthorized"}}, Upstream: rule.Upstream{URL: backend.URL}, }}, + rulesGlob: []rule.Rule{{ + Match: &rule.Match{Methods: []string{"GET"}, URL: ts.URL + "/authn-broken/authz-none/cred-none/<[0-9]*>"}, + Authenticators: []rule.Handler{{Handler: "unauthorized"}}, + Upstream: rule.Upstream{URL: backend.URL}, + }}, code: http.StatusUnauthorized, }, { d: "should fail because no mutator was configured", url: ts.URL + "/authn-anon/authz-deny/cred-noop/1234", - rules: []rule.Rule{{ + rulesRegexp: []rule.Rule{{ Match: &rule.Match{Methods: []string{"GET"}, URL: ts.URL + "/authn-anon/authz-deny/cred-noop/<[0-9]+>"}, Authenticators: []rule.Handler{{Handler: "anonymous"}}, Authorizer: rule.Handler{Handler: "allow"}, Upstream: rule.Upstream{URL: backend.URL}, }}, + rulesGlob: []rule.Rule{{ + Match: &rule.Match{Methods: []string{"GET"}, URL: ts.URL + "/authn-anon/authz-deny/cred-noop/<[0-9]*>"}, + Authenticators: []rule.Handler{{Handler: "anonymous"}}, + Authorizer: rule.Handler{Handler: "allow"}, + Upstream: rule.Upstream{URL: backend.URL}, + }}, code: http.StatusInternalServerError, }, { d: "should fail when one of the mutators fails", url: ts.URL + "/authn-anon/authz-deny/cred-noop/1234", - rules: []rule.Rule{{ + rulesRegexp: []rule.Rule{{ Match: &rule.Match{Methods: []string{"GET"}, URL: ts.URL + "/authn-anon/authz-deny/cred-noop/<[0-9]+>"}, Authenticators: []rule.Handler{{Handler: "anonymous"}}, Authorizer: rule.Handler{Handler: "allow"}, Mutators: []rule.Handler{{Handler: "noop"}, {Handler: "broken"}}, Upstream: rule.Upstream{URL: backend.URL}, }}, + rulesGlob: []rule.Rule{{ + Match: &rule.Match{Methods: []string{"GET"}, URL: ts.URL + "/authn-anon/authz-deny/cred-noop/<[0-9]*>"}, + Authenticators: []rule.Handler{{Handler: "anonymous"}}, + Authorizer: rule.Handler{Handler: "allow"}, + Mutators: []rule.Handler{{Handler: "noop"}, {Handler: "broken"}}, + Upstream: rule.Upstream{URL: backend.URL}, + }}, code: http.StatusInternalServerError, }, { d: "should fail when credentials issuer fails", url: ts.URL + "/authn-anonymous/authz-allow/cred-broken/1234", - rules: []rule.Rule{{ + rulesRegexp: []rule.Rule{{ Match: &rule.Match{Methods: []string{"GET"}, URL: ts.URL + "/authn-anonymous/authz-allow/cred-broken/<[0-9]+>"}, Authenticators: []rule.Handler{{Handler: "anonymous"}}, Authorizer: rule.Handler{Handler: "allow"}, Mutators: []rule.Handler{{Handler: "broken"}}, Upstream: rule.Upstream{URL: backend.URL}, }}, + rulesGlob: []rule.Rule{{ + Match: &rule.Match{Methods: []string{"GET"}, URL: ts.URL + "/authn-anonymous/authz-allow/cred-broken/<[0-9]*>"}, + Authenticators: []rule.Handler{{Handler: "anonymous"}}, + Authorizer: rule.Handler{Handler: "allow"}, + Mutators: []rule.Handler{{Handler: "broken"}}, + Upstream: rule.Upstream{URL: backend.URL}, + }}, code: http.StatusInternalServerError, }, } { t.Run(fmt.Sprintf("case=%d/description=%s", k, tc.d), func(t *testing.T) { - reg.RuleRepository().(*rule.RepositoryMemory).WithRules(tc.rules) + testFunc := func(strategy configuration.MatchingStrategy, rules []rule.Rule) { + reg.RuleRepository().(*rule.RepositoryMemory).WithRules(rules) + require.NoError(t, reg.RuleRepository().SetMatchingStrategy(context.Background(), strategy)) - req, err := http.NewRequest("GET", tc.url, nil) - require.NoError(t, err) - if tc.transform != nil { - tc.transform(req) - } + req, err := http.NewRequest("GET", tc.url, nil) + require.NoError(t, err) + if tc.transform != nil { + tc.transform(req) + } - res, err := http.DefaultClient.Do(req) - require.NoError(t, err) + res, err := http.DefaultClient.Do(req) + require.NoError(t, err) - greeting, err := ioutil.ReadAll(res.Body) - require.NoError(t, res.Body.Close()) - require.NoError(t, err) + greeting, err := ioutil.ReadAll(res.Body) + require.NoError(t, res.Body.Close()) + require.NoError(t, err) - assert.Equal(t, tc.code, res.StatusCode, "%s", res.Body) - for _, m := range tc.messages { - assert.True(t, strings.Contains(string(greeting), m), `Value "%s" not found in message: + assert.Equal(t, tc.code, res.StatusCode, "%s", res.Body) + for _, m := range tc.messages { + assert.True(t, strings.Contains(string(greeting), m), `Value "%s" not found in message: %s proxy_url=%s backend_url=%s `, m, greeting, ts.URL, backend.URL) + } + } + + t.Run("regexp", func(t *testing.T) { + testFunc(configuration.Regexp, tc.rulesRegexp) + }) + t.Run("glob", func(t *testing.T) { + testFunc(configuration.Glob, tc.rulesGlob) + }) + }) } } diff --git a/rule/engine_glob.go b/rule/engine_glob.go new file mode 100644 index 0000000000..a0c4693a00 --- /dev/null +++ b/rule/engine_glob.go @@ -0,0 +1,99 @@ +package rule + +import ( + "bytes" + "hash/crc64" + + "github.com/gobwas/glob" +) + +type globMatchingEngine struct { + compiled glob.Glob + checksum uint64 + table *crc64.Table +} + +// Checksum of a saved pattern. +func (ge *globMatchingEngine) Checksum() uint64 { + return ge.checksum +} + +// IsMatching determines whether the input matches the pattern. +func (ge *globMatchingEngine) IsMatching(pattern, matchAgainst string) (bool, error) { + if err := ge.compile(pattern); err != nil { + return false, err + } + return ge.compiled.Match(matchAgainst), nil +} + +// ReplaceAllString is noop for now and always returns an error. +func (ge *globMatchingEngine) ReplaceAllString(_, _, _ string) (string, error) { + return "", ErrMethodNotImplemented +} + +func (ge *globMatchingEngine) compile(pattern string) error { + if ge.table == nil { + ge.table = crc64.MakeTable(polynomial) + } + if checksum := crc64.Checksum([]byte(pattern), ge.table); checksum != ge.checksum { + compiled, err := compileGlob(pattern, '<', '>') + if err != nil { + return err + } + ge.checksum = checksum + ge.compiled = compiled + } + return nil +} + +// delimiterIndices returns the first level delimiter indices from a string. +// It returns an error in case of unbalanced delimiters. +func delimiterIndices(s string, delimiterStart, delimiterEnd rune) ([]int, error) { + var level, idx int + idxs := make([]int, 0) + for ind := 0; ind < len(s); ind++ { + switch s[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 +} + +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()) +} diff --git a/rule/engine_glob_test.go b/rule/engine_glob_test.go new file mode 100644 index 0000000000..da08639c5c --- /dev/null +++ b/rule/engine_glob_test.go @@ -0,0 +1,259 @@ +package rule + +import ( + "strconv" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestDelimiters(t *testing.T) { + var tests = []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, + }, + }, + } + + for tn, tc := range tests { + 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) { + type args struct { + pattern string + matchAgainst string + } + tests := []struct { + name string + args args + want bool + wantErr bool + }{ + { + name: "question mark1", + args: args{ + pattern: `urn:foo:`, + matchAgainst: "urn:foo:user", + }, + want: false, + wantErr: false, + }, + { + name: "question mark2", + args: args{ + pattern: `urn:foo:`, + matchAgainst: "urn:foo:u", + }, + want: true, + wantErr: false, + }, + { + name: "question mark3", + args: args{ + pattern: `urn:foo:`, + matchAgainst: "urn:foo:", + }, + want: false, + wantErr: false, + }, + { + name: "question mark4", + args: args{ + pattern: `urn:foo:&&`, + matchAgainst: "urn:foo:w&&r", + }, + want: true, + wantErr: false, + }, + { + name: "question mark5 - both as a special char and a literal", + args: args{ + pattern: `urn:foo:?`, + matchAgainst: "urn:foo:w&r", + }, + want: false, + wantErr: false, + }, + { + name: "question mark5 - both as a special char and a literal1", + args: args{ + pattern: `urn:foo:?`, + matchAgainst: "urn:foo:w?r", + }, + want: true, + wantErr: false, + }, + { + name: "asterisk", + args: args{ + pattern: `urn:foo:<*>`, + matchAgainst: "urn:foo:user", + }, + want: true, + wantErr: false, + }, + { + name: "asterisk1", + args: args{ + pattern: `urn:foo:<*>`, + matchAgainst: "urn:foo:", + }, + want: true, + wantErr: false, + }, + { + name: "asterisk2", + args: args{ + pattern: `urn:foo:<*>:<*>`, + matchAgainst: "urn:foo:usr:swen", + }, + want: true, + wantErr: false, + }, + { + name: "asterisk: both as a special char and a literal", + args: args{ + pattern: `*:foo:<*>:<*>`, + matchAgainst: "urn:foo:usr:swen", + }, + want: false, + wantErr: false, + }, + { + name: "asterisk: both as a special char and a literal1", + args: args{ + pattern: `*:foo:<*>:<*>`, + matchAgainst: "*:foo:usr:swen", + }, + want: true, + wantErr: false, + }, + { + name: "asterisk + question mark", + args: args{ + pattern: `urn:foo:<*>:role:`, + matchAgainst: "urn:foo:usr:role:a", + }, + want: true, + wantErr: false, + }, + { + name: "asterisk + question mark1", + args: args{ + pattern: `urn:foo:<*>:role:`, + matchAgainst: "urn:foo:usr:role:admin", + }, + want: false, + wantErr: false, + }, + { + name: "square brackets", + args: args{ + pattern: `urn:foo:`, + matchAgainst: "urn:foo:moon", + }, + want: false, + wantErr: false, + }, + { + name: "square brackets1", + args: args{ + pattern: `urn:foo:`, + matchAgainst: "urn:foo:man", + }, + want: true, + wantErr: false, + }, + { + name: "square brackets2", + args: args{ + pattern: `urn:foo:`, + matchAgainst: "urn:foo:man", + }, + want: false, + wantErr: false, + }, + { + name: "square brackets3", + args: args{ + pattern: `urn:foo:`, + matchAgainst: "urn:foo:min", + }, + want: true, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + globEngine := new(globMatchingEngine) + got, err := globEngine.IsMatching(tt.args.pattern, tt.args.matchAgainst) + if (err != nil) != tt.wantErr { + t.Errorf("IsMatching() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("IsMatching() got = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/rule/engine_regexp.go b/rule/engine_regexp.go new file mode 100644 index 0000000000..4ecc02d450 --- /dev/null +++ b/rule/engine_regexp.go @@ -0,0 +1,50 @@ +package rule + +import ( + "hash/crc64" + + "github.com/dlclark/regexp2" + "github.com/ory/ladon/compiler" +) + +type regexpMatchingEngine struct { + compiled *regexp2.Regexp + checksum uint64 + table *crc64.Table +} + +func (re *regexpMatchingEngine) compile(pattern string) error { + if re.table == nil { + re.table = crc64.MakeTable(polynomial) + } + if checksum := crc64.Checksum([]byte(pattern), re.table); checksum != re.checksum { + compiled, err := compiler.CompileRegex(pattern, '<', '>') + if err != nil { + return err + } + re.compiled = compiled + re.checksum = checksum + } + return nil +} + +// Checksum of a saved pattern. +func (re *regexpMatchingEngine) Checksum() uint64 { + return re.checksum +} + +// IsMatching determines whether the input matches the pattern. +func (re *regexpMatchingEngine) IsMatching(pattern, matchAgainst string) (bool, error) { + if err := re.compile(pattern); err != nil { + return false, err + } + return re.compiled.MatchString(matchAgainst) +} + +// ReplaceAllString replaces all matches in `input` with `replacement`. +func (re *regexpMatchingEngine) ReplaceAllString(pattern, input, replacement string) (string, error) { + if err := re.compile(pattern); err != nil { + return "", err + } + return re.compiled.Replace(input, replacement, -1, -1) +} diff --git a/rule/fetcher_default.go b/rule/fetcher_default.go index 25da8ec20f..0de8565260 100644 --- a/rule/fetcher_default.go +++ b/rule/fetcher_default.go @@ -40,8 +40,9 @@ type event struct { type eventType int const ( - eventRepositoryConfigChange eventType = iota + eventRepositoryConfigChanged eventType = iota eventFileChanged + eventMatchingStrategyChanged ) var _ Fetcher = new(FetcherDefault) @@ -197,12 +198,24 @@ func (f *FetcherDefault) watch(ctx context.Context, watcher *fsnotify.Watcher, e return nil } - f.enqueueEvent(events, event{et: eventRepositoryConfigChange, source: "viper_watcher"}) + f.enqueueEvent(events, event{et: eventRepositoryConfigChanged, source: "viper_watcher"}) return nil }) + f.enqueueEvent(events, event{et: eventRepositoryConfigChanged, source: "entrypoint"}) - f.enqueueEvent(events, event{et: eventRepositoryConfigChange, source: "entrypoint"}) + var strategy map[string]interface{} + viperx.AddWatcher(func(e fsnotify.Event) error { + if reflect.DeepEqual(strategy, viper.Get(configuration.ViperKeyAccessRuleMatchingStrategy)) { + f.r.Logger(). + Debug("Not reloading access rule matching strategy because configuration value has not changed.") + return nil + } + + f.enqueueEvent(events, event{et: eventMatchingStrategyChanged, source: "viper_watcher"}) + return nil + }) + f.enqueueEvent(events, event{et: eventMatchingStrategyChanged, source: "entrypoint"}) for { select { @@ -238,7 +251,7 @@ func (f *FetcherDefault) watch(ctx context.Context, watcher *fsnotify.Watcher, e WithField("op", e.Op.String()). Debugf("Detected file change in directory containing access rules. Triggering a reload.") - f.enqueueEvent(events, event{et: eventRepositoryConfigChange, source: "fsnotify"}) + f.enqueueEvent(events, event{et: eventRepositoryConfigChanged, source: "fsnotify"}) case e, ok := <-events: if !ok { // channel was closed @@ -247,7 +260,7 @@ func (f *FetcherDefault) watch(ctx context.Context, watcher *fsnotify.Watcher, e } switch e.et { - case eventRepositoryConfigChange: + case eventRepositoryConfigChanged: f.r.Logger(). WithField("event", "config_change"). WithField("source", e.source). @@ -255,6 +268,14 @@ func (f *FetcherDefault) watch(ctx context.Context, watcher *fsnotify.Watcher, e if err := f.configUpdate(ctx, watcher, f.c.AccessRuleRepositories(), events); err != nil { return err } + case eventMatchingStrategyChanged: + f.r.Logger(). + WithField("event", "matching_strategy_config_change"). + WithField("source", e.source). + Debugf("Viper detected a configuration change, updating matching strategy") + if err := f.r.RuleRepository().SetMatchingStrategy(ctx, f.c.AccessRuleMatchingStrategy()); err != nil { + return errors.Wrapf(err, "unable to update matching strategy") + } case eventFileChanged: f.r.Logger(). WithField("event", "repository_change"). diff --git a/rule/fetcher_default_test.go b/rule/fetcher_default_test.go index 3fc37bea8d..f606a9ea3b 100644 --- a/rule/fetcher_default_test.go +++ b/rule/fetcher_default_test.go @@ -28,6 +28,107 @@ import ( const testRule = `[{"id":"test-rule-5","upstream":{"preserve_host":true,"strip_path":"/api","url":"mybackend.com/api"},"match":{"url":"myproxy.com/api","methods":["GET","POST"]},"authenticators":[{"handler":"noop"},{"handler":"anonymous"}],"authorizer":{"handler":"allow"},"mutators":[{"handler":"noop"}]}]` +func TestFetcherReload(t *testing.T) { + viper.Reset() + conf := internal.NewConfigurationWithDefaults() // this resets viper and must be at the top + r := internal.NewRegistry(conf) + testConfigPath := "../test/update" + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(testRule)) + })) + defer ts.Close() + + tempdir := os.TempDir() + + id := uuid.New().String() + configFile := filepath.Join(tempdir, ".oathkeeper-"+id+".yml") + require.NoError(t, ioutil.WriteFile(configFile, []byte(""), 0666)) + + l := logrus.New() + l.Level = logrus.TraceLevel + viperx.InitializeConfig("oathkeeper-"+id, tempdir, nil) + viperx.WatchConfig(l, nil) + + go func() { + require.NoError(t, r.RuleFetcher().Watch(context.TODO())) + }() + + // initial config without a repo and without a matching strategy + config, err := ioutil.ReadFile(path.Join(testConfigPath, "config_no_repo.yaml")) + require.NoError(t, err) + require.NoError(t, ioutil.WriteFile(configFile, config, 0666)) + time.Sleep(time.Millisecond * 500) + + rules, err := r.RuleRepository().List(context.Background(), 500, 0) + require.NoError(t, err) + require.Empty(t, rules) + + strategy, err := r.RuleRepository().MatchingStrategy(context.Background()) + require.NoError(t, err) + require.Equal(t, configuration.MatchingStrategy(""), strategy) + + // config with a repo and without a matching strategy + config, err = ioutil.ReadFile(path.Join(testConfigPath, "config_default.yaml")) + require.NoError(t, err) + require.NoError(t, ioutil.WriteFile(configFile, config, 0666)) + time.Sleep(time.Millisecond * 500) + + rules, err = r.RuleRepository().List(context.Background(), 500, 0) + require.NoError(t, err) + require.Equal(t, 1, len(rules)) + require.Equal(t, "test-rule-1-glob", rules[0].ID) + + strategy, err = r.RuleRepository().MatchingStrategy(context.Background()) + require.NoError(t, err) + require.Equal(t, configuration.MatchingStrategy(""), strategy) + + // config with a glob matching strategy + config, err = ioutil.ReadFile(path.Join(testConfigPath, "config_glob.yaml")) + require.NoError(t, err) + require.NoError(t, ioutil.WriteFile(configFile, config, 0666)) + time.Sleep(time.Millisecond * 500) + + rules, err = r.RuleRepository().List(context.Background(), 500, 0) + require.NoError(t, err) + require.Equal(t, 1, len(rules)) + require.Equal(t, "test-rule-1-glob", rules[0].ID) + + strategy, err = r.RuleRepository().MatchingStrategy(context.Background()) + require.NoError(t, err) + require.Equal(t, configuration.Glob, strategy) + + // config with unknown matching strategy + config, err = ioutil.ReadFile(path.Join(testConfigPath, "config_error.yaml")) + require.NoError(t, err) + require.NoError(t, ioutil.WriteFile(configFile, config, 0666)) + time.Sleep(time.Millisecond * 500) + + rules, err = r.RuleRepository().List(context.Background(), 500, 0) + require.NoError(t, err) + require.Equal(t, 1, len(rules)) + require.Equal(t, "test-rule-1-glob", rules[0].ID) + + strategy, err = r.RuleRepository().MatchingStrategy(context.Background()) + require.NoError(t, err) + require.Equal(t, configuration.MatchingStrategy("UNKNOWN"), strategy) + + // config with regexp matching strategy + config, err = ioutil.ReadFile(path.Join(testConfigPath, "config_regexp.yaml")) + require.NoError(t, err) + require.NoError(t, ioutil.WriteFile(configFile, config, 0666)) + time.Sleep(time.Millisecond * 500) + + rules, err = r.RuleRepository().List(context.Background(), 500, 0) + require.NoError(t, err) + require.Equal(t, 1, len(rules)) + require.Equal(t, "test-rule-1-glob", rules[0].ID) + + strategy, err = r.RuleRepository().MatchingStrategy(context.Background()) + require.NoError(t, err) + require.Equal(t, configuration.Regexp, strategy) +} + func TestFetcherWatchConfig(t *testing.T) { viper.Reset() conf := internal.NewConfigurationWithDefaults() // this resets viper and must be at the top @@ -54,10 +155,11 @@ func TestFetcherWatchConfig(t *testing.T) { }() for k, tc := range []struct { - config string - tmpContent string - expectIDs []string - expectNone bool + config string + tmpContent string + expectIDs []string + expectNone bool + expectedStrategy configuration.MatchingStrategy }{ {config: ""}, { @@ -86,14 +188,18 @@ access_rules: access_rules: repositories: - file://../test/stub/rules.yaml + matching_strategy: glob `, - expectIDs: []string{"test-rule-1-yaml"}, + expectIDs: []string{"test-rule-1-yaml"}, + expectedStrategy: configuration.Glob, }, { config: ` access_rules: repositories: + matching_strategy: regexp `, + expectedStrategy: configuration.Regexp, }, } { t.Run(fmt.Sprintf("case=%d", k), func(t *testing.T) { @@ -104,6 +210,10 @@ access_rules: require.NoError(t, err) require.Len(t, rules, len(tc.expectIDs)) + strategy, err := r.RuleRepository().MatchingStrategy(context.Background()) + require.NoError(t, err) + require.Equal(t, tc.expectedStrategy, strategy) + ids := make([]string, len(rules)) for k, r := range rules { ids[k] = r.ID diff --git a/rule/matcher_test.go b/rule/matcher_test.go index f82ee4a813..86d02c97a6 100644 --- a/rule/matcher_test.go +++ b/rule/matcher_test.go @@ -28,8 +28,16 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/ory/oathkeeper/driver/configuration" ) +func mustParseURL(t *testing.T, u string) *url.URL { + p, err := url.Parse(u) + require.NoError(t, err) + return p +} + var testRules = []Rule{ { ID: "foo1", @@ -60,10 +68,34 @@ var testRules = []Rule{ }, } -func mustParseURL(t *testing.T, u string) *url.URL { - p, err := url.Parse(u) - require.NoError(t, err) - return p +var testRulesGlob = []Rule{ + { + ID: "foo1", + Match: &Match{URL: "https://localhost:1234/<{foo*,bar*}>", Methods: []string{"POST"}}, + Description: "Create users rule", + Authorizer: Handler{Handler: "allow", Config: []byte(`{"type":"any"}`)}, + Authenticators: []Handler{{Handler: "anonymous", Config: []byte(`{"name":"anonymous1"}`)}}, + Mutators: []Handler{{Handler: "id_token", Config: []byte(`{"issuer":"anything"}`)}}, + Upstream: Upstream{URL: "http://localhost:1235/", StripPath: "/bar", PreserveHost: true}, + }, + { + ID: "foo2", + Match: &Match{URL: "https://localhost:34/<{baz*,bar*}>", Methods: []string{"GET"}}, + Description: "Get users rule", + Authorizer: Handler{Handler: "deny", Config: []byte(`{"type":"any"}`)}, + Authenticators: []Handler{{Handler: "oauth2_introspection", Config: []byte(`{"name":"anonymous1"}`)}}, + Mutators: []Handler{{Handler: "id_token", Config: []byte(`{"issuer":"anything"}`)}}, + Upstream: Upstream{URL: "http://localhost:333/", StripPath: "/foo", PreserveHost: false}, + }, + { + ID: "foo3", + Match: &Match{URL: "https://localhost:343/<{baz*,bar*}>", Methods: []string{"GET"}}, + Description: "Get users rule", + Authorizer: Handler{Handler: "deny"}, + Authenticators: []Handler{{Handler: "oauth2_introspection"}}, + Mutators: []Handler{{Handler: "id_token"}}, + Upstream: Upstream{URL: "http://localhost:3333/", StripPath: "/foo", PreserveHost: false}, + }, } func TestMatcher(t *testing.T) { @@ -85,7 +117,7 @@ func TestMatcher(t *testing.T) { for name, matcher := range map[string]m{ "memory": NewRepositoryMemory(new(mockRepositoryRegistry)), } { - t.Run(fmt.Sprintf("matcher=%s", name), func(t *testing.T) { + t.Run(fmt.Sprintf("regexp matcher=%s", name), func(t *testing.T) { t.Run("case=empty", func(t *testing.T) { testMatcher(t, matcher, "GET", "https://localhost:34/baz", true, nil) testMatcher(t, matcher, "POST", "https://localhost:1234/foo", true, nil) @@ -105,8 +137,7 @@ func TestMatcher(t *testing.T) { require.NoError(t, err) got, err := matcher.Get(context.Background(), r.ID) require.NoError(t, err) - assert.NotEmpty(t, got.Match.compiledURL) - assert.NotEmpty(t, got.Match.compiledURLChecksum) + assert.NotEmpty(t, got.matchingEngine.Checksum()) }) require.NoError(t, matcher.Set(context.Background(), testRules[1:])) @@ -117,5 +148,38 @@ func TestMatcher(t *testing.T) { testMatcher(t, matcher, "DELETE", "https://localhost:1234/foo", true, nil) }) }) + t.Run(fmt.Sprintf("glob matcher=%s", name), func(t *testing.T) { + require.NoError(t, matcher.SetMatchingStrategy(context.Background(), configuration.Glob)) + require.NoError(t, matcher.Set(context.Background(), []Rule{})) + t.Run("case=empty", func(t *testing.T) { + testMatcher(t, matcher, "GET", "https://localhost:34/baz", true, nil) + testMatcher(t, matcher, "POST", "https://localhost:1234/foo", true, nil) + testMatcher(t, matcher, "DELETE", "https://localhost:1234/foo", true, nil) + }) + + require.NoError(t, matcher.Set(context.Background(), testRulesGlob)) + + t.Run("case=created", func(t *testing.T) { + testMatcher(t, matcher, "GET", "https://localhost:34/baz", false, &testRulesGlob[1]) + testMatcher(t, matcher, "POST", "https://localhost:1234/foo", false, &testRulesGlob[0]) + testMatcher(t, matcher, "DELETE", "https://localhost:1234/foo", true, nil) + }) + + t.Run("case=cache", func(t *testing.T) { + r, err := matcher.Match(context.Background(), "GET", mustParseURL(t, "https://localhost:34/baz")) + require.NoError(t, err) + got, err := matcher.Get(context.Background(), r.ID) + require.NoError(t, err) + assert.NotEmpty(t, got.matchingEngine.Checksum()) + }) + + require.NoError(t, matcher.Set(context.Background(), testRulesGlob[1:])) + + t.Run("case=updated", func(t *testing.T) { + testMatcher(t, matcher, "GET", "https://localhost:34/baz", false, &testRulesGlob[1]) + testMatcher(t, matcher, "POST", "https://localhost:1234/foo", true, nil) + testMatcher(t, matcher, "DELETE", "https://localhost:1234/foo", true, nil) + }) + }) } } diff --git a/rule/matching_engine.go b/rule/matching_engine.go new file mode 100644 index 0000000000..1acc3344fa --- /dev/null +++ b/rule/matching_engine.go @@ -0,0 +1,24 @@ +package rule + +import ( + "hash/crc64" + + "github.com/pkg/errors" +) + +// polynomial for crc64 table which is used for checking crc64 checksum +const polynomial = crc64.ECMA + +// common errors for MatchingEngine. +var ( + ErrUnbalancedPattern = errors.New("unbalanced pattern") + ErrMethodNotImplemented = errors.New("the method is not implemented") + ErrUnknownMatchingStrategy = errors.New("unknown matching strategy") +) + +// MatchingEngine describes an interface of matching engine such as regexp or glob. +type MatchingEngine interface { + IsMatching(pattern, matchAgainst string) (bool, error) + ReplaceAllString(pattern, input, replacement string) (string, error) + Checksum() uint64 +} diff --git a/rule/repository.go b/rule/repository.go index 9a63761acb..520b0b63b4 100644 --- a/rule/repository.go +++ b/rule/repository.go @@ -22,6 +22,8 @@ package rule import ( "context" + + "github.com/ory/oathkeeper/driver/configuration" ) type Repository interface { @@ -29,4 +31,6 @@ type Repository interface { Set(context.Context, []Rule) error Get(context.Context, string) (*Rule, error) Count(context.Context) (int, error) + MatchingStrategy(context.Context) (configuration.MatchingStrategy, error) + SetMatchingStrategy(context.Context, configuration.MatchingStrategy) error } diff --git a/rule/repository_memory.go b/rule/repository_memory.go index da4ac1bac0..9ee4b7d46f 100644 --- a/rule/repository_memory.go +++ b/rule/repository_memory.go @@ -29,6 +29,7 @@ import ( "github.com/ory/x/viperx" + "github.com/ory/oathkeeper/driver/configuration" "github.com/ory/oathkeeper/helper" "github.com/ory/oathkeeper/x" @@ -44,8 +45,24 @@ type repositoryMemoryRegistry interface { type RepositoryMemory struct { sync.RWMutex - rules []Rule - r repositoryMemoryRegistry + rules []Rule + matchingStrategy configuration.MatchingStrategy + r repositoryMemoryRegistry +} + +// MatchingStrategy returns current MatchingStrategy. +func (m *RepositoryMemory) MatchingStrategy(_ context.Context) (configuration.MatchingStrategy, error) { + m.RLock() + defer m.RUnlock() + return m.matchingStrategy, nil +} + +// SetMatchingStrategy updates MatchingStrategy. +func (m *RepositoryMemory) SetMatchingStrategy(_ context.Context, ms configuration.MatchingStrategy) error { + m.Lock() + defer m.Unlock() + m.matchingStrategy = ms + return nil } func NewRepositoryMemory(r repositoryMemoryRegistry) *RepositoryMemory { @@ -104,14 +121,16 @@ func (m *RepositoryMemory) Set(ctx context.Context, rules []Rule) error { return nil } -func (m *RepositoryMemory) Match(ctx context.Context, method string, u *url.URL) (*Rule, error) { +func (m *RepositoryMemory) Match(_ context.Context, method string, u *url.URL) (*Rule, error) { m.Lock() defer m.Unlock() var rules []Rule for k := range m.rules { r := &m.rules[k] - if err := r.IsMatching(method, u); err == nil { + if matched, err := r.IsMatching(m.matchingStrategy, method, u); err != nil { + return nil, errors.WithStack(err) + } else if matched { rules = append(rules, *r) } m.rules[k] = *r diff --git a/rule/repository_test.go b/rule/repository_test.go index 6ba7f5b1a9..103485c966 100644 --- a/rule/repository_test.go +++ b/rule/repository_test.go @@ -35,6 +35,8 @@ import ( "github.com/stretchr/testify/require" "github.com/ory/x/sqlcon/dockertest" + + "github.com/ory/oathkeeper/driver/configuration" ) func TestMain(m *testing.M) { @@ -118,6 +120,18 @@ func TestRepository(t *testing.T) { count, err = repo.Count(context.Background()) require.NoError(t, err) assert.Equal(t, len(rules)-1, count) + + strategy, err := repo.MatchingStrategy(context.Background()) + require.NoError(t, err) + require.Equal(t, configuration.MatchingStrategy(""), strategy) + + err = repo.SetMatchingStrategy(context.Background(), configuration.Glob) + require.NoError(t, err) + + strategy, err = repo.MatchingStrategy(context.Background()) + require.NoError(t, err) + require.Equal(t, configuration.Glob, strategy) + }) } diff --git a/rule/rule.go b/rule/rule.go index 80218c8c0f..5398ea475d 100644 --- a/rule/rule.go +++ b/rule/rule.go @@ -23,14 +23,12 @@ package rule import ( "encoding/json" "fmt" - "hash/crc32" "net/url" - "regexp" "strings" "github.com/pkg/errors" - "github.com/ory/ladon/compiler" + "github.com/ory/oathkeeper/driver/configuration" ) type Match struct { @@ -46,12 +44,12 @@ type Match struct { // request with this field. If a match is found, the rule is considered a partial match. // If the matchesMethods field is satisfied as well, the rule is considered a full match. // - // You can use regular expressions in this field to match more than one url. Regular expressions are encapsulated in - // brackets < and >. The following example matches all paths of the domain `mydomain.com`: `https://mydomain.com/<.*>`. + // You can use regular expressions or glob patterns in this field to match more than one url. + // The matching strategy is determined by configuration parameter MatchingStrategy. + // Regular expressions and glob patterns are encapsulated in brackets < and >. + // The following regexp example matches all paths of the domain `mydomain.com`: `https://mydomain.com/<.*>`. + // The glob equivalent of the above regexp example is `https://mydomain.com/<*>`. URL string `json:"url"` - - compiledURL *regexp.Regexp - compiledURLChecksum uint32 } type Handler struct { @@ -122,6 +120,8 @@ type Rule struct { // Upstream is the location of the server where requests matching this rule should be forwarded to. Upstream Upstream `json:"upstream"` + + matchingEngine MatchingEngine } type Upstream struct { @@ -138,14 +138,6 @@ type Upstream struct { var _ json.Unmarshaler = new(Rule) -func NewRule() *Rule { - return &Rule{ - Match: &Match{}, - Authenticators: []Handler{}, - Mutators: []Handler{}, - } -} - func (r *Rule) UnmarshalJSON(raw []byte) error { var rr struct { ID string `json:"id"` @@ -157,6 +149,7 @@ func (r *Rule) UnmarshalJSON(raw []byte) error { Mutators []Handler `json:"mutators"` Errors []ErrorHandler `json:"errors"` Upstream Upstream `json:"upstream"` + matchingEngine MatchingEngine } transformed, err := migrateRuleJSON(raw) @@ -177,37 +170,27 @@ func (r *Rule) GetID() string { return r.ID } -// IsMatching returns an error if the provided method and URL do not match the rule. -func (r *Rule) IsMatching(method string, u *url.URL) error { +// IsMatching checks whether the provided url and method match the rule. +// An error will be returned if a regexp matching strategy is selected and regexp timeout occurs. +func (r *Rule) IsMatching(strategy configuration.MatchingStrategy, method string, u *url.URL) (bool, error) { if !stringInSlice(method, r.Match.Methods) { - return errors.Errorf("rule %s does not match URL %s", r.ID, u) + return false, nil } - - c, err := r.CompileURL() - if err != nil { - return errors.WithStack(err) - } - - if !c.MatchString(fmt.Sprintf("%s://%s%s", u.Scheme, u.Host, u.Path)) { - return errors.Errorf("rule %s does not match URL %s", r.ID, u) + if err := ensureMatchingEngine(r, strategy); err != nil { + return false, err } - - return nil + matchAgainst := fmt.Sprintf("%s://%s%s", u.Scheme, u.Host, u.Path) + return r.matchingEngine.IsMatching(r.Match.URL, matchAgainst) } -func (r *Rule) CompileURL() (*regexp.Regexp, error) { - m := r.Match - c := crc32.ChecksumIEEE([]byte(m.URL)) - if m.compiledURL == nil || c != m.compiledURLChecksum { - regex, err := compiler.CompileRegex(m.URL, '<', '>') - if err != nil { - return nil, errors.Wrap(err, "Unable to compile URL matcher") - } - m.compiledURL = regex - m.compiledURLChecksum = c +// ReplaceAllString searches the input string and replaces each match (with the rule's pattern) +// found with the replacement text. +func (r *Rule) ReplaceAllString(strategy configuration.MatchingStrategy, input, replacement string) (string, error) { + if err := ensureMatchingEngine(r, strategy); err != nil { + return "", err } - return m.compiledURL, nil + return r.matchingEngine.ReplaceAllString(r.Match.URL, input, replacement) } func stringInSlice(a string, list []string) bool { @@ -218,3 +201,19 @@ func stringInSlice(a string, list []string) bool { } return false } + +func ensureMatchingEngine(rule *Rule, strategy configuration.MatchingStrategy) error { + if rule.matchingEngine != nil { + return nil + } + switch strategy { + case configuration.Glob: + rule.matchingEngine = new(globMatchingEngine) + return nil + case "", configuration.Regexp: + rule.matchingEngine = new(regexpMatchingEngine) + return nil + } + + return errors.Wrap(ErrUnknownMatchingStrategy, string(strategy)) +} diff --git a/rule/rule_test.go b/rule/rule_test.go index e5144e3d2d..c9abb1b06f 100644 --- a/rule/rule_test.go +++ b/rule/rule_test.go @@ -26,6 +26,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/ory/oathkeeper/driver/configuration" ) func mustParse(t *testing.T, u string) *url.URL { @@ -35,14 +37,110 @@ func mustParse(t *testing.T, u string) *url.URL { } func TestRule(t *testing.T) { + rules := []Rule{ + { + Match: &Match{ + Methods: []string{"DELETE"}, + URL: "https://localhost/users/<[0-9]+>", + }, + }, + { + Match: &Match{ + Methods: []string{"DELETE"}, + URL: "https://localhost/users/<[[:digit:]]*>", + }, + }, + { + Match: &Match{ + Methods: []string{"DELETE"}, + URL: "https://localhost/users/<[0-9]*>", + }, + }, + } + + var tests = []struct { + method string + url string + expectedMatch bool + expectedErr error + }{ + { + method: "DELETE", + url: "https://localhost/users/1234", + expectedMatch: true, + expectedErr: nil, + }, + { + method: "DELETE", + url: "https://localhost/users/1234?key=value&key1=value1", + expectedMatch: true, + expectedErr: nil, + }, + { + method: "DELETE", + url: "https://localhost/users/abcd", + expectedMatch: false, + expectedErr: nil, + }, + } + for ind, tcase := range tests { + t.Run(string(ind), func(t *testing.T) { + testFunc := func(rule Rule, strategy configuration.MatchingStrategy) { + matched, err := rule.IsMatching(strategy, tcase.method, mustParse(t, tcase.url)) + assert.Equal(t, tcase.expectedMatch, matched) + assert.Equal(t, tcase.expectedErr, err) + } + t.Run("rule0", func(t *testing.T) { + testFunc(rules[0], configuration.Regexp) + }) + t.Run("rule1", func(t *testing.T) { + testFunc(rules[1], configuration.Regexp) + }) + t.Run("rule2", func(t *testing.T) { + testFunc(rules[2], configuration.Glob) + }) + }) + } +} + +func TestRule1(t *testing.T) { r := &Rule{ Match: &Match{ Methods: []string{"DELETE"}, - URL: "https://localhost/users/<[0-9]+>", + URL: "https://localhost/users/<(?!admin).*>", }, } - assert.NoError(t, r.IsMatching("DELETE", mustParse(t, "https://localhost/users/1234"))) - assert.NoError(t, r.IsMatching("DELETE", mustParse(t, "https://localhost/users/1234?key=value&key1=value1"))) - assert.Error(t, r.IsMatching("DELETE", mustParse(t, "https://localhost/users/abcd"))) + var tests = []struct { + method string + url string + expectedMatch bool + expectedErr error + }{ + { + method: "DELETE", + url: "https://localhost/users/manager", + expectedMatch: true, + expectedErr: nil, + }, + { + method: "DELETE", + url: "https://localhost/users/1234?key=value&key1=value1", + expectedMatch: true, + expectedErr: nil, + }, + { + method: "DELETE", + url: "https://localhost/users/admin", + expectedMatch: false, + expectedErr: nil, + }, + } + for ind, tcase := range tests { + t.Run(string(ind), func(t *testing.T) { + matched, err := r.IsMatching(configuration.Regexp, tcase.method, mustParse(t, tcase.url)) + assert.Equal(t, tcase.expectedMatch, matched) + assert.Equal(t, tcase.expectedErr, err) + }) + } } diff --git a/test/update/config_default.yaml b/test/update/config_default.yaml new file mode 100644 index 0000000000..a11b1ef842 --- /dev/null +++ b/test/update/config_default.yaml @@ -0,0 +1,2 @@ +access_rules: + repositories: file://../test/update/rules_glob.yaml \ No newline at end of file diff --git a/test/update/config_error.yaml b/test/update/config_error.yaml new file mode 100644 index 0000000000..a9c0376ca6 --- /dev/null +++ b/test/update/config_error.yaml @@ -0,0 +1,3 @@ +access_rules: + repositories: file://../test/update/rules_glob.yaml + matching_strategy: UNKNOWN \ No newline at end of file diff --git a/test/update/config_glob.yaml b/test/update/config_glob.yaml new file mode 100644 index 0000000000..ec4aba7b3d --- /dev/null +++ b/test/update/config_glob.yaml @@ -0,0 +1,3 @@ +access_rules: + repositories: file://../test/update/rules_glob.yaml + matching_strategy: glob \ No newline at end of file diff --git a/test/update/config_no_repo.yaml b/test/update/config_no_repo.yaml new file mode 100644 index 0000000000..304048484a --- /dev/null +++ b/test/update/config_no_repo.yaml @@ -0,0 +1,2 @@ +access_rules: + repositories: \ No newline at end of file diff --git a/test/update/config_regexp.yaml b/test/update/config_regexp.yaml new file mode 100644 index 0000000000..369ac5c545 --- /dev/null +++ b/test/update/config_regexp.yaml @@ -0,0 +1,3 @@ +access_rules: + repositories: file://../test/update/rules_glob.yaml + matching_strategy: regexp \ No newline at end of file diff --git a/test/update/rules_glob.yaml b/test/update/rules_glob.yaml new file mode 100644 index 0000000000..e898ab305a --- /dev/null +++ b/test/update/rules_glob.yaml @@ -0,0 +1,6 @@ +- id: test-rule-1-glob + match: + url: myproxy.com/ + methods: + - GET + - POST \ No newline at end of file