From 8fcb57cc1510dd8893a1ae52276da0b4b8f9d7ab Mon Sep 17 00:00:00 2001 From: Dimitrij Drus Date: Fri, 12 Apr 2024 21:52:48 +0200 Subject: [PATCH 01/76] new string utils function to find a common prefix length of two strings --- internal/x/stringx/stringx.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/internal/x/stringx/stringx.go b/internal/x/stringx/stringx.go index 54676ecd3..78864a741 100644 --- a/internal/x/stringx/stringx.go +++ b/internal/x/stringx/stringx.go @@ -25,3 +25,12 @@ func ToString(b []byte) string { func ToBytes(str string) []byte { return unsafe.Slice(unsafe.StringData(str), len(str)) } + +func CommonPrefixLen(a, b string) int { + n := 0 + for n < len(a) && n < len(b) && a[n] == b[n] { + n++ + } + + return n +} From 7700693baf7b1d2ec2a77752d722bb101cd2b5cc Mon Sep 17 00:00:00 2001 From: Dimitrij Drus Date: Fri, 12 Apr 2024 21:54:19 +0200 Subject: [PATCH 02/76] initial implementation based on work from @davidspek --- internal/indextree/domain_node.go | 278 +++++++++++ internal/indextree/domain_node_test.go | 206 ++++++++ internal/indextree/index_tree.go | 43 ++ internal/indextree/index_tree_test.go | 55 +++ internal/indextree/matcher.go | 18 + internal/indextree/path_node.go | 444 ++++++++++++++++++ .../indextree/path_node_benchmark_test.go | 59 +++ internal/indextree/path_node_test.go | 325 +++++++++++++ internal/indextree/tree.go | 34 ++ 9 files changed, 1462 insertions(+) create mode 100644 internal/indextree/domain_node.go create mode 100644 internal/indextree/domain_node_test.go create mode 100644 internal/indextree/index_tree.go create mode 100644 internal/indextree/index_tree_test.go create mode 100644 internal/indextree/matcher.go create mode 100644 internal/indextree/path_node.go create mode 100644 internal/indextree/path_node_benchmark_test.go create mode 100644 internal/indextree/path_node_test.go create mode 100644 internal/indextree/tree.go diff --git a/internal/indextree/domain_node.go b/internal/indextree/domain_node.go new file mode 100644 index 000000000..aba71d268 --- /dev/null +++ b/internal/indextree/domain_node.go @@ -0,0 +1,278 @@ +package indextree + +import ( + "slices" + "strings" +) + +type domainNode[V any] struct { + // The full domain is stored here. + fullDomain string + + // The domain part is stored here. + domainPart string + + isLeaf bool + + priority int + + staticIndices []byte + staticChildren []*domainNode[V] + + wildcardChild *domainNode[V] + isWildcard bool + + pathRoot *pathNode[V] +} + +func (n *domainNode[V]) findNode(domain string) *domainNode[V] { + domainLen := len(domain) + + var found *domainNode[V] + + // only return a match if we're at a leaf node, this is to ensure that + // we don't match on partial domains (e.g. com matching on example.com) + if domainLen == 0 && n.isLeaf { + return n + } else if domainLen == 0 { + return nil + } + + token := domain[0] + for i, index := range n.staticIndices { + if token == index { + child := n.staticChildren[i] + childDomainLen := len(child.domainPart) + + if domainLen >= childDomainLen && child.domainPart == domain[:childDomainLen] { + nextDomain := domain[childDomainLen:] + found = child.findNode(nextDomain) + } + + break + } + } + + if n.wildcardChild != nil && found == nil { + // we don't iterate over periods so that we can match on multiple levels of subdomains + found = n.wildcardChild + } + + return found +} + +func (n *domainNode[V]) find(domain string) *domainNode[V] { + return n.findNode(reverseDomain(domain)) +} + +func (n *domainNode[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 + } + } +} + +func (n *domainNode[V]) delNode(domain string) bool { + domainLen := len(domain) + + if domainLen == 0 { + return n.isLeaf + } + + firstChar := domain[0] + if firstChar == '*' && n.wildcardChild != nil { + n.deleteChild(n.wildcardChild, firstChar) + + return true + } + + for i, staticIndex := range n.staticIndices { + if firstChar == staticIndex { + child := n.staticChildren[i] + childDomainLen := len(child.domainPart) + + if domainLen >= childDomainLen && child.domainPart == domain[:childDomainLen] { + nextToken := domain[childDomainLen:] + if child.delNode(nextToken) { + n.deleteChild(child, firstChar) + + return true + } + } + + break + } + } + + return false +} + +func (n *domainNode[V]) deleteChild(child *domainNode[V], token uint8) { + // Delete the child if it's a leaf node + if child.isLeaf { + child.isLeaf = false + } + + if len(child.staticIndices) == 1 && child.staticIndices[0] != '.' && child.domainPart != "." { + if len(child.staticChildren) == 1 { + old := child.staticChildren[0] + old.domainPart = child.domainPart + old.domainPart + *child = *old + } + } + + if child.isLeaf { + return + } + + // Delete the child from the parent only if the child has no children + if len(child.staticIndices) == 0 { + n.delEdge(token) + } + + // remove the wildcard child if it exists + if child.isWildcard { + n.wildcardChild = nil + } +} + +func (n *domainNode[V]) delete(domain string) bool { + return n.delNode(reverseDomain(domain)) +} + +func (n *domainNode[V]) add(fullDomain string) *domainNode[V] { + res := n.addNode(reverseDomain(fullDomain)) + + res.fullDomain = fullDomain + + if res.pathRoot == nil { + res.pathRoot = &pathNode[V]{} + } + + return res +} + +func (n *domainNode[V]) addNode(domain string) *domainNode[V] { + // If the domain is empty, we're done. + if len(domain) == 0 { + n.isLeaf = true + + return n + } + + token := domain[0] + nextPeriod := strings.Index(domain, ".") + + var ( + thisToken string + tokenEnd int + ) + + switch { + case token == '.': + thisToken = "." + tokenEnd = 1 + case nextPeriod == -1: + thisToken = domain + tokenEnd = len(domain) + default: + thisToken = domain[0:nextPeriod] + tokenEnd = nextPeriod + } + + remainingDomain := domain[tokenEnd:] + + if token == '*' { + if n.wildcardChild == nil { + n.wildcardChild = &domainNode[V]{ + domainPart: thisToken, + isWildcard: true, + isLeaf: true, + } + } + + return n.wildcardChild + } + + // Do we have an existing node that starts with the same letter? + for i, index := range n.staticIndices { + if token == index { + child, prefixSplit := n.splitCommonDomainPrefix(i, thisToken) + + child.priority++ + + n.sortStaticChild(i) + + return child.addNode(domain[prefixSplit:]) + } + } + + // No existing node starting with this letter, so create it. + child := &domainNode[V]{domainPart: thisToken} + + n.staticIndices = append(n.staticIndices, token) + n.staticChildren = append(n.staticChildren, child) + + return child.addNode(remainingDomain) +} + +func (n *domainNode[V]) sortStaticChild(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 *domainNode[V]) splitCommonDomainPrefix(existingNodeIndex int, domain string) (*domainNode[V], int) { + childNode := n.staticChildren[existingNodeIndex] + + if strings.HasPrefix(domain, childNode.domainPart) { + // 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.domainPart) + } + + var i int + // Find the length of the common prefix of the child node and the new path. + for i = range childNode.domainPart { + if i == len(domain) { + break + } + + if domain[i] != childNode.domainPart[i] { + break + } + } + + commonPrefix := domain[0:i] + childNode.domainPart = childNode.domainPart[i:] + + // Create a new intermediary node in the place of the existing node, with + // the existing node as a child. + newNode := &domainNode[V]{ + domainPart: commonPrefix, + priority: childNode.priority, + // Index is the first letter of the non-common part of the path. + staticIndices: []byte{childNode.domainPart[0]}, + staticChildren: []*domainNode[V]{childNode}, + } + n.staticChildren[existingNodeIndex] = newNode + + return newNode, i +} + +// reverseDomain returns the reverse octets of the given domain. +func reverseDomain(domain string) string { + domainSlice := strings.Split(domain, ".") + slices.Reverse(domainSlice) + + return strings.Join(domainSlice, ".") +} diff --git a/internal/indextree/domain_node_test.go b/internal/indextree/domain_node_test.go new file mode 100644 index 000000000..27e4c40b8 --- /dev/null +++ b/internal/indextree/domain_node_test.go @@ -0,0 +1,206 @@ +package indextree + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestReverseDomain(t *testing.T) { + t.Parallel() + + for _, test := range []struct { + domain string + expected string + }{ + {"example.com", "com.example"}, + {"subdomain.example.com", "com.example.subdomain"}, + {"www.github.com", "com.github.www"}, + {"test", "test"}, + {"", ""}, + } { + result := reverseDomain(test.domain) + assert.Equal(t, test.expected, result) + } +} + +func TestDomainTree(t *testing.T) { + t.Parallel() + + tree := &domainNode[string]{} + + for _, domain := range []string{ + "example.com", + "*.example.com", + "subdomain.example.com", + "static.example.com", + "www.github.com", + "test", + "foo.net", + "bar.net", + "baz.net", + } { + tree.add(domain) + } + + for _, tc := range []struct { + domain string + expected string + }{ + {"example.com", "example.com"}, + {"subdomain.example.com", "subdomain.example.com"}, + {"static.example.com", "static.example.com"}, + {"test.example.com", "*.example.com"}, + {"bar.example.com", "*.example.com"}, + {"foo.bar.example.com", "*.example.com"}, + {"www.github.com", "www.github.com"}, + {"test", "test"}, + {"foo.net", "foo.net"}, + {"bar.net", "bar.net"}, + {"baz.net", "baz.net"}, + {"com", ""}, + {"example", ""}, + {"example.com.", ""}, + {"example.com..", ""}, + {"example.com..subdomain", ""}, + {"example.com..subdomain.", ""}, + {"example.com..subdomain..", ""}, + {"bar.foo.net", ""}, + {"foo.bar.net", ""}, + {"baz.bar.net", ""}, + {"test.example.com.", ""}, + {"com.example", ""}, + {"com.example.subdomain", ""}, + {"com.github.www", ""}, + } { + t.Run(tc.domain, func(t *testing.T) { + res := tree.find(tc.domain) + + if tc.expected != "" { + require.NotNil(t, res) + assert.Equal(t, tc.expected, res.fullDomain) + } else if tc.expected == "" { + assert.Nil(t, res) + } + }) + } + + // test global wildcard + tree.add("*") + + for _, tc := range []struct { + domain string + expected string + }{ + {"com", "*"}, + {"example", "*"}, + {"example.com.", "*"}, + {"example.com..", "*"}, + {"example.com..subdomain", "*"}, + {"example.com..subdomain.", "*"}, + {"example.com..subdomain..", "*"}, + {"bar.foo.net", "*"}, + {"foo.bar.net", "*"}, + {"baz.bar.net", "*"}, + {"test.example.com.", "*"}, + {"com.example", "*"}, + {"com.example.subdomain", "*"}, + {"com.github.www", "*"}, + // ensure global wildcard doesn't override existing domains + {"example.com", "example.com"}, + {"subdomain.example.com", "subdomain.example.com"}, + {"static.example.com", "static.example.com"}, + {"test.example.com", "*.example.com"}, + {"bar.example.com", "*.example.com"}, + {"foo.bar.example.com", "*.example.com"}, + {"www.github.com", "www.github.com"}, + {"test", "test"}, + {"foo.net", "foo.net"}, + {"bar.net", "bar.net"}, + {"baz.net", "baz.net"}, + } { + t.Run(tc.domain, func(t *testing.T) { + res := tree.find(tc.domain) + + if tc.expected != "" { + require.NotNil(t, res) + assert.Equal(t, tc.expected, res.fullDomain) + } else if tc.expected == "" { + assert.Nil(t, res) + } + }) + } +} + +func TestDeleteDomain(t *testing.T) { + t.Parallel() + + tree := &domainNode[string]{} + + for _, domain := range []string{ + "example.com", + "*.example.com", + "subdomain.example.com", + "static.example.com", + "spoof.example.com", + "different.example.com", + "www.github.com", + "test", + "foo.net", + "bar.net", + "baz.net", + } { + tree.add(domain) + } + + for _, tc := range []struct { + domain string + expRemoved bool + expected string + }{ + {"www.github.com", true, ""}, + {"example.com", true, ""}, + {"subdomain.example.com", true, "*.example.com"}, + {"test.example.com", false, "*.example.com"}, + {"bar.example.com", false, "*.example.com"}, + {"foo.bar.example.com", false, "*.example.com"}, + {"*.example.com", true, ""}, + {"subdomain.example.com", false, ""}, + {"spoof.example.com", true, ""}, + {"test.example.com", false, ""}, + {"bar.example.com", false, ""}, + {"foo.bar.example.com", false, ""}, + {"test", true, ""}, + {"foo.net", true, ""}, + {"bar.net", true, ""}, + {"baz.net", true, ""}, + {"com", false, ""}, + {"example", false, ""}, + {"example.com.", false, ""}, + {"example.com..", false, ""}, + {"example.com..subdomain", false, ""}, + {"example.com..subdomain.", false, ""}, + {"example.com..subdomain..", false, ""}, + {"bar.foo.net", false, ""}, + {"foo.bar.net", false, ""}, + {"baz.bar.net", false, ""}, + {"test.example.com.", false, ""}, + {"com.example", false, ""}, + {"com.example.subdomain", false, ""}, + {"com.github.www", false, ""}, + {"test", false, ""}, + } { + result := tree.delete(tc.domain) + require.Equalf(t, tc.expRemoved, result, "Delete(%s) returned %v, expected %v", tc.domain, result, tc.expRemoved) + + res := tree.find(tc.domain) + + if tc.expected != "" { + require.NotNil(t, res) + assert.Equal(t, tc.expected, res.fullDomain) + } else if tc.expected == "" { + assert.Nil(t, res) + } + } +} diff --git a/internal/indextree/index_tree.go b/internal/indextree/index_tree.go new file mode 100644 index 000000000..2efffffe1 --- /dev/null +++ b/internal/indextree/index_tree.go @@ -0,0 +1,43 @@ +package indextree + +import "errors" + +func NewIndexTree[V any]() *IndexTree[V] { + return &IndexTree[V]{tree: &domainNode[V]{}} +} + +type IndexTree[V any] struct { + tree *domainNode[V] +} + +func (t *IndexTree[V]) Add(domain, path string, value V) error { + return t.tree.add(domain).pathRoot.add(path, value) +} + +func (t *IndexTree[V]) Find(domain, path string, matcher Matcher[V]) (V, map[string]string, error) { + var def V + + dn := t.tree.find(domain) + if dn == nil { + return def, nil, errors.New("not found") + } + + return dn.pathRoot.find(path, matcher) +} + +func (t *IndexTree[V]) Delete(domain, path string, matcher Matcher[V]) error { + dn := t.tree.find(domain) + if dn == nil { + return errors.New("not found") + } + + if !dn.pathRoot.delete(path, matcher) { + return errors.New("failed to delete") + } + + if dn.pathRoot.empty() && !t.tree.delete(domain) { + return errors.New("failed to delete") + } + + return nil +} diff --git a/internal/indextree/index_tree_test.go b/internal/indextree/index_tree_test.go new file mode 100644 index 000000000..06e5ee96b --- /dev/null +++ b/internal/indextree/index_tree_test.go @@ -0,0 +1,55 @@ +package indextree + +import ( + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "testing" +) + +func TestFoo(t *testing.T) { + t.Parallel() + + tree := NewIndexTree[string]() + + err := tree.Add("*.github.com", "/images/abc.jpg", "1") + require.NoError(t, err) + + err = tree.Add("*.github.com", "/images/abc.jpg", "2") + require.NoError(t, err) + + err = tree.Add("*.github.com", "/images/:imgname", "3") + require.NoError(t, err) + + err = tree.Add("www.github.com", "/images/*path", "4") + require.NoError(t, err) + + val, params, err := tree.Find("imgs.github.com", "/images/abc.jpg", testMatcher[string](true)) + require.NoError(t, err) + assert.Equal(t, "1", val) + assert.Empty(t, params) + + val, params, err = tree.Find("imgs.github.com", "/images/abc.jpg", MatcherFunc[string](func(value string) bool { + return value == "2" + })) + require.NoError(t, err) + assert.Equal(t, "2", val) + assert.Empty(t, params) + + val, params, err = tree.Find("imgs.github.com", "/images/cba.jpg", testMatcher[string](true)) + require.NoError(t, err) + assert.Equal(t, "3", val) + assert.Equal(t, map[string]string{"imgname": "cba.jpg"}, params) + + _, _, err = tree.Find("imgs.github.com", "/images/cba/abc.jpg", testMatcher[string](true)) + require.Error(t, err) + + val, params, err = tree.Find("www.github.com", "/images/cba.jpg", testMatcher[string](true)) + require.NoError(t, err) + assert.Equal(t, "4", val) + assert.Equal(t, map[string]string{"path": "cba.jpg"}, params) + + val, params, err = tree.Find("www.github.com", "/images/abc/cba.jpg", testMatcher[string](true)) + require.NoError(t, err) + assert.Equal(t, "4", val) + assert.Equal(t, map[string]string{"path": "abc/cba.jpg"}, params) +} diff --git a/internal/indextree/matcher.go b/internal/indextree/matcher.go new file mode 100644 index 000000000..2568724ae --- /dev/null +++ b/internal/indextree/matcher.go @@ -0,0 +1,18 @@ +package indextree + +// 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/indextree/path_node.go b/internal/indextree/path_node.go new file mode 100644 index 000000000..6b574275c --- /dev/null +++ b/internal/indextree/path_node.go @@ -0,0 +1,444 @@ +/* +Package indextree implements a tree lookup for values associated to +paths. + +This package is a fork of https://github.com/dimfeld/httptreemux. +*/ +package indextree + +import ( + "errors" + "net/url" + "slices" + "strings" + + "github.com/dadrus/heimdall/internal/x/errorchain" + "github.com/dadrus/heimdall/internal/x/stringx" +) + +var ( + ErrInvalidPath = errors.New("invalid path") + ErrNotFound = errors.New("not found") +) + +type pathNode[V any] struct { + path string + + priority int + + // The list of static children to check. + staticIndices []byte + staticChildren []*pathNode[V] + + // If none of the above match, check the wildcard children + wildcardChild *pathNode[V] + + // If none of the above match, then we use the catch-all, if applicable. + catchAllChild *pathNode[V] + + isCatchAll bool + isWildcard bool + + values []V + wildcardKeys []string +} + +func (n *pathNode[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 *pathNode[V]) nextSeparator(path string) int { + if idx := strings.IndexByte(path, '/'); idx != -1 { + return idx + } + + return len(path) +} + +//nolint:funlen,gocognit,cyclop +func (n *pathNode[V]) addNode(path string, wildcardKeys []string, inStaticToken bool) (*pathNode[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, errorchain.NewWithMessage(ErrInvalidPath, + "ambiguous path detected - wildcard keys differ") + } + + 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 { + switch token { + case '*': + thisToken = thisToken[1:] + + if nextSlash != -1 { + return nil, errorchain.NewWithMessagef(ErrInvalidPath, "/ after catch-all found in %s", path) + } + + if n.catchAllChild == nil { + n.catchAllChild = &pathNode[V]{ + path: thisToken, + isCatchAll: true, + } + } + + if path[1:] != n.catchAllChild.path { + return nil, errorchain.NewWithMessagef(ErrInvalidPath, + "catch-all name in %s doesn't match %s", path, n.catchAllChild.path) + } + + wildcardKeys = append(wildcardKeys, thisToken) + n.catchAllChild.wildcardKeys = wildcardKeys + + return n.catchAllChild, nil + case ':': + if n.wildcardChild == nil { + n.wildcardChild = &pathNode[V]{path: "wildcard", isWildcard: 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 := &pathNode[V]{path: thisToken} + + n.staticIndices = append(n.staticIndices, token) + n.staticChildren = append(n.staticChildren, child) + + // 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 != '/') +} + +func (n *pathNode[V]) delNode(path string, matcher Matcher[V]) bool { + pathLen := len(path) + if pathLen == 0 { + if n.values != nil && matcher.Match(n.values[0]) { + n.values = nil + + return true + } + + return false + } + + var ( + nextPath string + child *pathNode[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 && child.delNode(nextPath, matcher) { + if child.values == nil { + n.deleteChild(child, token) + } + + return true + } + + 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 child.values == nil { + n.deleteChild(child, token) + } + + return true + } + + break + } + } + + return false +} + +func (n *pathNode[V]) deleteChild(child *pathNode[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 child.values != nil { + 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 *pathNode[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 *pathNode[V]) findNode(path string, matcher Matcher[V]) (*pathNode[V], int, []string) { + var ( + found *pathNode[V] + params []string + idx int + value V + ) + + pathLen := len(path) + if pathLen == 0 { + if len(n.values) == 0 { + return nil, 0, nil + } + + for idx, value = range n.values { + if match := matcher.Match(value); match { + return n, idx, nil + } + } + + return nil, 0, nil + } + + // 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 = child.findNode(nextPath, matcher) + } + + break + } + } + + if found != nil { + return found, idx, params + } + + 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 = n.wildcardChild.findNode(nextToken, matcher) + if found != nil { + unescaped, err := url.PathUnescape(thisToken) + if err != nil { + unescaped = thisToken + } + + return found, idx, append(params, unescaped) + } + } + } + + if n.catchAllChild != nil { + // Hit the catchall, so just assign the whole remaining path. + unescaped, err := url.PathUnescape(path) + if err != nil { + unescaped = path + } + + for idx, value = range n.catchAllChild.values { + if match := matcher.Match(value); match { + return n.catchAllChild, idx, []string{unescaped} + } + } + + return nil, 0, nil + } + + return nil, 0, nil +} + +func (n *pathNode[V]) splitCommonPrefix(existingNodeIndex int, path string) (*pathNode[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 := stringx.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 := &pathNode[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: []*pathNode[V]{childNode}, + } + n.staticChildren[existingNodeIndex] = newNode + + return newNode, i +} + +func (n *pathNode[V]) add(path string, value V) error { + res, err := n.addNode(path, nil, false) + if err != nil { + return err + } + + res.values = append(res.values, value) + + return nil +} + +func (n *pathNode[V]) find(path string, m Matcher[V]) (V, map[string]string, error) { + var def V + + found, idx, params := n.findNode(path, m) + if found == nil { + return def, nil, ErrNotFound + } + + if len(found.wildcardKeys) == 0 { + return found.values[idx], nil, nil + } + + keys := make(map[string]string, len(params)) + if len(found.wildcardKeys) == 1 && found.wildcardKeys[0] == "*" { + return found.values[idx], keys, nil + } + + for i, param := range params { + keys[found.wildcardKeys[len(params)-1-i]] = param + } + + return found.values[idx], keys, nil +} + +func (n *pathNode[V]) empty() bool { + return len(n.values) == 0 && len(n.staticChildren) == 0 && n.wildcardChild == nil && n.catchAllChild == nil +} + +func (n *pathNode[V]) delete(path string, matcher Matcher[V]) bool { + return n.delNode(path, matcher) +} diff --git a/internal/indextree/path_node_benchmark_test.go b/internal/indextree/path_node_benchmark_test.go new file mode 100644 index 000000000..ffc0e32d3 --- /dev/null +++ b/internal/indextree/path_node_benchmark_test.go @@ -0,0 +1,59 @@ +package indextree + +import ( + "testing" +) + +func BenchmarkNodeSearchNoPaths(b *testing.B) { + tm := testMatcher[string](true) + tree := &pathNode[string]{path: "/"} + + b.ReportAllocs() + b.ResetTimer() + + for range b.N { + tree.find("", tm) + } +} + +func BenchmarkNodeSearchOneStaticPath(b *testing.B) { + tm := testMatcher[string](true) + tree := &pathNode[string]{path: "/"} + + tree.add("abc", "foo") + + b.ReportAllocs() + b.ResetTimer() + + for range b.N { + tree.find("abc", tm) + } +} + +func BenchmarkNodeSearchOneWildcardPath(b *testing.B) { + tm := testMatcher[string](true) + tree := &pathNode[string]{path: "/"} + + tree.add(":abc", "foo") + + b.ReportAllocs() + b.ResetTimer() + + for range b.N { + tree.find("abc", tm) + } +} + +func BenchmarkNodeSearchOneLongWildcards(b *testing.B) { + tm := testMatcher[string](true) + tree := &pathNode[string]{path: "/"} + + tree.add(":abc/:def/:ghi", "foo") + + b.ReportAllocs() + b.ResetTimer() + + for range b.N { + tree.find("abcdefghijklmnop/aaaabbbbccccddddeeeeffffgggg/hijkl", tm) + } +} diff --git a/internal/indextree/path_node_test.go b/internal/indextree/path_node_test.go new file mode 100644 index 000000000..5b1d32bd3 --- /dev/null +++ b/internal/indextree/path_node_test.go @@ -0,0 +1,325 @@ +package indextree + +import ( + "slices" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func testMatcher[V any](matches bool) MatcherFunc[V] { + return func(value V) bool { return matches } +} + +func TestNodeSearch(t *testing.T) { + t.Parallel() + + // Setup & populate tree + tree := &pathNode[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/"}}, + {path: "/post/ab%2fdef/page/2%2f", expPath: "/post/:post/page/:page", expParams: map[string]string{"post": "ab/def", "page": "2/"}}, + {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 + } + + resValue, paramList, 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, resValue, "Path %s matched %s, expected %s", tc.path, resValue, tc.expPath) + assert.Equal(t, tc.expParams, paramList, "Path %s expected parameters are %v, saw %v", tc.path, tc.expParams, paramList) + }) + } +} + +func TestNodeAddPathDuplicates(t *testing.T) { + t.Parallel() + + tree := &pathNode[string]{} + path := "/date/:year/:month/abc" + + err := tree.add(path, "first") + require.NoError(t, err) + + err = tree.add(path, "second") + require.NoError(t, err) + + value, params, err := tree.find("/date/2024/04/abc", MatcherFunc[string](func(value string) bool { + return value == "first" + })) + require.NoError(t, err) + assert.Equal(t, "first", value) + assert.Equal(t, map[string]string{"year": "2024", "month": "04"}, params) + + value, params, err = tree.find("/date/2024/04/abc", MatcherFunc[string](func(value string) bool { + return value == "second" + })) + require.NoError(t, err) + assert.Equal(t, "second", value) + assert.Equal(t, map[string]string{"year": "2024", "month": "04"}, params) +} + +func TestNodeAddPath(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 := &pathNode[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 TestNodeDeleteStaticPaths(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 := &pathNode[int]{} + + for idx, path := range paths { + err := tree.add(path, idx) + require.NoError(t, err) + } + + for i := len(paths) - 1; i >= 0; i-- { + require.True(t, tree.delete(paths[i], testMatcher[int](true))) + require.False(t, tree.delete(paths[i], testMatcher[int](true))) + } +} + +func TestNodeDeleteStaticAndWildcardPaths(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 := &pathNode[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] + require.Truef(t, tree.delete(tbdPath, testMatcher[int](true)), "Should be able to delete %s", paths[i]) + require.Falsef(t, tree.delete(tbdPath, testMatcher[int](true)), "Should not be able to delete %s", paths[i]) + + deletedPaths = append(deletedPaths, tbdPath) + + for idx, path := range paths { + val, _, 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, val) + } + } + } +} + +func TestNodeDeleteMixedPaths(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", + "/abb/:ba/*all", + "/abb/\\*all", + "/abb/*all", + } + + tree := &pathNode[int]{} + + for idx, path := range paths { + err := tree.add(path, idx+1) + require.NoError(t, err) + } + + for i := len(paths) - 1; i >= 0; i-- { + require.Truef(t, tree.delete(paths[i], testMatcher[int](true)), "Should be able to delete %s", paths[i]) + require.Falsef(t, tree.delete(paths[i], testMatcher[int](true)), "Should not be able to delete %s", paths[i]) + } + + require.True(t, tree.empty()) +} diff --git a/internal/indextree/tree.go b/internal/indextree/tree.go new file mode 100644 index 000000000..13daca670 --- /dev/null +++ b/internal/indextree/tree.go @@ -0,0 +1,34 @@ +package indextree + +// Tree structure to store values associated to paths. +type Tree[V any] pathNode[V] + +// Add a value to the tree associated with a path. Paths may contain +// wildcards. Wildcards can be of two types: +// +// - simple wildcard: e.g. /some/:wildcard/path, where a wildcard is +// matched to a single name in the path. +// +// - free wildcard: e.g. /some/path/*wildcard, where a wildcard at the +// end of a path matches anything. +// +// If the path segment has to start with : or *, it must be escaped +// with \ to be not confused with a wildcard +func (t *Tree[V]) Add(path string, value V) error { + return (*pathNode[V])(t).add(path[1:], value) +} + +// Lookup tries to find value in the tree associated to a path. +// If the found path definition contains wildcards, the values of the +// wildcards are returned in the second argument. While performing a +// lookup the matcher is called to check if the value attached to the +// found node meets the conditions implemented by the matcher. If it +// returns true, then the lookup is done. Otherwise, the lookup +// continues with backtracking from the current tree position. +func (t *Tree[V]) Lookup(path string, m Matcher[V]) (V, map[string]string, error) { + if path == "" { + path = "/" + } + + return (*pathNode[V])(t).find(path[1:], m) +} From 23deeaac57d472d0df527a4f843dacf339936b2e Mon Sep 17 00:00:00 2001 From: Dimitrij Drus Date: Sat, 13 Apr 2024 15:55:32 +0200 Subject: [PATCH 03/76] find method ensures unnamed wildcards are not included in the result --- internal/indextree/path_node.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/indextree/path_node.go b/internal/indextree/path_node.go index 6b574275c..e6d87b9d7 100644 --- a/internal/indextree/path_node.go +++ b/internal/indextree/path_node.go @@ -424,12 +424,12 @@ func (n *pathNode[V]) find(path string, m Matcher[V]) (V, map[string]string, err } keys := make(map[string]string, len(params)) - if len(found.wildcardKeys) == 1 && found.wildcardKeys[0] == "*" { - return found.values[idx], keys, nil - } for i, param := range params { - keys[found.wildcardKeys[len(params)-1-i]] = param + key := found.wildcardKeys[len(params)-1-i] + if key != "*" { + keys[found.wildcardKeys[len(params)-1-i]] = param + } } return found.values[idx], keys, nil From ecd178b34b7f5873ce5fae4deb1e3272a91fa9ad Mon Sep 17 00:00:00 2001 From: Dimitrij Drus Date: Sat, 13 Apr 2024 16:00:10 +0200 Subject: [PATCH 04/76] linter warnings resolved --- internal/indextree/errors.go | 9 +++++++++ internal/indextree/index_tree.go | 10 ++++------ internal/indextree/index_tree_test.go | 3 ++- internal/indextree/matcher.go | 2 +- internal/indextree/path_node.go | 8 ++------ internal/indextree/path_node_test.go | 4 +--- internal/indextree/tree.go | 2 +- 7 files changed, 20 insertions(+), 18 deletions(-) create mode 100644 internal/indextree/errors.go diff --git a/internal/indextree/errors.go b/internal/indextree/errors.go new file mode 100644 index 000000000..75626cb05 --- /dev/null +++ b/internal/indextree/errors.go @@ -0,0 +1,9 @@ +package indextree + +import "errors" + +var ( + ErrInvalidPath = errors.New("invalid path") + ErrNotFound = errors.New("not found") + ErrFailedToDelete = errors.New("failed to delete") +) diff --git a/internal/indextree/index_tree.go b/internal/indextree/index_tree.go index 2efffffe1..49edcfa9e 100644 --- a/internal/indextree/index_tree.go +++ b/internal/indextree/index_tree.go @@ -1,7 +1,5 @@ package indextree -import "errors" - func NewIndexTree[V any]() *IndexTree[V] { return &IndexTree[V]{tree: &domainNode[V]{}} } @@ -19,7 +17,7 @@ func (t *IndexTree[V]) Find(domain, path string, matcher Matcher[V]) (V, map[str dn := t.tree.find(domain) if dn == nil { - return def, nil, errors.New("not found") + return def, nil, ErrNotFound } return dn.pathRoot.find(path, matcher) @@ -28,15 +26,15 @@ func (t *IndexTree[V]) Find(domain, path string, matcher Matcher[V]) (V, map[str func (t *IndexTree[V]) Delete(domain, path string, matcher Matcher[V]) error { dn := t.tree.find(domain) if dn == nil { - return errors.New("not found") + return ErrNotFound } if !dn.pathRoot.delete(path, matcher) { - return errors.New("failed to delete") + return ErrFailedToDelete } if dn.pathRoot.empty() && !t.tree.delete(domain) { - return errors.New("failed to delete") + return ErrFailedToDelete } return nil diff --git a/internal/indextree/index_tree_test.go b/internal/indextree/index_tree_test.go index 06e5ee96b..cfdab2e14 100644 --- a/internal/indextree/index_tree_test.go +++ b/internal/indextree/index_tree_test.go @@ -1,9 +1,10 @@ package indextree import ( + "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "testing" ) func TestFoo(t *testing.T) { diff --git a/internal/indextree/matcher.go b/internal/indextree/matcher.go index 2568724ae..2cd01b116 100644 --- a/internal/indextree/matcher.go +++ b/internal/indextree/matcher.go @@ -1,6 +1,6 @@ package indextree -// Matcher is used for additional checks while performing the lookup in the spanned tree +// 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. diff --git a/internal/indextree/path_node.go b/internal/indextree/path_node.go index e6d87b9d7..5e4834ff9 100644 --- a/internal/indextree/path_node.go +++ b/internal/indextree/path_node.go @@ -7,7 +7,6 @@ This package is a fork of https://github.com/dimfeld/httptreemux. package indextree import ( - "errors" "net/url" "slices" "strings" @@ -16,11 +15,6 @@ import ( "github.com/dadrus/heimdall/internal/x/stringx" ) -var ( - ErrInvalidPath = errors.New("invalid path") - ErrNotFound = errors.New("not found") -) - type pathNode[V any] struct { path string @@ -174,6 +168,7 @@ func (n *pathNode[V]) addNode(path string, wildcardKeys []string, inStaticToken return child.addNode(remainingPath, wildcardKeys, token != '/') } +//nolint:cyclop func (n *pathNode[V]) delNode(path string, matcher Matcher[V]) bool { pathLen := len(path) if pathLen == 0 { @@ -248,6 +243,7 @@ func (n *pathNode[V]) delNode(path string, matcher Matcher[V]) bool { return false } +//nolint:cyclop func (n *pathNode[V]) deleteChild(child *pathNode[V], token uint8) { if len(child.staticIndices) == 1 && child.staticIndices[0] != '/' && child.path != "/" { if len(child.staticChildren) == 1 { diff --git a/internal/indextree/path_node_test.go b/internal/indextree/path_node_test.go index 5b1d32bd3..2e54dcbe7 100644 --- a/internal/indextree/path_node_test.go +++ b/internal/indextree/path_node_test.go @@ -8,9 +8,7 @@ import ( "github.com/stretchr/testify/require" ) -func testMatcher[V any](matches bool) MatcherFunc[V] { - return func(value V) bool { return matches } -} +func testMatcher[V any](matches bool) MatcherFunc[V] { return func(_ V) bool { return matches } } func TestNodeSearch(t *testing.T) { t.Parallel() diff --git a/internal/indextree/tree.go b/internal/indextree/tree.go index 13daca670..af52cb06a 100644 --- a/internal/indextree/tree.go +++ b/internal/indextree/tree.go @@ -13,7 +13,7 @@ type Tree[V any] pathNode[V] // end of a path matches anything. // // If the path segment has to start with : or *, it must be escaped -// with \ to be not confused with a wildcard +// with \ to be not confused with a wildcard. func (t *Tree[V]) Add(path string, value V) error { return (*pathNode[V])(t).add(path[1:], value) } From 55de7640e1f7eea616de56039f0d1532ee81ba3b Mon Sep 17 00:00:00 2001 From: Dimitrij Drus Date: Tue, 16 Apr 2024 18:29:44 +0200 Subject: [PATCH 05/76] first working version --- .../invalid-ruleset-for-proxy-usage.yaml | 8 +- cmd/validate/test_data/valid-ruleset.yaml | 8 +- .../envoyextauth/grpcv3/request_context.go | 2 +- .../handler/requestcontext/request_context.go | 2 +- internal/heimdall/context.go | 8 +- internal/heimdall/mocks/context.go | 23 +- internal/heimdall/mocks/request_functions.go | 27 +- internal/indextree/domain_node.go | 278 ------------------ internal/indextree/domain_node_test.go | 206 ------------- internal/indextree/errors.go | 1 + internal/indextree/index_tree.go | 38 +-- internal/indextree/index_tree_test.go | 23 +- internal/indextree/{path_node.go => node.go} | 86 +++--- ...nchmark_test.go => node_benchmark_test.go} | 22 +- .../{path_node_test.go => node_test.go} | 52 ++-- internal/indextree/tree.go | 34 --- .../rules/cel_execution_condition_test.go | 4 +- internal/rules/config/decoder.go | 2 +- internal/rules/config/mapstructure_decoder.go | 56 +--- .../rules/config/mapstructure_decoder_test.go | 101 ++----- internal/rules/config/matcher.go | 33 ++- internal/rules/config/matcher_test.go | 54 ++-- internal/rules/config/parser_test.go | 41 ++- internal/rules/config/rule.go | 14 +- internal/rules/config/rule_set.go | 7 +- internal/rules/config/rule_set_test.go | 6 +- internal/rules/config/rule_test.go | 48 ++- .../pattern_matcher.go => glob_matcher.go} | 32 +- .../composite_extract_strategy_test.go | 2 +- .../query_parameter_extract_strategy_test.go | 4 +- .../authorizers/cel_authorizer_test.go | 14 +- .../authorizers/remote_authorizer_test.go | 2 +- .../rules/mechanisms/cellib/requests_test.go | 18 +- internal/rules/mechanisms/cellib/urls.go | 28 +- internal/rules/mechanisms/cellib/urls_test.go | 12 +- .../generic_contextualizer_test.go | 2 +- .../redirect_error_handler_test.go | 6 +- .../mechanisms/template/template_test.go | 8 +- internal/rules/pattern_matcher.go | 5 + internal/rules/patternmatcher/glob_matcher.go | 107 ------- .../rules/patternmatcher/glob_matcher_test.go | 185 ------------ .../rules/provider/cloudblob/provider_test.go | 14 + .../cloudblob/ruleset_endpoint_test.go | 59 ++-- .../provider/filesystem/provider_test.go | 14 + .../provider/httpendpoint/provider_test.go | 28 ++ .../httpendpoint/ruleset_endpoint_test.go | 30 +- .../admissioncontroller/controller_test.go | 16 +- .../kubernetes/api/v1alpha3/client_test.go | 33 ++- .../kubernetes/api/v1alpha3/mocks/client.go | 17 +- .../api/v1alpha3/mocks/rule_set_repository.go | 27 +- .../provider/kubernetes/provider_test.go | 99 ++++--- .../{patternmatcher => }/regex_matcher.go | 19 +- internal/rules/repository_impl.go | 149 ++++++---- internal/rules/repository_impl_test.go | 252 +++++++++++----- internal/rules/rule/mocks/repository.go | 49 +-- internal/rules/rule/mocks/rule.go | 158 +++++++--- internal/rules/rule/repository.go | 4 +- internal/rules/rule/rule.go | 7 +- internal/rules/rule_executor_impl.go | 9 +- internal/rules/rule_executor_impl_test.go | 33 +-- internal/rules/rule_factory_impl.go | 85 ++++-- internal/rules/rule_factory_impl_test.go | 274 +++++++++++------ internal/rules/rule_impl.go | 62 ++-- internal/rules/rule_impl_test.go | 241 +++++---------- internal/validation/validation.go | 13 +- 65 files changed, 1455 insertions(+), 1846 deletions(-) delete mode 100644 internal/indextree/domain_node.go delete mode 100644 internal/indextree/domain_node_test.go rename internal/indextree/{path_node.go => node.go} (83%) rename internal/indextree/{path_node_benchmark_test.go => node_benchmark_test.go} (62%) rename internal/indextree/{path_node_test.go => node_test.go} (90%) delete mode 100644 internal/indextree/tree.go rename internal/rules/{patternmatcher/pattern_matcher.go => glob_matcher.go} (56%) create mode 100644 internal/rules/pattern_matcher.go delete mode 100644 internal/rules/patternmatcher/glob_matcher.go delete mode 100644 internal/rules/patternmatcher/glob_matcher_test.go rename internal/rules/{patternmatcher => }/regex_matcher.go (66%) 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..faea647bc 100644 --- a/cmd/validate/test_data/invalid-ruleset-for-proxy-usage.yaml +++ b/cmd/validate/test_data/invalid-ruleset-for-proxy-usage.yaml @@ -3,11 +3,9 @@ name: test-rule-set rules: - id: rule:foo match: - url: http://foo.bar/<**> - strategy: glob -# methods: # reuses default -# - GET -# - POST + scheme: http + host_glob: foo.bar + path: /** 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..49ef80225 100644 --- a/cmd/validate/test_data/valid-ruleset.yaml +++ b/cmd/validate/test_data/valid-ruleset.yaml @@ -3,17 +3,15 @@ name: test-rule-set rules: - id: rule:foo match: - url: http://foo.bar/<**> - strategy: glob + scheme: http + path: /** + host_glob: foo.bar 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/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/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/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/indextree/domain_node.go b/internal/indextree/domain_node.go deleted file mode 100644 index aba71d268..000000000 --- a/internal/indextree/domain_node.go +++ /dev/null @@ -1,278 +0,0 @@ -package indextree - -import ( - "slices" - "strings" -) - -type domainNode[V any] struct { - // The full domain is stored here. - fullDomain string - - // The domain part is stored here. - domainPart string - - isLeaf bool - - priority int - - staticIndices []byte - staticChildren []*domainNode[V] - - wildcardChild *domainNode[V] - isWildcard bool - - pathRoot *pathNode[V] -} - -func (n *domainNode[V]) findNode(domain string) *domainNode[V] { - domainLen := len(domain) - - var found *domainNode[V] - - // only return a match if we're at a leaf node, this is to ensure that - // we don't match on partial domains (e.g. com matching on example.com) - if domainLen == 0 && n.isLeaf { - return n - } else if domainLen == 0 { - return nil - } - - token := domain[0] - for i, index := range n.staticIndices { - if token == index { - child := n.staticChildren[i] - childDomainLen := len(child.domainPart) - - if domainLen >= childDomainLen && child.domainPart == domain[:childDomainLen] { - nextDomain := domain[childDomainLen:] - found = child.findNode(nextDomain) - } - - break - } - } - - if n.wildcardChild != nil && found == nil { - // we don't iterate over periods so that we can match on multiple levels of subdomains - found = n.wildcardChild - } - - return found -} - -func (n *domainNode[V]) find(domain string) *domainNode[V] { - return n.findNode(reverseDomain(domain)) -} - -func (n *domainNode[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 - } - } -} - -func (n *domainNode[V]) delNode(domain string) bool { - domainLen := len(domain) - - if domainLen == 0 { - return n.isLeaf - } - - firstChar := domain[0] - if firstChar == '*' && n.wildcardChild != nil { - n.deleteChild(n.wildcardChild, firstChar) - - return true - } - - for i, staticIndex := range n.staticIndices { - if firstChar == staticIndex { - child := n.staticChildren[i] - childDomainLen := len(child.domainPart) - - if domainLen >= childDomainLen && child.domainPart == domain[:childDomainLen] { - nextToken := domain[childDomainLen:] - if child.delNode(nextToken) { - n.deleteChild(child, firstChar) - - return true - } - } - - break - } - } - - return false -} - -func (n *domainNode[V]) deleteChild(child *domainNode[V], token uint8) { - // Delete the child if it's a leaf node - if child.isLeaf { - child.isLeaf = false - } - - if len(child.staticIndices) == 1 && child.staticIndices[0] != '.' && child.domainPart != "." { - if len(child.staticChildren) == 1 { - old := child.staticChildren[0] - old.domainPart = child.domainPart + old.domainPart - *child = *old - } - } - - if child.isLeaf { - return - } - - // Delete the child from the parent only if the child has no children - if len(child.staticIndices) == 0 { - n.delEdge(token) - } - - // remove the wildcard child if it exists - if child.isWildcard { - n.wildcardChild = nil - } -} - -func (n *domainNode[V]) delete(domain string) bool { - return n.delNode(reverseDomain(domain)) -} - -func (n *domainNode[V]) add(fullDomain string) *domainNode[V] { - res := n.addNode(reverseDomain(fullDomain)) - - res.fullDomain = fullDomain - - if res.pathRoot == nil { - res.pathRoot = &pathNode[V]{} - } - - return res -} - -func (n *domainNode[V]) addNode(domain string) *domainNode[V] { - // If the domain is empty, we're done. - if len(domain) == 0 { - n.isLeaf = true - - return n - } - - token := domain[0] - nextPeriod := strings.Index(domain, ".") - - var ( - thisToken string - tokenEnd int - ) - - switch { - case token == '.': - thisToken = "." - tokenEnd = 1 - case nextPeriod == -1: - thisToken = domain - tokenEnd = len(domain) - default: - thisToken = domain[0:nextPeriod] - tokenEnd = nextPeriod - } - - remainingDomain := domain[tokenEnd:] - - if token == '*' { - if n.wildcardChild == nil { - n.wildcardChild = &domainNode[V]{ - domainPart: thisToken, - isWildcard: true, - isLeaf: true, - } - } - - return n.wildcardChild - } - - // Do we have an existing node that starts with the same letter? - for i, index := range n.staticIndices { - if token == index { - child, prefixSplit := n.splitCommonDomainPrefix(i, thisToken) - - child.priority++ - - n.sortStaticChild(i) - - return child.addNode(domain[prefixSplit:]) - } - } - - // No existing node starting with this letter, so create it. - child := &domainNode[V]{domainPart: thisToken} - - n.staticIndices = append(n.staticIndices, token) - n.staticChildren = append(n.staticChildren, child) - - return child.addNode(remainingDomain) -} - -func (n *domainNode[V]) sortStaticChild(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 *domainNode[V]) splitCommonDomainPrefix(existingNodeIndex int, domain string) (*domainNode[V], int) { - childNode := n.staticChildren[existingNodeIndex] - - if strings.HasPrefix(domain, childNode.domainPart) { - // 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.domainPart) - } - - var i int - // Find the length of the common prefix of the child node and the new path. - for i = range childNode.domainPart { - if i == len(domain) { - break - } - - if domain[i] != childNode.domainPart[i] { - break - } - } - - commonPrefix := domain[0:i] - childNode.domainPart = childNode.domainPart[i:] - - // Create a new intermediary node in the place of the existing node, with - // the existing node as a child. - newNode := &domainNode[V]{ - domainPart: commonPrefix, - priority: childNode.priority, - // Index is the first letter of the non-common part of the path. - staticIndices: []byte{childNode.domainPart[0]}, - staticChildren: []*domainNode[V]{childNode}, - } - n.staticChildren[existingNodeIndex] = newNode - - return newNode, i -} - -// reverseDomain returns the reverse octets of the given domain. -func reverseDomain(domain string) string { - domainSlice := strings.Split(domain, ".") - slices.Reverse(domainSlice) - - return strings.Join(domainSlice, ".") -} diff --git a/internal/indextree/domain_node_test.go b/internal/indextree/domain_node_test.go deleted file mode 100644 index 27e4c40b8..000000000 --- a/internal/indextree/domain_node_test.go +++ /dev/null @@ -1,206 +0,0 @@ -package indextree - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestReverseDomain(t *testing.T) { - t.Parallel() - - for _, test := range []struct { - domain string - expected string - }{ - {"example.com", "com.example"}, - {"subdomain.example.com", "com.example.subdomain"}, - {"www.github.com", "com.github.www"}, - {"test", "test"}, - {"", ""}, - } { - result := reverseDomain(test.domain) - assert.Equal(t, test.expected, result) - } -} - -func TestDomainTree(t *testing.T) { - t.Parallel() - - tree := &domainNode[string]{} - - for _, domain := range []string{ - "example.com", - "*.example.com", - "subdomain.example.com", - "static.example.com", - "www.github.com", - "test", - "foo.net", - "bar.net", - "baz.net", - } { - tree.add(domain) - } - - for _, tc := range []struct { - domain string - expected string - }{ - {"example.com", "example.com"}, - {"subdomain.example.com", "subdomain.example.com"}, - {"static.example.com", "static.example.com"}, - {"test.example.com", "*.example.com"}, - {"bar.example.com", "*.example.com"}, - {"foo.bar.example.com", "*.example.com"}, - {"www.github.com", "www.github.com"}, - {"test", "test"}, - {"foo.net", "foo.net"}, - {"bar.net", "bar.net"}, - {"baz.net", "baz.net"}, - {"com", ""}, - {"example", ""}, - {"example.com.", ""}, - {"example.com..", ""}, - {"example.com..subdomain", ""}, - {"example.com..subdomain.", ""}, - {"example.com..subdomain..", ""}, - {"bar.foo.net", ""}, - {"foo.bar.net", ""}, - {"baz.bar.net", ""}, - {"test.example.com.", ""}, - {"com.example", ""}, - {"com.example.subdomain", ""}, - {"com.github.www", ""}, - } { - t.Run(tc.domain, func(t *testing.T) { - res := tree.find(tc.domain) - - if tc.expected != "" { - require.NotNil(t, res) - assert.Equal(t, tc.expected, res.fullDomain) - } else if tc.expected == "" { - assert.Nil(t, res) - } - }) - } - - // test global wildcard - tree.add("*") - - for _, tc := range []struct { - domain string - expected string - }{ - {"com", "*"}, - {"example", "*"}, - {"example.com.", "*"}, - {"example.com..", "*"}, - {"example.com..subdomain", "*"}, - {"example.com..subdomain.", "*"}, - {"example.com..subdomain..", "*"}, - {"bar.foo.net", "*"}, - {"foo.bar.net", "*"}, - {"baz.bar.net", "*"}, - {"test.example.com.", "*"}, - {"com.example", "*"}, - {"com.example.subdomain", "*"}, - {"com.github.www", "*"}, - // ensure global wildcard doesn't override existing domains - {"example.com", "example.com"}, - {"subdomain.example.com", "subdomain.example.com"}, - {"static.example.com", "static.example.com"}, - {"test.example.com", "*.example.com"}, - {"bar.example.com", "*.example.com"}, - {"foo.bar.example.com", "*.example.com"}, - {"www.github.com", "www.github.com"}, - {"test", "test"}, - {"foo.net", "foo.net"}, - {"bar.net", "bar.net"}, - {"baz.net", "baz.net"}, - } { - t.Run(tc.domain, func(t *testing.T) { - res := tree.find(tc.domain) - - if tc.expected != "" { - require.NotNil(t, res) - assert.Equal(t, tc.expected, res.fullDomain) - } else if tc.expected == "" { - assert.Nil(t, res) - } - }) - } -} - -func TestDeleteDomain(t *testing.T) { - t.Parallel() - - tree := &domainNode[string]{} - - for _, domain := range []string{ - "example.com", - "*.example.com", - "subdomain.example.com", - "static.example.com", - "spoof.example.com", - "different.example.com", - "www.github.com", - "test", - "foo.net", - "bar.net", - "baz.net", - } { - tree.add(domain) - } - - for _, tc := range []struct { - domain string - expRemoved bool - expected string - }{ - {"www.github.com", true, ""}, - {"example.com", true, ""}, - {"subdomain.example.com", true, "*.example.com"}, - {"test.example.com", false, "*.example.com"}, - {"bar.example.com", false, "*.example.com"}, - {"foo.bar.example.com", false, "*.example.com"}, - {"*.example.com", true, ""}, - {"subdomain.example.com", false, ""}, - {"spoof.example.com", true, ""}, - {"test.example.com", false, ""}, - {"bar.example.com", false, ""}, - {"foo.bar.example.com", false, ""}, - {"test", true, ""}, - {"foo.net", true, ""}, - {"bar.net", true, ""}, - {"baz.net", true, ""}, - {"com", false, ""}, - {"example", false, ""}, - {"example.com.", false, ""}, - {"example.com..", false, ""}, - {"example.com..subdomain", false, ""}, - {"example.com..subdomain.", false, ""}, - {"example.com..subdomain..", false, ""}, - {"bar.foo.net", false, ""}, - {"foo.bar.net", false, ""}, - {"baz.bar.net", false, ""}, - {"test.example.com.", false, ""}, - {"com.example", false, ""}, - {"com.example.subdomain", false, ""}, - {"com.github.www", false, ""}, - {"test", false, ""}, - } { - result := tree.delete(tc.domain) - require.Equalf(t, tc.expRemoved, result, "Delete(%s) returned %v, expected %v", tc.domain, result, tc.expRemoved) - - res := tree.find(tc.domain) - - if tc.expected != "" { - require.NotNil(t, res) - assert.Equal(t, tc.expected, res.fullDomain) - } else if tc.expected == "" { - assert.Nil(t, res) - } - } -} diff --git a/internal/indextree/errors.go b/internal/indextree/errors.go index 75626cb05..cd8be1435 100644 --- a/internal/indextree/errors.go +++ b/internal/indextree/errors.go @@ -6,4 +6,5 @@ var ( ErrInvalidPath = errors.New("invalid path") ErrNotFound = errors.New("not found") ErrFailedToDelete = errors.New("failed to delete") + ErrFailedToUpdate = errors.New("failed to delete") ) diff --git a/internal/indextree/index_tree.go b/internal/indextree/index_tree.go index 49edcfa9e..486230934 100644 --- a/internal/indextree/index_tree.go +++ b/internal/indextree/index_tree.go @@ -1,41 +1,35 @@ package indextree func NewIndexTree[V any]() *IndexTree[V] { - return &IndexTree[V]{tree: &domainNode[V]{}} + return &IndexTree[V]{tree: &node[V]{}} } type IndexTree[V any] struct { - tree *domainNode[V] + tree *node[V] } -func (t *IndexTree[V]) Add(domain, path string, value V) error { - return t.tree.add(domain).pathRoot.add(path, value) +func (t *IndexTree[V]) Add(path string, value V) error { + return t.tree.Add(path, value) } -func (t *IndexTree[V]) Find(domain, path string, matcher Matcher[V]) (V, map[string]string, error) { - var def V - - dn := t.tree.find(domain) - if dn == nil { - return def, nil, ErrNotFound - } - - return dn.pathRoot.find(path, matcher) +func (t *IndexTree[V]) Find(path string, matcher Matcher[V]) (V, map[string]string, error) { + return t.tree.Find(path, matcher) } -func (t *IndexTree[V]) Delete(domain, path string, matcher Matcher[V]) error { - dn := t.tree.find(domain) - if dn == nil { - return ErrNotFound - } - - if !dn.pathRoot.delete(path, matcher) { +func (t *IndexTree[V]) Delete(path string, matcher Matcher[V]) error { + if !t.tree.Delete(path, matcher) { return ErrFailedToDelete } - if dn.pathRoot.empty() && !t.tree.delete(domain) { - return ErrFailedToDelete + return nil +} + +func (t *IndexTree[V]) Update(path string, value V, matcher Matcher[V]) error { + if !t.tree.Update(path, value, matcher) { + return ErrFailedToUpdate } return nil } + +func (t *IndexTree[V]) Empty() bool { return t.tree.Empty() } diff --git a/internal/indextree/index_tree_test.go b/internal/indextree/index_tree_test.go index cfdab2e14..bf9b122b7 100644 --- a/internal/indextree/index_tree_test.go +++ b/internal/indextree/index_tree_test.go @@ -12,44 +12,41 @@ func TestFoo(t *testing.T) { tree := NewIndexTree[string]() - err := tree.Add("*.github.com", "/images/abc.jpg", "1") + err := tree.Add("/images/abc.jpg", "1") require.NoError(t, err) - err = tree.Add("*.github.com", "/images/abc.jpg", "2") + err = tree.Add("/images/abc.jpg", "2") require.NoError(t, err) - err = tree.Add("*.github.com", "/images/:imgname", "3") + err = tree.Add("/images/:imgname", "3") require.NoError(t, err) - err = tree.Add("www.github.com", "/images/*path", "4") + err = tree.Add("/images/*path", "4") require.NoError(t, err) - val, params, err := tree.Find("imgs.github.com", "/images/abc.jpg", testMatcher[string](true)) + val, params, err := tree.Find("/images/abc.jpg", testMatcher[string](true)) require.NoError(t, err) assert.Equal(t, "1", val) assert.Empty(t, params) - val, params, err = tree.Find("imgs.github.com", "/images/abc.jpg", MatcherFunc[string](func(value string) bool { + val, params, err = tree.Find("/images/abc.jpg", MatcherFunc[string](func(value string) bool { return value == "2" })) require.NoError(t, err) assert.Equal(t, "2", val) assert.Empty(t, params) - val, params, err = tree.Find("imgs.github.com", "/images/cba.jpg", testMatcher[string](true)) + val, params, err = tree.Find("/images/cba.jpg", testMatcher[string](true)) require.NoError(t, err) assert.Equal(t, "3", val) assert.Equal(t, map[string]string{"imgname": "cba.jpg"}, params) - _, _, err = tree.Find("imgs.github.com", "/images/cba/abc.jpg", testMatcher[string](true)) - require.Error(t, err) - - val, params, err = tree.Find("www.github.com", "/images/cba.jpg", testMatcher[string](true)) + val, params, err = tree.Find("/images/sub/cba.jpg", testMatcher[string](true)) require.NoError(t, err) assert.Equal(t, "4", val) - assert.Equal(t, map[string]string{"path": "cba.jpg"}, params) + assert.Equal(t, map[string]string{"path": "sub/cba.jpg"}, params) - val, params, err = tree.Find("www.github.com", "/images/abc/cba.jpg", testMatcher[string](true)) + val, params, err = tree.Find("/images/abc/cba.jpg", testMatcher[string](true)) require.NoError(t, err) assert.Equal(t, "4", val) assert.Equal(t, map[string]string{"path": "abc/cba.jpg"}, params) diff --git a/internal/indextree/path_node.go b/internal/indextree/node.go similarity index 83% rename from internal/indextree/path_node.go rename to internal/indextree/node.go index 5e4834ff9..db9d62628 100644 --- a/internal/indextree/path_node.go +++ b/internal/indextree/node.go @@ -15,20 +15,20 @@ import ( "github.com/dadrus/heimdall/internal/x/stringx" ) -type pathNode[V any] struct { +type node[V any] struct { path string priority int // The list of static children to check. staticIndices []byte - staticChildren []*pathNode[V] + staticChildren []*node[V] // If none of the above match, check the wildcard children - wildcardChild *pathNode[V] + wildcardChild *node[V] // If none of the above match, then we use the catch-all, if applicable. - catchAllChild *pathNode[V] + catchAllChild *node[V] isCatchAll bool isWildcard bool @@ -37,7 +37,7 @@ type pathNode[V any] struct { wildcardKeys []string } -func (n *pathNode[V]) sortStaticChildren(i int) { +func (n *node[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] @@ -46,7 +46,7 @@ func (n *pathNode[V]) sortStaticChildren(i int) { } } -func (n *pathNode[V]) nextSeparator(path string) int { +func (n *node[V]) nextSeparator(path string) int { if idx := strings.IndexByte(path, '/'); idx != -1 { return idx } @@ -55,7 +55,7 @@ func (n *pathNode[V]) nextSeparator(path string) int { } //nolint:funlen,gocognit,cyclop -func (n *pathNode[V]) addNode(path string, wildcardKeys []string, inStaticToken bool) (*pathNode[V], error) { +func (n *node[V]) addNode(path string, wildcardKeys []string, inStaticToken bool) (*node[V], error) { if len(path) == 0 { // we have a leaf node if len(wildcardKeys) != 0 { @@ -104,7 +104,7 @@ func (n *pathNode[V]) addNode(path string, wildcardKeys []string, inStaticToken } if n.catchAllChild == nil { - n.catchAllChild = &pathNode[V]{ + n.catchAllChild = &node[V]{ path: thisToken, isCatchAll: true, } @@ -121,7 +121,7 @@ func (n *pathNode[V]) addNode(path string, wildcardKeys []string, inStaticToken return n.catchAllChild, nil case ':': if n.wildcardChild == nil { - n.wildcardChild = &pathNode[V]{path: "wildcard", isWildcard: true} + n.wildcardChild = &node[V]{path: "wildcard", isWildcard: true} } return n.wildcardChild.addNode(remainingPath, append(wildcardKeys, thisToken[1:]), false) @@ -158,7 +158,7 @@ func (n *pathNode[V]) addNode(path string, wildcardKeys []string, inStaticToken } } - child := &pathNode[V]{path: thisToken} + child := &node[V]{path: thisToken} n.staticIndices = append(n.staticIndices, token) n.staticChildren = append(n.staticChildren, child) @@ -169,21 +169,22 @@ func (n *pathNode[V]) addNode(path string, wildcardKeys []string, inStaticToken } //nolint:cyclop -func (n *pathNode[V]) delNode(path string, matcher Matcher[V]) bool { +func (n *node[V]) delNode(path string, matcher Matcher[V]) bool { pathLen := len(path) if pathLen == 0 { - if n.values != nil && matcher.Match(n.values[0]) { - n.values = nil - - return true + if len(n.values) == 0 { + return false } - return false + oldSize := len(n.values) + n.values = slices.DeleteFunc(n.values, matcher.Match) + + return oldSize != len(n.values) } var ( nextPath string - child *pathNode[V] + child *node[V] ) token := path[0] @@ -206,12 +207,16 @@ func (n *pathNode[V]) delNode(path string, matcher Matcher[V]) bool { nextPath = "" } - if child != nil && child.delNode(nextPath, matcher) { - if child.values == nil { - n.deleteChild(child, token) + if child != nil { + if child.delNode(nextPath, matcher) { + if len(child.values) == 0 { + n.deleteChild(child, token) + } + + return true } - return true + return false } if len(path) >= 2 && @@ -229,7 +234,7 @@ func (n *pathNode[V]) delNode(path string, matcher Matcher[V]) bool { if pathLen >= childPathLen && child.path == path[:childPathLen] && child.delNode(path[childPathLen:], matcher) { - if child.values == nil { + if len(child.values) == 0 { n.deleteChild(child, token) } @@ -244,7 +249,7 @@ func (n *pathNode[V]) delNode(path string, matcher Matcher[V]) bool { } //nolint:cyclop -func (n *pathNode[V]) deleteChild(child *pathNode[V], token uint8) { +func (n *node[V]) deleteChild(child *node[V], token uint8) { if len(child.staticIndices) == 1 && child.staticIndices[0] != '/' && child.path != "/" { if len(child.staticChildren) == 1 { grandChild := child.staticChildren[0] @@ -253,7 +258,7 @@ func (n *pathNode[V]) deleteChild(child *pathNode[V], token uint8) { } // new leaf created - if child.values != nil { + if len(child.values) != 0 { return } } @@ -271,7 +276,7 @@ func (n *pathNode[V]) deleteChild(child *pathNode[V], token uint8) { } } -func (n *pathNode[V]) delEdge(token byte) { +func (n *node[V]) delEdge(token byte) { for i, index := range n.staticIndices { if token == index { n.staticChildren = append(n.staticChildren[:i], n.staticChildren[i+1:]...) @@ -283,9 +288,9 @@ func (n *pathNode[V]) delEdge(token byte) { } //nolint:funlen,gocognit,cyclop -func (n *pathNode[V]) findNode(path string, matcher Matcher[V]) (*pathNode[V], int, []string) { +func (n *node[V]) findNode(path string, matcher Matcher[V]) (*node[V], int, []string) { var ( - found *pathNode[V] + found *node[V] params []string idx int value V @@ -364,7 +369,7 @@ func (n *pathNode[V]) findNode(path string, matcher Matcher[V]) (*pathNode[V], i return nil, 0, nil } -func (n *pathNode[V]) splitCommonPrefix(existingNodeIndex int, path string) (*pathNode[V], int) { +func (n *node[V]) splitCommonPrefix(existingNodeIndex int, path string) (*node[V], int) { childNode := n.staticChildren[existingNodeIndex] if strings.HasPrefix(path, childNode.path) { @@ -384,19 +389,19 @@ func (n *pathNode[V]) splitCommonPrefix(existingNodeIndex int, path string) (*pa // Create a new intermediary node in the place of the existing node, with // the existing node as a child. - newNode := &pathNode[V]{ + newNode := &node[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: []*pathNode[V]{childNode}, + staticChildren: []*node[V]{childNode}, } n.staticChildren[existingNodeIndex] = newNode return newNode, i } -func (n *pathNode[V]) add(path string, value V) error { +func (n *node[V]) Add(path string, value V) error { res, err := n.addNode(path, nil, false) if err != nil { return err @@ -407,10 +412,10 @@ func (n *pathNode[V]) add(path string, value V) error { return nil } -func (n *pathNode[V]) find(path string, m Matcher[V]) (V, map[string]string, error) { +func (n *node[V]) Find(path string, matcher Matcher[V]) (V, map[string]string, error) { var def V - found, idx, params := n.findNode(path, m) + found, idx, params := n.findNode(path, matcher) if found == nil { return def, nil, ErrNotFound } @@ -431,10 +436,21 @@ func (n *pathNode[V]) find(path string, m Matcher[V]) (V, map[string]string, err return found.values[idx], keys, nil } -func (n *pathNode[V]) empty() bool { +func (n *node[V]) Empty() bool { return len(n.values) == 0 && len(n.staticChildren) == 0 && n.wildcardChild == nil && n.catchAllChild == nil } -func (n *pathNode[V]) delete(path string, matcher Matcher[V]) bool { +func (n *node[V]) Delete(path string, matcher Matcher[V]) bool { return n.delNode(path, matcher) } + +func (n *node[V]) Update(path string, value V, matcher Matcher[V]) bool { + found, idx, _ := n.findNode(path, matcher) + if found == nil { + return false + } + + found.values[idx] = value + + return true +} diff --git a/internal/indextree/path_node_benchmark_test.go b/internal/indextree/node_benchmark_test.go similarity index 62% rename from internal/indextree/path_node_benchmark_test.go rename to internal/indextree/node_benchmark_test.go index ffc0e32d3..fff346479 100644 --- a/internal/indextree/path_node_benchmark_test.go +++ b/internal/indextree/node_benchmark_test.go @@ -6,54 +6,54 @@ import ( func BenchmarkNodeSearchNoPaths(b *testing.B) { tm := testMatcher[string](true) - tree := &pathNode[string]{path: "/"} + tree := &node[string]{} b.ReportAllocs() b.ResetTimer() for range b.N { - tree.find("", tm) + tree.Find("", tm) } } func BenchmarkNodeSearchOneStaticPath(b *testing.B) { tm := testMatcher[string](true) - tree := &pathNode[string]{path: "/"} + tree := &node[string]{} - tree.add("abc", "foo") + tree.Add("/abc", "foo") b.ReportAllocs() b.ResetTimer() for range b.N { - tree.find("abc", tm) + tree.Find("/abc", tm) } } func BenchmarkNodeSearchOneWildcardPath(b *testing.B) { tm := testMatcher[string](true) - tree := &pathNode[string]{path: "/"} + tree := &node[string]{} - tree.add(":abc", "foo") + tree.Add("/:abc", "foo") b.ReportAllocs() b.ResetTimer() for range b.N { - tree.find("abc", tm) + tree.Find("/abc", tm) } } func BenchmarkNodeSearchOneLongWildcards(b *testing.B) { tm := testMatcher[string](true) - tree := &pathNode[string]{path: "/"} + tree := &node[string]{} - tree.add(":abc/:def/:ghi", "foo") + tree.Add("/:abc/:def/:ghi", "foo") b.ReportAllocs() b.ResetTimer() for range b.N { - tree.find("abcdefghijklmnop/aaaabbbbccccddddeeeeffffgggg/hijkl", tm) + tree.Find("/abcdefghijklmnop/aaaabbbbccccddddeeeeffffgggg/hijkl", tm) } } diff --git a/internal/indextree/path_node_test.go b/internal/indextree/node_test.go similarity index 90% rename from internal/indextree/path_node_test.go rename to internal/indextree/node_test.go index 2e54dcbe7..163215617 100644 --- a/internal/indextree/path_node_test.go +++ b/internal/indextree/node_test.go @@ -14,7 +14,7 @@ func TestNodeSearch(t *testing.T) { t.Parallel() // Setup & populate tree - tree := &pathNode[string]{} + tree := &node[string]{} for _, path := range []string{ "/", @@ -57,7 +57,7 @@ func TestNodeSearch(t *testing.T) { "/マ", "/カ", } { - err := tree.add(path, path) + err := tree.Add(path, path) require.NoError(t, err) } @@ -127,7 +127,7 @@ func TestNodeSearch(t *testing.T) { matcher = tc.matcher } - resValue, paramList, err := tree.find(tc.path, matcher) + resValue, paramList, err := tree.Find(tc.path, matcher) if tc.expErr != nil { require.Error(t, err) require.ErrorIs(t, err, tc.expErr) @@ -145,23 +145,23 @@ func TestNodeSearch(t *testing.T) { func TestNodeAddPathDuplicates(t *testing.T) { t.Parallel() - tree := &pathNode[string]{} + tree := &node[string]{} path := "/date/:year/:month/abc" - err := tree.add(path, "first") + err := tree.Add(path, "first") require.NoError(t, err) - err = tree.add(path, "second") + err = tree.Add(path, "second") require.NoError(t, err) - value, params, err := tree.find("/date/2024/04/abc", MatcherFunc[string](func(value string) bool { + value, params, err := tree.Find("/date/2024/04/abc", MatcherFunc[string](func(value string) bool { return value == "first" })) require.NoError(t, err) assert.Equal(t, "first", value) assert.Equal(t, map[string]string{"year": "2024", "month": "04"}, params) - value, params, err = tree.find("/date/2024/04/abc", MatcherFunc[string](func(value string) bool { + value, params, err = tree.Find("/date/2024/04/abc", MatcherFunc[string](func(value string) bool { return value == "second" })) require.NoError(t, err) @@ -194,12 +194,12 @@ func TestNodeAddPath(t *testing.T) { {"katakana /カ", []string{"/カ"}, false}, } { t.Run(tc.uc, func(t *testing.T) { - tree := &pathNode[string]{} + tree := &node[string]{} var err error for _, path := range tc.paths { - err = tree.add(path, path) + err = tree.Add(path, path) if err != nil { break } @@ -229,16 +229,16 @@ func TestNodeDeleteStaticPaths(t *testing.T) { "/app/les/or/bananas", } - tree := &pathNode[int]{} + tree := &node[int]{} for idx, path := range paths { - err := tree.add(path, idx) + err := tree.Add(path, idx) require.NoError(t, err) } for i := len(paths) - 1; i >= 0; i-- { - require.True(t, tree.delete(paths[i], testMatcher[int](true))) - require.False(t, tree.delete(paths[i], testMatcher[int](true))) + require.True(t, tree.Delete(paths[i], testMatcher[int](true))) + require.False(t, tree.Delete(paths[i], testMatcher[int](true))) } } @@ -257,10 +257,10 @@ func TestNodeDeleteStaticAndWildcardPaths(t *testing.T) { "/abc/:les/bananas", } - tree := &pathNode[int]{} + tree := &node[int]{} for idx, path := range paths { - err := tree.add(path, idx+1) + err := tree.Add(path, idx+1) require.NoError(t, err) } @@ -268,13 +268,13 @@ func TestNodeDeleteStaticAndWildcardPaths(t *testing.T) { for i := len(paths) - 1; i >= 0; i-- { tbdPath := paths[i] - require.Truef(t, tree.delete(tbdPath, testMatcher[int](true)), "Should be able to delete %s", paths[i]) - require.Falsef(t, tree.delete(tbdPath, testMatcher[int](true)), "Should not be able to delete %s", paths[i]) + require.Truef(t, tree.Delete(tbdPath, testMatcher[int](true)), "Should be able to delete %s", paths[i]) + require.Falsef(t, tree.Delete(tbdPath, testMatcher[int](true)), "Should not be able to delete %s", paths[i]) deletedPaths = append(deletedPaths, tbdPath) for idx, path := range paths { - val, _, err := tree.find(path, testMatcher[int](true)) + val, _, 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) @@ -299,25 +299,27 @@ func TestNodeDeleteMixedPaths(t *testing.T) { "/abc/cba", "/abc/:les", "/abc/les/bananas", - "/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 := &pathNode[int]{} + tree := &node[int]{} for idx, path := range paths { - err := tree.add(path, idx+1) + err := tree.Add(path, idx+1) require.NoError(t, err) } for i := len(paths) - 1; i >= 0; i-- { - require.Truef(t, tree.delete(paths[i], testMatcher[int](true)), "Should be able to delete %s", paths[i]) - require.Falsef(t, tree.delete(paths[i], testMatcher[int](true)), "Should not be able to delete %s", paths[i]) + require.Truef(t, tree.Delete(paths[i], testMatcher[int](true)), "Should be able to delete %s", paths[i]) + require.Falsef(t, tree.Delete(paths[i], testMatcher[int](true)), "Should not be able to delete %s", paths[i]) } - require.True(t, tree.empty()) + require.True(t, tree.Empty()) } diff --git a/internal/indextree/tree.go b/internal/indextree/tree.go deleted file mode 100644 index af52cb06a..000000000 --- a/internal/indextree/tree.go +++ /dev/null @@ -1,34 +0,0 @@ -package indextree - -// Tree structure to store values associated to paths. -type Tree[V any] pathNode[V] - -// Add a value to the tree associated with a path. Paths may contain -// wildcards. Wildcards can be of two types: -// -// - simple wildcard: e.g. /some/:wildcard/path, where a wildcard is -// matched to a single name in the path. -// -// - free wildcard: e.g. /some/path/*wildcard, where a wildcard at the -// end of a path matches anything. -// -// If the path segment has to start with : or *, it must be escaped -// with \ to be not confused with a wildcard. -func (t *Tree[V]) Add(path string, value V) error { - return (*pathNode[V])(t).add(path[1:], value) -} - -// Lookup tries to find value in the tree associated to a path. -// If the found path definition contains wildcards, the values of the -// wildcards are returned in the second argument. While performing a -// lookup the matcher is called to check if the value attached to the -// found node meets the conditions implemented by the matcher. If it -// returns true, then the lookup is done. Otherwise, the lookup -// continues with backtracking from the current tree position. -func (t *Tree[V]) Lookup(path string, m Matcher[V]) (V, map[string]string, error) { - if path == "" { - path = "/" - } - - return (*pathNode[V])(t).find(path[1:], m) -} 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/decoder.go b/internal/rules/config/decoder.go index 36a38391c..02369a2b4 100644 --- a/internal/rules/config/decoder.go +++ b/internal/rules/config/decoder.go @@ -28,7 +28,7 @@ func DecodeConfig(input any, output any) error { dec, err := mapstructure.NewDecoder( &mapstructure.DecoderConfig{ DecodeHook: mapstructure.ComposeDecodeHookFunc( - matcherDecodeHookFunc, + pathExpressionDecodeHookFunc, mapstructure.StringToTimeDurationHookFunc(), ), Result: output, diff --git a/internal/rules/config/mapstructure_decoder.go b/internal/rules/config/mapstructure_decoder.go index a8ff0bd02..df715be76 100644 --- a/internal/rules/config/mapstructure_decoder.go +++ b/internal/rules/config/mapstructure_decoder.go @@ -17,65 +17,17 @@ 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{}) { +func pathExpressionDecodeHookFunc(from reflect.Type, to reflect.Type, data any) (any, error) { + if to != reflect.TypeOf(Path{}) { return data, nil } - if from.Kind() != reflect.String && from.Kind() != reflect.Map { + if from.Kind() != reflect.String { 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 + return Path{Expression: data.(string)}, nil } diff --git a/internal/rules/config/mapstructure_decoder_test.go b/internal/rules/config/mapstructure_decoder_test.go index 6af3d069c..44b7bd6ac 100644 --- a/internal/rules/config/mapstructure_decoder_test.go +++ b/internal/rules/config/mapstructure_decoder_test.go @@ -29,121 +29,64 @@ func TestMatcherDecodeHookFunc(t *testing.T) { t.Parallel() type Typ struct { - Matcher Matcher `json:"match"` + Path Path `json:"path"` } for _, tc := range []struct { uc string config []byte - assert func(t *testing.T, err error, matcher *Matcher) + assert func(t *testing.T, err error, path *Path) }{ { uc: "specified as string", - config: []byte(`match: foo.bar`), - assert: func(t *testing.T, err error, matcher *Matcher) { + config: []byte(`path: foo.bar`), + assert: func(t *testing.T, err error, path *Path) { t.Helper() require.NoError(t, err) - assert.Equal(t, "foo.bar", matcher.URL) - assert.Equal(t, "glob", matcher.Strategy) + assert.Equal(t, "foo.bar", path.Expression) + assert.Empty(t, path.Glob) + assert.Empty(t, path.Regex) }, }, { - uc: "specified as structured type without url", + uc: "specified as structured type without path expression", config: []byte(` -match: - strategy: foo +path: + glob: foo `), - assert: func(t *testing.T, err error, _ *Matcher) { + assert: func(t *testing.T, err error, _ *Path) { t.Helper() require.Error(t, err) - assert.Contains(t, err.Error(), ErrURLMissing.Error()) + assert.Contains(t, err.Error(), "'path'.'expression' is a required field") }, }, { uc: "specified as structured type with bad url type", config: []byte(` -match: - url: 1 +path: + expression: 1 `), - assert: func(t *testing.T, err error, _ *Matcher) { + assert: func(t *testing.T, err error, _ *Path) { t.Helper() require.Error(t, err) - assert.Contains(t, err.Error(), ErrURLType.Error()) + assert.Contains(t, err.Error(), "unconvertible type 'int'") }, }, { - uc: "specified as structured type with bad strategy type", + uc: "specified as structured type with unsupported property", config: []byte(` -match: - url: foo.bar +path: + expression: foo.bar strategy: true `), - assert: func(t *testing.T, err error, _ *Matcher) { + assert: func(t *testing.T, err error, _ *Path) { 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) + assert.Contains(t, err.Error(), "invalid keys: strategy") }, }, } { @@ -158,7 +101,7 @@ match: err = DecodeConfig(raw, &typ) // THEN - tc.assert(t, err, &typ.Matcher) + tc.assert(t, err, &typ.Path) }) } } diff --git a/internal/rules/config/matcher.go b/internal/rules/config/matcher.go index 92f0549da..5804edae5 100644 --- a/internal/rules/config/matcher.go +++ b/internal/rules/config/matcher.go @@ -17,21 +17,21 @@ package config import ( - "github.com/goccy/go-json" - "github.com/dadrus/heimdall/internal/x/stringx" + "github.com/goccy/go-json" + "slices" ) -type Matcher struct { - URL string `json:"url" yaml:"url"` - Strategy string `json:"strategy" yaml:"strategy"` +type Path struct { + Expression string `json:"expression" yaml:"expression" validate:"required"` + Glob string `json:"glob" yaml:"glob"` + Regex string `json:"regex" yaml:"regex"` } -func (m *Matcher) UnmarshalJSON(data []byte) error { +func (p *Path) 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" + // data contains just the path expression + p.Expression = stringx.ToString(data[1 : len(data)-1]) return nil } @@ -42,5 +42,18 @@ func (m *Matcher) UnmarshalJSON(data []byte) error { return err } - return DecodeConfig(rawData, m) + return DecodeConfig(rawData, p) +} + +type Matcher struct { + Scheme string `json:"scheme" yaml:"scheme"` + Methods []string `json:"methods" yaml:"methods"` + HostGlob string `json:"host_glob" yaml:"host_glob"` + HostRegex string `json:"host_regex" yaml:"host_regex"` + Path Path `json:"path" yaml:"path"` +} + +func (m *Matcher) DeepCopyInto(out *Matcher) { + *out = *m + out.Methods = slices.Clone(m.Methods) } diff --git a/internal/rules/config/matcher_test.go b/internal/rules/config/matcher_test.go index b3ccd652d..abf510ca5 100644 --- a/internal/rules/config/matcher_test.go +++ b/internal/rules/config/matcher_test.go @@ -24,37 +24,38 @@ import ( "github.com/stretchr/testify/require" ) -func TestMatcherUnmarshalJSON(t *testing.T) { +func TestPathUnmarshalJSON(t *testing.T) { t.Parallel() type Typ struct { - Matcher Matcher `json:"match"` + Path Path `json:"path"` } for _, tc := range []struct { uc string config []byte - assert func(t *testing.T, err error, matcher *Matcher) + assert func(t *testing.T, err error, path *Path) }{ { uc: "specified as string", - config: []byte(`{ "match": "foo.bar" }`), - assert: func(t *testing.T, err error, matcher *Matcher) { + config: []byte(`{ "path": "foo.bar" }`), + assert: func(t *testing.T, err error, path *Path) { t.Helper() require.NoError(t, err) - assert.Equal(t, "foo.bar", matcher.URL) - assert.Equal(t, "glob", matcher.Strategy) + assert.Equal(t, "foo.bar", path.Expression) + assert.Empty(t, path.Glob) + assert.Empty(t, path.Regex) }, }, { uc: "specified as structured type with invalid json structure", config: []byte(`{ -"match": { - strategy: foo -} + "path": { + expression: foo + } }`), - assert: func(t *testing.T, err error, _ *Matcher) { + assert: func(t *testing.T, err error, _ *Path) { t.Helper() require.Error(t, err) @@ -62,32 +63,35 @@ func TestMatcherUnmarshalJSON(t *testing.T) { }, }, { - uc: "specified as structured type without url", + uc: "specified as structured type without expression", config: []byte(`{ -"match": { - "strategy": "foo" -} + "path": { + "regex": "foo" + } }`), - assert: func(t *testing.T, err error, _ *Matcher) { + assert: func(t *testing.T, err error, _ *Path) { t.Helper() require.Error(t, err) - assert.Contains(t, err.Error(), ErrURLMissing.Error()) + assert.Contains(t, err.Error(), "'expression' is a required field") }, }, { - uc: "specified as structured type without strategy specified", + uc: "specified as structured type with everything specified", config: []byte(`{ -"match": { - "url": "foo.bar" -} + "path": { + "expression": "foo.bar", + "glob": "**.css", + "regex": ".*\\.css" + } }`), - assert: func(t *testing.T, err error, matcher *Matcher) { + assert: func(t *testing.T, err error, path *Path) { t.Helper() require.NoError(t, err) - assert.Equal(t, "foo.bar", matcher.URL) - assert.Equal(t, "glob", matcher.Strategy) + assert.Equal(t, "foo.bar", path.Expression) + assert.Equal(t, "**.css", path.Glob) + assert.Equal(t, ".*\\.css", path.Regex) }, }, } { @@ -98,7 +102,7 @@ func TestMatcherUnmarshalJSON(t *testing.T) { err := json.Unmarshal(tc.config, &typ) // THEN - tc.assert(t, err, &typ.Matcher) + tc.assert(t, err, &typ.Path) }) } } diff --git a/internal/rules/config/parser_test.go b/internal/rules/config/parser_test.go index ed2256733..d4bbda178 100644 --- a/internal/rules/config/parser_test.go +++ b/internal/rules/config/parser_test.go @@ -63,7 +63,7 @@ func TestParseRules(t *testing.T) { content: []byte(`{ "version": "1", "name": "foo", -"rules": [{"id": "bar"}] +"rules": [{"id": "bar", "match": {"path": "foobar"}}] }`), assert: func(t *testing.T, err error, ruleSet *RuleSet) { t.Helper() @@ -73,6 +73,11 @@ func TestParseRules(t *testing.T) { 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, "foobar", rul.Matcher.Path.Expression) }, }, { @@ -108,7 +113,9 @@ version: "1" name: foo rules: - id: bar - allow_encoded_slashes: off + allow_encoded_slashes: no_decode + match: + path: foo `), assert: func(t *testing.T, err error, ruleSet *RuleSet) { t.Helper() @@ -118,6 +125,12 @@ 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, EncodedSlashesNoDecode, rul.EncodedSlashesHandling) + assert.Equal(t, "foo", rul.Matcher.Path.Expression) }, }, { @@ -191,6 +204,8 @@ version: "1" name: foo rules: - id: bar + match: + path: foo `), assert: func(t *testing.T, err error, ruleSet *RuleSet) { t.Helper() @@ -200,7 +215,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.Expression) }, }, { @@ -229,6 +248,8 @@ version: "1" name: ${FOO} rules: - id: bar + match: + path: foo `), assert: func(t *testing.T, err error, ruleSet *RuleSet) { t.Helper() @@ -238,7 +259,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.Expression) }, }, { @@ -248,6 +273,8 @@ version: "1" name: ${FOO} rules: - id: bar + match: + path: foo `), assert: func(t *testing.T, err error, ruleSet *RuleSet) { t.Helper() @@ -257,7 +284,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.Expression) }, }, } { diff --git a/internal/rules/config/rule.go b/internal/rules/config/rule.go index 8648fdaf7..5c2634154 100644 --- a/internal/rules/config/rule.go +++ b/internal/rules/config/rule.go @@ -31,16 +31,17 @@ const ( type Rule struct { ID string `json:"id" yaml:"id"` 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"` + Matcher 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"` ErrorHandler []config.MechanismConfig `json:"on_error" yaml:"on_error"` } func (in *Rule) DeepCopyInto(out *Rule) { *out = *in - out.RuleMatcher = in.RuleMatcher + + inm, outm := &in.Matcher, &out.Matcher + inm.DeepCopyInto(outm) if in.Backend != nil { in, out := in.Backend, out.Backend @@ -48,13 +49,6 @@ func (in *Rule) DeepCopyInto(out *Rule) { in.DeepCopyInto(out) } - if in.Methods != nil { - in, out := &in.Methods, &out.Methods - - *out = make([]string, len(*in)) - copy(*out, *in) - } - if in.Execute != nil { in, out := &in.Execute, &out.Execute diff --git a/internal/rules/config/rule_set.go b/internal/rules/config/rule_set.go index 8e1b33524..3ecf341a1 100644 --- a/internal/rules/config/rule_set.go +++ b/internal/rules/config/rule_set.go @@ -40,12 +40,7 @@ type RuleSet struct { 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) { + if !strings.HasPrefix(rule.Matcher.Path.Expression, prefix) { return errorchain.NewWithMessage(heimdall.ErrConfiguration, "path prefix validation failed for rule ID=%s") } diff --git a/internal/rules/config/rule_set_test.go b/internal/rules/config/rule_set_test.go index 03002dfb3..9518ebe6c 100644 --- a/internal/rules/config/rule_set_test.go +++ b/internal/rules/config/rule_set_test.go @@ -33,13 +33,13 @@ func TestRuleSetConfigurationVerifyPathPrefixPathPrefixVerify(t *testing.T) { }{ {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}}}, + Rules: []Rule{ + {Matcher: Matcher{Path: Path{Expression: tc.url}}}, + }, } // WHEN diff --git a/internal/rules/config/rule_test.go b/internal/rules/config/rule_test.go index 42d653976..575d2b3ec 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{ + Scheme: "https", + HostGlob: "**.example.com", + HostRegex: ".*\\.example.com", + Methods: []string{"GET", "PATCH"}, + Path: Path{ + Expression: "bar", + Regex: ".*\\.css", + Glob: "**.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.Scheme, out.Matcher.Scheme) + assert.Equal(t, in.Matcher.HostGlob, out.Matcher.HostGlob) + assert.Equal(t, in.Matcher.HostRegex, out.Matcher.HostRegex) + assert.Equal(t, in.Matcher.Methods, out.Matcher.Methods) + assert.Equal(t, in.Matcher.Path.Expression, out.Matcher.Path.Expression) + assert.Equal(t, in.Matcher.Path.Glob, out.Matcher.Path.Glob) + assert.Equal(t, in.Matcher.Path.Regex, out.Matcher.Path.Regex) 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{ + Scheme: "https", + HostGlob: "**.example.com", + HostRegex: ".*\\.example.com", + Methods: []string{"GET", "PATCH"}, + Path: Path{ + Expression: "bar", + Regex: ".*\\.css", + Glob: "**.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.Scheme, out.Matcher.Scheme) + assert.Equal(t, in.Matcher.HostGlob, out.Matcher.HostGlob) + assert.Equal(t, in.Matcher.HostRegex, out.Matcher.HostRegex) + assert.Equal(t, in.Matcher.Methods, out.Matcher.Methods) + assert.Equal(t, in.Matcher.Path.Expression, out.Matcher.Path.Expression) + assert.Equal(t, in.Matcher.Path.Glob, out.Matcher.Path.Glob) + assert.Equal(t, in.Matcher.Path.Regex, out.Matcher.Path.Regex) 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/patternmatcher/pattern_matcher.go b/internal/rules/glob_matcher.go similarity index 56% rename from internal/rules/patternmatcher/pattern_matcher.go rename to internal/rules/glob_matcher.go index 4a0ae68d4..bed283213 100644 --- a/internal/rules/patternmatcher/pattern_matcher.go +++ b/internal/rules/glob_matcher.go @@ -14,25 +14,33 @@ // // SPDX-License-Identifier: Apache-2.0 -package patternmatcher +package rules import ( "errors" + + "github.com/gobwas/glob" ) -var ErrUnsupportedPatternMatcher = errors.New("unsupported pattern matcher") +var ErrNoGlobPatternDefined = errors.New("no glob pattern defined") + +type globMatcher struct { + compiled glob.Glob +} -type PatternMatcher interface { - Match(value string) bool +func (m *globMatcher) Match(value string) bool { + return m.compiled.Match(value) } -func NewPatternMatcher(typ, pattern string) (PatternMatcher, error) { - switch typ { - case "glob": - return newGlobMatcher(pattern) - case "regex": - return newRegexMatcher(pattern) - default: - return nil, ErrUnsupportedPatternMatcher +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 } 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..71bec8ea9 100644 --- a/internal/rules/mechanisms/cellib/urls.go +++ b/internal/rules/mechanisms/cellib/urls.go @@ -17,7 +17,7 @@ package cellib import ( - "net/url" + "github.com/dadrus/heimdall/internal/heimdall" "reflect" "github.com/google/cel-go/cel" @@ -42,16 +42,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 +60,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..a400bfedf 100644 --- a/internal/rules/mechanisms/cellib/urls_test.go +++ b/internal/rules/mechanisms/cellib/urls_test.go @@ -17,6 +17,7 @@ package cellib import ( + "github.com/dadrus/heimdall/internal/heimdall" "net/url" "testing" @@ -33,8 +34,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 +44,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 +64,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/pattern_matcher.go b/internal/rules/pattern_matcher.go new file mode 100644 index 000000000..ecdd2e607 --- /dev/null +++ b/internal/rules/pattern_matcher.go @@ -0,0 +1,5 @@ +package rules + +type PatternMatcher interface { + Match(pattern string) bool +} 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/provider/cloudblob/provider_test.go b/internal/rules/provider/cloudblob/provider_test.go index 82726c556..9fc55d8b1 100644 --- a/internal/rules/provider/cloudblob/provider_test.go +++ b/internal/rules/provider/cloudblob/provider_test.go @@ -243,6 +243,8 @@ version: "1" name: test rules: - id: foo + match: + path: /foo ` _, err := backend.PutObject(bucketName, "test-rule", @@ -287,6 +289,8 @@ version: "1" name: test rules: - id: foo + match: + path: /foo ` _, err := backend.PutObject(bucketName, "test-rule", @@ -336,6 +340,8 @@ version: "1" name: test rules: - id: foo + match: + path: /foo ` _, err := backend.PutObject(bucketName, "test-rule1", @@ -350,6 +356,8 @@ version: "1" name: test rules: - id: bar + match: + path: /bar ` _, err := backend.PutObject(bucketName, "test-rule2", @@ -422,6 +430,8 @@ version: "1" name: test rules: - id: foo + match: + path: /foo ` _, err := backend.PutObject(bucketName, "test-rule", @@ -434,6 +444,8 @@ version: "1" name: test rules: - id: bar + match: + path: /bar ` _, err := backend.PutObject(bucketName, "test-rule", @@ -446,6 +458,8 @@ version: "1" name: test rules: - id: baz + match: + path: /baz ` _, err := backend.PutObject(bucketName, "test-rule", diff --git a/internal/rules/provider/cloudblob/ruleset_endpoint_test.go b/internal/rules/provider/cloudblob/ruleset_endpoint_test.go index fdaa54f4a..aec057416 100644 --- a/internal/rules/provider/cloudblob/ruleset_endpoint_test.go +++ b/internal/rules/provider/cloudblob/ruleset_endpoint_test.go @@ -177,7 +177,7 @@ 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", + RulesPathPrefix: "/foo", }, setup: func(t *testing.T) { t.Helper() @@ -188,8 +188,12 @@ func TestFetchRuleSets(t *testing.T) { "name": "test", "rules": [{ "id": "foobar", - "match": "http://<**>/bar/foo/api", - "methods": ["GET", "POST"], + "match": { + "scheme": "http", + "host_glob": "**", + "path": "/bar/foo/api", + "methods": ["GET", "POST"] + }, "execute": [ { "authenticator": "foobar" } ] @@ -217,7 +221,7 @@ 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", + RulesPathPrefix: "/foo/bar", }, setup: func(t *testing.T) { t.Helper() @@ -228,8 +232,12 @@ func TestFetchRuleSets(t *testing.T) { "name": "test", "rules": [{ "id": "foobar", - "match": "http://<**>/foo/bar/api1", - "methods": ["GET", "POST"], + "match": { + "scheme": "http", + "host_glob": "**", + "path": "/foo/bar/api1", + "methods": ["GET", "POST"] + }, "execute": [ { "authenticator": "foobar" } ] @@ -241,13 +249,16 @@ version: "1" name: test2 rules: - id: barfoo - match: http://<**>/foo/bar/api2 - methods: - - GET - - POST + match: + scheme: http + host_glob: "**" + path: /foo/bar/api2 + 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 +305,12 @@ rules: "name": "test1", "rules": [{ "id": "foobar", - "match": "http://<**>/foo/bar/api1", - "methods": ["GET", "POST"], + "match": { + "scheme": "http", + "host_glob": "**", + "path": "/foo/bar/api1", + "methods": ["GET", "POST"] + }, "execute": [ { "authenticator": "foobar" } ] @@ -306,8 +321,12 @@ rules: "name": "test2", "rules": [{ "id": "barfoo", - "url": "http://<**>/foo/bar/api2", - "methods": ["GET", "POST"], + "match": { + "scheme": "http", + "host_glob": "**", + "path": "/foo/bar/api2", + "methods": ["GET", "POST"] + }, "execute": [ { "authenticator": "barfoo" } ] @@ -400,8 +419,12 @@ rules: "name": "test", "rules": [{ "id": "foobar", - "match": "http://<**>/foo/bar/api1", - "methods": ["GET", "POST"], + "match": { + "scheme": "http", + "host_glob": "**", + "path": "/foo/bar/api1", + "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..b2e857dac 100644 --- a/internal/rules/provider/filesystem/provider_test.go +++ b/internal/rules/provider/filesystem/provider_test.go @@ -202,6 +202,8 @@ func TestProviderLifecycle(t *testing.T) { version: "1" rules: - id: foo + match: + path: /foo/bar `) require.NoError(t, err) @@ -251,6 +253,8 @@ rules: version: "2" rules: - id: foo + match: + path: /foo/bar `) require.NoError(t, err) @@ -290,6 +294,8 @@ rules: version: "1" rules: - id: foo + match: + path: /foo/bar `) require.NoError(t, err) @@ -322,6 +328,8 @@ rules: version: "1" rules: - id: foo + match: + path: /foo/bar `) require.NoError(t, err) @@ -369,6 +377,8 @@ rules: version: "1" rules: - id: foo + match: + path: /foo `) require.NoError(t, err) @@ -381,6 +391,8 @@ rules: version: "1" rules: - id: foo + match: + path: /foo `) require.NoError(t, err) @@ -393,6 +405,8 @@ rules: version: "2" rules: - id: bar + match: + path: /bar `) require.NoError(t, err) diff --git a/internal/rules/provider/httpendpoint/provider_test.go b/internal/rules/provider/httpendpoint/provider_test.go index ecacf4439..b11038532 100644 --- a/internal/rules/provider/httpendpoint/provider_test.go +++ b/internal/rules/provider/httpendpoint/provider_test.go @@ -262,6 +262,8 @@ version: "1" name: test rules: - id: foo + match: + path: /foo `)) require.NoError(t, err) }, @@ -304,6 +306,8 @@ version: "1" name: test rules: - id: bar + match: + path: /bar `)) require.NoError(t, err) }, @@ -351,6 +355,8 @@ version: "1" name: test rules: - id: foo + match: + path: /foo `)) require.NoError(t, err) case 2: @@ -362,6 +368,8 @@ version: "2" name: test rules: - id: bar + match: + path: /bar `)) require.NoError(t, err) } @@ -427,6 +435,8 @@ version: "1" name: test rules: - id: bar + match: + path: /bar `)) require.NoError(t, err) case 2: @@ -436,6 +446,8 @@ version: "1" name: test rules: - id: baz + match: + path: /baz `)) require.NoError(t, err) case 3: @@ -445,6 +457,8 @@ version: "1" name: test rules: - id: foo + match: + path: /foo `)) require.NoError(t, err) default: @@ -454,6 +468,8 @@ version: "1" name: test rules: - id: foz + match: + path: /foz `)) require.NoError(t, err) } @@ -524,6 +540,8 @@ version: "1" name: test rules: - id: bar + match: + path: /bar `)) require.NoError(t, err) }, @@ -569,6 +587,8 @@ version: "1" name: test rules: - id: bar + match: + path: /bar `)) require.NoError(t, err) }, @@ -612,6 +632,8 @@ version: "1" name: test rules: - id: foo + match: + path: /foo `)) require.NoError(t, err) }, @@ -649,6 +671,8 @@ version: "1" name: test rules: - id: bar + match: + path: /bar `)) require.NoError(t, err) } else { @@ -658,6 +682,8 @@ version: "1" name: test rules: - id: baz + match: + path: /baz `)) require.NoError(t, err) } @@ -700,6 +726,8 @@ version: "1" name: test rules: - id: bar + match: + path: /bar `)) require.NoError(t, err) } else { diff --git a/internal/rules/provider/httpendpoint/ruleset_endpoint_test.go b/internal/rules/provider/httpendpoint/ruleset_endpoint_test.go index e26b39ce0..39c58341c 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,8 @@ version: "1" name: test rules: - id: foo + match: + path: /foo `)) require.NoError(t, err) }, @@ -212,7 +216,7 @@ rules: "version": "1", "name": "test", "rules": [ - { "id": "foo" } + { "id": "foo", "match": { "path": "/foo"} } ] }`)) require.NoError(t, err) @@ -245,7 +249,7 @@ rules: "version": "1", "name": "test", "rules": [ - { "id": "foo", "match":"/bar/foo/<**>" } + { "id": "foo", "match": { "path": "/bar/foo/<**>" }} ] }`)) require.NoError(t, err) @@ -275,7 +279,16 @@ rules: "version": "1", "name": "test", "rules": [ - { "id": "foo", "match":"<**>://moobar.local:9090/bar/foo/<**>" } + { + "id": "foo", + "match": { + "path": { + "expression": "/bar/foo/:*", + "glob": "/bar/foo/**" + }, + "host_glob": "moobar.local:9090" + } + } ] }`)) require.NoError(t, err) @@ -305,7 +318,16 @@ rules: "version": "1", "name": "test", "rules": [ - { "id": "foo", "match":"<**>://moobar.local:9090/foo/bar/<**>" } + { + "id": "foo", + "match": { + "path": { + "expression": "/foo/bar/:*", + "glob": "/foo/bar/**" + }, + "host_glob": "moobar.local:9090" + } + } ] }`)) 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..90afe956d 100644 --- a/internal/rules/provider/kubernetes/admissioncontroller/controller_test.go +++ b/internal/rules/provider/kubernetes/admissioncontroller/controller_test.go @@ -271,9 +271,10 @@ func TestControllerLifecycle(t *testing.T) { Rules: []config2.Rule{ { ID: "test", - RuleMatcher: config2.Matcher{ - URL: "http://foo.bar", - Strategy: "glob", + Matcher: config2.Matcher{ + Scheme: "http", + Path: config2.Path{Expression: "/foo.bar"}, + Methods: []string{http.MethodGet}, }, Backend: &config2.Backend{ Host: "baz", @@ -284,7 +285,6 @@ func TestControllerLifecycle(t *testing.T) { QueryParamsToRemove: []string{"baz"}, }, }, - Methods: []string{http.MethodGet}, Execute: []config.MechanismConfig{ {"authenticator": "authn"}, {"authorizer": "authz"}, @@ -364,9 +364,10 @@ func TestControllerLifecycle(t *testing.T) { Rules: []config2.Rule{ { ID: "test", - RuleMatcher: config2.Matcher{ - URL: "http://foo.bar", - Strategy: "glob", + Matcher: config2.Matcher{ + Scheme: "http", + Path: config2.Path{Expression: "/foo.bar"}, + Methods: []string{http.MethodGet}, }, Backend: &config2.Backend{ Host: "baz", @@ -377,7 +378,6 @@ func TestControllerLifecycle(t *testing.T) { QueryParamsToRemove: []string{"baz"}, }, }, - Methods: []string{http.MethodGet}, Execute: []config.MechanismConfig{ {"authenticator": "authn"}, {"authorizer": "authz"}, diff --git a/internal/rules/provider/kubernetes/api/v1alpha3/client_test.go b/internal/rules/provider/kubernetes/api/v1alpha3/client_test.go index 9d6f9bf60..f5ada3c15 100644 --- a/internal/rules/provider/kubernetes/api/v1alpha3/client_test.go +++ b/internal/rules/provider/kubernetes/api/v1alpha3/client_test.go @@ -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": { + "scheme": "http", + "host_glob": "127.0.0.1:*", + "path": { + "expression": "/foobar/:*", + "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"] + } } } ] @@ -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, "http", rule.Matcher.Scheme) + assert.Equal(t, "127.0.0.1:*", rule.Matcher.HostGlob) + assert.Equal(t, "/foobar/:*", rule.Matcher.Path.Expression) + assert.Equal(t, "/foobar/foos*", rule.Matcher.Path.Glob) + assert.ElementsMatch(t, rule.Matcher.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/mocks/client.go b/internal/rules/provider/kubernetes/api/v1alpha3/mocks/client.go index 2405f7232..9e704a6c7 100644 --- a/internal/rules/provider/kubernetes/api/v1alpha3/mocks/client.go +++ b/internal/rules/provider/kubernetes/api/v1alpha3/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" + v1alpha3 "github.com/dadrus/heimdall/internal/rules/provider/kubernetes/api/v1alpha3" mock "github.com/stretchr/testify/mock" ) @@ -24,6 +24,10 @@ func (_m *ClientMock) EXPECT() *ClientMock_Expecter { func (_m *ClientMock) RuleSetRepository(namespace string) v1alpha3.RuleSetRepository { ret := _m.Called(namespace) + if len(ret) == 0 { + panic("no return value specified for RuleSetRepository") + } + var r0 v1alpha3.RuleSetRepository if rf, ok := ret.Get(0).(func(string) v1alpha3.RuleSetRepository); ok { r0 = rf(namespace) @@ -64,13 +68,12 @@ func (_c *ClientMock_RuleSetRepository_Call) RunAndReturn(run func(string) v1alp 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/v1alpha3/mocks/rule_set_repository.go index d0051ce10..78f50a83d 100644 --- a/internal/rules/provider/kubernetes/api/v1alpha3/mocks/rule_set_repository.go +++ b/internal/rules/provider/kubernetes/api/v1alpha3/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 @@ -32,6 +32,10 @@ func (_m *RuleSetRepositoryMock) EXPECT() *RuleSetRepositoryMock_Expecter { func (_m *RuleSetRepositoryMock) Get(ctx context.Context, key types.NamespacedName, opts v1.GetOptions) (*v1alpha3.RuleSet, error) { ret := _m.Called(ctx, key, opts) + if len(ret) == 0 { + panic("no return value specified for Get") + } + var r0 *v1alpha3.RuleSet var r1 error if rf, ok := ret.Get(0).(func(context.Context, types.NamespacedName, v1.GetOptions) (*v1alpha3.RuleSet, error)); ok { @@ -88,6 +92,10 @@ func (_c *RuleSetRepositoryMock_Get_Call) RunAndReturn(run func(context.Context, func (_m *RuleSetRepositoryMock) List(ctx context.Context, opts v1.ListOptions) (*v1alpha3.RuleSetList, error) { ret := _m.Called(ctx, opts) + if len(ret) == 0 { + panic("no return value specified for List") + } + var r0 *v1alpha3.RuleSetList var r1 error if rf, ok := ret.Get(0).(func(context.Context, v1.ListOptions) (*v1alpha3.RuleSetList, error)); ok { @@ -143,6 +151,10 @@ func (_c *RuleSetRepositoryMock_List_Call) RunAndReturn(run func(context.Context func (_m *RuleSetRepositoryMock) PatchStatus(ctx context.Context, patch v1alpha3.Patch, opts v1.PatchOptions) (*v1alpha3.RuleSet, error) { ret := _m.Called(ctx, patch, opts) + if len(ret) == 0 { + panic("no return value specified for PatchStatus") + } + var r0 *v1alpha3.RuleSet var r1 error if rf, ok := ret.Get(0).(func(context.Context, v1alpha3.Patch, v1.PatchOptions) (*v1alpha3.RuleSet, error)); ok { @@ -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/provider_test.go b/internal/rules/provider/kubernetes/provider_test.go index e708e58e6..b3928f121 100644 --- a/internal/rules/provider/kubernetes/provider_test.go +++ b/internal/rules/provider/kubernetes/provider_test.go @@ -210,9 +210,11 @@ func (h *RuleSetResourceHandler) writeListResponse(t *testing.T, w http.Response Rules: []config2.Rule{ { ID: "test", - RuleMatcher: config2.Matcher{ - URL: "http://foo.bar", - Strategy: "glob", + Matcher: config2.Matcher{ + Scheme: "http", + HostGlob: "foo.bar", + Methods: []string{http.MethodGet}, + Path: config2.Path{Expression: "/"}, }, Backend: &config2.Backend{ Host: "baz", @@ -223,7 +225,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"}, @@ -367,11 +368,12 @@ func TestProviderLifecycle(t *testing.T) { 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.Scheme) + assert.Equal(t, "foo.bar", rule.Matcher.HostGlob) + assert.Equal(t, "/", rule.Matcher.Path.Expression) + assert.Len(t, rule.Matcher.Methods, 1) + assert.Contains(t, rule.Matcher.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"]) @@ -466,11 +468,12 @@ func TestProviderLifecycle(t *testing.T) { 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.Scheme) + assert.Equal(t, "foo.bar", createdRule.Matcher.HostGlob) + assert.Equal(t, "/", createdRule.Matcher.Path.Expression) + assert.Len(t, createdRule.Matcher.Methods, 1) + assert.Contains(t, createdRule.Matcher.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"]) @@ -529,11 +532,12 @@ func TestProviderLifecycle(t *testing.T) { 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.Scheme) + assert.Equal(t, "foo.bar", createdRule.Matcher.HostGlob) + assert.Equal(t, "/", createdRule.Matcher.Path.Expression) + assert.Len(t, createdRule.Matcher.Methods, 1) + assert.Contains(t, createdRule.Matcher.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"]) @@ -596,11 +600,12 @@ func TestProviderLifecycle(t *testing.T) { 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.Scheme) + assert.Equal(t, "foo.bar", createdRule.Matcher.HostGlob) + assert.Equal(t, "/", createdRule.Matcher.Path.Expression) + assert.Len(t, createdRule.Matcher.Methods, 1) + assert.Contains(t, createdRule.Matcher.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"]) @@ -672,9 +677,11 @@ func TestProviderLifecycle(t *testing.T) { Rules: []config2.Rule{ { ID: "test", - RuleMatcher: config2.Matcher{ - URL: "http://foo.bar", - Strategy: "glob", + Matcher: config2.Matcher{ + Scheme: "http", + HostGlob: "foo.bar", + Path: config2.Path{Expression: "/"}, + Methods: []string{http.MethodGet}, }, Backend: &config2.Backend{ Host: "bar", @@ -685,7 +692,6 @@ func TestProviderLifecycle(t *testing.T) { QueryParamsToRemove: []string{"baz"}, }, }, - Methods: []string{http.MethodGet}, Execute: []config.MechanismConfig{ {"authenticator": "test_authn"}, {"authorizer": "test_authz"}, @@ -724,11 +730,12 @@ func TestProviderLifecycle(t *testing.T) { 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.Scheme) + assert.Equal(t, "foo.bar", createdRule.Matcher.HostGlob) + assert.Equal(t, "/", createdRule.Matcher.Path.Expression) + assert.Len(t, createdRule.Matcher.Methods, 1) + assert.Contains(t, createdRule.Matcher.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"]) @@ -742,11 +749,12 @@ func TestProviderLifecycle(t *testing.T) { 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.Scheme) + assert.Equal(t, "foo.bar", createdRule.Matcher.HostGlob) + assert.Equal(t, "/", createdRule.Matcher.Path.Expression) + assert.Len(t, createdRule.Matcher.Methods, 1) + assert.Contains(t, createdRule.Matcher.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"]) @@ -813,11 +821,12 @@ func TestProviderLifecycle(t *testing.T) { 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.Scheme) + assert.Equal(t, "foo.bar", createdRule.Matcher.HostGlob) + assert.Equal(t, "/", createdRule.Matcher.Path.Expression) + assert.Len(t, createdRule.Matcher.Methods, 1) + assert.Contains(t, createdRule.Matcher.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"]) @@ -831,11 +840,12 @@ func TestProviderLifecycle(t *testing.T) { 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.Scheme) + assert.Equal(t, "foo.bar", createdRule.Matcher.HostGlob) + assert.Equal(t, "/", createdRule.Matcher.Path.Expression) + assert.Len(t, createdRule.Matcher.Methods, 1) + assert.Contains(t, createdRule.Matcher.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"]) @@ -867,9 +877,11 @@ func TestProviderLifecycle(t *testing.T) { Rules: []config2.Rule{ { ID: "test", - RuleMatcher: config2.Matcher{ - URL: "http://foo.bar", - Strategy: "glob", + Matcher: config2.Matcher{ + Scheme: "http", + Methods: []string{http.MethodGet}, + HostGlob: "foo.bar", + Path: config2.Path{Expression: "/"}, }, Backend: &config2.Backend{ Host: "bar", @@ -880,7 +892,6 @@ func TestProviderLifecycle(t *testing.T) { QueryParamsToRemove: []string{"baz"}, }, }, - Methods: []string{http.MethodGet}, Execute: []config.MechanismConfig{ {"authenticator": "test_authn"}, {"authorizer": "test_authz"}, diff --git a/internal/rules/patternmatcher/regex_matcher.go b/internal/rules/regex_matcher.go similarity index 66% rename from internal/rules/patternmatcher/regex_matcher.go rename to internal/rules/regex_matcher.go index 7b223b3d6..ac1a44641 100644 --- a/internal/rules/patternmatcher/regex_matcher.go +++ b/internal/rules/regex_matcher.go @@ -14,27 +14,25 @@ // // SPDX-License-Identifier: Apache-2.0 -package patternmatcher +package rules import ( "errors" - - "github.com/dlclark/regexp2" - "github.com/ory/ladon/compiler" + "regexp" ) -var ErrNoRegexPatternDefined = errors.New("no glob pattern defined") +var ErrNoRegexPatternDefined = errors.New("no regex pattern defined") type regexpMatcher struct { - compiled *regexp2.Regexp + compiled *regexp.Regexp } -func newRegexMatcher(pattern string) (*regexpMatcher, error) { +func newRegexMatcher(pattern string) (PatternMatcher, error) { if len(pattern) == 0 { return nil, ErrNoRegexPatternDefined } - compiled, err := compiler.CompileRegex(pattern, '<', '>') + compiled, err := regexp.Compile(pattern) if err != nil { return nil, err } @@ -43,8 +41,5 @@ func newRegexMatcher(pattern string) (*regexpMatcher, error) { } 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 + return m.compiled.MatchString(matchAgainst) } diff --git a/internal/rules/repository_impl.go b/internal/rules/repository_impl.go index c846b1f2a..4fc06a468 100644 --- a/internal/rules/repository_impl.go +++ b/internal/rules/repository_impl.go @@ -19,12 +19,12 @@ package rules import ( "bytes" "context" - "net/url" "sync" "github.com/rs/zerolog" "github.com/dadrus/heimdall/internal/heimdall" + "github.com/dadrus/heimdall/internal/indextree" "github.com/dadrus/heimdall/internal/rules/event" "github.com/dadrus/heimdall/internal/rules/rule" "github.com/dadrus/heimdall/internal/x" @@ -41,9 +41,10 @@ func newRepository( dr: x.IfThenElseExec(ruleFactory.HasDefaultRule(), func() rule.Rule { return ruleFactory.DefaultRule() }, func() rule.Rule { return nil }), - logger: logger, - queue: queue, - quit: make(chan bool), + logger: logger, + queue: queue, + quit: make(chan bool), + rulesTree: indextree.NewIndexTree[rule.Rule](), } } @@ -51,29 +52,35 @@ type repository struct { dr rule.Rule logger zerolog.Logger - rules []rule.Rule - mutex sync.RWMutex + knownRules []rule.Rule + + rulesTree *indextree.IndexTree[rule.Rule] + mutex sync.RWMutex queue event.RuleSetChangedEventQueue quit chan bool } -func (r *repository) FindRule(requestURL *url.URL) (rule.Rule, error) { +func (r *repository) FindRule(request *heimdall.Request) (rule.Rule, error) { r.mutex.RLock() defer r.mutex.RUnlock() - for _, rul := range r.rules { - if rul.MatchesURL(requestURL) { - return rul, nil + rul, params, err := r.rulesTree.Find( + request.URL.Path, + indextree.MatcherFunc[rule.Rule](func(candidate rule.Rule) bool { return candidate.Matches(request) }), + ) + 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()) + request.URL.Captures = params + + return rul, nil } func (r *repository) Start(_ context.Context) error { @@ -119,12 +126,8 @@ func (r *repository) watchRuleSetChanges() { } func (r *repository) addRuleSet(srcID string, rules []rule.Rule) { - // create rules r.logger.Info().Str("_src", srcID).Msg("Adding rule set") - r.mutex.Lock() - defer r.mutex.Unlock() - // add them r.addRules(rules) } @@ -134,12 +137,7 @@ func (r *repository) updateRuleSet(srcID string, rules []rule.Rule) { r.logger.Info().Str("_src", srcID).Msg("Updating rule set") // find all rules for the given src id - applicable := func() []rule.Rule { - r.mutex.Lock() - defer r.mutex.Unlock() - - return slicex.Filter(r.rules, func(r rule.Rule) bool { return r.SrcID() == srcID }) - }() + applicable := slicex.Filter(r.knownRules, func(r rule.Rule) bool { return r.SrcID() == srcID }) // find new rules newRules := slicex.Filter(rules, func(r rule.Rule) bool { @@ -191,9 +189,6 @@ func (r *repository) updateRuleSet(srcID string, rules []rule.Rule) { }) func() { - r.mutex.Lock() - defer r.mutex.Unlock() - // remove deleted rules r.removeRules(deletedRules) @@ -208,11 +203,8 @@ func (r *repository) updateRuleSet(srcID string, rules []rule.Rule) { func (r *repository) deleteRuleSet(srcID string) { r.logger.Info().Str("_src", srcID).Msg("Deleting rule set") - r.mutex.Lock() - defer r.mutex.Unlock() - // find all rules for the given src id - applicable := slicex.Filter(r.rules, func(r rule.Rule) bool { return r.SrcID() == srcID }) + applicable := slicex.Filter(r.knownRules, func(r rule.Rule) bool { return r.SrcID() == srcID }) // remove them r.removeRules(applicable) @@ -220,9 +212,23 @@ func (r *repository) deleteRuleSet(srcID string) { func (r *repository) addRules(rules []rule.Rule) { for _, rul := range rules { - r.rules = append(r.rules, rul) + r.knownRules = append(r.knownRules, rul) - r.logger.Debug().Str("_src", rul.SrcID()).Str("_id", rul.ID()).Msg("Rule added") + r.mutex.Lock() + err := r.rulesTree.Add(rul.PathExpression(), rul) + r.mutex.Unlock() + + if err != nil { + r.logger.Error().Err(err). + Str("_src", rul.SrcID()). + Str("_id", rul.ID()). + Msg("Failed to add rule") + } else { + r.logger.Debug(). + Str("_src", rul.SrcID()). + Str("_id", rul.ID()). + Msg("Rule added") + } } } @@ -230,49 +236,88 @@ func (r *repository) removeRules(rules []rule.Rule) { // find all indexes for affected rules var idxs []int - for idx, rul := range r.rules { + for idx, rul := range r.knownRules { for _, tbd := range rules { if rul.SrcID() == tbd.SrcID() && rul.ID() == tbd.ID() { idxs = append(idxs, idx) - - r.logger.Debug().Str("_src", rul.SrcID()).Str("_id", rul.ID()).Msg("Rule removed") } } } - // if all rules should be dropped, just create a new slice - if len(idxs) == len(r.rules) { - r.rules = nil + // if all rules should be dropped, just create a new slice and new tree + if len(idxs) == len(r.knownRules) { + r.knownRules = nil + + r.mutex.Lock() + r.rulesTree = indextree.NewIndexTree[rule.Rule]() + r.mutex.Unlock() return } + for _, rul := range rules { + r.mutex.Lock() + err := r.rulesTree.Delete( + rul.PathExpression(), + indextree.MatcherFunc[rule.Rule](func(existing rule.Rule) bool { return existing.SameAs(rul) }), + ) + r.mutex.Unlock() + + if err != nil { + r.logger.Error().Err(err). + Str("_src", rul.SrcID()). + Str("_id", rul.ID()). + Msg("Failed to remove rule") + // TODO: remove idx of the failed rule from the idxs slice + } else { + r.logger.Debug(). + Str("_src", rul.SrcID()). + Str("_id", rul.ID()). + Msg("Rule removed") + } + } + // 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) + tailIdx := len(r.knownRules) - (1 + i) - r.rules[idx] = r.rules[tailIdx] + r.knownRules[idx] = r.knownRules[tailIdx] // the below re-slice preserves the capacity of the slice. // this is required to avoid memory leaks - r.rules[tailIdx] = nil + r.knownRules[tailIdx] = nil } // re-slice - r.rules = r.rules[:len(r.rules)-len(idxs)] + r.knownRules = r.knownRules[:len(r.knownRules)-len(idxs)] } 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") + r.mutex.Lock() + err := r.rulesTree.Update( + updated.PathExpression(), + updated, + indextree.MatcherFunc[rule.Rule](func(existing rule.Rule) bool { return existing.SameAs(updated) }), + ) + r.mutex.Unlock() + + if err != nil { + r.logger.Error().Err(err). + Str("_src", updated.SrcID()). + Str("_id", updated.ID()). + Msg("Failed to replace rule") + } else { + r.logger.Debug(). + Str("_src", updated.SrcID()). + Str("_id", updated.ID()). + Msg("Rule replaced") + } + + for idx, existing := range r.knownRules { + if updated.SameAs(existing) { + r.knownRules[idx] = updated break } diff --git a/internal/rules/repository_impl_test.go b/internal/rules/repository_impl_test.go index 9d84f17ee..a9e3d04bf 100644 --- a/internal/rules/repository_impl_test.go +++ b/internal/rules/repository_impl_test.go @@ -18,6 +18,7 @@ package rules import ( "context" + "net/http" "net/url" "testing" "time" @@ -28,13 +29,17 @@ import ( "github.com/stretchr/testify/require" "github.com/dadrus/heimdall/internal/heimdall" + "github.com/dadrus/heimdall/internal/indextree" "github.com/dadrus/heimdall/internal/rules/event" - "github.com/dadrus/heimdall/internal/rules/patternmatcher" "github.com/dadrus/heimdall/internal/rules/rule" "github.com/dadrus/heimdall/internal/rules/rule/mocks" "github.com/dadrus/heimdall/internal/x" ) +type testMatcher bool + +func (m testMatcher) Match(_ string) bool { return bool(m) } + func TestRepositoryAddAndRemoveRulesFromSameRuleSet(t *testing.T) { t.Parallel() @@ -43,20 +48,22 @@ func TestRepositoryAddAndRemoveRulesFromSameRuleSet(t *testing.T) { // 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"}, + &ruleImpl{id: "1", srcID: "bar", pathExpression: "/foo/1"}, + &ruleImpl{id: "2", srcID: "bar", pathExpression: "/foo/2"}, + &ruleImpl{id: "3", srcID: "bar", pathExpression: "/foo/3"}, + &ruleImpl{id: "4", srcID: "bar", pathExpression: "/foo/4"}, }) // THEN - assert.Len(t, repo.rules, 4) + assert.Len(t, repo.knownRules, 4) + assert.False(t, repo.rulesTree.Empty()) // WHEN repo.deleteRuleSet("bar") // THEN - assert.Empty(t, repo.rules) + assert.Empty(t, repo.knownRules) + assert.True(t, repo.rulesTree.Empty()) } func TestRepositoryFindRule(t *testing.T) { @@ -70,7 +77,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 +92,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,7 +108,7 @@ func TestRepositoryFindRule(t *testing.T) { }, }, { - uc: "matching rule", + uc: "matches upstream rule", requestURL: &url.URL{Scheme: "http", Host: "foo.bar", Path: "/baz"}, configureFactory: func(t *testing.T, factory *mocks.FactoryMock) { t.Helper() @@ -111,28 +118,33 @@ func TestRepositoryFindRule(t *testing.T) { addRules: func(t *testing.T, repo *repository) { t.Helper() - repo.rules = append(repo.rules, + fooBarMatcher, err := newGlobMatcher("foo.bar", '.') + require.NoError(t, err) + + exampleComMatcher, err := newGlobMatcher("example.com", '.') + require.NoError(t, err) + + repo.addRuleSet("bar", []rule.Rule{ &ruleImpl{ - id: "test1", - srcID: "bar", - urlMatcher: func() patternmatcher.PatternMatcher { - matcher, _ := patternmatcher.NewPatternMatcher("glob", - "http://heimdall.test.local/baz") - - return matcher - }(), + id: "test1", + srcID: "bar", + pathExpression: "/baz", + hostMatcher: exampleComMatcher, + pathMatcher: testMatcher(true), + allowedMethods: []string{http.MethodGet}, }, + }) + + repo.addRuleSet("baz", []rule.Rule{ &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", + hostMatcher: fooBarMatcher, + pathMatcher: testMatcher(true), + allowedMethods: []string{http.MethodGet}, }, - ) + }) }, assert: func(t *testing.T, err error, rul rule.Rule) { t.Helper() @@ -160,8 +172,10 @@ func TestRepositoryFindRule(t *testing.T) { addRules(t, repo) + req := &heimdall.Request{Method: http.MethodGet, URL: &heimdall.URL{URL: *tc.requestURL}} + // WHEN - rul, err := repo.FindRule(tc.requestURL) + rul, err := repo.FindRule(req) // THEN tc.assert(t, err, rul) @@ -175,42 +189,77 @@ func TestRepositoryAddAndRemoveRulesFromDifferentRuleSets(t *testing.T) { // GIVEN repo := newRepository(nil, &ruleFactory{}, *zerolog.Ctx(context.Background())) + rules := []rule.Rule{ + &ruleImpl{ + id: "1", srcID: "bar", pathExpression: "/bar/1", + hostMatcher: testMatcher(true), pathMatcher: testMatcher(true), allowedMethods: []string{http.MethodGet}, + }, + &ruleImpl{ + id: "2", srcID: "baz", pathExpression: "/baz/2", + hostMatcher: testMatcher(true), pathMatcher: testMatcher(true), allowedMethods: []string{http.MethodGet}, + }, + &ruleImpl{ + id: "3", srcID: "bar", pathExpression: "/bar/3", + hostMatcher: testMatcher(true), pathMatcher: testMatcher(true), allowedMethods: []string{http.MethodGet}, + }, + &ruleImpl{ + id: "4", srcID: "bar", pathExpression: "/bar/4", + hostMatcher: testMatcher(true), pathMatcher: testMatcher(true), allowedMethods: []string{http.MethodGet}, + }, + &ruleImpl{ + id: "4", srcID: "foo", pathExpression: "/foo/4", + hostMatcher: testMatcher(true), pathMatcher: testMatcher(true), allowedMethods: []string{http.MethodGet}, + }, + } + // 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"}, - }) + repo.addRules(rules) // THEN - assert.Len(t, repo.rules, 5) + assert.Len(t, repo.knownRules, 5) + assert.False(t, repo.rulesTree.Empty()) // 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"}, - }) + assert.Len(t, repo.knownRules, 2) + assert.ElementsMatch(t, repo.knownRules, []rule.Rule{rules[1], rules[4]}) + + _, _, err := repo.rulesTree.Find("/bar/1", indextree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) + assert.Error(t, err) //nolint:testifylint + + _, _, err = repo.rulesTree.Find("/bar/3", indextree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) + assert.Error(t, err) //nolint:testifylint + + _, _, err = repo.rulesTree.Find("/bar/4", indextree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) + assert.Error(t, err) //nolint:testifylint + + _, _, err = repo.rulesTree.Find("/baz/2", indextree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) + assert.NoError(t, err) //nolint:testifylint + + _, _, err = repo.rulesTree.Find("/foo/4", indextree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) + assert.NoError(t, err) //nolint:testifylint // WHEN repo.deleteRuleSet("foo") // THEN - assert.Len(t, repo.rules, 1) - assert.ElementsMatch(t, repo.rules, []rule.Rule{ - &ruleImpl{id: "2", srcID: "baz"}, - }) + assert.Len(t, repo.knownRules, 1) + assert.ElementsMatch(t, repo.knownRules, []rule.Rule{rules[1]}) + + _, _, err = repo.rulesTree.Find("/foo/4", indextree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) + assert.Error(t, err) //nolint:testifylint + + _, _, err = repo.rulesTree.Find("/baz/2", indextree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) + assert.NoError(t, err) //nolint:testifylint // WHEN repo.deleteRuleSet("baz") // THEN - assert.Empty(t, repo.rules) + assert.Empty(t, repo.knownRules) + assert.True(t, repo.rulesTree.Empty()) } func TestRepositoryRuleSetLifecycleManagement(t *testing.T) { @@ -227,7 +276,8 @@ func TestRepositoryRuleSetLifecycleManagement(t *testing.T) { assert: func(t *testing.T, repo *repository) { t.Helper() - assert.Empty(t, repo.rules) + assert.Empty(t, repo.knownRules) + assert.True(t, repo.rulesTree.Empty()) }, }, { @@ -236,14 +286,23 @@ func TestRepositoryRuleSetLifecycleManagement(t *testing.T) { { Source: "test", ChangeType: event.Create, - Rules: []rule.Rule{&ruleImpl{id: "rule:foo", srcID: "test"}}, + Rules: []rule.Rule{ + &ruleImpl{id: "rule:foo", srcID: "test", pathExpression: "/foo/1"}, + }, }, }, 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]) + assert.Len(t, repo.knownRules, 1) + assert.False(t, repo.rulesTree.Empty()) + + rul, _, err := repo.rulesTree.Find("/foo/1", indextree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) + require.NoError(t, err) + + assert.Equal(t, repo.knownRules[0], rul) + assert.Equal(t, "rule:foo", rul.ID()) + assert.Equal(t, "test", rul.SrcID()) }, }, { @@ -252,20 +311,30 @@ func TestRepositoryRuleSetLifecycleManagement(t *testing.T) { { Source: "test1", ChangeType: event.Create, - Rules: []rule.Rule{&ruleImpl{id: "rule:bar", srcID: "test1"}}, + Rules: []rule.Rule{&ruleImpl{id: "rule:bar", srcID: "test1", pathExpression: "/bar/1"}}, }, { Source: "test2", ChangeType: event.Create, - Rules: []rule.Rule{&ruleImpl{id: "rule:foo", srcID: "test2"}}, + Rules: []rule.Rule{&ruleImpl{id: "rule:foo", srcID: "test2", pathExpression: "/foo/1"}}, }, }, 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]) + assert.Len(t, repo.knownRules, 2) + + rul1, _, err := repo.rulesTree.Find("/bar/1", indextree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) + require.NoError(t, err) + assert.Equal(t, repo.knownRules[0], rul1) + assert.Equal(t, "rule:bar", rul1.ID()) + assert.Equal(t, "test1", rul1.SrcID()) + + rul2, _, err := repo.rulesTree.Find("/foo/1", indextree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) + require.NoError(t, err) + assert.Equal(t, repo.knownRules[1], rul2) + assert.Equal(t, "rule:foo", rul2.ID()) + assert.Equal(t, "test2", rul2.SrcID()) }, }, { @@ -274,23 +343,30 @@ func TestRepositoryRuleSetLifecycleManagement(t *testing.T) { { Source: "test1", ChangeType: event.Create, - Rules: []rule.Rule{&ruleImpl{id: "rule:bar", srcID: "test1"}}, + Rules: []rule.Rule{&ruleImpl{id: "rule:bar", srcID: "test1", pathExpression: "/bar/1"}}, }, { Source: "test2", ChangeType: event.Create, - Rules: []rule.Rule{&ruleImpl{id: "rule:foo", srcID: "test2"}}, + Rules: []rule.Rule{&ruleImpl{id: "rule:foo", srcID: "test2", pathExpression: "/foo/1"}}, }, { - Source: "test2", + Source: "test1", 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]) + assert.Len(t, repo.knownRules, 1) + assert.False(t, repo.rulesTree.Empty()) + + rul, _, err := repo.rulesTree.Find("/foo/1", indextree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) + require.NoError(t, err) + + assert.Equal(t, repo.knownRules[0], rul) + assert.Equal(t, "rule:foo", rul.ID()) + assert.Equal(t, "test2", rul.SrcID()) }, }, { @@ -299,39 +375,61 @@ func TestRepositoryRuleSetLifecycleManagement(t *testing.T) { { Source: "test1", ChangeType: event.Create, - Rules: []rule.Rule{&ruleImpl{id: "rule:bar", srcID: "test1"}}, + Rules: []rule.Rule{&ruleImpl{id: "rule:bar", srcID: "test1", pathExpression: "/bar/1"}}, }, { 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}}, + &ruleImpl{id: "rule:foo1", srcID: "test2", hash: []byte{1}, pathExpression: "/foo/1"}, + &ruleImpl{id: "rule:foo2", srcID: "test2", hash: []byte{2}, pathExpression: "/foo/2"}, + &ruleImpl{id: "rule:foo3", srcID: "test2", hash: []byte{3}, pathExpression: "/foo/3"}, + &ruleImpl{id: "rule:foo4", srcID: "test2", hash: []byte{4}, pathExpression: "/foo/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 + &ruleImpl{id: "rule:foo1", srcID: "test2", hash: []byte{5}, pathExpression: "/foo/1"}, // updated + &ruleImpl{id: "rule:foo2", srcID: "test2", hash: []byte{2}, pathExpression: "/foo/2"}, // as before + // &ruleImpl{id: "rule:foo3", srcID: "test2", hash: []byte{3}, pathExpression: "/foo/3"}, // deleted + &ruleImpl{id: "rule:foo4", srcID: "test2", hash: []byte{4}, pathExpression: "/foo/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}}, - }) + require.Len(t, repo.knownRules, 4) + assert.False(t, repo.rulesTree.Empty()) + + rulBar, _, err := repo.rulesTree.Find("/bar/1", indextree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) + require.NoError(t, err) + assert.Equal(t, repo.knownRules[0], rulBar) + assert.Equal(t, "rule:bar", rulBar.ID()) + assert.Equal(t, "test1", rulBar.SrcID()) + + rulFoo1, _, err := repo.rulesTree.Find("/foo/1", indextree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) + require.NoError(t, err) + assert.Equal(t, repo.knownRules[1], rulFoo1) + assert.Equal(t, "rule:foo1", rulFoo1.ID()) + assert.Equal(t, "test2", rulFoo1.SrcID()) + assert.Equal(t, []byte{5}, rulFoo1.(*ruleImpl).hash) //nolint: forcetypeassert + + rulFoo2, _, err := repo.rulesTree.Find("/foo/2", indextree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) + require.NoError(t, err) + assert.Equal(t, repo.knownRules[2], rulFoo2) + assert.Equal(t, "rule:foo2", rulFoo2.ID()) + assert.Equal(t, "test2", rulFoo2.SrcID()) + assert.Equal(t, []byte{2}, rulFoo2.(*ruleImpl).hash) //nolint: forcetypeassert + + rulFoo4, _, err := repo.rulesTree.Find("/foo/4", indextree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) + require.NoError(t, err) + assert.Equal(t, repo.knownRules[3], rulFoo4) + assert.Equal(t, "rule:foo4", rulFoo4.ID()) + assert.Equal(t, "test2", rulFoo4.SrcID()) + assert.Equal(t, []byte{4}, rulFoo4.(*ruleImpl).hash) //nolint: forcetypeassert }, }, } { diff --git a/internal/rules/rule/mocks/repository.go b/internal/rules/rule/mocks/repository.go index d59c4edea..91ffac6a6 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,29 @@ 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) +// FindRule provides a mock function with given fields: request +func (_m *RepositoryMock) FindRule(request *heimdall.Request) (rule.Rule, error) { + ret := _m.Called(request) + + 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.Request) (rule.Rule, error)); ok { + return rf(request) } - if rf, ok := ret.Get(0).(func(*url.URL) rule.Rule); ok { - r0 = rf(_a0) + if rf, ok := ret.Get(0).(func(*heimdall.Request) rule.Rule); ok { + r0 = rf(request) } 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.Request) error); ok { + r1 = rf(request) } else { r1 = ret.Error(1) } @@ -54,14 +58,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)} +// - request *heimdall.Request +func (_e *RepositoryMock_Expecter) FindRule(request interface{}) *RepositoryMock_FindRule_Call { + return &RepositoryMock_FindRule_Call{Call: _e.mock.On("FindRule", request)} } -func (_c *RepositoryMock_FindRule_Call) Run(run func(_a0 *url.URL)) *RepositoryMock_FindRule_Call { +func (_c *RepositoryMock_FindRule_Call) Run(run func(request *heimdall.Request)) *RepositoryMock_FindRule_Call { _c.Call.Run(func(args mock.Arguments) { - run(args[0].(*url.URL)) + run(args[0].(*heimdall.Request)) }) return _c } @@ -71,18 +75,17 @@ 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.Request) (rule.Rule, error)) *RepositoryMock_FindRule_Call { _c.Call.Return(run) return _c } -type mockConstructorTestingTNewRepositoryMock interface { +// 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. +// The first argument is typically a *testing.T value. +func NewRepositoryMock(t interface { mock.TestingT Cleanup(func()) -} - -// 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 { +}) *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..490e4a4c3 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,21 @@ 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) +// 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 +44,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 +58,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 +84,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 +125,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: request +func (_m *RuleMock) Matches(request *heimdall.Request) bool { + ret := _m.Called(request) + + 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.Request) bool); ok { + r0 = rf(request) } else { r0 = ret.Get(0).(bool) } @@ -133,41 +143,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 +} + +// Matches is a helper method to define mock.On call +// - request *heimdall.Request +func (_e *RuleMock_Expecter) Matches(request interface{}) *RuleMock_Matches_Call { + return &RuleMock_Matches_Call{Call: _e.mock.On("Matches", request)} +} + +func (_c *RuleMock_Matches_Call) Run(run func(request *heimdall.Request)) *RuleMock_Matches_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(*heimdall.Request)) + }) + return _c +} + +func (_c *RuleMock_Matches_Call) Return(_a0 bool) *RuleMock_Matches_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *RuleMock_Matches_Call) RunAndReturn(run func(*heimdall.Request) bool) *RuleMock_Matches_Call { + _c.Call.Return(run) + return _c +} + +// 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 } -// 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)} +// 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_MatchesMethod_Call) Run(run func(_a0 string)) *RuleMock_MatchesMethod_Call { +func (_c *RuleMock_PathExpression_Call) Run(run func()) *RuleMock_PathExpression_Call { _c.Call.Run(func(args mock.Arguments) { - run(args[0].(string)) + run() }) return _c } -func (_c *RuleMock_MatchesMethod_Call) Return(_a0 bool) *RuleMock_MatchesMethod_Call { +func (_c *RuleMock_PathExpression_Call) Return(_a0 string) *RuleMock_PathExpression_Call { _c.Call.Return(_a0) return _c } -func (_c *RuleMock_MatchesMethod_Call) RunAndReturn(run func(string) bool) *RuleMock_MatchesMethod_Call { +func (_c *RuleMock_PathExpression_Call) RunAndReturn(run func() string) *RuleMock_PathExpression_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) +// 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 +234,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 +266,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 +307,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..927e6d5d9 100644 --- a/internal/rules/rule/repository.go +++ b/internal/rules/rule/repository.go @@ -17,11 +17,11 @@ 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(request *heimdall.Request) (Rule, error) } diff --git a/internal/rules/rule/rule.go b/internal/rules/rule/rule.go index d3d0aefa3..4f42fe15d 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,7 @@ type Rule interface { ID() string SrcID() string Execute(ctx heimdall.Context) (Backend, error) - MatchesURL(match *url.URL) bool - MatchesMethod(method string) bool + Matches(request *heimdall.Request) bool + PathExpression() string + SameAs(other Rule) bool } diff --git a/internal/rules/rule_executor_impl.go b/internal/rules/rule_executor_impl.go index 3dac1d8c4..59b7d831c 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 { @@ -41,16 +40,10 @@ func (e *ruleExecutor) Execute(ctx heimdall.Context) (rule.Backend, error) { Str("_url", req.URL.String()). Msg("Analyzing request") - rul, err := e.r.FindRule(req.URL) + rul, err := e.r.FindRule(req) 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..041b5cbe6 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(req).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(req).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(req).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..56115e113 100644 --- a/internal/rules/rule_factory_impl.go +++ b/internal/rules/rule_factory_impl.go @@ -31,13 +31,16 @@ import ( "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" ) +type alwaysMatcher struct{} + +func (alwaysMatcher) Match(_ string) bool { return true } + func NewRuleFactory( hf mechanisms.Factory, conf *config.Configuration, @@ -163,18 +166,59 @@ func (f *ruleFactory) CreateRule(version, srcID string, ruleConfig config2.Rule) "no ID defined for rule ID=%s from %s", ruleConfig.ID, srcID) } + if len(ruleConfig.Matcher.Path.Expression) == 0 { + return nil, errorchain.NewWithMessagef(heimdall.ErrConfiguration, + "no path matching expression defined for rule ID=%s from %s", ruleConfig.ID, srcID) + } + + if len(ruleConfig.Matcher.HostGlob) != 0 && len(ruleConfig.Matcher.HostRegex) != 0 { + return nil, errorchain.NewWithMessagef(heimdall.ErrConfiguration, + "host glob and regex expressions are defined for rule ID=%s from %s", ruleConfig.ID, srcID) + } + + if len(ruleConfig.Matcher.Path.Glob) != 0 && len(ruleConfig.Matcher.Path.Regex) != 0 { + return nil, errorchain.NewWithMessagef(heimdall.ErrConfiguration, + "path glob and regex expressions are defined for rule ID=%s from %s", ruleConfig.ID, srcID) + } + if f.mode == config.ProxyMode { if err := checkProxyModeApplicability(srcID, ruleConfig); err != nil { return nil, err } } - matcher, err := patternmatcher.NewPatternMatcher( - ruleConfig.RuleMatcher.Strategy, ruleConfig.RuleMatcher.URL) + var ( + hostMatcher PatternMatcher + pathMatcher PatternMatcher + err error + ) + + switch { + case len(ruleConfig.Matcher.HostGlob) != 0: + hostMatcher, err = newGlobMatcher(ruleConfig.Matcher.HostGlob, '.') + case len(ruleConfig.Matcher.HostRegex) != 0: + hostMatcher, err = newRegexMatcher(ruleConfig.Matcher.HostRegex) + default: + hostMatcher = alwaysMatcher{} + } + 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) + "filed to compile host pattern defined for rule ID=%s from %s", ruleConfig.ID, srcID).CausedBy(err) + } + + switch { + case len(ruleConfig.Matcher.Path.Glob) != 0: + pathMatcher, err = newGlobMatcher(ruleConfig.Matcher.Path.Glob, '/') + case len(ruleConfig.Matcher.Path.Regex) != 0: + pathMatcher, err = newRegexMatcher(ruleConfig.Matcher.Path.Regex) + default: + pathMatcher = alwaysMatcher{} + } + + if err != nil { + return nil, errorchain.NewWithMessagef(heimdall.ErrConfiguration, + "filed to compile path pattern defined for rule ID=%s from %s", ruleConfig.ID, srcID).CausedBy(err) } authenticators, subHandlers, finalizers, err := f.createExecutePipeline(version, ruleConfig.Execute) @@ -187,7 +231,7 @@ func (f *ruleFactory) CreateRule(version, srcID string, ruleConfig config2.Rule) return nil, err } - methods, err := expandHTTPMethods(ruleConfig.Methods) + methods, err := expandHTTPMethods(ruleConfig.Matcher.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) @@ -198,7 +242,7 @@ func (f *ruleFactory) CreateRule(version, srcID string, ruleConfig config2.Rule) 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) + methods = x.IfThenElse(len(methods) != 0, methods, f.defaultRule.allowedMethods) } if len(authenticators) == 0 { @@ -218,22 +262,25 @@ func (f *ruleFactory) CreateRule(version, srcID string, ruleConfig config2.Rule) } return &ruleImpl{ - id: ruleConfig.ID, + id: ruleConfig.ID, + srcID: srcID, + isDefault: false, 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, + allowedScheme: ruleConfig.Matcher.Scheme, + allowedMethods: methods, + hostMatcher: hostMatcher, + pathMatcher: pathMatcher, + pathExpression: ruleConfig.Matcher.Path.Expression, + backend: ruleConfig.Backend, + hash: hash, + sc: authenticators, + sh: subHandlers, + fi: finalizers, + eh: errorHandlers, }, nil } @@ -353,7 +400,7 @@ func (f *ruleFactory) initWithDefaultRule(ruleConfig *config.DefaultRule, logger f.defaultRule = &ruleImpl{ id: "default", encodedSlashesHandling: config2.EncodedSlashesOff, - methods: methods, + allowedMethods: methods, srcID: "config", isDefault: true, sc: authenticators, diff --git a/internal/rules/rule_factory_impl_test.go b/internal/rules/rule_factory_impl_test.go index 21fad4011..b2e90bbcd 100644 --- a/internal/rules/rule_factory_impl_test.go +++ b/internal/rules/rule_factory_impl_test.go @@ -448,7 +448,7 @@ func TestRuleFactoryNew(t *testing.T) { 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.ElementsMatch(t, defRule.allowedMethods, []string{"FOO"}) assert.Len(t, defRule.sc, 1) assert.Empty(t, defRule.sh) assert.Empty(t, defRule.fi) @@ -495,7 +495,7 @@ func TestRuleFactoryNew(t *testing.T) { 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.ElementsMatch(t, defRule.allowedMethods, []string{"FOO", "BAR"}) assert.Len(t, defRule.sc, 1) assert.Len(t, defRule.sh, 2) assert.Len(t, defRule.fi, 1) @@ -544,7 +544,7 @@ func TestRuleFactoryCreateRule(t *testing.T) { assert func(t *testing.T, err error, rul *ruleImpl) }{ { - uc: "without default rule and with missing id", + uc: "with missing id", config: config2.Rule{}, assert: func(t *testing.T, err error, _ *ruleImpl) { t.Helper() @@ -555,21 +555,73 @@ func TestRuleFactoryCreateRule(t *testing.T) { }, }, { - uc: "in proxy mode, with id, but missing forward_to definition", - opMode: config.ProxyMode, + uc: "without match path expression", 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 path matching expression") + }, + }, + { + uc: "with both glob and regex host patterns configured", + config: config2.Rule{ + ID: "foobar", + Matcher: config2.Matcher{ + Path: config2.Path{Expression: "/foo/bar"}, + HostRegex: ".*", + HostGlob: "**", + }, + }, + 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(), "host glob and regex") + }, + }, + { + uc: "with both glob and regex path patterns configured", + config: config2.Rule{ + ID: "foobar", + Matcher: config2.Matcher{ + Path: config2.Path{Expression: "/foo/bar", Regex: ".*", Glob: "**"}, + }, + }, + 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(), "path glob and regex") + }, + }, + { + uc: "in proxy mode without forward_to definition", + opMode: config.ProxyMode, + config: config2.Rule{ + ID: "foobar", + Matcher: config2.Matcher{Path: config2.Path{Expression: "/foo/bar"}}, + }, + 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") }, }, { - uc: "in proxy mode, with id and empty forward_to definition", + uc: "in proxy mode and empty forward_to definition", opMode: config.ProxyMode, - config: config2.Rule{ID: "foobar", Backend: &config2.Backend{}}, + config: config2.Rule{ + ID: "foobar", + Matcher: config2.Matcher{Path: config2.Path{Expression: "/foo/bar"}}, + Backend: &config2.Backend{}, + }, assert: func(t *testing.T, err error, _ *ruleImpl) { t.Helper() @@ -579,10 +631,11 @@ func TestRuleFactoryCreateRule(t *testing.T) { }, }, { - uc: "in proxy mode, with id and forward_to.host, but empty rewrite definition", + uc: "in proxy mode, with forward_to.host, but empty rewrite definition", opMode: config.ProxyMode, config: config2.Rule{ - ID: "foobar", + ID: "foobar", + Matcher: config2.Matcher{Path: config2.Path{Expression: "/foo/bar"}}, Backend: &config2.Backend{ Host: "foo.bar", URLRewriter: &config2.URLRewriter{}, @@ -597,33 +650,42 @@ func TestRuleFactoryCreateRule(t *testing.T) { }, }, { - uc: "without default rule, with id, but without url", - config: config2.Rule{ID: "foobar"}, + uc: "with bad host pattern", + config: config2.Rule{ + ID: "foobar", + Matcher: config2.Matcher{ + HostRegex: "?>?<*??", + Path: config2.Path{Expression: "/foo/bar"}, + }, + }, 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 pattern") }, }, { - uc: "without default rule, with id, but bad url pattern", - config: config2.Rule{ID: "foobar", RuleMatcher: config2.Matcher{URL: "?>?<*??"}}, + uc: "with bad path pattern", + config: config2.Rule{ + ID: "foobar", + Matcher: config2.Matcher{Path: config2.Path{Expression: "/foo/bar", Glob: "!*][)(*"}}, + }, 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 path pattern") }, }, { 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: config2.Path{Expression: "/foo/bar"}}, + Execute: []config.MechanismConfig{{"authenticator": "foo"}}, }, configureMocks: func(t *testing.T, mhf *mocks3.FactoryMock) { t.Helper() @@ -641,7 +703,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: config2.Path{Expression: "/foo/bar"}}, ErrorHandler: []config.MechanismConfig{{"error_handler": "foo"}}, }, configureMocks: func(t *testing.T, mhf *mocks3.FactoryMock) { @@ -659,8 +721,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: config2.Path{Expression: "/foo/bar"}}, }, assert: func(t *testing.T, err error, _ *ruleImpl) { t.Helper() @@ -673,9 +735,9 @@ func TestRuleFactoryCreateRule(t *testing.T) { { uc: "without default rule and with only authenticator configured", config: config2.Rule{ - ID: "foobar", - RuleMatcher: config2.Matcher{URL: "http://foo.bar", Strategy: "glob"}, - Execute: []config.MechanismConfig{{"authenticator": "foo"}}, + ID: "foobar", + Matcher: config2.Matcher{Path: config2.Path{Expression: "/foo/bar"}}, + Execute: []config.MechanismConfig{{"authenticator": "foo"}}, }, configureMocks: func(t *testing.T, mhf *mocks3.FactoryMock) { t.Helper() @@ -693,8 +755,8 @@ func TestRuleFactoryCreateRule(t *testing.T) { { 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: config2.Path{Expression: "/foo/bar"}}, Execute: []config.MechanismConfig{ {"authenticator": "foo"}, {"contextualizer": "bar"}, @@ -717,8 +779,8 @@ func TestRuleFactoryCreateRule(t *testing.T) { { 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"}, + ID: "foobar", + Matcher: config2.Matcher{Path: config2.Path{Expression: "/foo/bar"}}, Execute: []config.MechanismConfig{ {"authenticator": "foo"}, {"contextualizer": "bar"}, @@ -743,13 +805,12 @@ func TestRuleFactoryCreateRule(t *testing.T) { { 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"}, + ID: "foobar", + Matcher: config2.Matcher{Path: config2.Path{Expression: "/foo/bar"}, Methods: []string{"FOO", ""}}, Execute: []config.MechanismConfig{ {"authenticator": "foo"}, {"finalizer": "bar"}, }, - Methods: []string{"FOO", ""}, }, configureMocks: func(t *testing.T, mhf *mocks3.FactoryMock) { t.Helper() @@ -768,8 +829,8 @@ func TestRuleFactoryCreateRule(t *testing.T) { { 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"}, + ID: "foobar", + Matcher: config2.Matcher{Path: config2.Path{Expression: "/foo/bar"}}, Execute: []config.MechanismConfig{ {"authenticator": "foo"}, {"finalizer": "bar"}, @@ -792,12 +853,11 @@ func TestRuleFactoryCreateRule(t *testing.T) { { 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"}, + ID: "foobar", + Matcher: config2.Matcher{Path: config2.Path{Expression: "/foo/bar"}, Methods: []string{"FOO", "BAR"}}, Execute: []config.MechanismConfig{ {"authenticator": "foo"}, }, - Methods: []string{"FOO", "BAR"}, }, configureMocks: func(t *testing.T, mhf *mocks3.FactoryMock) { t.Helper() @@ -814,8 +874,11 @@ func TestRuleFactoryCreateRule(t *testing.T) { 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.Empty(t, rul.allowedScheme) + assert.Equal(t, "/foo/bar", rul.PathExpression()) + assert.IsType(t, alwaysMatcher{}, rul.hostMatcher) + assert.IsType(t, alwaysMatcher{}, rul.pathMatcher) + assert.ElementsMatch(t, rul.allowedMethods, []string{"FOO", "BAR"}) assert.Len(t, rul.sc, 1) assert.Empty(t, rul.sh) assert.Empty(t, rul.fi) @@ -826,13 +889,12 @@ func TestRuleFactoryCreateRule(t *testing.T) { uc: "without default rule but with 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: config2.Path{Expression: "/foo/bar"}, Methods: []string{"FOO", "BAR"}}, Execute: []config.MechanismConfig{ {"authenticator": "foo"}, }, - Methods: []string{"FOO", "BAR"}, }, configureMocks: func(t *testing.T, mhf *mocks3.FactoryMock) { t.Helper() @@ -849,8 +911,11 @@ func TestRuleFactoryCreateRule(t *testing.T) { 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.Empty(t, rul.allowedScheme) + assert.Equal(t, "/foo/bar", rul.PathExpression()) + assert.IsType(t, alwaysMatcher{}, rul.hostMatcher) + assert.IsType(t, alwaysMatcher{}, rul.pathMatcher) + assert.ElementsMatch(t, rul.allowedMethods, []string{"FOO", "BAR"}) assert.Len(t, rul.sc, 1) assert.Empty(t, rul.sh) assert.Empty(t, rul.fi) @@ -859,17 +924,17 @@ 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: config2.Path{Expression: "/foo/bar"}}, }, defaultRule: &ruleImpl{ - methods: []string{"FOO"}, - sc: compositeSubjectCreator{&mocks.SubjectCreatorMock{}}, - sh: compositeSubjectHandler{&mocks.SubjectHandlerMock{}}, - fi: compositeSubjectHandler{&mocks.SubjectHandlerMock{}}, - eh: compositeErrorHandler{&mocks.ErrorHandlerMock{}}, + allowedMethods: []string{"FOO"}, + 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 +945,11 @@ 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.Empty(t, rul.allowedScheme) + assert.Equal(t, "/foo/bar", rul.PathExpression()) + assert.IsType(t, alwaysMatcher{}, rul.hostMatcher) + assert.IsType(t, alwaysMatcher{}, rul.pathMatcher) + assert.ElementsMatch(t, rul.allowedMethods, []string{"FOO"}) assert.Len(t, rul.sc, 1) assert.Len(t, rul.sh, 1) assert.Len(t, rul.fi, 1) @@ -891,8 +959,16 @@ 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"}, + ID: "foobar", + Matcher: config2.Matcher{ + Scheme: "https", + HostGlob: "**.example.com", + Methods: []string{"BAR", "BAZ"}, + Path: config2.Path{ + Expression: "/foo/:resource", + Regex: "^/foo/(bar|baz)", + }, + }, EncodedSlashesHandling: config2.EncodedSlashesNoDecode, Execute: []config.MechanismConfig{ {"authenticator": "foo"}, @@ -903,14 +979,13 @@ 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{}}, + allowedMethods: []string{"FOO"}, + 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() @@ -936,8 +1011,13 @@ func TestRuleFactoryCreateRule(t *testing.T) { 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, "https", rul.allowedScheme) + assert.Equal(t, "/foo/:resource", rul.PathExpression()) + require.IsType(t, &globMatcher{}, rul.hostMatcher) + assert.True(t, rul.hostMatcher.Match("foo.example.com")) + require.IsType(t, ®expMatcher{}, rul.pathMatcher) + assert.True(t, rul.pathMatcher.Match("/foo/bar")) + assert.ElementsMatch(t, rul.allowedMethods, []string{"BAR", "BAZ"}) // nil checks above mean the responses from the mockHandlerFactory are used // and not the values from the default rule @@ -956,8 +1036,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{ + Scheme: "https", + HostGlob: "**.example.com", + Methods: []string{"BAR", "BAZ"}, + Path: config2.Path{ + Expression: "/foo/:resource", + Regex: "^/foo/(bar|baz)", + }, + }, EncodedSlashesHandling: config2.EncodedSlashesOn, Backend: &config2.Backend{ Host: "bar.foo", @@ -977,14 +1065,13 @@ 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{}}, + allowedMethods: []string{"FOO"}, + 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() @@ -1010,8 +1097,13 @@ func TestRuleFactoryCreateRule(t *testing.T) { 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, "https", rul.allowedScheme) + assert.Equal(t, "/foo/:resource", rul.PathExpression()) + require.IsType(t, &globMatcher{}, rul.hostMatcher) + assert.True(t, rul.hostMatcher.Match("foo.example.com")) + require.IsType(t, ®expMatcher{}, rul.pathMatcher) + assert.True(t, rul.pathMatcher.Match("/foo/bar")) + assert.ElementsMatch(t, rul.allowedMethods, []string{"BAR", "BAZ"}) assert.Equal(t, "https://bar.foo/baz/bar?foo=bar", rul.backend.CreateURL(&url.URL{ Scheme: "http", Host: "foo.bar:8888", @@ -1036,13 +1128,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: config2.Path{Expression: "/foo/bar"}, Methods: []string{"FOO"}}, Execute: []config.MechanismConfig{ {"authenticator": "foo"}, {"finalizer": "bar", "if": 1}, }, - Methods: []string{"FOO"}, }, configureMocks: func(t *testing.T, mhf *mocks3.FactoryMock) { t.Helper() @@ -1060,13 +1151,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: config2.Path{Expression: "/foo/bar"}, Methods: []string{"FOO"}}, Execute: []config.MechanismConfig{ {"authenticator": "foo"}, {"finalizer": "bar", "if": ""}, }, - Methods: []string{"FOO"}, }, configureMocks: func(t *testing.T, mhf *mocks3.FactoryMock) { t.Helper() @@ -1084,8 +1174,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: config2.Path{Expression: "/foo/bar"}, Methods: []string{"FOO"}}, Execute: []config.MechanismConfig{ {"authenticator": "foo"}, {"authorizer": "bar", "if": "false"}, @@ -1093,7 +1183,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 +1205,11 @@ 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.Empty(t, rul.allowedScheme) + assert.Equal(t, "/foo/bar", rul.PathExpression()) + assert.IsType(t, alwaysMatcher{}, rul.hostMatcher) + assert.IsType(t, alwaysMatcher{}, rul.pathMatcher) + assert.ElementsMatch(t, rul.allowedMethods, []string{"FOO"}) require.Len(t, rul.sc, 1) assert.NotNil(t, rul.sc[0]) @@ -1150,8 +1242,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: config2.Path{Expression: "/foo/bar"}, Methods: []string{"FOO"}}, Execute: []config.MechanismConfig{ {"authenticator": "foo"}, {"authorizer": "bar"}, @@ -1161,7 +1253,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 +1279,11 @@ 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.Empty(t, rul.allowedScheme) + assert.Equal(t, "/foo/bar", rul.PathExpression()) + assert.IsType(t, alwaysMatcher{}, rul.hostMatcher) + assert.IsType(t, alwaysMatcher{}, rul.pathMatcher) + assert.ElementsMatch(t, rul.allowedMethods, []string{"FOO"}) require.Len(t, rul.sc, 1) assert.NotNil(t, rul.sc[0]) diff --git a/internal/rules/rule_impl.go b/internal/rules/rule_impl.go index 7e882d072..d47c6d901 100644 --- a/internal/rules/rule_impl.go +++ b/internal/rules/rule_impl.go @@ -17,7 +17,6 @@ package rules import ( - "fmt" "net/url" "slices" "strings" @@ -26,16 +25,18 @@ import ( "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" ) type ruleImpl struct { id string encodedSlashesHandling config.EncodedSlashesHandling - urlMatcher patternmatcher.PatternMatcher + pathExpression string + allowedScheme string + hostMatcher PatternMatcher + pathMatcher PatternMatcher + allowedMethods []string backend *config.Backend - methods []string srcID string isDefault bool hash []byte @@ -73,52 +74,59 @@ func (r *ruleImpl) Execute(ctx heimdall.Context) (rule.Backend, error) { var upstream rule.Backend if r.backend != nil { - targetURL := *ctx.Request().URL + 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(&targetURL.URL), } } return upstream, nil } -func (r *ruleImpl) MatchesURL(requestURL *url.URL) bool { - var path string +func (r *ruleImpl) Matches(request *heimdall.Request) bool { + // fastest checks first + // match scheme + if len(r.allowedScheme) != 0 && r.allowedScheme != request.URL.Scheme { + return false + } - switch r.encodedSlashesHandling { - case config.EncodedSlashesOff: - if strings.Contains(requestURL.RawPath, "%2F") { - return false - } + // match methods + if !slices.Contains(r.allowedMethods, request.Method) { + return false + } - 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") + // check encoded slash handling + if r.encodedSlashesHandling == config.EncodedSlashesOff && strings.Contains(request.URL.RawPath, "%2F") { + return false + } - break - } + // match host + if !r.hostMatcher.Match(request.URL.Host) { + return false + } - fallthrough - default: - path = requestURL.Path + // match path + if !r.pathMatcher.Match(request.URL.Path) { + return false } - return r.urlMatcher.Match(fmt.Sprintf("%s://%s%s", requestURL.Scheme, requestURL.Host, path)) + return true } -func (r *ruleImpl) MatchesMethod(method string) bool { return slices.Contains(r.methods, method) } - 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) SameAs(other rule.Rule) bool { + return r.ID() == other.ID() && r.SrcID() == other.SrcID() +} + type backend struct { targetURL *url.URL } diff --git a/internal/rules/rule_impl_test.go b/internal/rules/rule_impl_test.go index 4374fcb81..4f59a6c00 100644 --- a/internal/rules/rule_impl_test.go +++ b/internal/rules/rule_impl_test.go @@ -18,6 +18,7 @@ package rules import ( "context" + "net/http" "net/url" "testing" @@ -29,202 +30,94 @@ import ( "github.com/dadrus/heimdall/internal/rules/config" "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: "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 string + rule *ruleImpl + toMatch *heimdall.Request + matches 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) - }, + rule: &ruleImpl{ + hostMatcher: testMatcher(true), + pathMatcher: testMatcher(true), + allowedMethods: []string{http.MethodGet}, + encodedSlashesHandling: config.EncodedSlashesOn, + }, + toMatch: &heimdall.Request{Method: http.MethodGet, URL: &heimdall.URL{}}, + matches: true, }, { - 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 scheme", + rule: &ruleImpl{ + hostMatcher: testMatcher(true), + pathMatcher: testMatcher(true), + allowedScheme: "https", + allowedMethods: []string{http.MethodGet}, + encodedSlashesHandling: config.EncodedSlashesOn, + }, + toMatch: &heimdall.Request{Method: http.MethodGet, URL: &heimdall.URL{}}, + matches: false, }, { - 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: "doesn't match method", + rule: &ruleImpl{ + hostMatcher: testMatcher(true), + pathMatcher: testMatcher(true), + allowedMethods: []string{http.MethodGet}, + encodedSlashesHandling: config.EncodedSlashesOn, + }, + toMatch: &heimdall.Request{Method: http.MethodPost, URL: &heimdall.URL{}}, + matches: false, }, { - 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: "doesn't match due to not allowed encoded slash", + rule: &ruleImpl{ + hostMatcher: testMatcher(true), + pathMatcher: testMatcher(true), + allowedMethods: []string{http.MethodGet}, + encodedSlashesHandling: config.EncodedSlashesOff, + }, + toMatch: &heimdall.Request{Method: http.MethodGet, URL: &heimdall.URL{URL: url.URL{RawPath: "/foo%2Fbar"}}}, + matches: false, }, { - 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 host", + rule: &ruleImpl{ + hostMatcher: testMatcher(false), + pathMatcher: testMatcher(true), + allowedMethods: []string{http.MethodGet}, + encodedSlashesHandling: config.EncodedSlashesOn, + }, + toMatch: &heimdall.Request{Method: http.MethodGet, URL: &heimdall.URL{}}, + matches: false, }, { - 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() - - assert.False(t, matched) - }, - }, - { - 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() - - assert.False(t, matched) - }, + uc: "doesn't match path", + rule: &ruleImpl{ + hostMatcher: testMatcher(true), + pathMatcher: testMatcher(false), + allowedMethods: []string{http.MethodGet}, + encodedSlashesHandling: config.EncodedSlashesOn, + }, + toMatch: &heimdall.Request{Method: http.MethodGet, URL: &heimdall.URL{}}, + matches: false, }, } { 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) - // WHEN - matched := rul.MatchesURL(tbmu) + matched := tc.rule.Matches(tc.toMatch) // THEN - tc.assert(t, matched) + assert.Equal(t, tc.matches, matched) }) } } @@ -401,7 +294,7 @@ 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}}) }, assert: func(t *testing.T, err error, backend rule.Backend) { t.Helper() @@ -431,7 +324,7 @@ 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}}) }, assert: func(t *testing.T, err error, backend rule.Backend) { t.Helper() @@ -461,7 +354,7 @@ 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}}) }, assert: func(t *testing.T, err error, backend rule.Backend) { t.Helper() @@ -491,7 +384,7 @@ 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}}) }, assert: func(t *testing.T, err error, backend rule.Backend) { t.Helper() @@ -521,7 +414,7 @@ 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) { t.Helper() 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 }) } From e47a7246f33805cc6631d88a971868f3a41ecd2a Mon Sep 17 00:00:00 2001 From: Dimitrij Drus Date: Tue, 16 Apr 2024 19:13:01 +0200 Subject: [PATCH 06/76] ruleset version updated to 1alpha4 respectively to v1alpha4 for kubernetes provider --- internal/rules/config/version.go | 2 +- .../admissioncontroller/controller_test.go | 28 ++--- .../admissioncontroller/validator.go | 8 +- .../api/{v1alpha3 => v1alpha4}/client.go | 4 +- .../api/{v1alpha3 => v1alpha4}/client_test.go | 8 +- .../api/{v1alpha3 => v1alpha4}/json_patch.go | 2 +- .../{v1alpha3 => v1alpha4}/mocks/client.go | 2 +- .../mocks/rule_set_repository.go | 4 +- .../rule_set_repository.go | 2 +- .../rule_set_repository_impl.go | 2 +- .../api/{v1alpha3 => v1alpha4}/types.go | 2 +- .../zz_generated.deepcopy.go | 2 +- .../rules/provider/kubernetes/provider.go | 48 ++++---- .../provider/kubernetes/provider_test.go | 110 +++++++++--------- 14 files changed, 112 insertions(+), 112 deletions(-) rename internal/rules/provider/kubernetes/api/{v1alpha3 => v1alpha4}/client.go (97%) rename internal/rules/provider/kubernetes/api/{v1alpha3 => v1alpha4}/client_test.go (96%) rename internal/rules/provider/kubernetes/api/{v1alpha3 => v1alpha4}/json_patch.go (99%) rename internal/rules/provider/kubernetes/api/{v1alpha3 => v1alpha4}/mocks/client.go (99%) rename internal/rules/provider/kubernetes/api/{v1alpha3 => v1alpha4}/mocks/rule_set_repository.go (99%) rename internal/rules/provider/kubernetes/api/{v1alpha3 => v1alpha4}/rule_set_repository.go (98%) rename internal/rules/provider/kubernetes/api/{v1alpha3 => v1alpha4}/rule_set_repository_impl.go (99%) rename internal/rules/provider/kubernetes/api/{v1alpha3 => v1alpha4}/types.go (99%) rename internal/rules/provider/kubernetes/api/{v1alpha3 => v1alpha4}/zz_generated.deepcopy.go (99%) 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/provider/kubernetes/admissioncontroller/controller_test.go b/internal/rules/provider/kubernetes/admissioncontroller/controller_test.go index 90afe956d..834552446 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,7 +266,7 @@ func TestControllerLifecycle(t *testing.T) { Generation: 1, CreationTimestamp: metav1.NewTime(time.Now()), }, - Spec: v1alpha3.RuleSetSpec{ + Spec: v1alpha4.RuleSetSpec{ AuthClassName: authClass, Rules: []config2.Rule{ { @@ -346,9 +346,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,7 +359,7 @@ func TestControllerLifecycle(t *testing.T) { Generation: 1, CreationTimestamp: metav1.NewTime(time.Now()), }, - Spec: v1alpha3.RuleSetSpec{ + Spec: v1alpha4.RuleSetSpec{ AuthClassName: authClass, Rules: []config2.Rule{ { diff --git a/internal/rules/provider/kubernetes/admissioncontroller/validator.go b/internal/rules/provider/kubernetes/admissioncontroller/validator.go index 3f56956d9..e46deecc3 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 + // currently the only possible version is v1alpha4, which is mapped to the version "1alpha3" used internally return "1alpha3" } 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 96% rename from internal/rules/provider/kubernetes/api/v1alpha3/client_test.go rename to internal/rules/provider/kubernetes/api/v1alpha4/client_test.go index f5ada3c15..d9aadf893 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", @@ -133,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) 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 99% rename from internal/rules/provider/kubernetes/api/v1alpha3/mocks/client.go rename to internal/rules/provider/kubernetes/api/v1alpha4/mocks/client.go index 9e704a6c7..f13364c78 100644 --- a/internal/rules/provider/kubernetes/api/v1alpha3/mocks/client.go +++ b/internal/rules/provider/kubernetes/api/v1alpha4/mocks/client.go @@ -3,7 +3,7 @@ package mocks import ( - v1alpha3 "github.com/dadrus/heimdall/internal/rules/provider/kubernetes/api/v1alpha3" + v1alpha3 "github.com/dadrus/heimdall/internal/rules/provider/kubernetes/api/v1alpha4" mock "github.com/stretchr/testify/mock" ) 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 99% 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 78f50a83d..dd15ed7f4 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 @@ -10,7 +10,7 @@ import ( v1 "k8s.io/apimachinery/pkg/apis/meta/v1" - v1alpha3 "github.com/dadrus/heimdall/internal/rules/provider/kubernetes/api/v1alpha3" + v1alpha3 "github.com/dadrus/heimdall/internal/rules/provider/kubernetes/api/v1alpha4" watch "k8s.io/apimachinery/pkg/watch" ) @@ -184,7 +184,7 @@ 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)} 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..444afc569 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 + // currently the only possible version is v1alpha4, which is mapped to the version "1alpha3" used internally return "1alpha3" } 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 b3928f121..1a5e4d506 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,7 +205,7 @@ 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{ { @@ -234,13 +234,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 @@ -273,7 +273,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) @@ -332,15 +332,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 @@ -355,7 +355,7 @@ 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) @@ -385,13 +385,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) { @@ -399,7 +399,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) @@ -410,13 +410,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 @@ -426,7 +426,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{ @@ -455,7 +455,7 @@ 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) @@ -490,13 +490,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) @@ -509,7 +509,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) { @@ -519,7 +519,7 @@ 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) @@ -549,7 +549,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) @@ -562,7 +562,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{ @@ -587,7 +587,7 @@ 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) @@ -617,13 +617,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 @@ -640,7 +640,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) @@ -650,19 +650,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 @@ -672,7 +672,7 @@ 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{ { @@ -717,7 +717,7 @@ 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) @@ -765,19 +765,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" @@ -808,7 +808,7 @@ 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) @@ -856,13 +856,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 @@ -872,7 +872,7 @@ 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{ { @@ -912,7 +912,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) @@ -922,13 +922,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)) }, }, } { @@ -938,8 +938,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, } From c467a90fc89e5fd312a7ff79a8c0a15a6d86c0fc Mon Sep 17 00:00:00 2001 From: Dimitrij Drus Date: Tue, 16 Apr 2024 19:13:41 +0200 Subject: [PATCH 07/76] ruleset version in helm chart updated --- charts/heimdall/crds/ruleset.yaml | 95 +++++++++++-------- charts/heimdall/templates/demo/test-rule.yaml | 8 +- 2 files changed, 62 insertions(+), 41 deletions(-) diff --git a/charts/heimdall/crds/ruleset.yaml b/charts/heimdall/crds/ruleset.yaml index 6c50e05b3..538ef4a6b 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 + - methods properties: - url: - description: The url to match + 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 - strategy: - description: Strategy to match the url. Can either be regex or glob. + 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: 5 - default: glob - enum: - - regex - - glob + maxLength: 512 + path: + description: The path to match + type: object + required: + - expression + properties: + expression: + description: The actual path expression to match. Simple and free (named) wildcards can be used + type: string + maxLength: 256 + glob: + description: Additional glob expression the matched path should be matched against. Mutual exclusive with 'regex'. + type: string + maxLength: 256 + regex: + description: Additional regular expression the matched path should be matched against. Mutual exclusive with 'glob' + type: string + maxLength: 256 + 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" 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/test-rule.yaml b/charts/heimdall/templates/demo/test-rule.yaml index 1ce7b4b40..984e471ae 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,8 @@ spec: rules: - id: public-access match: - url: http://<**>/pub/<**> + path: + expression: /pub/** forward_to: host: {{ include "heimdall.demo.fullname" . }}.heimdall-demo.svc.cluster.local:8080 execute: @@ -35,7 +36,8 @@ spec: - finalizer: noop_finalizer - id: anonymous-access match: - url: http://<**>/anon/<**> + path: + expression: /anon/** forward_to: host: {{ include "heimdall.demo.fullname" . }}.heimdall-demo.svc.cluster.local:8080 execute: From 7a3787e7ce7098fc6d83b19e3f380a933057b2e8 Mon Sep 17 00:00:00 2001 From: Dimitrij Drus Date: Tue, 16 Apr 2024 19:14:08 +0200 Subject: [PATCH 08/76] validation command test data updated --- cmd/validate/test_data/invalid-ruleset-for-proxy-usage.yaml | 2 +- cmd/validate/test_data/valid-ruleset.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 faea647bc..750e61043 100644 --- a/cmd/validate/test_data/invalid-ruleset-for-proxy-usage.yaml +++ b/cmd/validate/test_data/invalid-ruleset-for-proxy-usage.yaml @@ -1,4 +1,4 @@ -version: "1alpha3" +version: "1alpha4" name: test-rule-set rules: - id: rule:foo diff --git a/cmd/validate/test_data/valid-ruleset.yaml b/cmd/validate/test_data/valid-ruleset.yaml index 49ef80225..4a6f6300d 100644 --- a/cmd/validate/test_data/valid-ruleset.yaml +++ b/cmd/validate/test_data/valid-ruleset.yaml @@ -1,4 +1,4 @@ -version: "1alpha3" +version: "1alpha4" name: test-rule-set rules: - id: rule:foo From be24aa190c01494c8baff78dba7f4ea97fe22e4a Mon Sep 17 00:00:00 2001 From: Dimitrij Drus Date: Tue, 16 Apr 2024 19:19:13 +0200 Subject: [PATCH 09/76] first updates to examples --- .../docker-compose/quickstarts/upstream-rules.yaml | 7 ++++--- .../kubernetes/quickstarts/demo-app/base/rules.yaml | 11 +++++++---- .../quickstarts/proxy-demo/heimdall-rules.yaml | 11 +++++++---- 3 files changed, 18 insertions(+), 11 deletions(-) diff --git a/examples/docker-compose/quickstarts/upstream-rules.yaml b/examples/docker-compose/quickstarts/upstream-rules.yaml index 68b9ed363..a784d31f5 100644 --- a/examples/docker-compose/quickstarts/upstream-rules.yaml +++ b/examples/docker-compose/quickstarts/upstream-rules.yaml @@ -1,8 +1,8 @@ -version: "1alpha3" +version: "1alpha4" rules: - id: demo:public match: - url: http://<**>/public + path: /public forward_to: host: upstream:8081 execute: @@ -11,7 +11,8 @@ rules: - id: demo:protected match: - url: http://<**>/<{user,admin}> + path: /:user + regex: ^/(user|admin) forward_to: host: upstream:8081 execute: diff --git a/examples/kubernetes/quickstarts/demo-app/base/rules.yaml b/examples/kubernetes/quickstarts/demo-app/base/rules.yaml index 877ecd6bd..20e218caa 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,16 @@ spec: rules: - id: public-access match: - url: <**>://<**>/pub/<**> + path: + expression: /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: + expression: /anon/** forward_to: # only required for proxy operation mode host: echo-app.quickstarts.svc.cluster.local:8080 execute: @@ -24,7 +26,8 @@ spec: - finalizer: create_jwt - id: redirect match: - url: <**>://<**>/redir/<**> + path: + expression: /redir/** forward_to: # only required for proxy operation mode host: echo-app.quickstarts.svc.cluster.local:8080 execute: diff --git a/examples/kubernetes/quickstarts/proxy-demo/heimdall-rules.yaml b/examples/kubernetes/quickstarts/proxy-demo/heimdall-rules.yaml index 076866291..9360e6a52 100644 --- a/examples/kubernetes/quickstarts/proxy-demo/heimdall-rules.yaml +++ b/examples/kubernetes/quickstarts/proxy-demo/heimdall-rules.yaml @@ -8,11 +8,12 @@ metadata: immutable: true data: rules.yaml: | - version: "1alpha3" + version: "1alpha4" rules: - id: public-access match: - url: <**>://<**>/pub/<**> + path: + expression: /pub/<**> forward_to: host: localhost:8080 rewrite: @@ -22,7 +23,8 @@ data: - id: anonymous-access match: - url: <**>://<**>/anon/<**> + path: + expression: /anon/<**> forward_to: host: localhost:8080 rewrite: @@ -33,7 +35,8 @@ data: - id: redirect match: - url: <**>://<**>/redir/<**> + path: + expression: /redir/<**> forward_to: host: localhost:8080 rewrite: From 109c95d1909611b7ebdb9381a91b7e229d2c8a99 Mon Sep 17 00:00:00 2001 From: Dimitrij Drus Date: Tue, 16 Apr 2024 19:21:07 +0200 Subject: [PATCH 10/76] first changes to the docs --- docs/content/_index.adoc | 7 ++- docs/content/docs/rules/regular_rule.adoc | 56 ++++++++++++----------- docs/content/docs/rules/rule_sets.adoc | 21 ++++++--- docs/openapi/specification.yaml | 23 ++++------ 4 files changed, 59 insertions(+), 48 deletions(-) diff --git a/docs/content/_index.adoc b/docs/content/_index.adoc index f667e4b11..26df73ee7 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/<**> + scheme: http + host_glob: 127.0.0.1:9090 + path: + expression: /api/** execute: - authenticator: keycloak - authorizer: opa diff --git a/docs/content/docs/rules/regular_rule.adoc b/docs/content/docs/rules/regular_rule.adoc index 60df11ada..2e7149376 100644 --- a/docs/content/docs/rules/regular_rule.adoc +++ b/docs/content/docs/rules/regular_rule.adoc @@ -26,46 +26,39 @@ The unique identifier of a rule. It must be unique across all rules loaded by th + Defines how to match a rule and supports the following properties: -** *`url`*: _string_ (mandatory) +** *`scheme`*: _string_ (optional) + -Glob or Regex pattern of the endpoints of your upstream service, which this rule should apply to. Query parameters are ignored. +Which HTTP scheme is allowed. If not specified, both http and https are accepted. -** *`strategy`*: _string_ (optional) +** *`host_glob`*: _string_ (optional) + -Which strategy to use for matching of the value, provided in the `url` property. Can be one of: +Glob expression to match the host. Used after the rule is matched with the `path` definition (see below). Mutually exclusive with `host_regex`. ++ +Head over to https://github.com/gobwas/glob[gobwas/glob] to get more insights about possible options. -*** `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. +** *`host_regex`*: _string_ (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`. -==== +Regular expression to match the host. Used after the rule is matched with the `path` definition (see below). Mutually exclusive with `host_glob`. -*** `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. +** *`path`*: _PathExpression_ (mandatory) + -.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`. -==== +Definitions on how to match the path. -* *`allow_encoded_slashes`*: _string_ (optional) +*** *`expression`*: _string_ (mandatory) + -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`: +The actual matching expression. Simple and free (named and unnamed) wildcards can be used -** *`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. +*** *`glob`*: _string_ (optional) ++ +An additional glob expression, which should be satisfied after the request has been matched by the `expression` value. Mutually exclusive with `regex`. +*** *`regex`*: _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. +An additional regular expression, which should be satisfied after the request has been matched by the `expression` value. Mutually exclusive with `glob`. -* *`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 for the matched URL. If not specified, no rule will feel responsible resulting in `404 Not Found` 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. + .Methods list which effectively expands to all HTTP methods ==== @@ -87,6 +80,17 @@ methods: ---- ==== +* *`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 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. + ++ +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) + Defines where to forward the proxied request to. Used only when heimdall is operated in the Proxy operation mode and supports the following properties: diff --git a/docs/content/docs/rules/rule_sets.adoc b/docs/content/docs/rules/rule_sets.adoc index faa0383ae..555f8929e 100644 --- a/docs/content/docs/rules/rule_sets.adoc +++ b/docs/content/docs/rules/rule_sets.adoc @@ -44,19 +44,23 @@ 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" ] + scheme: https + host_glob: my-service1.local + path: /** + methods: [ "GET" ] execute: - authorizer: foobar - id: rule:2 match: - url: https://my-service2.local/<**> - methods: [ "GET" ] + scheme: https + host_glob: my-service2.local + path: /** + methods: [ "GET" ] execute: - authorizer: barfoo ---- @@ -108,7 +112,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 +121,10 @@ spec: rules: - id: "" match: - url: http://127.0.0.1:9090/foo/<**> + scheme: https + host_glob: 127.0.0.1:9090 + path: + expression: /foo/** execute: - authenticator: foo - authorizer: bar 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/**" } } ] From 12564544df942455b6c586048693d993cd69af4f Mon Sep 17 00:00:00 2001 From: Dimitrij Drus Date: Tue, 16 Apr 2024 19:24:38 +0200 Subject: [PATCH 11/76] some linter warnings resolved --- internal/rules/config/mapstructure_decoder.go | 1 + internal/rules/config/matcher.go | 8 +++++--- internal/rules/mechanisms/cellib/urls.go | 3 ++- internal/rules/mechanisms/cellib/urls_test.go | 3 ++- 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/internal/rules/config/mapstructure_decoder.go b/internal/rules/config/mapstructure_decoder.go index df715be76..f82931c04 100644 --- a/internal/rules/config/mapstructure_decoder.go +++ b/internal/rules/config/mapstructure_decoder.go @@ -29,5 +29,6 @@ func pathExpressionDecodeHookFunc(from reflect.Type, to reflect.Type, data any) return data, nil } + //nolint: forcetypeassert return Path{Expression: data.(string)}, nil } diff --git a/internal/rules/config/matcher.go b/internal/rules/config/matcher.go index 5804edae5..246b70df7 100644 --- a/internal/rules/config/matcher.go +++ b/internal/rules/config/matcher.go @@ -17,13 +17,15 @@ package config import ( - "github.com/dadrus/heimdall/internal/x/stringx" - "github.com/goccy/go-json" "slices" + + "github.com/goccy/go-json" + + "github.com/dadrus/heimdall/internal/x/stringx" ) type Path struct { - Expression string `json:"expression" yaml:"expression" validate:"required"` + Expression string `json:"expression" yaml:"expression" validate:"required"` //nolint:tagalign Glob string `json:"glob" yaml:"glob"` Regex string `json:"regex" yaml:"regex"` } diff --git a/internal/rules/mechanisms/cellib/urls.go b/internal/rules/mechanisms/cellib/urls.go index 71bec8ea9..b9f6bf989 100644 --- a/internal/rules/mechanisms/cellib/urls.go +++ b/internal/rules/mechanisms/cellib/urls.go @@ -17,7 +17,6 @@ package cellib import ( - "github.com/dadrus/heimdall/internal/heimdall" "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 { diff --git a/internal/rules/mechanisms/cellib/urls_test.go b/internal/rules/mechanisms/cellib/urls_test.go index a400bfedf..30e05663e 100644 --- a/internal/rules/mechanisms/cellib/urls_test.go +++ b/internal/rules/mechanisms/cellib/urls_test.go @@ -17,12 +17,13 @@ package cellib import ( - "github.com/dadrus/heimdall/internal/heimdall" "net/url" "testing" "github.com/google/cel-go/cel" "github.com/stretchr/testify/require" + + "github.com/dadrus/heimdall/internal/heimdall" ) func TestUrls(t *testing.T) { From 3c6590f58ab38c87c95d8886e47ae6613fa8c372 Mon Sep 17 00:00:00 2001 From: Dimitrij Drus Date: Sat, 20 Apr 2024 20:04:53 +0200 Subject: [PATCH 12/76] some errors fixed, enhanced internal apis and linter warnings resolved --- go.mod | 1 - go.sum | 2 -- internal/rules/repository_impl.go | 23 ++++++++++------- internal/rules/repository_impl_test.go | 6 ++++- internal/rules/rule/mocks/repository.go | 30 +++++++++++------------ internal/rules/rule/mocks/rule.go | 22 ++++++++--------- internal/rules/rule/repository.go | 2 +- internal/rules/rule/rule.go | 2 +- internal/rules/rule_executor_impl.go | 11 +++++---- internal/rules/rule_executor_impl_test.go | 6 ++--- internal/rules/rule_factory_impl.go | 23 +++++++++++------ internal/rules/rule_impl.go | 19 +++++++++++++- internal/rules/rule_impl_test.go | 6 ++++- 13 files changed, 94 insertions(+), 59 deletions(-) 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/rules/repository_impl.go b/internal/rules/repository_impl.go index 4fc06a468..aefe913b5 100644 --- a/internal/rules/repository_impl.go +++ b/internal/rules/repository_impl.go @@ -22,6 +22,7 @@ import ( "sync" "github.com/rs/zerolog" + "golang.org/x/exp/maps" "github.com/dadrus/heimdall/internal/heimdall" "github.com/dadrus/heimdall/internal/indextree" @@ -61,13 +62,15 @@ type repository struct { quit chan bool } -func (r *repository) FindRule(request *heimdall.Request) (rule.Rule, error) { +func (r *repository) FindRule(ctx heimdall.Context) (rule.Rule, error) { + request := ctx.Request() + r.mutex.RLock() defer r.mutex.RUnlock() rul, params, err := r.rulesTree.Find( request.URL.Path, - indextree.MatcherFunc[rule.Rule](func(candidate rule.Rule) bool { return candidate.Matches(request) }), + indextree.MatcherFunc[rule.Rule](func(candidate rule.Rule) bool { return candidate.Matches(ctx) }), ) if err != nil { if r.dr != nil { @@ -232,14 +235,16 @@ func (r *repository) addRules(rules []rule.Rule) { } } -func (r *repository) removeRules(rules []rule.Rule) { +func (r *repository) removeRules(tbdRules []rule.Rule) { // find all indexes for affected rules - var idxs []int + idxs := make(map[int]int, len(tbdRules)) for idx, rul := range r.knownRules { - for _, tbd := range rules { + for i, tbd := range tbdRules { if rul.SrcID() == tbd.SrcID() && rul.ID() == tbd.ID() { - idxs = append(idxs, idx) + idxs[i] = idx + + break } } } @@ -255,7 +260,7 @@ func (r *repository) removeRules(rules []rule.Rule) { return } - for _, rul := range rules { + for i, rul := range tbdRules { r.mutex.Lock() err := r.rulesTree.Delete( rul.PathExpression(), @@ -268,7 +273,7 @@ func (r *repository) removeRules(rules []rule.Rule) { Str("_src", rul.SrcID()). Str("_id", rul.ID()). Msg("Failed to remove rule") - // TODO: remove idx of the failed rule from the idxs slice + delete(idxs, i) } else { r.logger.Debug(). Str("_src", rul.SrcID()). @@ -279,7 +284,7 @@ func (r *repository) removeRules(rules []rule.Rule) { // 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 { + for i, idx := range maps.Values(idxs) { tailIdx := len(r.knownRules) - (1 + i) r.knownRules[idx] = r.knownRules[tailIdx] diff --git a/internal/rules/repository_impl_test.go b/internal/rules/repository_impl_test.go index a9e3d04bf..880ed3997 100644 --- a/internal/rules/repository_impl_test.go +++ b/internal/rules/repository_impl_test.go @@ -29,6 +29,7 @@ import ( "github.com/stretchr/testify/require" "github.com/dadrus/heimdall/internal/heimdall" + mocks2 "github.com/dadrus/heimdall/internal/heimdall/mocks" "github.com/dadrus/heimdall/internal/indextree" "github.com/dadrus/heimdall/internal/rules/event" "github.com/dadrus/heimdall/internal/rules/rule" @@ -173,9 +174,12 @@ func TestRepositoryFindRule(t *testing.T) { addRules(t, repo) 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 - rul, err := repo.FindRule(req) + rul, err := repo.FindRule(ctx) // THEN tc.assert(t, err, rul) diff --git a/internal/rules/rule/mocks/repository.go b/internal/rules/rule/mocks/repository.go index 91ffac6a6..d770777b7 100644 --- a/internal/rules/rule/mocks/repository.go +++ b/internal/rules/rule/mocks/repository.go @@ -22,9 +22,9 @@ func (_m *RepositoryMock) EXPECT() *RepositoryMock_Expecter { return &RepositoryMock_Expecter{mock: &_m.Mock} } -// FindRule provides a mock function with given fields: request -func (_m *RepositoryMock) FindRule(request *heimdall.Request) (rule.Rule, error) { - ret := _m.Called(request) +// 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") @@ -32,19 +32,19 @@ func (_m *RepositoryMock) FindRule(request *heimdall.Request) (rule.Rule, error) var r0 rule.Rule var r1 error - if rf, ok := ret.Get(0).(func(*heimdall.Request) (rule.Rule, error)); ok { - return rf(request) + if rf, ok := ret.Get(0).(func(heimdall.Context) (rule.Rule, error)); ok { + return rf(ctx) } - if rf, ok := ret.Get(0).(func(*heimdall.Request) rule.Rule); ok { - r0 = rf(request) + 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(*heimdall.Request) error); ok { - r1 = rf(request) + if rf, ok := ret.Get(1).(func(heimdall.Context) error); ok { + r1 = rf(ctx) } else { r1 = ret.Error(1) } @@ -58,14 +58,14 @@ type RepositoryMock_FindRule_Call struct { } // FindRule is a helper method to define mock.On call -// - request *heimdall.Request -func (_e *RepositoryMock_Expecter) FindRule(request interface{}) *RepositoryMock_FindRule_Call { - return &RepositoryMock_FindRule_Call{Call: _e.mock.On("FindRule", request)} +// - 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(request *heimdall.Request)) *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].(*heimdall.Request)) + run(args[0].(heimdall.Context)) }) return _c } @@ -75,7 +75,7 @@ func (_c *RepositoryMock_FindRule_Call) Return(_a0 rule.Rule, _a1 error) *Reposi return _c } -func (_c *RepositoryMock_FindRule_Call) RunAndReturn(run func(*heimdall.Request) (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 } diff --git a/internal/rules/rule/mocks/rule.go b/internal/rules/rule/mocks/rule.go index 490e4a4c3..d97e193fc 100644 --- a/internal/rules/rule/mocks/rule.go +++ b/internal/rules/rule/mocks/rule.go @@ -125,17 +125,17 @@ func (_c *RuleMock_ID_Call) RunAndReturn(run func() string) *RuleMock_ID_Call { return _c } -// Matches provides a mock function with given fields: request -func (_m *RuleMock) Matches(request *heimdall.Request) bool { - ret := _m.Called(request) +// 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(*heimdall.Request) bool); ok { - r0 = rf(request) + if rf, ok := ret.Get(0).(func(heimdall.Context) bool); ok { + r0 = rf(ctx) } else { r0 = ret.Get(0).(bool) } @@ -149,14 +149,14 @@ type RuleMock_Matches_Call struct { } // Matches is a helper method to define mock.On call -// - request *heimdall.Request -func (_e *RuleMock_Expecter) Matches(request interface{}) *RuleMock_Matches_Call { - return &RuleMock_Matches_Call{Call: _e.mock.On("Matches", request)} +// - 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_Matches_Call) Run(run func(request *heimdall.Request)) *RuleMock_Matches_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].(*heimdall.Request)) + run(args[0].(heimdall.Context)) }) return _c } @@ -166,7 +166,7 @@ func (_c *RuleMock_Matches_Call) Return(_a0 bool) *RuleMock_Matches_Call { return _c } -func (_c *RuleMock_Matches_Call) RunAndReturn(run func(*heimdall.Request) bool) *RuleMock_Matches_Call { +func (_c *RuleMock_Matches_Call) RunAndReturn(run func(heimdall.Context) bool) *RuleMock_Matches_Call { _c.Call.Return(run) return _c } diff --git a/internal/rules/rule/repository.go b/internal/rules/rule/repository.go index 927e6d5d9..6a6586d7d 100644 --- a/internal/rules/rule/repository.go +++ b/internal/rules/rule/repository.go @@ -23,5 +23,5 @@ import ( //go:generate mockery --name Repository --structname RepositoryMock type Repository interface { - FindRule(request *heimdall.Request) (Rule, error) + FindRule(ctx heimdall.Context) (Rule, error) } diff --git a/internal/rules/rule/rule.go b/internal/rules/rule/rule.go index 4f42fe15d..0ed441c80 100644 --- a/internal/rules/rule/rule.go +++ b/internal/rules/rule/rule.go @@ -26,7 +26,7 @@ type Rule interface { ID() string SrcID() string Execute(ctx heimdall.Context) (Backend, error) - Matches(request *heimdall.Request) bool + Matches(ctx heimdall.Context) bool PathExpression() string SameAs(other Rule) bool } diff --git a/internal/rules/rule_executor_impl.go b/internal/rules/rule_executor_impl.go index 59b7d831c..a76cde970 100644 --- a/internal/rules/rule_executor_impl.go +++ b/internal/rules/rule_executor_impl.go @@ -32,15 +32,16 @@ func newRuleExecutor(repository rule.Repository) rule.Executor { } func (e *ruleExecutor) Execute(ctx heimdall.Context) (rule.Backend, error) { - req := ctx.Request() + request := ctx.Request() + reqCtx := ctx.AppContext() //nolint:contextcheck - zerolog.Ctx(ctx.AppContext()).Debug(). - Str("_method", req.Method). - Str("_url", req.URL.String()). + zerolog.Ctx(reqCtx).Debug(). + Str("_method", request.Method). + Str("_url", request.URL.String()). Msg("Analyzing request") - rul, err := e.r.FindRule(req) + rul, err := e.r.FindRule(ctx) if err != nil { return nil, err } diff --git a/internal/rules/rule_executor_impl_test.go b/internal/rules/rule_executor_impl_test.go index 041b5cbe6..e1f23ddd2 100644 --- a/internal/rules/rule_executor_impl_test.go +++ b/internal/rules/rule_executor_impl_test.go @@ -52,7 +52,7 @@ func TestRuleExecutorExecute(t *testing.T) { ctx.EXPECT().AppContext().Return(context.Background()) ctx.EXPECT().Request().Return(req) - repo.EXPECT().FindRule(req).Return(nil, heimdall.ErrNoRuleFound) + repo.EXPECT().FindRule(ctx).Return(nil, heimdall.ErrNoRuleFound) }, }, { @@ -65,7 +65,7 @@ func TestRuleExecutorExecute(t *testing.T) { ctx.EXPECT().AppContext().Return(context.Background()) ctx.EXPECT().Request().Return(req) - repo.EXPECT().FindRule(req).Return(rule, nil) + repo.EXPECT().FindRule(ctx).Return(rule, nil) rule.EXPECT().Execute(ctx).Return(nil, heimdall.ErrAuthentication) }, }, @@ -79,7 +79,7 @@ func TestRuleExecutorExecute(t *testing.T) { ctx.EXPECT().AppContext().Return(context.Background()) ctx.EXPECT().Request().Return(req) - repo.EXPECT().FindRule(req).Return(rule, nil) + repo.EXPECT().FindRule(ctx).Return(rule, nil) rule.EXPECT().Execute(ctx).Return(upstream, nil) }, }, diff --git a/internal/rules/rule_factory_impl.go b/internal/rules/rule_factory_impl.go index 56115e113..829bbf131 100644 --- a/internal/rules/rule_factory_impl.go +++ b/internal/rules/rule_factory_impl.go @@ -193,11 +193,18 @@ func (f *ruleFactory) CreateRule(version, srcID string, ruleConfig config2.Rule) err error ) + spaceReplacer := strings.NewReplacer("\t", "", "\n", "", "\v", "", "\f", "", "\r", "", " ", "") + + hostGlob := spaceReplacer.Replace(ruleConfig.Matcher.HostGlob) + hostRegex := spaceReplacer.Replace(ruleConfig.Matcher.HostRegex) + pathGlob := spaceReplacer.Replace(ruleConfig.Matcher.Path.Glob) + pathRegex := spaceReplacer.Replace(ruleConfig.Matcher.Path.Regex) + switch { - case len(ruleConfig.Matcher.HostGlob) != 0: - hostMatcher, err = newGlobMatcher(ruleConfig.Matcher.HostGlob, '.') - case len(ruleConfig.Matcher.HostRegex) != 0: - hostMatcher, err = newRegexMatcher(ruleConfig.Matcher.HostRegex) + case len(hostGlob) != 0: + hostMatcher, err = newGlobMatcher(hostGlob, '.') + case len(hostRegex) != 0: + hostMatcher, err = newRegexMatcher(hostRegex) default: hostMatcher = alwaysMatcher{} } @@ -208,10 +215,10 @@ func (f *ruleFactory) CreateRule(version, srcID string, ruleConfig config2.Rule) } switch { - case len(ruleConfig.Matcher.Path.Glob) != 0: - pathMatcher, err = newGlobMatcher(ruleConfig.Matcher.Path.Glob, '/') - case len(ruleConfig.Matcher.Path.Regex) != 0: - pathMatcher, err = newRegexMatcher(ruleConfig.Matcher.Path.Regex) + case len(pathGlob) != 0: + pathMatcher, err = newGlobMatcher(pathGlob, '/') + case len(pathRegex) != 0: + pathMatcher, err = newRegexMatcher(pathRegex) default: pathMatcher = alwaysMatcher{} } diff --git a/internal/rules/rule_impl.go b/internal/rules/rule_impl.go index d47c6d901..689f93d01 100644 --- a/internal/rules/rule_impl.go +++ b/internal/rules/rule_impl.go @@ -87,33 +87,50 @@ func (r *ruleImpl) Execute(ctx heimdall.Context) (rule.Backend, error) { return upstream, nil } -func (r *ruleImpl) Matches(request *heimdall.Request) bool { +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() + + logger.Debug().Msg("Matching rule") + // fastest checks first // match scheme if len(r.allowedScheme) != 0 && r.allowedScheme != request.URL.Scheme { + logger.Debug().Msg("Allowed scheme mismatch") + return false } // match methods if !slices.Contains(r.allowedMethods, request.Method) { + logger.Debug().Msg("Allowed method mismatch") + return false } // check encoded slash handling if r.encodedSlashesHandling == config.EncodedSlashesOff && strings.Contains(request.URL.RawPath, "%2F") { + logger.Debug().Msg("Path contains encoded slashes, which is not allowed") + return false } // match host if !r.hostMatcher.Match(request.URL.Host) { + logger.Debug().Msg("Host does not satisfy configured expression") + return false } // match path if !r.pathMatcher.Match(request.URL.Path) { + logger.Debug().Msgf("Path %s does not satisfy configured expression", request.URL.Path) + return false } + logger.Debug().Msg("Rule matched") + return true } diff --git a/internal/rules/rule_impl_test.go b/internal/rules/rule_impl_test.go index 4f59a6c00..e3e79d814 100644 --- a/internal/rules/rule_impl_test.go +++ b/internal/rules/rule_impl_test.go @@ -113,8 +113,12 @@ func TestRuleMatches(t *testing.T) { }, } { t.Run("case="+tc.uc, func(t *testing.T) { + ctx := heimdallmocks.NewContextMock(t) + ctx.EXPECT().AppContext().Return(context.TODO()) + ctx.EXPECT().Request().Return(tc.toMatch) + // WHEN - matched := tc.rule.Matches(tc.toMatch) + matched := tc.rule.Matches(ctx) // THEN assert.Equal(t, tc.matches, matched) From 77312a006384ed8f3951d7477667b3a8f25e148e Mon Sep 17 00:00:00 2001 From: Dimitrij Drus Date: Sat, 20 Apr 2024 20:07:45 +0200 Subject: [PATCH 13/76] some code reformatting --- internal/indextree/node.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/indextree/node.go b/internal/indextree/node.go index db9d62628..94869cdf9 100644 --- a/internal/indextree/node.go +++ b/internal/indextree/node.go @@ -436,10 +436,6 @@ func (n *node[V]) Find(path string, matcher Matcher[V]) (V, map[string]string, e return found.values[idx], keys, nil } -func (n *node[V]) Empty() bool { - return len(n.values) == 0 && len(n.staticChildren) == 0 && n.wildcardChild == nil && n.catchAllChild == nil -} - func (n *node[V]) Delete(path string, matcher Matcher[V]) bool { return n.delNode(path, matcher) } @@ -454,3 +450,7 @@ func (n *node[V]) Update(path string, value V, matcher Matcher[V]) bool { return true } + +func (n *node[V]) Empty() bool { + return len(n.values) == 0 && len(n.staticChildren) == 0 && n.wildcardChild == nil && n.catchAllChild == nil +} From 655c5902bfc44eaa3bd42bcade2d30dd6a8fde0f Mon Sep 17 00:00:00 2001 From: Dimitrij Drus Date: Sun, 21 Apr 2024 15:04:41 +0200 Subject: [PATCH 14/76] rule repository updated to cover additional new cases, implementation simplified --- internal/rules/repository_impl.go | 134 +++++++++---------------- internal/rules/repository_impl_test.go | 6 +- 2 files changed, 51 insertions(+), 89 deletions(-) diff --git a/internal/rules/repository_impl.go b/internal/rules/repository_impl.go index aefe913b5..9faaaf17c 100644 --- a/internal/rules/repository_impl.go +++ b/internal/rules/repository_impl.go @@ -19,10 +19,10 @@ package rules import ( "bytes" "context" + "slices" "sync" "github.com/rs/zerolog" - "golang.org/x/exp/maps" "github.com/dadrus/heimdall/internal/heimdall" "github.com/dadrus/heimdall/internal/indextree" @@ -143,64 +143,52 @@ func (r *repository) updateRuleSet(srcID string, rules []rule.Rule) { applicable := slicex.Filter(r.knownRules, func(r rule.Rule) bool { return r.SrcID() == srcID }) // find new rules - newRules := slicex.Filter(rules, func(r rule.Rule) bool { - var known bool + newRules := slicex.Filter(rules, func(newRule rule.Rule) 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 + pathExpressionChanged := slices.ContainsFunc(applicable, func(existingRule rule.Rule) bool { + return existingRule.ID() == newRule.ID() && existingRule.PathExpression() != newRule.PathExpression() + }) - break - } - } - - return !known + return ruleIsNew || pathExpressionChanged }) - // find updated rules + // find updated rules with same path expression updatedRules := slicex.Filter(rules, func(r rule.Rule) bool { loaded := r.(*ruleImpl) // nolint: forcetypeassert - var updated bool - - for _, existing := range applicable { + return slices.ContainsFunc(applicable, func(existing rule.Rule) bool { known := existing.(*ruleImpl) // nolint: forcetypeassert - if known.id == loaded.id && !bytes.Equal(known.hash, loaded.hash) { - updated = true - - break - } - } - - return updated + return known.id == loaded.id && // same id + !bytes.Equal(known.hash, loaded.hash) && // different hash + known.pathExpression == loaded.pathExpression // same path expressions + }) }) // find deleted rules - deletedRules := slicex.Filter(applicable, func(r rule.Rule) bool { - var present bool + deletedRules := slicex.Filter(applicable, func(existingRule rule.Rule) bool { + ruleGone := !slices.ContainsFunc(rules, func(newRule rule.Rule) bool { + return newRule.ID() == existingRule.ID() + }) - for _, loaded := range rules { - if loaded.ID() == r.ID() { - present = true + pathExpressionChanged := slices.ContainsFunc(rules, func(newRule rule.Rule) bool { + return existingRule.ID() == newRule.ID() && existingRule.PathExpression() != newRule.PathExpression() + }) - break - } - } - - return !present + return ruleGone || pathExpressionChanged }) - func() { - // remove deleted rules - r.removeRules(deletedRules) + // remove deleted rules + r.removeRules(deletedRules) - // replace updated rules - r.replaceRules(updatedRules) + // replace updated rules + r.replaceRules(updatedRules) - // add new rules - r.addRules(newRules) - }() + // add new rules + r.addRules(newRules) } func (r *repository) deleteRuleSet(srcID string) { @@ -215,8 +203,6 @@ func (r *repository) deleteRuleSet(srcID string) { func (r *repository) addRules(rules []rule.Rule) { for _, rul := range rules { - r.knownRules = append(r.knownRules, rul) - r.mutex.Lock() err := r.rulesTree.Add(rul.PathExpression(), rul) r.mutex.Unlock() @@ -231,36 +217,16 @@ func (r *repository) addRules(rules []rule.Rule) { Str("_src", rul.SrcID()). Str("_id", rul.ID()). Msg("Rule added") + + r.knownRules = append(r.knownRules, rul) } } } func (r *repository) removeRules(tbdRules []rule.Rule) { - // find all indexes for affected rules - idxs := make(map[int]int, len(tbdRules)) - - for idx, rul := range r.knownRules { - for i, tbd := range tbdRules { - if rul.SrcID() == tbd.SrcID() && rul.ID() == tbd.ID() { - idxs[i] = idx + var failed []rule.Rule - break - } - } - } - - // if all rules should be dropped, just create a new slice and new tree - if len(idxs) == len(r.knownRules) { - r.knownRules = nil - - r.mutex.Lock() - r.rulesTree = indextree.NewIndexTree[rule.Rule]() - r.mutex.Unlock() - - return - } - - for i, rul := range tbdRules { + for _, rul := range tbdRules { r.mutex.Lock() err := r.rulesTree.Delete( rul.PathExpression(), @@ -272,8 +238,9 @@ func (r *repository) removeRules(tbdRules []rule.Rule) { r.logger.Error().Err(err). Str("_src", rul.SrcID()). Str("_id", rul.ID()). - Msg("Failed to remove rule") - delete(idxs, i) + Msg("Failed to remove rule. Please file a bug report!") + + failed = append(failed, rul) } else { r.logger.Debug(). Str("_src", rul.SrcID()). @@ -282,23 +249,14 @@ func (r *repository) removeRules(tbdRules []rule.Rule) { } } - // 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 maps.Values(idxs) { - tailIdx := len(r.knownRules) - (1 + i) - - r.knownRules[idx] = r.knownRules[tailIdx] - - // the below re-slice preserves the capacity of the slice. - // this is required to avoid memory leaks - r.knownRules[tailIdx] = nil - } - - // re-slice - r.knownRules = r.knownRules[:len(r.knownRules)-len(idxs)] + r.knownRules = slices.DeleteFunc(r.knownRules, func(r rule.Rule) bool { + return !slices.Contains(failed, r) && slices.Contains(tbdRules, r) + }) } func (r *repository) replaceRules(rules []rule.Rule) { + var failed []rule.Rule + for _, updated := range rules { r.mutex.Lock() err := r.rulesTree.Update( @@ -312,16 +270,20 @@ func (r *repository) replaceRules(rules []rule.Rule) { r.logger.Error().Err(err). Str("_src", updated.SrcID()). Str("_id", updated.ID()). - Msg("Failed to replace rule") + Msg("Failed to replace rule. Please file a bug report!") + + failed = append(failed, updated) } else { r.logger.Debug(). Str("_src", updated.SrcID()). Str("_id", updated.ID()). Msg("Rule replaced") } + } - for idx, existing := range r.knownRules { - if updated.SameAs(existing) { + for idx, existing := range r.knownRules { + for _, updated := range rules { + if updated.SameAs(existing) && !slices.Contains(failed, updated) { r.knownRules[idx] = updated break diff --git a/internal/rules/repository_impl_test.go b/internal/rules/repository_impl_test.go index 880ed3997..274ecb6ec 100644 --- a/internal/rules/repository_impl_test.go +++ b/internal/rules/repository_impl_test.go @@ -398,7 +398,7 @@ func TestRepositoryRuleSetLifecycleManagement(t *testing.T) { &ruleImpl{id: "rule:foo1", srcID: "test2", hash: []byte{5}, pathExpression: "/foo/1"}, // updated &ruleImpl{id: "rule:foo2", srcID: "test2", hash: []byte{2}, pathExpression: "/foo/2"}, // as before // &ruleImpl{id: "rule:foo3", srcID: "test2", hash: []byte{3}, pathExpression: "/foo/3"}, // deleted - &ruleImpl{id: "rule:foo4", srcID: "test2", hash: []byte{4}, pathExpression: "/foo/4"}, // as before + &ruleImpl{id: "rule:foo4", srcID: "test2", hash: []byte{6}, pathExpression: "/foo/6"}, // updated path }, }, }, @@ -428,12 +428,12 @@ func TestRepositoryRuleSetLifecycleManagement(t *testing.T) { assert.Equal(t, "test2", rulFoo2.SrcID()) assert.Equal(t, []byte{2}, rulFoo2.(*ruleImpl).hash) //nolint: forcetypeassert - rulFoo4, _, err := repo.rulesTree.Find("/foo/4", indextree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) + rulFoo4, _, err := repo.rulesTree.Find("/foo/6", indextree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) require.NoError(t, err) assert.Equal(t, repo.knownRules[3], rulFoo4) assert.Equal(t, "rule:foo4", rulFoo4.ID()) assert.Equal(t, "test2", rulFoo4.SrcID()) - assert.Equal(t, []byte{4}, rulFoo4.(*ruleImpl).hash) //nolint: forcetypeassert + assert.Equal(t, []byte{6}, rulFoo4.(*ruleImpl).hash) //nolint: forcetypeassert }, }, } { From 8ee0a96485535c601983b0c4138b2efc2e11eb29 Mon Sep 17 00:00:00 2001 From: Dimitrij Drus Date: Sun, 21 Apr 2024 15:15:20 +0200 Subject: [PATCH 15/76] more code comments --- internal/rules/repository_impl.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/internal/rules/repository_impl.go b/internal/rules/repository_impl.go index 9faaaf17c..379d62a2a 100644 --- a/internal/rules/repository_impl.go +++ b/internal/rules/repository_impl.go @@ -142,7 +142,8 @@ func (r *repository) updateRuleSet(srcID string, rules []rule.Rule) { // find all rules for the given src id applicable := slicex.Filter(r.knownRules, func(r rule.Rule) bool { return r.SrcID() == srcID }) - // find new rules + // find new rules - these are completely new ones, as well as those, which have their path expressions + // updated, so that the old ones must be removed and the updated ones must be inserted into the tree. newRules := slicex.Filter(rules, func(newRule rule.Rule) bool { ruleIsNew := !slices.ContainsFunc(applicable, func(existingRule rule.Rule) bool { return existingRule.ID() == newRule.ID() @@ -155,7 +156,8 @@ func (r *repository) updateRuleSet(srcID string, rules []rule.Rule) { return ruleIsNew || pathExpressionChanged }) - // find updated rules with same path expression + // find updated rules - those, which have the same ID and same path expression. These can be just updated + // in the tree without the need to remove the old ones first and insert the updated ones afterwards. updatedRules := slicex.Filter(rules, func(r rule.Rule) bool { loaded := r.(*ruleImpl) // nolint: forcetypeassert @@ -168,7 +170,8 @@ func (r *repository) updateRuleSet(srcID string, rules []rule.Rule) { }) }) - // find deleted rules + // find deleted rules - those, which are gone, or still present, but have a different path + // expression. Latter means, the old ones needs to be removed and the updated ones inserted deletedRules := slicex.Filter(applicable, func(existingRule rule.Rule) bool { ruleGone := !slices.ContainsFunc(rules, func(newRule rule.Rule) bool { return newRule.ID() == existingRule.ID() From fc537cc7fbbc7b6f8015fd00a02d42268ae68511 Mon Sep 17 00:00:00 2001 From: Dimitrij Drus Date: Sun, 21 Apr 2024 23:33:50 +0200 Subject: [PATCH 16/76] indextree simplifications and refactorings --- internal/indextree/errors.go | 10 --- internal/indextree/index_tree.go | 39 ++++------- internal/indextree/index_tree_test.go | 53 -------------- internal/indextree/node.go | 73 ++++++++++++------- internal/indextree/node_benchmark_test.go | 85 ++++++++++++++++++++--- internal/indextree/node_test.go | 62 ++++++++++------- internal/indextree/options.go | 11 +++ internal/indextree/options_test.go | 32 +++++++++ 8 files changed, 217 insertions(+), 148 deletions(-) delete mode 100644 internal/indextree/errors.go delete mode 100644 internal/indextree/index_tree_test.go create mode 100644 internal/indextree/options.go create mode 100644 internal/indextree/options_test.go diff --git a/internal/indextree/errors.go b/internal/indextree/errors.go deleted file mode 100644 index cd8be1435..000000000 --- a/internal/indextree/errors.go +++ /dev/null @@ -1,10 +0,0 @@ -package indextree - -import "errors" - -var ( - ErrInvalidPath = errors.New("invalid path") - ErrNotFound = errors.New("not found") - ErrFailedToDelete = errors.New("failed to delete") - ErrFailedToUpdate = errors.New("failed to delete") -) diff --git a/internal/indextree/index_tree.go b/internal/indextree/index_tree.go index 486230934..f5b0bcfec 100644 --- a/internal/indextree/index_tree.go +++ b/internal/indextree/index_tree.go @@ -1,35 +1,26 @@ package indextree -func NewIndexTree[V any]() *IndexTree[V] { - return &IndexTree[V]{tree: &node[V]{}} +type Entry[V any] struct { + Value V + Parameters map[string]string } -type IndexTree[V any] struct { - tree *node[V] +type IndexTree[V any] interface { + Add(path string, value V) error + Find(path string, matcher Matcher[V]) (*Entry[V], error) + Delete(path string, matcher Matcher[V]) error + Update(path string, value V, matcher Matcher[V]) error + Empty() bool } -func (t *IndexTree[V]) Add(path string, value V) error { - return t.tree.Add(path, value) -} - -func (t *IndexTree[V]) Find(path string, matcher Matcher[V]) (V, map[string]string, error) { - return t.tree.Find(path, matcher) -} - -func (t *IndexTree[V]) Delete(path string, matcher Matcher[V]) error { - if !t.tree.Delete(path, matcher) { - return ErrFailedToDelete +func New[V any](opts ...Option[V]) IndexTree[V] { + root := &node[V]{ + canAdd: func(_ []V, _ V) bool { return true }, } - return nil -} - -func (t *IndexTree[V]) Update(path string, value V, matcher Matcher[V]) error { - if !t.tree.Update(path, value, matcher) { - return ErrFailedToUpdate + for _, opt := range opts { + opt(root) } - return nil + return root } - -func (t *IndexTree[V]) Empty() bool { return t.tree.Empty() } diff --git a/internal/indextree/index_tree_test.go b/internal/indextree/index_tree_test.go deleted file mode 100644 index bf9b122b7..000000000 --- a/internal/indextree/index_tree_test.go +++ /dev/null @@ -1,53 +0,0 @@ -package indextree - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestFoo(t *testing.T) { - t.Parallel() - - tree := NewIndexTree[string]() - - err := tree.Add("/images/abc.jpg", "1") - require.NoError(t, err) - - err = tree.Add("/images/abc.jpg", "2") - require.NoError(t, err) - - err = tree.Add("/images/:imgname", "3") - require.NoError(t, err) - - err = tree.Add("/images/*path", "4") - require.NoError(t, err) - - val, params, err := tree.Find("/images/abc.jpg", testMatcher[string](true)) - require.NoError(t, err) - assert.Equal(t, "1", val) - assert.Empty(t, params) - - val, params, err = tree.Find("/images/abc.jpg", MatcherFunc[string](func(value string) bool { - return value == "2" - })) - require.NoError(t, err) - assert.Equal(t, "2", val) - assert.Empty(t, params) - - val, params, err = tree.Find("/images/cba.jpg", testMatcher[string](true)) - require.NoError(t, err) - assert.Equal(t, "3", val) - assert.Equal(t, map[string]string{"imgname": "cba.jpg"}, params) - - val, params, err = tree.Find("/images/sub/cba.jpg", testMatcher[string](true)) - require.NoError(t, err) - assert.Equal(t, "4", val) - assert.Equal(t, map[string]string{"path": "sub/cba.jpg"}, params) - - val, params, err = tree.Find("/images/abc/cba.jpg", testMatcher[string](true)) - require.NoError(t, err) - assert.Equal(t, "4", val) - assert.Equal(t, map[string]string{"path": "abc/cba.jpg"}, params) -} diff --git a/internal/indextree/node.go b/internal/indextree/node.go index 94869cdf9..250dd3f3c 100644 --- a/internal/indextree/node.go +++ b/internal/indextree/node.go @@ -7,7 +7,7 @@ This package is a fork of https://github.com/dimfeld/httptreemux. package indextree import ( - "net/url" + "errors" "slices" "strings" @@ -15,6 +15,16 @@ import ( "github.com/dadrus/heimdall/internal/x/stringx" ) +var ( + ErrInvalidPath = errors.New("invalid path") + ErrNotFound = errors.New("not found") + ErrFailedToDelete = errors.New("failed to delete") + ErrFailedToUpdate = errors.New("failed to delete") + ErrConstraintsViolation = errors.New("constraints violation") +) + +type ConstraintsFunc[V any] func(oldValues []V, newValue V) bool + type node[V any] struct { path string @@ -35,6 +45,8 @@ type node[V any] struct { values []V wildcardKeys []string + + canAdd ConstraintsFunc[V] } func (n *node[V]) sortStaticChildren(i int) { @@ -340,26 +352,27 @@ func (n *node[V]) findNode(path string, matcher Matcher[V]) (*node[V], int, []st if len(thisToken) > 0 { // Don't match on empty tokens. found, idx, params = n.wildcardChild.findNode(nextToken, matcher) if found != nil { - unescaped, err := url.PathUnescape(thisToken) - if err != nil { - unescaped = thisToken + 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, unescaped) + return found, idx, append(params, thisToken) } } } if n.catchAllChild != nil { // Hit the catchall, so just assign the whole remaining path. - unescaped, err := url.PathUnescape(path) - if err != nil { - unescaped = path - } - for idx, value = range n.catchAllChild.values { if match := matcher.Match(value); match { - return n.catchAllChild, idx, []string{unescaped} + // 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 } } @@ -407,48 +420,58 @@ func (n *node[V]) Add(path string, value V) error { return err } + if !n.canAdd(res.values, value) { + return ErrConstraintsViolation + } + res.values = append(res.values, value) return nil } -func (n *node[V]) Find(path string, matcher Matcher[V]) (V, map[string]string, error) { - var def V - +func (n *node[V]) Find(path string, matcher Matcher[V]) (*Entry[V], error) { found, idx, params := n.findNode(path, matcher) if found == nil { - return def, nil, ErrNotFound + return nil, ErrNotFound + } + + entry := &Entry[V]{ + Value: found.values[idx], } - if len(found.wildcardKeys) == 0 { - return found.values[idx], nil, nil + if len(params) == 0 { + return entry, nil } - keys := make(map[string]string, len(params)) + entry.Parameters = make(map[string]string, len(params)) for i, param := range params { key := found.wildcardKeys[len(params)-1-i] if key != "*" { - keys[found.wildcardKeys[len(params)-1-i]] = param + entry.Parameters[key] = param } } - return found.values[idx], keys, nil + return entry, nil } -func (n *node[V]) Delete(path string, matcher Matcher[V]) bool { - return n.delNode(path, matcher) +func (n *node[V]) Delete(path string, matcher Matcher[V]) error { + if !n.delNode(path, matcher) { + return ErrFailedToDelete + } + + return nil } -func (n *node[V]) Update(path string, value V, matcher Matcher[V]) bool { +func (n *node[V]) Update(path string, value V, matcher Matcher[V]) error { found, idx, _ := n.findNode(path, matcher) if found == nil { - return false + return ErrFailedToUpdate } found.values[idx] = value - return true + return nil } func (n *node[V]) Empty() bool { diff --git a/internal/indextree/node_benchmark_test.go b/internal/indextree/node_benchmark_test.go index fff346479..a884a1965 100644 --- a/internal/indextree/node_benchmark_test.go +++ b/internal/indextree/node_benchmark_test.go @@ -2,58 +2,121 @@ package indextree import ( "testing" + + "github.com/stretchr/testify/require" ) func BenchmarkNodeSearchNoPaths(b *testing.B) { tm := testMatcher[string](true) - tree := &node[string]{} + tree := &node[string]{ + path: "/", + canAdd: func(_ []string, _ string) bool { return true }, + } b.ReportAllocs() b.ResetTimer() for range b.N { - tree.Find("", tm) + tree.findNode("", tm) + } +} + +func BenchmarkNodeSearchRoot(b *testing.B) { + tm := testMatcher[string](true) + tree := &node[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 := &node[string]{} + tree := &node[string]{ + path: "/", + canAdd: func(_ []string, _ string) bool { return true }, + } - tree.Add("/abc", "foo") + tree.Add("abc", "foo") b.ReportAllocs() b.ResetTimer() for range b.N { - tree.Find("/abc", tm) + tree.findNode("abc", tm) + } +} + +func BenchmarkNodeSearchOneLongStaticPath(b *testing.B) { + tm := testMatcher[string](true) + tree := &node[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 := &node[string]{} + tree := &node[string]{ + path: "/", + canAdd: func(_ []string, _ string) bool { return true }, + } - tree.Add("/:abc", "foo") + require.NoError(b, tree.Add(":abc", "foo")) b.ReportAllocs() b.ResetTimer() for range b.N { - tree.Find("/abc", tm) + tree.findNode("abc", tm) } } func BenchmarkNodeSearchOneLongWildcards(b *testing.B) { tm := testMatcher[string](true) - tree := &node[string]{} + tree := &node[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 := &node[string]{ + path: "/", + canAdd: func(_ []string, _ string) bool { return true }, + } - tree.Add("/:abc/:def/:ghi", "foo") + require.NoError(b, tree.Add("*abc", "foo")) b.ReportAllocs() b.ResetTimer() for range b.N { - tree.Find("/abcdefghijklmnop/aaaabbbbccccddddeeeeffffgggg/hijkl", tm) + tree.findNode("foo", tm) } } diff --git a/internal/indextree/node_test.go b/internal/indextree/node_test.go index 163215617..89d2de9ea 100644 --- a/internal/indextree/node_test.go +++ b/internal/indextree/node_test.go @@ -14,7 +14,7 @@ func TestNodeSearch(t *testing.T) { t.Parallel() // Setup & populate tree - tree := &node[string]{} + tree := New[string]() for _, path := range []string{ "/", @@ -96,8 +96,8 @@ func TestNodeSearch(t *testing.T) { {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/"}}, - {path: "/post/ab%2fdef/page/2%2f", expPath: "/post/:post/page/:page", expParams: map[string]string{"post": "ab/def", "page": "2/"}}, + {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 @@ -127,7 +127,7 @@ func TestNodeSearch(t *testing.T) { matcher = tc.matcher } - resValue, paramList, err := tree.Find(tc.path, matcher) + entry, err := tree.Find(tc.path, matcher) if tc.expErr != nil { require.Error(t, err) require.ErrorIs(t, err, tc.expErr) @@ -136,8 +136,8 @@ func TestNodeSearch(t *testing.T) { } require.NoError(t, err) - assert.Equalf(t, tc.expPath, resValue, "Path %s matched %s, expected %s", tc.path, resValue, tc.expPath) - assert.Equal(t, tc.expParams, paramList, "Path %s expected parameters are %v, saw %v", tc.path, tc.expParams, paramList) + 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) }) } } @@ -145,7 +145,7 @@ func TestNodeSearch(t *testing.T) { func TestNodeAddPathDuplicates(t *testing.T) { t.Parallel() - tree := &node[string]{} + tree := New[string]() path := "/date/:year/:month/abc" err := tree.Add(path, "first") @@ -154,19 +154,19 @@ func TestNodeAddPathDuplicates(t *testing.T) { err = tree.Add(path, "second") require.NoError(t, err) - value, params, err := tree.Find("/date/2024/04/abc", MatcherFunc[string](func(value string) bool { + 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", value) - assert.Equal(t, map[string]string{"year": "2024", "month": "04"}, params) + assert.Equal(t, "first", entry.Value) + assert.Equal(t, map[string]string{"year": "2024", "month": "04"}, entry.Parameters) - value, params, err = tree.Find("/date/2024/04/abc", MatcherFunc[string](func(value string) bool { + 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", value) - assert.Equal(t, map[string]string{"year": "2024", "month": "04"}, params) + assert.Equal(t, "second", entry.Value) + assert.Equal(t, map[string]string{"year": "2024", "month": "04"}, entry.Parameters) } func TestNodeAddPath(t *testing.T) { @@ -194,7 +194,7 @@ func TestNodeAddPath(t *testing.T) { {"katakana /カ", []string{"/カ"}, false}, } { t.Run(tc.uc, func(t *testing.T) { - tree := &node[string]{} + tree := New[string]() var err error @@ -229,7 +229,7 @@ func TestNodeDeleteStaticPaths(t *testing.T) { "/app/les/or/bananas", } - tree := &node[int]{} + tree := New[int]() for idx, path := range paths { err := tree.Add(path, idx) @@ -237,8 +237,11 @@ func TestNodeDeleteStaticPaths(t *testing.T) { } for i := len(paths) - 1; i >= 0; i-- { - require.True(t, tree.Delete(paths[i], testMatcher[int](true))) - require.False(t, tree.Delete(paths[i], testMatcher[int](true))) + err := tree.Delete(paths[i], testMatcher[int](true)) + require.NoError(t, err) + + err = tree.Delete(paths[i], testMatcher[int](true)) + require.Error(t, err) } } @@ -257,7 +260,7 @@ func TestNodeDeleteStaticAndWildcardPaths(t *testing.T) { "/abc/:les/bananas", } - tree := &node[int]{} + tree := New[int]() for idx, path := range paths { err := tree.Add(path, idx+1) @@ -268,19 +271,23 @@ func TestNodeDeleteStaticAndWildcardPaths(t *testing.T) { for i := len(paths) - 1; i >= 0; i-- { tbdPath := paths[i] - require.Truef(t, tree.Delete(tbdPath, testMatcher[int](true)), "Should be able to delete %s", paths[i]) - require.Falsef(t, tree.Delete(tbdPath, testMatcher[int](true)), "Should not be able to delete %s", 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 { - val, _, err := tree.Find(path, testMatcher[int](true)) + 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, val) + assert.Equal(t, idx+1, entry.Value) } } } @@ -309,7 +316,7 @@ func TestNodeDeleteMixedPaths(t *testing.T) { "/abb/*all", } - tree := &node[int]{} + tree := New[int]() for idx, path := range paths { err := tree.Add(path, idx+1) @@ -317,8 +324,13 @@ func TestNodeDeleteMixedPaths(t *testing.T) { } for i := len(paths) - 1; i >= 0; i-- { - require.Truef(t, tree.Delete(paths[i], testMatcher[int](true)), "Should be able to delete %s", paths[i]) - require.Falsef(t, tree.Delete(paths[i], testMatcher[int](true)), "Should not be able to delete %s", paths[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()) diff --git a/internal/indextree/options.go b/internal/indextree/options.go new file mode 100644 index 000000000..d0a35bc91 --- /dev/null +++ b/internal/indextree/options.go @@ -0,0 +1,11 @@ +package indextree + +type Option[V any] func(n *node[V]) + +func WithValuesConstraints[V any](constraints ConstraintsFunc[V]) Option[V] { + return func(n *node[V]) { + if constraints != nil { + n.canAdd = constraints + } + } +} diff --git a/internal/indextree/options_test.go b/internal/indextree/options_test.go new file mode 100644 index 000000000..c71661c49 --- /dev/null +++ b/internal/indextree/options_test.go @@ -0,0 +1,32 @@ +package indextree + +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) +} From c6f579036d1e508260dc56e01b888c0a6f537440 Mon Sep 17 00:00:00 2001 From: Dimitrij Drus Date: Sun, 21 Apr 2024 23:34:40 +0200 Subject: [PATCH 17/76] repository impl updated to comply with the updated indextree api --- internal/rules/repository_impl.go | 10 +-- internal/rules/repository_impl_test.go | 84 +++++++++++++------------- 2 files changed, 47 insertions(+), 47 deletions(-) diff --git a/internal/rules/repository_impl.go b/internal/rules/repository_impl.go index 379d62a2a..65e276bbc 100644 --- a/internal/rules/repository_impl.go +++ b/internal/rules/repository_impl.go @@ -45,7 +45,7 @@ func newRepository( logger: logger, queue: queue, quit: make(chan bool), - rulesTree: indextree.NewIndexTree[rule.Rule](), + rulesTree: indextree.New[rule.Rule](), } } @@ -55,7 +55,7 @@ type repository struct { knownRules []rule.Rule - rulesTree *indextree.IndexTree[rule.Rule] + rulesTree indextree.IndexTree[rule.Rule] mutex sync.RWMutex queue event.RuleSetChangedEventQueue @@ -68,7 +68,7 @@ func (r *repository) FindRule(ctx heimdall.Context) (rule.Rule, error) { r.mutex.RLock() defer r.mutex.RUnlock() - rul, params, err := r.rulesTree.Find( + entry, err := r.rulesTree.Find( request.URL.Path, indextree.MatcherFunc[rule.Rule](func(candidate rule.Rule) bool { return candidate.Matches(ctx) }), ) @@ -81,9 +81,9 @@ func (r *repository) FindRule(ctx heimdall.Context) (rule.Rule, error) { "no applicable rule found for %s", request.URL.String()) } - request.URL.Captures = params + request.URL.Captures = entry.Parameters - return rul, nil + return entry.Value, nil } func (r *repository) Start(_ context.Context) error { diff --git a/internal/rules/repository_impl_test.go b/internal/rules/repository_impl_test.go index 274ecb6ec..9b913c208 100644 --- a/internal/rules/repository_impl_test.go +++ b/internal/rules/repository_impl_test.go @@ -230,19 +230,19 @@ func TestRepositoryAddAndRemoveRulesFromDifferentRuleSets(t *testing.T) { assert.Len(t, repo.knownRules, 2) assert.ElementsMatch(t, repo.knownRules, []rule.Rule{rules[1], rules[4]}) - _, _, err := repo.rulesTree.Find("/bar/1", indextree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) + _, err := repo.rulesTree.Find("/bar/1", indextree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) assert.Error(t, err) //nolint:testifylint - _, _, err = repo.rulesTree.Find("/bar/3", indextree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) + _, err = repo.rulesTree.Find("/bar/3", indextree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) assert.Error(t, err) //nolint:testifylint - _, _, err = repo.rulesTree.Find("/bar/4", indextree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) + _, err = repo.rulesTree.Find("/bar/4", indextree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) assert.Error(t, err) //nolint:testifylint - _, _, err = repo.rulesTree.Find("/baz/2", indextree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) + _, err = repo.rulesTree.Find("/baz/2", indextree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) assert.NoError(t, err) //nolint:testifylint - _, _, err = repo.rulesTree.Find("/foo/4", indextree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) + _, err = repo.rulesTree.Find("/foo/4", indextree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) assert.NoError(t, err) //nolint:testifylint // WHEN @@ -252,10 +252,10 @@ func TestRepositoryAddAndRemoveRulesFromDifferentRuleSets(t *testing.T) { assert.Len(t, repo.knownRules, 1) assert.ElementsMatch(t, repo.knownRules, []rule.Rule{rules[1]}) - _, _, err = repo.rulesTree.Find("/foo/4", indextree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) + _, err = repo.rulesTree.Find("/foo/4", indextree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) assert.Error(t, err) //nolint:testifylint - _, _, err = repo.rulesTree.Find("/baz/2", indextree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) + _, err = repo.rulesTree.Find("/baz/2", indextree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) assert.NoError(t, err) //nolint:testifylint // WHEN @@ -301,12 +301,12 @@ func TestRepositoryRuleSetLifecycleManagement(t *testing.T) { assert.Len(t, repo.knownRules, 1) assert.False(t, repo.rulesTree.Empty()) - rul, _, err := repo.rulesTree.Find("/foo/1", indextree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) + entry, err := repo.rulesTree.Find("/foo/1", indextree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) require.NoError(t, err) - assert.Equal(t, repo.knownRules[0], rul) - assert.Equal(t, "rule:foo", rul.ID()) - assert.Equal(t, "test", rul.SrcID()) + assert.Equal(t, repo.knownRules[0], entry.Value) + assert.Equal(t, "rule:foo", entry.Value.ID()) + assert.Equal(t, "test", entry.Value.SrcID()) }, }, { @@ -328,17 +328,17 @@ func TestRepositoryRuleSetLifecycleManagement(t *testing.T) { assert.Len(t, repo.knownRules, 2) - rul1, _, err := repo.rulesTree.Find("/bar/1", indextree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) + entry1, err := repo.rulesTree.Find("/bar/1", indextree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) require.NoError(t, err) - assert.Equal(t, repo.knownRules[0], rul1) - assert.Equal(t, "rule:bar", rul1.ID()) - assert.Equal(t, "test1", rul1.SrcID()) + assert.Equal(t, repo.knownRules[0], entry1.Value) + assert.Equal(t, "rule:bar", entry1.Value.ID()) + assert.Equal(t, "test1", entry1.Value.SrcID()) - rul2, _, err := repo.rulesTree.Find("/foo/1", indextree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) + entry2, err := repo.rulesTree.Find("/foo/1", indextree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) require.NoError(t, err) - assert.Equal(t, repo.knownRules[1], rul2) - assert.Equal(t, "rule:foo", rul2.ID()) - assert.Equal(t, "test2", rul2.SrcID()) + assert.Equal(t, repo.knownRules[1], entry2.Value) + assert.Equal(t, "rule:foo", entry2.Value.ID()) + assert.Equal(t, "test2", entry2.Value.SrcID()) }, }, { @@ -365,12 +365,12 @@ func TestRepositoryRuleSetLifecycleManagement(t *testing.T) { assert.Len(t, repo.knownRules, 1) assert.False(t, repo.rulesTree.Empty()) - rul, _, err := repo.rulesTree.Find("/foo/1", indextree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) + entry, err := repo.rulesTree.Find("/foo/1", indextree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) require.NoError(t, err) - assert.Equal(t, repo.knownRules[0], rul) - assert.Equal(t, "rule:foo", rul.ID()) - assert.Equal(t, "test2", rul.SrcID()) + assert.Equal(t, repo.knownRules[0], entry.Value) + assert.Equal(t, "rule:foo", entry.Value.ID()) + assert.Equal(t, "test2", entry.Value.SrcID()) }, }, { @@ -408,32 +408,32 @@ func TestRepositoryRuleSetLifecycleManagement(t *testing.T) { require.Len(t, repo.knownRules, 4) assert.False(t, repo.rulesTree.Empty()) - rulBar, _, err := repo.rulesTree.Find("/bar/1", indextree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) + entryBar, err := repo.rulesTree.Find("/bar/1", indextree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) require.NoError(t, err) - assert.Equal(t, repo.knownRules[0], rulBar) - assert.Equal(t, "rule:bar", rulBar.ID()) - assert.Equal(t, "test1", rulBar.SrcID()) + assert.Equal(t, repo.knownRules[0], entryBar.Value) + assert.Equal(t, "rule:bar", entryBar.Value.ID()) + assert.Equal(t, "test1", entryBar.Value.SrcID()) - rulFoo1, _, err := repo.rulesTree.Find("/foo/1", indextree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) + entryFoo1, err := repo.rulesTree.Find("/foo/1", indextree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) require.NoError(t, err) - assert.Equal(t, repo.knownRules[1], rulFoo1) - assert.Equal(t, "rule:foo1", rulFoo1.ID()) - assert.Equal(t, "test2", rulFoo1.SrcID()) - assert.Equal(t, []byte{5}, rulFoo1.(*ruleImpl).hash) //nolint: forcetypeassert + assert.Equal(t, repo.knownRules[1], entryFoo1.Value) + assert.Equal(t, "rule:foo1", entryFoo1.Value.ID()) + assert.Equal(t, "test2", entryFoo1.Value.SrcID()) + assert.Equal(t, []byte{5}, entryFoo1.Value.(*ruleImpl).hash) //nolint: forcetypeassert - rulFoo2, _, err := repo.rulesTree.Find("/foo/2", indextree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) + entryFoo2, err := repo.rulesTree.Find("/foo/2", indextree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) require.NoError(t, err) - assert.Equal(t, repo.knownRules[2], rulFoo2) - assert.Equal(t, "rule:foo2", rulFoo2.ID()) - assert.Equal(t, "test2", rulFoo2.SrcID()) - assert.Equal(t, []byte{2}, rulFoo2.(*ruleImpl).hash) //nolint: forcetypeassert + assert.Equal(t, repo.knownRules[2], entryFoo2.Value) + assert.Equal(t, "rule:foo2", entryFoo2.Value.ID()) + assert.Equal(t, "test2", entryFoo2.Value.SrcID()) + assert.Equal(t, []byte{2}, entryFoo2.Value.(*ruleImpl).hash) //nolint: forcetypeassert - rulFoo4, _, err := repo.rulesTree.Find("/foo/6", indextree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) + entryFoo4, err := repo.rulesTree.Find("/foo/6", indextree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) require.NoError(t, err) - assert.Equal(t, repo.knownRules[3], rulFoo4) - assert.Equal(t, "rule:foo4", rulFoo4.ID()) - assert.Equal(t, "test2", rulFoo4.SrcID()) - assert.Equal(t, []byte{6}, rulFoo4.(*ruleImpl).hash) //nolint: forcetypeassert + assert.Equal(t, repo.knownRules[3], entryFoo4.Value) + assert.Equal(t, "rule:foo4", entryFoo4.Value.ID()) + assert.Equal(t, "test2", entryFoo4.Value.SrcID()) + assert.Equal(t, []byte{6}, entryFoo4.Value.(*ruleImpl).hash) //nolint: forcetypeassert }, }, } { From 672c4dbf736e8a0a2419db22b94ceb081c0666b9 Mon Sep 17 00:00:00 2001 From: Dimitrij Drus Date: Sun, 21 Apr 2024 23:36:00 +0200 Subject: [PATCH 18/76] package indextree renamed to radixtree --- .../{indextree => radixtree}/index_tree.go | 2 +- internal/{indextree => radixtree}/matcher.go | 2 +- internal/{indextree => radixtree}/node.go | 4 +-- .../node_benchmark_test.go | 2 +- .../{indextree => radixtree}/node_test.go | 2 +- internal/{indextree => radixtree}/options.go | 2 +- .../{indextree => radixtree}/options_test.go | 2 +- internal/rules/repository_impl.go | 12 +++---- internal/rules/repository_impl_test.go | 32 +++++++++---------- 9 files changed, 30 insertions(+), 30 deletions(-) rename internal/{indextree => radixtree}/index_tree.go (96%) rename internal/{indextree => radixtree}/matcher.go (97%) rename internal/{indextree => radixtree}/node.go (99%) rename internal/{indextree => radixtree}/node_benchmark_test.go (99%) rename internal/{indextree => radixtree}/node_test.go (99%) rename internal/{indextree => radixtree}/options.go (91%) rename internal/{indextree => radixtree}/options_test.go (96%) diff --git a/internal/indextree/index_tree.go b/internal/radixtree/index_tree.go similarity index 96% rename from internal/indextree/index_tree.go rename to internal/radixtree/index_tree.go index f5b0bcfec..3d0dce2fd 100644 --- a/internal/indextree/index_tree.go +++ b/internal/radixtree/index_tree.go @@ -1,4 +1,4 @@ -package indextree +package radixtree type Entry[V any] struct { Value V diff --git a/internal/indextree/matcher.go b/internal/radixtree/matcher.go similarity index 97% rename from internal/indextree/matcher.go rename to internal/radixtree/matcher.go index 2cd01b116..7a6f84249 100644 --- a/internal/indextree/matcher.go +++ b/internal/radixtree/matcher.go @@ -1,4 +1,4 @@ -package indextree +package radixtree // Matcher is used for additional checks while performing the lookup in the spanned tree. type Matcher[V any] interface { diff --git a/internal/indextree/node.go b/internal/radixtree/node.go similarity index 99% rename from internal/indextree/node.go rename to internal/radixtree/node.go index 250dd3f3c..599448388 100644 --- a/internal/indextree/node.go +++ b/internal/radixtree/node.go @@ -1,10 +1,10 @@ /* -Package indextree implements a tree lookup for values associated to +Package radixtree implements a tree lookup for values associated to paths. This package is a fork of https://github.com/dimfeld/httptreemux. */ -package indextree +package radixtree import ( "errors" diff --git a/internal/indextree/node_benchmark_test.go b/internal/radixtree/node_benchmark_test.go similarity index 99% rename from internal/indextree/node_benchmark_test.go rename to internal/radixtree/node_benchmark_test.go index a884a1965..3025554d4 100644 --- a/internal/indextree/node_benchmark_test.go +++ b/internal/radixtree/node_benchmark_test.go @@ -1,4 +1,4 @@ -package indextree +package radixtree import ( "testing" diff --git a/internal/indextree/node_test.go b/internal/radixtree/node_test.go similarity index 99% rename from internal/indextree/node_test.go rename to internal/radixtree/node_test.go index 89d2de9ea..ee08de3ab 100644 --- a/internal/indextree/node_test.go +++ b/internal/radixtree/node_test.go @@ -1,4 +1,4 @@ -package indextree +package radixtree import ( "slices" diff --git a/internal/indextree/options.go b/internal/radixtree/options.go similarity index 91% rename from internal/indextree/options.go rename to internal/radixtree/options.go index d0a35bc91..b5198f5d6 100644 --- a/internal/indextree/options.go +++ b/internal/radixtree/options.go @@ -1,4 +1,4 @@ -package indextree +package radixtree type Option[V any] func(n *node[V]) diff --git a/internal/indextree/options_test.go b/internal/radixtree/options_test.go similarity index 96% rename from internal/indextree/options_test.go rename to internal/radixtree/options_test.go index c71661c49..9c3289f2a 100644 --- a/internal/indextree/options_test.go +++ b/internal/radixtree/options_test.go @@ -1,4 +1,4 @@ -package indextree +package radixtree import ( "testing" diff --git a/internal/rules/repository_impl.go b/internal/rules/repository_impl.go index 65e276bbc..aad0fd204 100644 --- a/internal/rules/repository_impl.go +++ b/internal/rules/repository_impl.go @@ -25,7 +25,7 @@ import ( "github.com/rs/zerolog" "github.com/dadrus/heimdall/internal/heimdall" - "github.com/dadrus/heimdall/internal/indextree" + "github.com/dadrus/heimdall/internal/radixtree" "github.com/dadrus/heimdall/internal/rules/event" "github.com/dadrus/heimdall/internal/rules/rule" "github.com/dadrus/heimdall/internal/x" @@ -45,7 +45,7 @@ func newRepository( logger: logger, queue: queue, quit: make(chan bool), - rulesTree: indextree.New[rule.Rule](), + rulesTree: radixtree.New[rule.Rule](), } } @@ -55,7 +55,7 @@ type repository struct { knownRules []rule.Rule - rulesTree indextree.IndexTree[rule.Rule] + rulesTree radixtree.IndexTree[rule.Rule] mutex sync.RWMutex queue event.RuleSetChangedEventQueue @@ -70,7 +70,7 @@ func (r *repository) FindRule(ctx heimdall.Context) (rule.Rule, error) { entry, err := r.rulesTree.Find( request.URL.Path, - indextree.MatcherFunc[rule.Rule](func(candidate rule.Rule) bool { return candidate.Matches(ctx) }), + radixtree.MatcherFunc[rule.Rule](func(candidate rule.Rule) bool { return candidate.Matches(ctx) }), ) if err != nil { if r.dr != nil { @@ -233,7 +233,7 @@ func (r *repository) removeRules(tbdRules []rule.Rule) { r.mutex.Lock() err := r.rulesTree.Delete( rul.PathExpression(), - indextree.MatcherFunc[rule.Rule](func(existing rule.Rule) bool { return existing.SameAs(rul) }), + radixtree.MatcherFunc[rule.Rule](func(existing rule.Rule) bool { return existing.SameAs(rul) }), ) r.mutex.Unlock() @@ -265,7 +265,7 @@ func (r *repository) replaceRules(rules []rule.Rule) { err := r.rulesTree.Update( updated.PathExpression(), updated, - indextree.MatcherFunc[rule.Rule](func(existing rule.Rule) bool { return existing.SameAs(updated) }), + radixtree.MatcherFunc[rule.Rule](func(existing rule.Rule) bool { return existing.SameAs(updated) }), ) r.mutex.Unlock() diff --git a/internal/rules/repository_impl_test.go b/internal/rules/repository_impl_test.go index 9b913c208..d53cd0429 100644 --- a/internal/rules/repository_impl_test.go +++ b/internal/rules/repository_impl_test.go @@ -30,7 +30,7 @@ import ( "github.com/dadrus/heimdall/internal/heimdall" mocks2 "github.com/dadrus/heimdall/internal/heimdall/mocks" - "github.com/dadrus/heimdall/internal/indextree" + "github.com/dadrus/heimdall/internal/radixtree" "github.com/dadrus/heimdall/internal/rules/event" "github.com/dadrus/heimdall/internal/rules/rule" "github.com/dadrus/heimdall/internal/rules/rule/mocks" @@ -230,19 +230,19 @@ func TestRepositoryAddAndRemoveRulesFromDifferentRuleSets(t *testing.T) { assert.Len(t, repo.knownRules, 2) assert.ElementsMatch(t, repo.knownRules, []rule.Rule{rules[1], rules[4]}) - _, err := repo.rulesTree.Find("/bar/1", indextree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) + _, err := repo.rulesTree.Find("/bar/1", radixtree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) assert.Error(t, err) //nolint:testifylint - _, err = repo.rulesTree.Find("/bar/3", indextree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) + _, err = repo.rulesTree.Find("/bar/3", radixtree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) assert.Error(t, err) //nolint:testifylint - _, err = repo.rulesTree.Find("/bar/4", indextree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) + _, err = repo.rulesTree.Find("/bar/4", radixtree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) assert.Error(t, err) //nolint:testifylint - _, err = repo.rulesTree.Find("/baz/2", indextree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) + _, err = repo.rulesTree.Find("/baz/2", radixtree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) assert.NoError(t, err) //nolint:testifylint - _, err = repo.rulesTree.Find("/foo/4", indextree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) + _, err = repo.rulesTree.Find("/foo/4", radixtree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) assert.NoError(t, err) //nolint:testifylint // WHEN @@ -252,10 +252,10 @@ func TestRepositoryAddAndRemoveRulesFromDifferentRuleSets(t *testing.T) { assert.Len(t, repo.knownRules, 1) assert.ElementsMatch(t, repo.knownRules, []rule.Rule{rules[1]}) - _, err = repo.rulesTree.Find("/foo/4", indextree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) + _, err = repo.rulesTree.Find("/foo/4", radixtree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) assert.Error(t, err) //nolint:testifylint - _, err = repo.rulesTree.Find("/baz/2", indextree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) + _, err = repo.rulesTree.Find("/baz/2", radixtree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) assert.NoError(t, err) //nolint:testifylint // WHEN @@ -301,7 +301,7 @@ func TestRepositoryRuleSetLifecycleManagement(t *testing.T) { assert.Len(t, repo.knownRules, 1) assert.False(t, repo.rulesTree.Empty()) - entry, err := repo.rulesTree.Find("/foo/1", indextree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) + entry, err := repo.rulesTree.Find("/foo/1", radixtree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) require.NoError(t, err) assert.Equal(t, repo.knownRules[0], entry.Value) @@ -328,13 +328,13 @@ func TestRepositoryRuleSetLifecycleManagement(t *testing.T) { assert.Len(t, repo.knownRules, 2) - entry1, err := repo.rulesTree.Find("/bar/1", indextree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) + entry1, err := repo.rulesTree.Find("/bar/1", radixtree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) require.NoError(t, err) assert.Equal(t, repo.knownRules[0], entry1.Value) assert.Equal(t, "rule:bar", entry1.Value.ID()) assert.Equal(t, "test1", entry1.Value.SrcID()) - entry2, err := repo.rulesTree.Find("/foo/1", indextree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) + entry2, err := repo.rulesTree.Find("/foo/1", radixtree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) require.NoError(t, err) assert.Equal(t, repo.knownRules[1], entry2.Value) assert.Equal(t, "rule:foo", entry2.Value.ID()) @@ -365,7 +365,7 @@ func TestRepositoryRuleSetLifecycleManagement(t *testing.T) { assert.Len(t, repo.knownRules, 1) assert.False(t, repo.rulesTree.Empty()) - entry, err := repo.rulesTree.Find("/foo/1", indextree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) + entry, err := repo.rulesTree.Find("/foo/1", radixtree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) require.NoError(t, err) assert.Equal(t, repo.knownRules[0], entry.Value) @@ -408,27 +408,27 @@ func TestRepositoryRuleSetLifecycleManagement(t *testing.T) { require.Len(t, repo.knownRules, 4) assert.False(t, repo.rulesTree.Empty()) - entryBar, err := repo.rulesTree.Find("/bar/1", indextree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) + entryBar, err := repo.rulesTree.Find("/bar/1", radixtree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) require.NoError(t, err) assert.Equal(t, repo.knownRules[0], entryBar.Value) assert.Equal(t, "rule:bar", entryBar.Value.ID()) assert.Equal(t, "test1", entryBar.Value.SrcID()) - entryFoo1, err := repo.rulesTree.Find("/foo/1", indextree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) + entryFoo1, err := repo.rulesTree.Find("/foo/1", radixtree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) require.NoError(t, err) assert.Equal(t, repo.knownRules[1], entryFoo1.Value) assert.Equal(t, "rule:foo1", entryFoo1.Value.ID()) assert.Equal(t, "test2", entryFoo1.Value.SrcID()) assert.Equal(t, []byte{5}, entryFoo1.Value.(*ruleImpl).hash) //nolint: forcetypeassert - entryFoo2, err := repo.rulesTree.Find("/foo/2", indextree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) + entryFoo2, err := repo.rulesTree.Find("/foo/2", radixtree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) require.NoError(t, err) assert.Equal(t, repo.knownRules[2], entryFoo2.Value) assert.Equal(t, "rule:foo2", entryFoo2.Value.ID()) assert.Equal(t, "test2", entryFoo2.Value.SrcID()) assert.Equal(t, []byte{2}, entryFoo2.Value.(*ruleImpl).hash) //nolint: forcetypeassert - entryFoo4, err := repo.rulesTree.Find("/foo/6", indextree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) + entryFoo4, err := repo.rulesTree.Find("/foo/6", radixtree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) require.NoError(t, err) assert.Equal(t, repo.knownRules[3], entryFoo4.Value) assert.Equal(t, "rule:foo4", entryFoo4.Value.ID()) From 4dad2ce5e589d5a7912413c86d93dfcd35b71de6 Mon Sep 17 00:00:00 2001 From: Dimitrij Drus Date: Sun, 21 Apr 2024 23:36:50 +0200 Subject: [PATCH 19/76] radixtree package move to x --- internal/rules/repository_impl.go | 12 ++++++------ internal/rules/repository_impl_test.go | 2 +- internal/{ => x}/radixtree/index_tree.go | 0 internal/{ => x}/radixtree/matcher.go | 0 internal/{ => x}/radixtree/node.go | 0 internal/{ => x}/radixtree/node_benchmark_test.go | 0 internal/{ => x}/radixtree/node_test.go | 0 internal/{ => x}/radixtree/options.go | 0 internal/{ => x}/radixtree/options_test.go | 0 9 files changed, 7 insertions(+), 7 deletions(-) rename internal/{ => x}/radixtree/index_tree.go (100%) rename internal/{ => x}/radixtree/matcher.go (100%) rename internal/{ => x}/radixtree/node.go (100%) rename internal/{ => x}/radixtree/node_benchmark_test.go (100%) rename internal/{ => x}/radixtree/node_test.go (100%) rename internal/{ => x}/radixtree/options.go (100%) rename internal/{ => x}/radixtree/options_test.go (100%) diff --git a/internal/rules/repository_impl.go b/internal/rules/repository_impl.go index aad0fd204..87a4529a0 100644 --- a/internal/rules/repository_impl.go +++ b/internal/rules/repository_impl.go @@ -19,13 +19,13 @@ package rules import ( "bytes" "context" + radixtree2 "github.com/dadrus/heimdall/internal/x/radixtree" "slices" "sync" "github.com/rs/zerolog" "github.com/dadrus/heimdall/internal/heimdall" - "github.com/dadrus/heimdall/internal/radixtree" "github.com/dadrus/heimdall/internal/rules/event" "github.com/dadrus/heimdall/internal/rules/rule" "github.com/dadrus/heimdall/internal/x" @@ -45,7 +45,7 @@ func newRepository( logger: logger, queue: queue, quit: make(chan bool), - rulesTree: radixtree.New[rule.Rule](), + rulesTree: radixtree2.New[rule.Rule](), } } @@ -55,7 +55,7 @@ type repository struct { knownRules []rule.Rule - rulesTree radixtree.IndexTree[rule.Rule] + rulesTree radixtree2.IndexTree[rule.Rule] mutex sync.RWMutex queue event.RuleSetChangedEventQueue @@ -70,7 +70,7 @@ func (r *repository) FindRule(ctx heimdall.Context) (rule.Rule, error) { entry, err := r.rulesTree.Find( request.URL.Path, - radixtree.MatcherFunc[rule.Rule](func(candidate rule.Rule) bool { return candidate.Matches(ctx) }), + radixtree2.MatcherFunc[rule.Rule](func(candidate rule.Rule) bool { return candidate.Matches(ctx) }), ) if err != nil { if r.dr != nil { @@ -233,7 +233,7 @@ func (r *repository) removeRules(tbdRules []rule.Rule) { r.mutex.Lock() err := r.rulesTree.Delete( rul.PathExpression(), - radixtree.MatcherFunc[rule.Rule](func(existing rule.Rule) bool { return existing.SameAs(rul) }), + radixtree2.MatcherFunc[rule.Rule](func(existing rule.Rule) bool { return existing.SameAs(rul) }), ) r.mutex.Unlock() @@ -265,7 +265,7 @@ func (r *repository) replaceRules(rules []rule.Rule) { err := r.rulesTree.Update( updated.PathExpression(), updated, - radixtree.MatcherFunc[rule.Rule](func(existing rule.Rule) bool { return existing.SameAs(updated) }), + radixtree2.MatcherFunc[rule.Rule](func(existing rule.Rule) bool { return existing.SameAs(updated) }), ) r.mutex.Unlock() diff --git a/internal/rules/repository_impl_test.go b/internal/rules/repository_impl_test.go index d53cd0429..6782724f7 100644 --- a/internal/rules/repository_impl_test.go +++ b/internal/rules/repository_impl_test.go @@ -18,6 +18,7 @@ package rules import ( "context" + "github.com/dadrus/heimdall/internal/x/radixtree" "net/http" "net/url" "testing" @@ -30,7 +31,6 @@ import ( "github.com/dadrus/heimdall/internal/heimdall" mocks2 "github.com/dadrus/heimdall/internal/heimdall/mocks" - "github.com/dadrus/heimdall/internal/radixtree" "github.com/dadrus/heimdall/internal/rules/event" "github.com/dadrus/heimdall/internal/rules/rule" "github.com/dadrus/heimdall/internal/rules/rule/mocks" diff --git a/internal/radixtree/index_tree.go b/internal/x/radixtree/index_tree.go similarity index 100% rename from internal/radixtree/index_tree.go rename to internal/x/radixtree/index_tree.go diff --git a/internal/radixtree/matcher.go b/internal/x/radixtree/matcher.go similarity index 100% rename from internal/radixtree/matcher.go rename to internal/x/radixtree/matcher.go diff --git a/internal/radixtree/node.go b/internal/x/radixtree/node.go similarity index 100% rename from internal/radixtree/node.go rename to internal/x/radixtree/node.go diff --git a/internal/radixtree/node_benchmark_test.go b/internal/x/radixtree/node_benchmark_test.go similarity index 100% rename from internal/radixtree/node_benchmark_test.go rename to internal/x/radixtree/node_benchmark_test.go diff --git a/internal/radixtree/node_test.go b/internal/x/radixtree/node_test.go similarity index 100% rename from internal/radixtree/node_test.go rename to internal/x/radixtree/node_test.go diff --git a/internal/radixtree/options.go b/internal/x/radixtree/options.go similarity index 100% rename from internal/radixtree/options.go rename to internal/x/radixtree/options.go diff --git a/internal/radixtree/options_test.go b/internal/x/radixtree/options_test.go similarity index 100% rename from internal/radixtree/options_test.go rename to internal/x/radixtree/options_test.go From 4f89a3e501f1ed46825b7f30e067d0c2d5e7e8d7 Mon Sep 17 00:00:00 2001 From: Dimitrij Drus Date: Sun, 21 Apr 2024 23:39:05 +0200 Subject: [PATCH 20/76] further renamings --- internal/rules/repository_impl.go | 12 ++++++------ internal/rules/repository_impl_test.go | 2 +- internal/x/radixtree/index_tree.go | 4 ++-- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/internal/rules/repository_impl.go b/internal/rules/repository_impl.go index 87a4529a0..39b871cb0 100644 --- a/internal/rules/repository_impl.go +++ b/internal/rules/repository_impl.go @@ -19,7 +19,6 @@ package rules import ( "bytes" "context" - radixtree2 "github.com/dadrus/heimdall/internal/x/radixtree" "slices" "sync" @@ -30,6 +29,7 @@ import ( "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" ) @@ -45,7 +45,7 @@ func newRepository( logger: logger, queue: queue, quit: make(chan bool), - rulesTree: radixtree2.New[rule.Rule](), + rulesTree: radixtree.New[rule.Rule](), } } @@ -55,7 +55,7 @@ type repository struct { knownRules []rule.Rule - rulesTree radixtree2.IndexTree[rule.Rule] + rulesTree radixtree.Tree[rule.Rule] mutex sync.RWMutex queue event.RuleSetChangedEventQueue @@ -70,7 +70,7 @@ func (r *repository) FindRule(ctx heimdall.Context) (rule.Rule, error) { entry, err := r.rulesTree.Find( request.URL.Path, - radixtree2.MatcherFunc[rule.Rule](func(candidate rule.Rule) bool { return candidate.Matches(ctx) }), + radixtree.MatcherFunc[rule.Rule](func(candidate rule.Rule) bool { return candidate.Matches(ctx) }), ) if err != nil { if r.dr != nil { @@ -233,7 +233,7 @@ func (r *repository) removeRules(tbdRules []rule.Rule) { r.mutex.Lock() err := r.rulesTree.Delete( rul.PathExpression(), - radixtree2.MatcherFunc[rule.Rule](func(existing rule.Rule) bool { return existing.SameAs(rul) }), + radixtree.MatcherFunc[rule.Rule](func(existing rule.Rule) bool { return existing.SameAs(rul) }), ) r.mutex.Unlock() @@ -265,7 +265,7 @@ func (r *repository) replaceRules(rules []rule.Rule) { err := r.rulesTree.Update( updated.PathExpression(), updated, - radixtree2.MatcherFunc[rule.Rule](func(existing rule.Rule) bool { return existing.SameAs(updated) }), + radixtree.MatcherFunc[rule.Rule](func(existing rule.Rule) bool { return existing.SameAs(updated) }), ) r.mutex.Unlock() diff --git a/internal/rules/repository_impl_test.go b/internal/rules/repository_impl_test.go index 6782724f7..ac14cd92d 100644 --- a/internal/rules/repository_impl_test.go +++ b/internal/rules/repository_impl_test.go @@ -18,7 +18,6 @@ package rules import ( "context" - "github.com/dadrus/heimdall/internal/x/radixtree" "net/http" "net/url" "testing" @@ -35,6 +34,7 @@ import ( "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" ) type testMatcher bool diff --git a/internal/x/radixtree/index_tree.go b/internal/x/radixtree/index_tree.go index 3d0dce2fd..0df90206d 100644 --- a/internal/x/radixtree/index_tree.go +++ b/internal/x/radixtree/index_tree.go @@ -5,7 +5,7 @@ type Entry[V any] struct { Parameters map[string]string } -type IndexTree[V any] interface { +type Tree[V any] interface { Add(path string, value V) error Find(path string, matcher Matcher[V]) (*Entry[V], error) Delete(path string, matcher Matcher[V]) error @@ -13,7 +13,7 @@ type IndexTree[V any] interface { Empty() bool } -func New[V any](opts ...Option[V]) IndexTree[V] { +func New[V any](opts ...Option[V]) Tree[V] { root := &node[V]{ canAdd: func(_ []V, _ V) bool { return true }, } From 0abc4742507ba895914cfe390028081b457d2b01 Mon Sep 17 00:00:00 2001 From: Dimitrij Drus Date: Sun, 21 Apr 2024 23:46:43 +0200 Subject: [PATCH 21/76] only rules from the same rule set can added to the same node --- internal/rules/repository_impl.go | 13 +++++++++---- internal/rules/repository_impl_test.go | 14 -------------- 2 files changed, 9 insertions(+), 18 deletions(-) diff --git a/internal/rules/repository_impl.go b/internal/rules/repository_impl.go index 39b871cb0..abcc9f7d6 100644 --- a/internal/rules/repository_impl.go +++ b/internal/rules/repository_impl.go @@ -42,10 +42,15 @@ func newRepository( dr: x.IfThenElseExec(ruleFactory.HasDefaultRule(), func() rule.Rule { return ruleFactory.DefaultRule() }, func() rule.Rule { return nil }), - logger: logger, - queue: queue, - quit: make(chan bool), - rulesTree: radixtree.New[rule.Rule](), + logger: logger, + queue: queue, + quit: make(chan bool), + rulesTree: 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() + }), + ), } } diff --git a/internal/rules/repository_impl_test.go b/internal/rules/repository_impl_test.go index ac14cd92d..faddbd402 100644 --- a/internal/rules/repository_impl_test.go +++ b/internal/rules/repository_impl_test.go @@ -122,20 +122,6 @@ func TestRepositoryFindRule(t *testing.T) { fooBarMatcher, err := newGlobMatcher("foo.bar", '.') require.NoError(t, err) - exampleComMatcher, err := newGlobMatcher("example.com", '.') - require.NoError(t, err) - - repo.addRuleSet("bar", []rule.Rule{ - &ruleImpl{ - id: "test1", - srcID: "bar", - pathExpression: "/baz", - hostMatcher: exampleComMatcher, - pathMatcher: testMatcher(true), - allowedMethods: []string{http.MethodGet}, - }, - }) - repo.addRuleSet("baz", []rule.Rule{ &ruleImpl{ id: "test2", From effbc987007babfebaaf8da9bc21cd153e80342a Mon Sep 17 00:00:00 2001 From: Dimitrij Drus Date: Mon, 22 Apr 2024 10:10:37 +0200 Subject: [PATCH 22/76] better error handling and less dependencies in radix tree impl --- internal/x/radixtree/node.go | 25 +++++++++++-------------- internal/x/stringx/stringx.go | 9 --------- 2 files changed, 11 insertions(+), 23 deletions(-) diff --git a/internal/x/radixtree/node.go b/internal/x/radixtree/node.go index 599448388..ec16a0506 100644 --- a/internal/x/radixtree/node.go +++ b/internal/x/radixtree/node.go @@ -8,18 +8,16 @@ package radixtree import ( "errors" + "fmt" "slices" "strings" - - "github.com/dadrus/heimdall/internal/x/errorchain" - "github.com/dadrus/heimdall/internal/x/stringx" ) var ( ErrInvalidPath = errors.New("invalid path") ErrNotFound = errors.New("not found") ErrFailedToDelete = errors.New("failed to delete") - ErrFailedToUpdate = errors.New("failed to delete") + ErrFailedToUpdate = errors.New("failed to update") ErrConstraintsViolation = errors.New("constraints violation") ) @@ -73,8 +71,7 @@ func (n *node[V]) addNode(path string, wildcardKeys []string, inStaticToken bool 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, errorchain.NewWithMessage(ErrInvalidPath, - "ambiguous path detected - wildcard keys differ") + return nil, fmt.Errorf("%w: %s is ambigous - wildcard keys differ", ErrInvalidPath, path) } n.wildcardKeys = wildcardKeys @@ -112,7 +109,7 @@ func (n *node[V]) addNode(path string, wildcardKeys []string, inStaticToken bool thisToken = thisToken[1:] if nextSlash != -1 { - return nil, errorchain.NewWithMessagef(ErrInvalidPath, "/ after catch-all found in %s", path) + return nil, fmt.Errorf("%w: %s has '/' after a free wildcard", ErrInvalidPath, path) } if n.catchAllChild == nil { @@ -123,8 +120,8 @@ func (n *node[V]) addNode(path string, wildcardKeys []string, inStaticToken bool } if path[1:] != n.catchAllChild.path { - return nil, errorchain.NewWithMessagef(ErrInvalidPath, - "catch-all name in %s doesn't match %s", path, 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) @@ -395,7 +392,7 @@ func (n *node[V]) splitCommonPrefix(existingNodeIndex int, path string) (*node[V } // Find the length of the common prefix of the child node and the new path. - i := stringx.CommonPrefixLen(childNode.path, path) + i := commonPrefixLen(childNode.path, path) commonPrefix := path[0:i] childNode.path = childNode.path[i:] @@ -421,7 +418,7 @@ func (n *node[V]) Add(path string, value V) error { } if !n.canAdd(res.values, value) { - return ErrConstraintsViolation + return fmt.Errorf("%w: %s", ErrConstraintsViolation, path) } res.values = append(res.values, value) @@ -432,7 +429,7 @@ func (n *node[V]) Add(path string, value V) error { func (n *node[V]) Find(path string, matcher Matcher[V]) (*Entry[V], error) { found, idx, params := n.findNode(path, matcher) if found == nil { - return nil, ErrNotFound + return nil, fmt.Errorf("%w: %s", ErrNotFound, path) } entry := &Entry[V]{ @@ -457,7 +454,7 @@ func (n *node[V]) Find(path string, matcher Matcher[V]) (*Entry[V], error) { func (n *node[V]) Delete(path string, matcher Matcher[V]) error { if !n.delNode(path, matcher) { - return ErrFailedToDelete + return fmt.Errorf("%w: %s", ErrFailedToDelete, path) } return nil @@ -466,7 +463,7 @@ func (n *node[V]) Delete(path string, matcher Matcher[V]) error { func (n *node[V]) Update(path string, value V, matcher Matcher[V]) error { found, idx, _ := n.findNode(path, matcher) if found == nil { - return ErrFailedToUpdate + return fmt.Errorf("%w: %s", ErrFailedToUpdate, path) } found.values[idx] = value diff --git a/internal/x/stringx/stringx.go b/internal/x/stringx/stringx.go index 78864a741..54676ecd3 100644 --- a/internal/x/stringx/stringx.go +++ b/internal/x/stringx/stringx.go @@ -25,12 +25,3 @@ func ToString(b []byte) string { func ToBytes(str string) []byte { return unsafe.Slice(unsafe.StringData(str), len(str)) } - -func CommonPrefixLen(a, b string) int { - n := 0 - for n < len(a) && n < len(b) && a[n] == b[n] { - n++ - } - - return n -} From 55d7c23662b909b84cec8fd37c6bfb7f0a1ab6d5 Mon Sep 17 00:00:00 2001 From: Dimitrij Drus Date: Mon, 22 Apr 2024 10:21:02 +0200 Subject: [PATCH 23/76] lookup of requests with escaped parts in URL fixed --- internal/rules/repository_impl.go | 4 +- internal/rules/repository_impl_test.go | 51 ++++++++++++++++++++++++-- internal/x/radixtree/utils.go | 10 +++++ 3 files changed, 60 insertions(+), 5 deletions(-) create mode 100644 internal/x/radixtree/utils.go diff --git a/internal/rules/repository_impl.go b/internal/rules/repository_impl.go index abcc9f7d6..0faab63e4 100644 --- a/internal/rules/repository_impl.go +++ b/internal/rules/repository_impl.go @@ -74,7 +74,7 @@ func (r *repository) FindRule(ctx heimdall.Context) (rule.Rule, error) { defer r.mutex.RUnlock() entry, err := r.rulesTree.Find( - request.URL.Path, + 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 { @@ -162,7 +162,7 @@ func (r *repository) updateRuleSet(srcID string, rules []rule.Rule) { }) // find updated rules - those, which have the same ID and same path expression. These can be just updated - // in the tree without the need to remove the old ones first and insert the updated ones afterwards. + // in the tree without the need to remove the old ones first and insert the updated ones afterward. updatedRules := slicex.Filter(rules, func(r rule.Rule) bool { loaded := r.(*ruleImpl) // nolint: forcetypeassert diff --git a/internal/rules/repository_impl_test.go b/internal/rules/repository_impl_test.go index faddbd402..8c3fa3ea3 100644 --- a/internal/rules/repository_impl_test.go +++ b/internal/rules/repository_impl_test.go @@ -18,6 +18,7 @@ package rules import ( "context" + "fmt" "net/http" "net/url" "testing" @@ -109,8 +110,45 @@ func TestRepositoryFindRule(t *testing.T) { }, }, { - uc: "matches upstream rule", - requestURL: &url.URL{Scheme: "http", Host: "foo.bar", Path: "/baz"}, + uc: "matches upstream rule having path without escaped parts", + requestURL: &url.URL{Scheme: "http", Host: "foo.bar", Path: "/baz/bar"}, + configureFactory: func(t *testing.T, factory *mocks.FactoryMock) { + t.Helper() + + factory.EXPECT().HasDefaultRule().Return(false) + }, + addRules: func(t *testing.T, repo *repository) { + t.Helper() + + fooBarMatcher, err := newGlobMatcher("foo.bar", '.') + require.NoError(t, err) + + repo.addRuleSet("baz", []rule.Rule{ + &ruleImpl{ + id: "test2", + srcID: "baz", + pathExpression: "/baz/bar", + hostMatcher: fooBarMatcher, + pathMatcher: testMatcher(true), + allowedMethods: []string{http.MethodGet}, + }, + }) + }, + assert: func(t *testing.T, err error, rul rule.Rule) { + t.Helper() + + require.NoError(t, err) + + impl, ok := rul.(*ruleImpl) + require.True(t, ok) + + require.Equal(t, "test2", impl.id) + require.Equal(t, "baz", impl.srcID) + }, + }, + { + uc: "matches upstream rule having path with escaped parts", + requestURL: &url.URL{Scheme: "http", Host: "foo.bar", Path: "/baz/bar", RawPath: "/baz%2Fbar"}, configureFactory: func(t *testing.T, factory *mocks.FactoryMock) { t.Helper() @@ -126,7 +164,7 @@ func TestRepositoryFindRule(t *testing.T) { &ruleImpl{ id: "test2", srcID: "baz", - pathExpression: "/baz", + pathExpression: "/baz%2Fbar", hostMatcher: fooBarMatcher, pathMatcher: testMatcher(true), allowedMethods: []string{http.MethodGet}, @@ -447,3 +485,10 @@ func TestRepositoryRuleSetLifecycleManagement(t *testing.T) { }) } } + +func TestFoo(t *testing.T) { + uri, err := url.Parse("http://localhost/foo%2fbar/baz?d=1") + require.NoError(t, err) + + fmt.Println(uri.String()) +} 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 +} From a94d5a22eb240c330d2221f11648bac5911ebf89 Mon Sep 17 00:00:00 2001 From: Dimitrij Drus Date: Mon, 22 Apr 2024 10:27:16 +0200 Subject: [PATCH 24/76] useless test removed --- internal/rules/repository_impl_test.go | 8 -------- 1 file changed, 8 deletions(-) diff --git a/internal/rules/repository_impl_test.go b/internal/rules/repository_impl_test.go index 8c3fa3ea3..064ad8fc2 100644 --- a/internal/rules/repository_impl_test.go +++ b/internal/rules/repository_impl_test.go @@ -18,7 +18,6 @@ package rules import ( "context" - "fmt" "net/http" "net/url" "testing" @@ -485,10 +484,3 @@ func TestRepositoryRuleSetLifecycleManagement(t *testing.T) { }) } } - -func TestFoo(t *testing.T) { - uri, err := url.Parse("http://localhost/foo%2fbar/baz?d=1") - require.NoError(t, err) - - fmt.Println(uri.String()) -} From 6cd57a3c8efc50307963e3a9eee3269aa75dad19 Mon Sep 17 00:00:00 2001 From: Dimitrij Drus Date: Mon, 22 Apr 2024 10:40:39 +0200 Subject: [PATCH 25/76] code simplifications --- internal/rules/rule_executor_impl.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/internal/rules/rule_executor_impl.go b/internal/rules/rule_executor_impl.go index a76cde970..5b27c8583 100644 --- a/internal/rules/rule_executor_impl.go +++ b/internal/rules/rule_executor_impl.go @@ -33,10 +33,8 @@ func newRuleExecutor(repository rule.Repository) rule.Executor { func (e *ruleExecutor) Execute(ctx heimdall.Context) (rule.Backend, error) { request := ctx.Request() - reqCtx := ctx.AppContext() - //nolint:contextcheck - zerolog.Ctx(reqCtx).Debug(). + zerolog.Ctx(ctx.AppContext()).Debug(). Str("_method", request.Method). Str("_url", request.URL.String()). Msg("Analyzing request") From df558ebadc1bc650b3204263729fa57bd5fcea18 Mon Sep 17 00:00:00 2001 From: Dimitrij Drus Date: Mon, 22 Apr 2024 12:10:28 +0200 Subject: [PATCH 26/76] proper handling of encoded slashes --- internal/rules/config/parser_test.go | 2 +- internal/rules/config/rule.go | 6 +-- internal/rules/rule_factory_impl_test.go | 4 +- internal/rules/rule_impl.go | 47 ++++++++++++++++++++---- internal/rules/rule_impl_test.go | 14 ++++++- 5 files changed, 59 insertions(+), 14 deletions(-) diff --git a/internal/rules/config/parser_test.go b/internal/rules/config/parser_test.go index d4bbda178..beedc6139 100644 --- a/internal/rules/config/parser_test.go +++ b/internal/rules/config/parser_test.go @@ -129,7 +129,7 @@ rules: rul := ruleSet.Rules[0] require.NotNil(t, rul) assert.Equal(t, "bar", rul.ID) - assert.Equal(t, EncodedSlashesNoDecode, rul.EncodedSlashesHandling) + assert.Equal(t, EncodedSlashesOnNoDecode, rul.EncodedSlashesHandling) assert.Equal(t, "foo", rul.Matcher.Path.Expression) }, }, diff --git a/internal/rules/config/rule.go b/internal/rules/config/rule.go index 5c2634154..f809783c2 100644 --- a/internal/rules/config/rule.go +++ b/internal/rules/config/rule.go @@ -23,9 +23,9 @@ import ( type EncodedSlashesHandling string const ( - EncodedSlashesOff EncodedSlashesHandling = "off" - EncodedSlashesOn EncodedSlashesHandling = "on" - EncodedSlashesNoDecode EncodedSlashesHandling = "no_decode" + EncodedSlashesOff EncodedSlashesHandling = "off" + EncodedSlashesOn EncodedSlashesHandling = "on" + EncodedSlashesOnNoDecode EncodedSlashesHandling = "no_decode" ) type Rule struct { diff --git a/internal/rules/rule_factory_impl_test.go b/internal/rules/rule_factory_impl_test.go index b2e90bbcd..4aa4a2113 100644 --- a/internal/rules/rule_factory_impl_test.go +++ b/internal/rules/rule_factory_impl_test.go @@ -969,7 +969,7 @@ func TestRuleFactoryCreateRule(t *testing.T) { Regex: "^/foo/(bar|baz)", }, }, - EncodedSlashesHandling: config2.EncodedSlashesNoDecode, + EncodedSlashesHandling: config2.EncodedSlashesOnNoDecode, Execute: []config.MechanismConfig{ {"authenticator": "foo"}, {"contextualizer": "bar"}, @@ -1010,7 +1010,7 @@ 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.Equal(t, config2.EncodedSlashesOnNoDecode, rul.encodedSlashesHandling) assert.Equal(t, "https", rul.allowedScheme) assert.Equal(t, "/foo/:resource", rul.PathExpression()) require.IsType(t, &globMatcher{}, rul.hostMatcher) diff --git a/internal/rules/rule_impl.go b/internal/rules/rule_impl.go index 689f93d01..dd9566f79 100644 --- a/internal/rules/rule_impl.go +++ b/internal/rules/rule_impl.go @@ -26,6 +26,7 @@ import ( "github.com/dadrus/heimdall/internal/heimdall" "github.com/dadrus/heimdall/internal/rules/config" "github.com/dadrus/heimdall/internal/rules/rule" + "github.com/dadrus/heimdall/internal/x" ) type ruleImpl struct { @@ -55,6 +56,20 @@ 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() + if len(request.URL.RawPath) != 0 { + // unescape captures + captures := request.URL.Captures + for k, v := range captures { + captures[k] = unescape(v, r.encodedSlashesHandling) + } + + // unescape path + if r.encodedSlashesHandling == config.EncodedSlashesOn { + request.URL.RawPath = "" + } + } + // authenticators sub, err := r.sc.Execute(ctx) if err != nil { @@ -74,13 +89,8 @@ 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.URL), + targetURL: r.backend.CreateURL(&request.URL.URL), } } @@ -123,7 +133,14 @@ func (r *ruleImpl) Matches(ctx heimdall.Context) bool { } // match path - if !r.pathMatcher.Match(request.URL.Path) { + var path string + if r.encodedSlashesHandling == config.EncodedSlashesOn { + path = request.URL.Path + } else { + path = x.IfThenElse(len(request.URL.RawPath) != 0, request.URL.RawPath, request.URL.Path) + } + + if !r.pathMatcher.Match(path) { logger.Debug().Msgf("Path %s does not satisfy configured expression", request.URL.Path) return false @@ -149,3 +166,19 @@ type backend struct { } func (b *backend) URL() *url.URL { return b.targetURL } + +func unescape(value string, handling config.EncodedSlashesHandling) string { + switch handling { + case config.EncodedSlashesOn: + unescaped, _ := url.PathUnescape(value) + + return unescaped + case config.EncodedSlashesOnNoDecode: + unescaped := strings.ReplaceAll(value, "%2F", "$$$escaped-slash$$$") + unescaped, _ = url.PathUnescape(unescaped) + + return strings.ReplaceAll(unescaped, "$$$escaped-slash$$$", "%2F") + default: + return value + } +} diff --git a/internal/rules/rule_impl_test.go b/internal/rules/rule_impl_test.go index e3e79d814..9a29b3e63 100644 --- a/internal/rules/rule_impl_test.go +++ b/internal/rules/rule_impl_test.go @@ -151,6 +151,8 @@ 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) @@ -171,6 +173,8 @@ 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) @@ -192,6 +196,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) @@ -215,6 +221,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) @@ -239,6 +247,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) @@ -263,6 +273,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) @@ -371,7 +383,7 @@ func TestRuleExecute(t *testing.T) { }, { 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", }, From 12ce84d2fa326a896791d452cdea1a667e18f6a7 Mon Sep 17 00:00:00 2001 From: Dimitrij Drus Date: Mon, 22 Apr 2024 13:21:58 +0200 Subject: [PATCH 27/76] new mock --- internal/rules/mocks/pattern_matcher.go | 78 +++++++++++++++++++++++++ internal/rules/pattern_matcher.go | 2 + 2 files changed, 80 insertions(+) create mode 100644 internal/rules/mocks/pattern_matcher.go diff --git a/internal/rules/mocks/pattern_matcher.go b/internal/rules/mocks/pattern_matcher.go new file mode 100644 index 000000000..37fd43aa9 --- /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/pattern_matcher.go b/internal/rules/pattern_matcher.go index ecdd2e607..75a96c401 100644 --- a/internal/rules/pattern_matcher.go +++ b/internal/rules/pattern_matcher.go @@ -1,5 +1,7 @@ package rules +//go:generate mockery --name PatternMatcher --structname PatternMatcherMock + type PatternMatcher interface { Match(pattern string) bool } From c9a47d944015a54a483ab84ffbe63b88562654ee Mon Sep 17 00:00:00 2001 From: Dimitrij Drus Date: Mon, 22 Apr 2024 13:22:25 +0200 Subject: [PATCH 28/76] more tests and some fixes for found issues --- internal/rules/rule_impl.go | 39 ++++----- internal/rules/rule_impl_test.go | 134 +++++++++++++++++++++++-------- 2 files changed, 117 insertions(+), 56 deletions(-) diff --git a/internal/rules/rule_impl.go b/internal/rules/rule_impl.go index dd9566f79..666308fae 100644 --- a/internal/rules/rule_impl.go +++ b/internal/rules/rule_impl.go @@ -26,7 +26,6 @@ import ( "github.com/dadrus/heimdall/internal/heimdall" "github.com/dadrus/heimdall/internal/rules/config" "github.com/dadrus/heimdall/internal/rules/rule" - "github.com/dadrus/heimdall/internal/x" ) type ruleImpl struct { @@ -57,17 +56,16 @@ func (r *ruleImpl) Execute(ctx heimdall.Context) (rule.Backend, error) { } request := ctx.Request() - if len(request.URL.RawPath) != 0 { - // unescape captures - captures := request.URL.Captures - for k, v := range captures { - captures[k] = unescape(v, r.encodedSlashesHandling) - } - // unescape path - if r.encodedSlashesHandling == config.EncodedSlashesOn { - request.URL.RawPath = "" - } + // unescape captures + captures := request.URL.Captures + for k, v := range captures { + captures[k] = unescape(v, r.encodedSlashesHandling) + } + + // unescape path + if r.encodedSlashesHandling == config.EncodedSlashesOn { + request.URL.RawPath = "" } // authenticators @@ -134,10 +132,10 @@ func (r *ruleImpl) Matches(ctx heimdall.Context) bool { // match path var path string - if r.encodedSlashesHandling == config.EncodedSlashesOn { + if len(request.URL.RawPath) == 0 { path = request.URL.Path } else { - path = x.IfThenElse(len(request.URL.RawPath) != 0, request.URL.RawPath, request.URL.Path) + path = unescape(request.URL.RawPath, r.encodedSlashesHandling) } if !r.pathMatcher.Match(path) { @@ -168,17 +166,14 @@ type backend struct { func (b *backend) URL() *url.URL { return b.targetURL } func unescape(value string, handling config.EncodedSlashesHandling) string { - switch handling { - case config.EncodedSlashesOn: + if handling == config.EncodedSlashesOn { unescaped, _ := url.PathUnescape(value) return unescaped - case config.EncodedSlashesOnNoDecode: - unescaped := strings.ReplaceAll(value, "%2F", "$$$escaped-slash$$$") - unescaped, _ = url.PathUnescape(unescaped) - - return strings.ReplaceAll(unescaped, "$$$escaped-slash$$$", "%2F") - default: - return value } + + unescaped := strings.ReplaceAll(value, "%2F", "$$$escaped-slash$$$") + unescaped, _ = url.PathUnescape(unescaped) + + return strings.ReplaceAll(unescaped, "$$$escaped-slash$$$", "%2F") } diff --git a/internal/rules/rule_impl_test.go b/internal/rules/rule_impl_test.go index 9a29b3e63..053d9938c 100644 --- a/internal/rules/rule_impl_test.go +++ b/internal/rules/rule_impl_test.go @@ -44,17 +44,6 @@ func TestRuleMatches(t *testing.T) { toMatch *heimdall.Request matches bool }{ - { - uc: "matches", - rule: &ruleImpl{ - hostMatcher: testMatcher(true), - pathMatcher: testMatcher(true), - allowedMethods: []string{http.MethodGet}, - encodedSlashesHandling: config.EncodedSlashesOn, - }, - toMatch: &heimdall.Request{Method: http.MethodGet, URL: &heimdall.URL{}}, - matches: true, - }, { uc: "doesn't match scheme", rule: &ruleImpl{ @@ -86,7 +75,7 @@ func TestRuleMatches(t *testing.T) { allowedMethods: []string{http.MethodGet}, encodedSlashesHandling: config.EncodedSlashesOff, }, - toMatch: &heimdall.Request{Method: http.MethodGet, URL: &heimdall.URL{URL: url.URL{RawPath: "/foo%2Fbar"}}}, + toMatch: &heimdall.Request{Method: http.MethodGet, URL: &heimdall.URL{URL: url.URL{Path: "/foo/bar", RawPath: "/foo%2Fbar"}}}, matches: false, }, { @@ -101,7 +90,7 @@ func TestRuleMatches(t *testing.T) { matches: false, }, { - uc: "doesn't match path", + uc: "doesn't match path with allowed encoded slashes", rule: &ruleImpl{ hostMatcher: testMatcher(true), pathMatcher: testMatcher(false), @@ -111,6 +100,49 @@ func TestRuleMatches(t *testing.T) { toMatch: &heimdall.Request{Method: http.MethodGet, URL: &heimdall.URL{}}, matches: false, }, + { + uc: "doesn't match path with encoded slash with allowed encoded slashes without decoding them", + rule: &ruleImpl{ + hostMatcher: testMatcher(true), + pathMatcher: testMatcher(false), + allowedMethods: []string{http.MethodGet}, + encodedSlashesHandling: config.EncodedSlashesOnNoDecode, + }, + toMatch: &heimdall.Request{Method: http.MethodGet, URL: &heimdall.URL{URL: url.URL{Path: "/foo/bar", RawPath: "/foo%2Fbar"}}}, + matches: false, + }, + { + uc: "match path with encoded slash with allowed encoded slashes without decoding them", + rule: &ruleImpl{ + hostMatcher: testMatcher(true), + pathMatcher: func() PatternMatcher { + matcher := &mocks.PatternMatcherMock{} + matcher.EXPECT().Match("/foo%2Fbar").Return(true) + + return matcher + }(), + allowedMethods: []string{http.MethodGet}, + encodedSlashesHandling: config.EncodedSlashesOnNoDecode, + }, + toMatch: &heimdall.Request{Method: http.MethodGet, URL: &heimdall.URL{URL: url.URL{Path: "/foo/bar", RawPath: "/foo%2Fbar"}}}, + matches: true, + }, + { + uc: "match path with encoded slash with allowed encoded slashes with decoding them", + rule: &ruleImpl{ + hostMatcher: testMatcher(true), + pathMatcher: func() PatternMatcher { + matcher := &mocks.PatternMatcherMock{} + matcher.EXPECT().Match("/foo/bar").Return(true) + + return matcher + }(), + allowedMethods: []string{http.MethodGet}, + encodedSlashesHandling: config.EncodedSlashesOn, + }, + toMatch: &heimdall.Request{Method: http.MethodGet, URL: &heimdall.URL{URL: url.URL{Path: "/foo/bar", RawPath: "/foo%2Fbar"}}}, + matches: true, + }, } { t.Run("case="+tc.uc, func(t *testing.T) { ctx := heimdallmocks.NewContextMock(t) @@ -141,7 +173,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", @@ -158,7 +190,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) @@ -180,7 +212,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) @@ -206,7 +238,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) @@ -231,7 +263,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) @@ -258,7 +290,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) @@ -284,7 +316,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) @@ -310,15 +342,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: &heimdall.URL{URL: *targetURL}}) - }, - assert: func(t *testing.T, err error, backend rule.Backend) { + 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, 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"]) }, }, { @@ -340,15 +381,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: &heimdall.URL{URL: *targetURL}}) - }, - assert: func(t *testing.T, err error, backend rule.Backend) { + 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, 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"]) }, }, { @@ -370,15 +420,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: &heimdall.URL{URL: *targetURL}}) - }, - assert: func(t *testing.T, err error, backend rule.Backend) { + 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, 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"]) }, }, { @@ -400,15 +458,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: &heimdall.URL{URL: *targetURL}}) - }, - assert: func(t *testing.T, err error, backend rule.Backend) { + 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, 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"]) }, }, { @@ -432,7 +498,7 @@ func TestRuleExecute(t *testing.T) { targetURL, _ := url.Parse("http://foo.local/api/v1/foo") 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) @@ -467,7 +533,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) }) } } From 18499b6f4a96e49749b725fd1a15c2d47c61330b Mon Sep 17 00:00:00 2001 From: Dimitrij Drus Date: Mon, 22 Apr 2024 13:35:40 +0200 Subject: [PATCH 29/76] some code simplifications --- internal/rules/rule_impl.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/rules/rule_impl.go b/internal/rules/rule_impl.go index 666308fae..4569c174d 100644 --- a/internal/rules/rule_impl.go +++ b/internal/rules/rule_impl.go @@ -132,10 +132,11 @@ func (r *ruleImpl) Matches(ctx heimdall.Context) bool { // match path var path string - if len(request.URL.RawPath) == 0 { + if len(request.URL.RawPath) == 0 || r.encodedSlashesHandling == config.EncodedSlashesOn { path = request.URL.Path } else { - path = unescape(request.URL.RawPath, r.encodedSlashesHandling) + unescaped, _ := url.PathUnescape(strings.ReplaceAll(request.URL.RawPath, "%2F", "$$$escaped-slash$$$")) + path = strings.ReplaceAll(unescaped, "$$$escaped-slash$$$", "%2F") } if !r.pathMatcher.Match(path) { @@ -172,8 +173,7 @@ func unescape(value string, handling config.EncodedSlashesHandling) string { return unescaped } - unescaped := strings.ReplaceAll(value, "%2F", "$$$escaped-slash$$$") - unescaped, _ = url.PathUnescape(unescaped) + unescaped, _ := url.PathUnescape(strings.ReplaceAll(value, "%2F", "$$$escaped-slash$$$")) return strings.ReplaceAll(unescaped, "$$$escaped-slash$$$", "%2F") } From aeecd3deab043e4c72af400b1611f81662641ba8 Mon Sep 17 00:00:00 2001 From: Dimitrij Drus Date: Mon, 22 Apr 2024 17:19:40 +0200 Subject: [PATCH 30/76] new mutex to ensure multiple providers can manage rules in parallel --- internal/rules/repository_impl.go | 32 ++++++++++++++++++++----------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/internal/rules/repository_impl.go b/internal/rules/repository_impl.go index 0faab63e4..75c813045 100644 --- a/internal/rules/repository_impl.go +++ b/internal/rules/repository_impl.go @@ -58,10 +58,11 @@ type repository struct { dr rule.Rule logger zerolog.Logger - knownRules []rule.Rule + knownRules []rule.Rule + knownRulesMutex sync.Mutex - rulesTree radixtree.Tree[rule.Rule] - mutex sync.RWMutex + rulesTree radixtree.Tree[rule.Rule] + rulesTreeMutex sync.RWMutex queue event.RuleSetChangedEventQueue quit chan bool @@ -70,8 +71,8 @@ type repository struct { func (r *repository) FindRule(ctx heimdall.Context) (rule.Rule, error) { request := ctx.Request() - r.mutex.RLock() - defer r.mutex.RUnlock() + r.rulesTreeMutex.RLock() + defer r.rulesTreeMutex.RUnlock() entry, err := r.rulesTree.Find( x.IfThenElse(len(request.URL.RawPath) != 0, request.URL.RawPath, request.URL.Path), @@ -134,6 +135,9 @@ func (r *repository) watchRuleSetChanges() { } func (r *repository) addRuleSet(srcID string, rules []rule.Rule) { + r.knownRulesMutex.Lock() + defer r.knownRulesMutex.Unlock() + r.logger.Info().Str("_src", srcID).Msg("Adding rule set") // add them @@ -142,6 +146,9 @@ func (r *repository) addRuleSet(srcID string, rules []rule.Rule) { func (r *repository) updateRuleSet(srcID string, rules []rule.Rule) { // create rules + r.knownRulesMutex.Lock() + defer r.knownRulesMutex.Unlock() + r.logger.Info().Str("_src", srcID).Msg("Updating rule set") // find all rules for the given src id @@ -200,6 +207,9 @@ func (r *repository) updateRuleSet(srcID string, rules []rule.Rule) { } func (r *repository) deleteRuleSet(srcID string) { + r.knownRulesMutex.Lock() + defer r.knownRulesMutex.Unlock() + r.logger.Info().Str("_src", srcID).Msg("Deleting rule set") // find all rules for the given src id @@ -211,9 +221,9 @@ func (r *repository) deleteRuleSet(srcID string) { func (r *repository) addRules(rules []rule.Rule) { for _, rul := range rules { - r.mutex.Lock() + r.rulesTreeMutex.Lock() err := r.rulesTree.Add(rul.PathExpression(), rul) - r.mutex.Unlock() + r.rulesTreeMutex.Unlock() if err != nil { r.logger.Error().Err(err). @@ -235,12 +245,12 @@ func (r *repository) removeRules(tbdRules []rule.Rule) { var failed []rule.Rule for _, rul := range tbdRules { - r.mutex.Lock() + r.rulesTreeMutex.Lock() err := r.rulesTree.Delete( rul.PathExpression(), radixtree.MatcherFunc[rule.Rule](func(existing rule.Rule) bool { return existing.SameAs(rul) }), ) - r.mutex.Unlock() + r.rulesTreeMutex.Unlock() if err != nil { r.logger.Error().Err(err). @@ -266,13 +276,13 @@ func (r *repository) replaceRules(rules []rule.Rule) { var failed []rule.Rule for _, updated := range rules { - r.mutex.Lock() + r.rulesTreeMutex.Lock() err := r.rulesTree.Update( updated.PathExpression(), updated, radixtree.MatcherFunc[rule.Rule](func(existing rule.Rule) bool { return existing.SameAs(updated) }), ) - r.mutex.Unlock() + r.rulesTreeMutex.Unlock() if err != nil { r.logger.Error().Err(err). From bd29116121ae349104e85105277d3467c88ca668 Mon Sep 17 00:00:00 2001 From: Dimitrij Drus Date: Tue, 23 Apr 2024 14:44:37 +0200 Subject: [PATCH 31/76] making updating rules index atomic, provider have access to the rule index errors, further simplifications and refactorings --- cmd/validate/ruleset.go | 18 +- cmd/validate/ruleset_test.go | 2 +- internal/rules/event/event.go | 50 -- internal/rules/event/queue.go | 19 - internal/rules/module.go | 30 +- internal/rules/repository_impl.go | 247 ++++----- internal/rules/repository_impl_test.go | 513 ++++++++---------- internal/rules/rule/mocks/factory.go | 23 +- internal/rules/rule/mocks/repository.go | 140 +++++ internal/rules/rule/repository.go | 4 + internal/rules/rule_factory_impl.go | 51 +- internal/rules/rule_factory_impl_test.go | 4 +- internal/rules/ruleset_processor_impl.go | 56 +- internal/rules/ruleset_processor_test.go | 204 ++++--- internal/x/radixtree/index_tree.go | 26 - internal/x/radixtree/options.go | 4 +- internal/x/radixtree/{node.go => tree.go} | 133 +++-- ...nchmark_test.go => tree_benchmark_test.go} | 109 +++- .../radixtree/{node_test.go => tree_test.go} | 0 19 files changed, 840 insertions(+), 793 deletions(-) delete mode 100644 internal/rules/event/event.go delete mode 100644 internal/rules/event/queue.go delete mode 100644 internal/x/radixtree/index_tree.go rename internal/x/radixtree/{node.go => tree.go} (78%) rename internal/x/radixtree/{node_benchmark_test.go => tree_benchmark_test.go} (51%) rename internal/x/radixtree/{node_test.go => tree_test.go} (100%) diff --git a/cmd/validate/ruleset.go b/cmd/validate/ruleset.go index 5fe271a5d..99a104cd0 100644 --- a/cmd/validate/ruleset.go +++ b/cmd/validate/ruleset.go @@ -18,6 +18,8 @@ package validate import ( "context" + "github.com/dadrus/heimdall/internal/heimdall" + "github.com/dadrus/heimdall/internal/rules/rule" "os" "github.com/rs/zerolog" @@ -25,7 +27,6 @@ import ( "github.com/dadrus/heimdall/internal/config" "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" ) @@ -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/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/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/repository_impl.go b/internal/rules/repository_impl.go index 75c813045..edad649cb 100644 --- a/internal/rules/repository_impl.go +++ b/internal/rules/repository_impl.go @@ -18,14 +18,10 @@ package rules import ( "bytes" - "context" "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" @@ -33,19 +29,22 @@ import ( "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), - rulesTree: radixtree.New[rule.Rule]( + 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() @@ -54,27 +53,13 @@ func newRepository( } } -type repository struct { - dr rule.Rule - logger zerolog.Logger - - knownRules []rule.Rule - knownRulesMutex sync.Mutex - - rulesTree radixtree.Tree[rule.Rule] - rulesTreeMutex sync.RWMutex - - queue event.RuleSetChangedEventQueue - quit chan bool -} - func (r *repository) FindRule(ctx heimdall.Context) (rule.Rule, error) { request := ctx.Request() r.rulesTreeMutex.RLock() defer r.rulesTreeMutex.RUnlock() - entry, err := r.rulesTree.Find( + 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) }), ) @@ -92,65 +77,30 @@ func (r *repository) FindRule(ctx heimdall.Context) (rule.Rule, error) { return entry.Value, nil } -func (r *repository) Start(_ context.Context) error { - r.logger.Info().Msg("Starting rule definition loader") - - go r.watchRuleSetChanges() - - return nil -} - -func (r *repository) Stop(_ context.Context) error { - r.logger.Info().Msg("Tearing down rule definition loader") - - r.quit <- true - - close(r.quit) - - return nil -} - -func (r *repository) watchRuleSetChanges() { - for { - select { - case evt, ok := <-r.queue: - if !ok { - r.logger.Debug().Msg("Rule set definition queue closed") - } +func (r *repository) AddRuleSet(_ string, rules []rule.Rule) error { + r.knownRulesMutex.Lock() + defer r.knownRulesMutex.Unlock() - 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") + tmp := r.index.Clone() - return - } + if err := r.addRulesTo(tmp, rules); err != nil { + return err } -} -func (r *repository) addRuleSet(srcID string, rules []rule.Rule) { - r.knownRulesMutex.Lock() - defer r.knownRulesMutex.Unlock() + r.knownRules = append(r.knownRules, rules...) - r.logger.Info().Str("_src", srcID).Msg("Adding rule set") + 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.knownRulesMutex.Lock() defer r.knownRulesMutex.Unlock() - r.logger.Info().Str("_src", srcID).Msg("Updating rule set") - // find all rules for the given src id applicable := slicex.Filter(r.knownRules, func(r rule.Rule) bool { return r.SrcID() == srcID }) @@ -196,116 +146,109 @@ func (r *repository) updateRuleSet(srcID string, rules []rule.Rule) { return ruleGone || pathExpressionChanged }) + tmp := r.index.Clone() + // remove deleted rules - r.removeRules(deletedRules) + if err := r.removeRulesFrom(tmp, deletedRules); err != nil { + return err + } // replace updated rules - r.replaceRules(updatedRules) + if err := r.replaceRulesIn(tmp, updatedRules); err != nil { + return err + } // add new rules - r.addRules(newRules) + if err := r.addRulesTo(tmp, newRules); err != nil { + return err + } + + r.knownRules = slices.DeleteFunc(r.knownRules, func(loaded rule.Rule) bool { + return slices.Contains(deletedRules, loaded) + }) + + for idx, existing := range r.knownRules { + for _, updated := range updatedRules { + if updated.SameAs(existing) { + r.knownRules[idx] = updated + + break + } + } + } + + r.knownRules = append(r.knownRules, newRules...) + + r.rulesTreeMutex.Lock() + r.index = tmp + r.rulesTreeMutex.Unlock() + + return nil } -func (r *repository) deleteRuleSet(srcID string) { +func (r *repository) DeleteRuleSet(srcID string) error { r.knownRulesMutex.Lock() defer r.knownRulesMutex.Unlock() - r.logger.Info().Str("_src", srcID).Msg("Deleting rule set") - // find all rules for the given src id applicable := slicex.Filter(r.knownRules, func(r rule.Rule) bool { return r.SrcID() == srcID }) + tmp := r.index.Clone() + // remove them - r.removeRules(applicable) + if err := r.removeRulesFrom(tmp, applicable); err != nil { + return err + } + + r.knownRules = slices.DeleteFunc(r.knownRules, func(r rule.Rule) bool { + return slices.Contains(applicable, r) + }) + + r.rulesTreeMutex.Lock() + r.index = tmp + r.rulesTreeMutex.Unlock() + + return nil } -func (r *repository) addRules(rules []rule.Rule) { +func (r *repository) addRulesTo(tree *radixtree.Tree[rule.Rule], rules []rule.Rule) error { for _, rul := range rules { - r.rulesTreeMutex.Lock() - err := r.rulesTree.Add(rul.PathExpression(), rul) - r.rulesTreeMutex.Unlock() - - if err != nil { - r.logger.Error().Err(err). - Str("_src", rul.SrcID()). - Str("_id", rul.ID()). - Msg("Failed to add rule") - } else { - r.logger.Debug(). - Str("_src", rul.SrcID()). - Str("_id", rul.ID()). - Msg("Rule added") - - r.knownRules = append(r.knownRules, rul) + if err := tree.Add(rul.PathExpression(), rul); err != nil { + return errorchain.NewWithMessagef(heimdall.ErrInternal, "failed adding rule ID='%s'", rul.ID()). + CausedBy(err) } } -} -func (r *repository) removeRules(tbdRules []rule.Rule) { - var failed []rule.Rule + return nil +} +func (r *repository) removeRulesFrom(tree *radixtree.Tree[rule.Rule], tbdRules []rule.Rule) error { for _, rul := range tbdRules { - r.rulesTreeMutex.Lock() - err := r.rulesTree.Delete( + if err := tree.Delete( rul.PathExpression(), radixtree.MatcherFunc[rule.Rule](func(existing rule.Rule) bool { return existing.SameAs(rul) }), - ) - r.rulesTreeMutex.Unlock() - - if err != nil { - r.logger.Error().Err(err). - Str("_src", rul.SrcID()). - Str("_id", rul.ID()). - Msg("Failed to remove rule. Please file a bug report!") - - failed = append(failed, rul) - } else { - r.logger.Debug(). - Str("_src", rul.SrcID()). - Str("_id", rul.ID()). - Msg("Rule removed") + ); err != nil { + return errorchain.NewWithMessagef(heimdall.ErrInternal, "failed deleting rule ID='%s'", rul.ID()). + CausedBy(err) } } - r.knownRules = slices.DeleteFunc(r.knownRules, func(r rule.Rule) bool { - return !slices.Contains(failed, r) && slices.Contains(tbdRules, r) - }) + return nil } -func (r *repository) replaceRules(rules []rule.Rule) { - var failed []rule.Rule - +func (r *repository) replaceRulesIn(tree *radixtree.Tree[rule.Rule], rules []rule.Rule) error { for _, updated := range rules { - r.rulesTreeMutex.Lock() - err := r.rulesTree.Update( + if err := tree.Update( updated.PathExpression(), updated, - radixtree.MatcherFunc[rule.Rule](func(existing rule.Rule) bool { return existing.SameAs(updated) }), - ) - r.rulesTreeMutex.Unlock() - - if err != nil { - r.logger.Error().Err(err). - Str("_src", updated.SrcID()). - Str("_id", updated.ID()). - Msg("Failed to replace rule. Please file a bug report!") - - failed = append(failed, updated) - } else { - r.logger.Debug(). - Str("_src", updated.SrcID()). - Str("_id", updated.ID()). - Msg("Rule replaced") + radixtree.MatcherFunc[rule.Rule](func(existing rule.Rule) bool { + return existing.SameAs(updated) + }), + ); err != nil { + return errorchain.NewWithMessagef(heimdall.ErrInternal, "failed replacing rule ID='%s'", updated.ID()). + CausedBy(err) } } - for idx, existing := range r.knownRules { - for _, updated := range rules { - if updated.SameAs(existing) && !slices.Contains(failed, updated) { - r.knownRules[idx] = updated - - break - } - } - } + return nil } diff --git a/internal/rules/repository_impl_test.go b/internal/rules/repository_impl_test.go index 064ad8fc2..05c2210be 100644 --- a/internal/rules/repository_impl_test.go +++ b/internal/rules/repository_impl_test.go @@ -21,16 +21,12 @@ import ( "net/http" "net/url" "testing" - "time" - "github.com/rs/zerolog" - "github.com/rs/zerolog/log" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/dadrus/heimdall/internal/heimdall" mocks2 "github.com/dadrus/heimdall/internal/heimdall/mocks" - "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" @@ -41,30 +37,234 @@ type testMatcher bool func (m testMatcher) Match(_ string) bool { return bool(m) } -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 - repo.addRuleSet("bar", []rule.Rule{ - &ruleImpl{id: "1", srcID: "bar", pathExpression: "/foo/1"}, - &ruleImpl{id: "2", srcID: "bar", pathExpression: "/foo/2"}, - &ruleImpl{id: "3", srcID: "bar", pathExpression: "/foo/3"}, - &ruleImpl{id: "4", srcID: "bar", pathExpression: "/foo/4"}, - }) + 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.rulesTree.Empty()) + assert.False(t, repo.index.Empty()) // WHEN - repo.deleteRuleSet("bar") + err := repo.DeleteRuleSet("1") // THEN + require.NoError(t, err) assert.Empty(t, repo.knownRules) - assert.True(t, repo.rulesTree.Empty()) + 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", + hostMatcher: testMatcher(true), pathMatcher: testMatcher(true), allowedMethods: []string{http.MethodGet}, + }, + + &ruleImpl{ + id: "3", srcID: "bar", pathExpression: "/bar/3", + hostMatcher: testMatcher(true), pathMatcher: testMatcher(true), allowedMethods: []string{http.MethodGet}, + }, + &ruleImpl{ + id: "4", srcID: "bar", pathExpression: "/bar/4", + hostMatcher: testMatcher(true), pathMatcher: testMatcher(true), allowedMethods: []string{http.MethodGet}, + }, + } + rules2 := []rule.Rule{ + &ruleImpl{ + id: "2", srcID: "baz", pathExpression: "/baz/2", + hostMatcher: testMatcher(true), pathMatcher: testMatcher(true), allowedMethods: []string{http.MethodGet}, + }, + } + rules3 := []rule.Rule{ + &ruleImpl{ + id: "4", srcID: "foo", pathExpression: "/foo/4", + hostMatcher: testMatcher(true), pathMatcher: testMatcher(true), allowedMethods: []string{http.MethodGet}, + }, + } + + // 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 + err = repo.DeleteRuleSet("foo") + + // THEN + 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}, + hostMatcher: testMatcher(true), pathMatcher: testMatcher(true), allowedMethods: []string{http.MethodGet}, + }, + &ruleImpl{ + id: "2", srcID: "1", pathExpression: "/bar/2", hash: []byte{1}, + hostMatcher: testMatcher(true), pathMatcher: testMatcher(true), allowedMethods: []string{http.MethodGet}, + }, + &ruleImpl{ + id: "3", srcID: "1", pathExpression: "/bar/3", hash: []byte{1}, + hostMatcher: testMatcher(true), pathMatcher: testMatcher(true), allowedMethods: []string{http.MethodGet}, + }, + &ruleImpl{ + id: "4", srcID: "1", pathExpression: "/bar/4", hash: []byte{1}, + hostMatcher: testMatcher(true), pathMatcher: testMatcher(true), allowedMethods: []string{http.MethodGet}, + }, + } + + require.NoError(t, repo.AddRuleSet("1", initialRules)) + + updatedRules := []rule.Rule{ + &ruleImpl{ + // changed + id: "1", srcID: "1", pathExpression: "/bar/1", hash: []byte{2}, + hostMatcher: testMatcher(true), pathMatcher: testMatcher(true), allowedMethods: []string{http.MethodGet}, + }, + // rule with id 2 is deleted + &ruleImpl{ + // changed and path expression changed + id: "3", srcID: "1", pathExpression: "/foo/3", hash: []byte{2}, + hostMatcher: testMatcher(true), pathMatcher: testMatcher(true), allowedMethods: []string{http.MethodGet}, + }, + &ruleImpl{ + // same as before + id: "4", srcID: "1", pathExpression: "/bar/4", hash: []byte{1}, + hostMatcher: testMatcher(true), pathMatcher: testMatcher(true), allowedMethods: []string{http.MethodGet}, + }, + } + + // WHEN + err := repo.UpdateRuleSet("1", updatedRules) + + // THEN + 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) { @@ -122,7 +322,7 @@ func TestRepositoryFindRule(t *testing.T) { fooBarMatcher, err := newGlobMatcher("foo.bar", '.') require.NoError(t, err) - repo.addRuleSet("baz", []rule.Rule{ + err = repo.AddRuleSet("baz", []rule.Rule{ &ruleImpl{ id: "test2", srcID: "baz", @@ -132,6 +332,7 @@ func TestRepositoryFindRule(t *testing.T) { allowedMethods: []string{http.MethodGet}, }, }) + require.NoError(t, err) }, assert: func(t *testing.T, err error, rul rule.Rule) { t.Helper() @@ -159,7 +360,7 @@ func TestRepositoryFindRule(t *testing.T) { fooBarMatcher, err := newGlobMatcher("foo.bar", '.') require.NoError(t, err) - repo.addRuleSet("baz", []rule.Rule{ + err = repo.AddRuleSet("baz", []rule.Rule{ &ruleImpl{ id: "test2", srcID: "baz", @@ -169,6 +370,7 @@ func TestRepositoryFindRule(t *testing.T) { allowedMethods: []string{http.MethodGet}, }, }) + require.NoError(t, err) }, assert: func(t *testing.T, err error, rul rule.Rule) { t.Helper() @@ -192,7 +394,7 @@ 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) @@ -209,278 +411,3 @@ func TestRepositoryFindRule(t *testing.T) { }) } } - -func TestRepositoryAddAndRemoveRulesFromDifferentRuleSets(t *testing.T) { - t.Parallel() - - // GIVEN - repo := newRepository(nil, &ruleFactory{}, *zerolog.Ctx(context.Background())) - - rules := []rule.Rule{ - &ruleImpl{ - id: "1", srcID: "bar", pathExpression: "/bar/1", - hostMatcher: testMatcher(true), pathMatcher: testMatcher(true), allowedMethods: []string{http.MethodGet}, - }, - &ruleImpl{ - id: "2", srcID: "baz", pathExpression: "/baz/2", - hostMatcher: testMatcher(true), pathMatcher: testMatcher(true), allowedMethods: []string{http.MethodGet}, - }, - &ruleImpl{ - id: "3", srcID: "bar", pathExpression: "/bar/3", - hostMatcher: testMatcher(true), pathMatcher: testMatcher(true), allowedMethods: []string{http.MethodGet}, - }, - &ruleImpl{ - id: "4", srcID: "bar", pathExpression: "/bar/4", - hostMatcher: testMatcher(true), pathMatcher: testMatcher(true), allowedMethods: []string{http.MethodGet}, - }, - &ruleImpl{ - id: "4", srcID: "foo", pathExpression: "/foo/4", - hostMatcher: testMatcher(true), pathMatcher: testMatcher(true), allowedMethods: []string{http.MethodGet}, - }, - } - - // WHEN - repo.addRules(rules) - - // THEN - assert.Len(t, repo.knownRules, 5) - assert.False(t, repo.rulesTree.Empty()) - - // WHEN - repo.deleteRuleSet("bar") - - // THEN - assert.Len(t, repo.knownRules, 2) - assert.ElementsMatch(t, repo.knownRules, []rule.Rule{rules[1], rules[4]}) - - _, err := repo.rulesTree.Find("/bar/1", radixtree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) - assert.Error(t, err) //nolint:testifylint - - _, err = repo.rulesTree.Find("/bar/3", radixtree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) - assert.Error(t, err) //nolint:testifylint - - _, err = repo.rulesTree.Find("/bar/4", radixtree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) - assert.Error(t, err) //nolint:testifylint - - _, err = repo.rulesTree.Find("/baz/2", radixtree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) - assert.NoError(t, err) //nolint:testifylint - - _, err = repo.rulesTree.Find("/foo/4", radixtree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) - assert.NoError(t, err) //nolint:testifylint - - // WHEN - repo.deleteRuleSet("foo") - - // THEN - assert.Len(t, repo.knownRules, 1) - assert.ElementsMatch(t, repo.knownRules, []rule.Rule{rules[1]}) - - _, err = repo.rulesTree.Find("/foo/4", radixtree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) - assert.Error(t, err) //nolint:testifylint - - _, err = repo.rulesTree.Find("/baz/2", radixtree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) - assert.NoError(t, err) //nolint:testifylint - - // WHEN - repo.deleteRuleSet("baz") - - // THEN - assert.Empty(t, repo.knownRules) - assert.True(t, repo.rulesTree.Empty()) -} - -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.knownRules) - assert.True(t, repo.rulesTree.Empty()) - }, - }, - { - uc: "rule set with one rule", - events: []event.RuleSetChanged{ - { - Source: "test", - ChangeType: event.Create, - Rules: []rule.Rule{ - &ruleImpl{id: "rule:foo", srcID: "test", pathExpression: "/foo/1"}, - }, - }, - }, - assert: func(t *testing.T, repo *repository) { - t.Helper() - - assert.Len(t, repo.knownRules, 1) - assert.False(t, repo.rulesTree.Empty()) - - entry, err := repo.rulesTree.Find("/foo/1", radixtree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) - require.NoError(t, err) - - assert.Equal(t, repo.knownRules[0], entry.Value) - assert.Equal(t, "rule:foo", entry.Value.ID()) - assert.Equal(t, "test", entry.Value.SrcID()) - }, - }, - { - uc: "multiple rule sets", - events: []event.RuleSetChanged{ - { - Source: "test1", - ChangeType: event.Create, - Rules: []rule.Rule{&ruleImpl{id: "rule:bar", srcID: "test1", pathExpression: "/bar/1"}}, - }, - { - Source: "test2", - ChangeType: event.Create, - Rules: []rule.Rule{&ruleImpl{id: "rule:foo", srcID: "test2", pathExpression: "/foo/1"}}, - }, - }, - assert: func(t *testing.T, repo *repository) { - t.Helper() - - assert.Len(t, repo.knownRules, 2) - - entry1, err := repo.rulesTree.Find("/bar/1", radixtree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) - require.NoError(t, err) - assert.Equal(t, repo.knownRules[0], entry1.Value) - assert.Equal(t, "rule:bar", entry1.Value.ID()) - assert.Equal(t, "test1", entry1.Value.SrcID()) - - entry2, err := repo.rulesTree.Find("/foo/1", radixtree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) - require.NoError(t, err) - assert.Equal(t, repo.knownRules[1], entry2.Value) - assert.Equal(t, "rule:foo", entry2.Value.ID()) - assert.Equal(t, "test2", entry2.Value.SrcID()) - }, - }, - { - 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", pathExpression: "/bar/1"}}, - }, - { - Source: "test2", - ChangeType: event.Create, - Rules: []rule.Rule{&ruleImpl{id: "rule:foo", srcID: "test2", pathExpression: "/foo/1"}}, - }, - { - Source: "test1", - ChangeType: event.Remove, - }, - }, - assert: func(t *testing.T, repo *repository) { - t.Helper() - - assert.Len(t, repo.knownRules, 1) - assert.False(t, repo.rulesTree.Empty()) - - entry, err := repo.rulesTree.Find("/foo/1", radixtree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) - require.NoError(t, err) - - assert.Equal(t, repo.knownRules[0], entry.Value) - assert.Equal(t, "rule:foo", entry.Value.ID()) - assert.Equal(t, "test2", entry.Value.SrcID()) - }, - }, - { - 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", pathExpression: "/bar/1"}}, - }, - { - Source: "test2", - ChangeType: event.Create, - Rules: []rule.Rule{ - &ruleImpl{id: "rule:foo1", srcID: "test2", hash: []byte{1}, pathExpression: "/foo/1"}, - &ruleImpl{id: "rule:foo2", srcID: "test2", hash: []byte{2}, pathExpression: "/foo/2"}, - &ruleImpl{id: "rule:foo3", srcID: "test2", hash: []byte{3}, pathExpression: "/foo/3"}, - &ruleImpl{id: "rule:foo4", srcID: "test2", hash: []byte{4}, pathExpression: "/foo/4"}, - }, - }, - { - Source: "test2", - ChangeType: event.Update, - Rules: []rule.Rule{ - &ruleImpl{id: "rule:foo1", srcID: "test2", hash: []byte{5}, pathExpression: "/foo/1"}, // updated - &ruleImpl{id: "rule:foo2", srcID: "test2", hash: []byte{2}, pathExpression: "/foo/2"}, // as before - // &ruleImpl{id: "rule:foo3", srcID: "test2", hash: []byte{3}, pathExpression: "/foo/3"}, // deleted - &ruleImpl{id: "rule:foo4", srcID: "test2", hash: []byte{6}, pathExpression: "/foo/6"}, // updated path - }, - }, - }, - assert: func(t *testing.T, repo *repository) { - t.Helper() - - require.Len(t, repo.knownRules, 4) - assert.False(t, repo.rulesTree.Empty()) - - entryBar, err := repo.rulesTree.Find("/bar/1", radixtree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) - require.NoError(t, err) - assert.Equal(t, repo.knownRules[0], entryBar.Value) - assert.Equal(t, "rule:bar", entryBar.Value.ID()) - assert.Equal(t, "test1", entryBar.Value.SrcID()) - - entryFoo1, err := repo.rulesTree.Find("/foo/1", radixtree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) - require.NoError(t, err) - assert.Equal(t, repo.knownRules[1], entryFoo1.Value) - assert.Equal(t, "rule:foo1", entryFoo1.Value.ID()) - assert.Equal(t, "test2", entryFoo1.Value.SrcID()) - assert.Equal(t, []byte{5}, entryFoo1.Value.(*ruleImpl).hash) //nolint: forcetypeassert - - entryFoo2, err := repo.rulesTree.Find("/foo/2", radixtree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) - require.NoError(t, err) - assert.Equal(t, repo.knownRules[2], entryFoo2.Value) - assert.Equal(t, "rule:foo2", entryFoo2.Value.ID()) - assert.Equal(t, "test2", entryFoo2.Value.SrcID()) - assert.Equal(t, []byte{2}, entryFoo2.Value.(*ruleImpl).hash) //nolint: forcetypeassert - - entryFoo4, err := repo.rulesTree.Find("/foo/6", radixtree.MatcherFunc[rule.Rule](func(_ rule.Rule) bool { return true })) - require.NoError(t, err) - assert.Equal(t, repo.knownRules[3], entryFoo4.Value) - assert.Equal(t, "rule:foo4", entryFoo4.Value.ID()) - assert.Equal(t, "test2", entryFoo4.Value.SrcID()) - assert.Equal(t, []byte{6}, entryFoo4.Value.(*ruleImpl).hash) //nolint: forcetypeassert - }, - }, - } { - 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) - - // WHEN - for _, evt := range tc.events { - queue <- evt - } - - time.Sleep(100 * time.Millisecond) - - // THEN - tc.assert(t, repo) - }) - } -} 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 d770777b7..345b7d240 100644 --- a/internal/rules/rule/mocks/repository.go +++ b/internal/rules/rule/mocks/repository.go @@ -22,6 +22,99 @@ func (_m *RepositoryMock) EXPECT() *RepositoryMock_Expecter { return &RepositoryMock_Expecter{mock: &_m.Mock} } +// 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) @@ -80,6 +173,53 @@ func (_c *RepositoryMock_FindRule_Call) RunAndReturn(run func(heimdall.Context) return _c } +// 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. // The first argument is typically a *testing.T value. func NewRepositoryMock(t interface { diff --git a/internal/rules/rule/repository.go b/internal/rules/rule/repository.go index 6a6586d7d..1108aa509 100644 --- a/internal/rules/rule/repository.go +++ b/internal/rules/rule/repository.go @@ -24,4 +24,8 @@ import ( type Repository interface { 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_factory_impl.go b/internal/rules/rule_factory_impl.go index 829bbf131..91ba3e12c 100644 --- a/internal/rules/rule_factory_impl.go +++ b/internal/rules/rule_factory_impl.go @@ -162,27 +162,26 @@ 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) + return nil, errorchain.NewWithMessage(heimdall.ErrConfiguration, "no ID defined") } if len(ruleConfig.Matcher.Path.Expression) == 0 { - return nil, errorchain.NewWithMessagef(heimdall.ErrConfiguration, - "no path matching expression defined for rule ID=%s from %s", ruleConfig.ID, srcID) + return nil, errorchain.NewWithMessage(heimdall.ErrConfiguration, + "no path matching expression defined") } if len(ruleConfig.Matcher.HostGlob) != 0 && len(ruleConfig.Matcher.HostRegex) != 0 { - return nil, errorchain.NewWithMessagef(heimdall.ErrConfiguration, - "host glob and regex expressions are defined for rule ID=%s from %s", ruleConfig.ID, srcID) + return nil, errorchain.NewWithMessage(heimdall.ErrConfiguration, + "host glob and regex expressions are defined") } if len(ruleConfig.Matcher.Path.Glob) != 0 && len(ruleConfig.Matcher.Path.Regex) != 0 { - return nil, errorchain.NewWithMessagef(heimdall.ErrConfiguration, - "path glob and regex expressions are defined for rule ID=%s from %s", ruleConfig.ID, srcID) + return nil, errorchain.NewWithMessage(heimdall.ErrConfiguration, + "path glob and regex expressions are defined") } if f.mode == config.ProxyMode { - if err := checkProxyModeApplicability(srcID, ruleConfig); err != nil { + if err := checkProxyModeApplicability(ruleConfig); err != nil { return nil, err } } @@ -210,8 +209,8 @@ func (f *ruleFactory) CreateRule(version, srcID string, ruleConfig config2.Rule) } if err != nil { - return nil, errorchain.NewWithMessagef(heimdall.ErrConfiguration, - "filed to compile host pattern defined for rule ID=%s from %s", ruleConfig.ID, srcID).CausedBy(err) + return nil, errorchain.NewWithMessage(heimdall.ErrConfiguration, + "filed to compile host pattern defined").CausedBy(err) } switch { @@ -224,8 +223,8 @@ func (f *ruleFactory) CreateRule(version, srcID string, ruleConfig config2.Rule) } if err != nil { - return nil, errorchain.NewWithMessagef(heimdall.ErrConfiguration, - "filed to compile path pattern defined for rule ID=%s from %s", ruleConfig.ID, srcID).CausedBy(err) + return nil, errorchain.NewWithMessage(heimdall.ErrConfiguration, + "filed to compile path pattern defined").CausedBy(err) } authenticators, subHandlers, finalizers, err := f.createExecutePipeline(version, ruleConfig.Execute) @@ -240,8 +239,8 @@ func (f *ruleFactory) CreateRule(version, srcID string, ruleConfig config2.Rule) methods, err := expandHTTPMethods(ruleConfig.Matcher.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) + return nil, errorchain.NewWithMessage(heimdall.ErrConfiguration, + "failed to expand allowed HTTP methods").CausedBy(err) } if f.defaultRule != nil { @@ -253,19 +252,16 @@ func (f *ruleFactory) CreateRule(version, srcID string, ruleConfig config2.Rule) } if len(authenticators) == 0 { - return nil, errorchain.NewWithMessagef(heimdall.ErrConfiguration, - "no authenticator defined for rule ID=%s from %s", ruleConfig.ID, srcID) + return nil, errorchain.NewWithMessage(heimdall.ErrConfiguration, "no authenticator defined") } if len(methods) == 0 { - return nil, errorchain.NewWithMessagef(heimdall.ErrConfiguration, - "no methods defined for rule ID=%s from %s", ruleConfig.ID, srcID) + return nil, errorchain.NewWithMessage(heimdall.ErrConfiguration, "no methods defined") } 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 nil, errorchain.NewWithMessage(heimdall.ErrConfiguration, "failed to create hash") } return &ruleImpl{ @@ -291,17 +287,13 @@ func (f *ruleFactory) CreateRule(version, srcID string, ruleConfig config2.Rule) }, nil } -func checkProxyModeApplicability(srcID string, ruleConfig config2.Rule) error { +func checkProxyModeApplicability(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) + return errorchain.NewWithMessage(heimdall.ErrConfiguration, "proxy mode requires forward_to definition") } 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 errorchain.NewWithMessage(heimdall.ErrConfiguration, "missing host definition in forward_to") } urlRewriter := ruleConfig.Backend.URLRewriter @@ -313,8 +305,7 @@ func checkProxyModeApplicability(srcID string, ruleConfig config2.Rule) error { 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 errorchain.NewWithMessage(heimdall.ErrConfiguration, "rewrite is defined in forward_to, but is empty") } return nil diff --git a/internal/rules/rule_factory_impl_test.go b/internal/rules/rule_factory_impl_test.go index 4aa4a2113..e1ab122d4 100644 --- a/internal/rules/rule_factory_impl_test.go +++ b/internal/rules/rule_factory_impl_test.go @@ -611,7 +611,7 @@ func TestRuleFactoryCreateRule(t *testing.T) { require.Error(t, err) require.ErrorIs(t, err, heimdall.ErrConfiguration) - assert.Contains(t, err.Error(), "no forward_to") + assert.Contains(t, err.Error(), "requires forward_to") }, }, { @@ -1460,7 +1460,7 @@ func TestRuleFactoryProxyModeApplicability(t *testing.T) { } { t.Run(tc.uc, func(t *testing.T) { // WHEN - err := checkProxyModeApplicability("test", tc.ruleConfig) + err := checkProxyModeApplicability(tc.ruleConfig) // THEN if tc.shouldError { 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/x/radixtree/index_tree.go b/internal/x/radixtree/index_tree.go deleted file mode 100644 index 0df90206d..000000000 --- a/internal/x/radixtree/index_tree.go +++ /dev/null @@ -1,26 +0,0 @@ -package radixtree - -type Entry[V any] struct { - Value V - Parameters map[string]string -} - -type Tree[V any] interface { - Add(path string, value V) error - Find(path string, matcher Matcher[V]) (*Entry[V], error) - Delete(path string, matcher Matcher[V]) error - Update(path string, value V, matcher Matcher[V]) error - Empty() bool -} - -func New[V any](opts ...Option[V]) Tree[V] { - root := &node[V]{ - canAdd: func(_ []V, _ V) bool { return true }, - } - - for _, opt := range opts { - opt(root) - } - - return root -} diff --git a/internal/x/radixtree/options.go b/internal/x/radixtree/options.go index b5198f5d6..53863b468 100644 --- a/internal/x/radixtree/options.go +++ b/internal/x/radixtree/options.go @@ -1,9 +1,9 @@ package radixtree -type Option[V any] func(n *node[V]) +type Option[V any] func(n *Tree[V]) func WithValuesConstraints[V any](constraints ConstraintsFunc[V]) Option[V] { - return func(n *node[V]) { + return func(n *Tree[V]) { if constraints != nil { n.canAdd = constraints } diff --git a/internal/x/radixtree/node.go b/internal/x/radixtree/tree.go similarity index 78% rename from internal/x/radixtree/node.go rename to internal/x/radixtree/tree.go index ec16a0506..6fd2942c6 100644 --- a/internal/x/radixtree/node.go +++ b/internal/x/radixtree/tree.go @@ -21,33 +21,52 @@ var ( ErrConstraintsViolation = errors.New("constraints violation") ) -type ConstraintsFunc[V any] func(oldValues []V, newValue V) bool +type ( + ConstraintsFunc[V any] func(oldValues []V, newValue V) bool -type node[V any] struct { - path string + 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] - priority int + // If none of the above match, check the wildcard children + wildcardChild *Tree[V] - // The list of static children to check. - staticIndices []byte - staticChildren []*node[V] + // If none of the above match, then we use the catch-all, if applicable. + catchAllChild *Tree[V] - // If none of the above match, check the wildcard children - wildcardChild *node[V] + isCatchAll bool + isWildcard bool - // If none of the above match, then we use the catch-all, if applicable. - catchAllChild *node[V] + values []V + wildcardKeys []string - isCatchAll bool - isWildcard bool + canAdd ConstraintsFunc[V] + } +) - values []V - wildcardKeys []string +func New[V any](opts ...Option[V]) *Tree[V] { + root := &Tree[V]{ + canAdd: func(_ []V, _ V) bool { return true }, + } - canAdd ConstraintsFunc[V] + for _, opt := range opts { + opt(root) + } + + return root } -func (n *node[V]) sortStaticChildren(i int) { +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] @@ -56,7 +75,7 @@ func (n *node[V]) sortStaticChildren(i int) { } } -func (n *node[V]) nextSeparator(path string) int { +func (n *Tree[V]) nextSeparator(path string) int { if idx := strings.IndexByte(path, '/'); idx != -1 { return idx } @@ -65,7 +84,7 @@ func (n *node[V]) nextSeparator(path string) int { } //nolint:funlen,gocognit,cyclop -func (n *node[V]) addNode(path string, wildcardKeys []string, inStaticToken bool) (*node[V], error) { +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 { @@ -113,7 +132,7 @@ func (n *node[V]) addNode(path string, wildcardKeys []string, inStaticToken bool } if n.catchAllChild == nil { - n.catchAllChild = &node[V]{ + n.catchAllChild = &Tree[V]{ path: thisToken, isCatchAll: true, } @@ -130,7 +149,7 @@ func (n *node[V]) addNode(path string, wildcardKeys []string, inStaticToken bool return n.catchAllChild, nil case ':': if n.wildcardChild == nil { - n.wildcardChild = &node[V]{path: "wildcard", isWildcard: true} + n.wildcardChild = &Tree[V]{path: "wildcard", isWildcard: true} } return n.wildcardChild.addNode(remainingPath, append(wildcardKeys, thisToken[1:]), false) @@ -167,7 +186,7 @@ func (n *node[V]) addNode(path string, wildcardKeys []string, inStaticToken bool } } - child := &node[V]{path: thisToken} + child := &Tree[V]{path: thisToken} n.staticIndices = append(n.staticIndices, token) n.staticChildren = append(n.staticChildren, child) @@ -178,7 +197,7 @@ func (n *node[V]) addNode(path string, wildcardKeys []string, inStaticToken bool } //nolint:cyclop -func (n *node[V]) delNode(path string, matcher Matcher[V]) bool { +func (n *Tree[V]) delNode(path string, matcher Matcher[V]) bool { pathLen := len(path) if pathLen == 0 { if len(n.values) == 0 { @@ -193,7 +212,7 @@ func (n *node[V]) delNode(path string, matcher Matcher[V]) bool { var ( nextPath string - child *node[V] + child *Tree[V] ) token := path[0] @@ -258,7 +277,7 @@ func (n *node[V]) delNode(path string, matcher Matcher[V]) bool { } //nolint:cyclop -func (n *node[V]) deleteChild(child *node[V], token uint8) { +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] @@ -285,7 +304,7 @@ func (n *node[V]) deleteChild(child *node[V], token uint8) { } } -func (n *node[V]) delEdge(token byte) { +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:]...) @@ -297,9 +316,9 @@ func (n *node[V]) delEdge(token byte) { } //nolint:funlen,gocognit,cyclop -func (n *node[V]) findNode(path string, matcher Matcher[V]) (*node[V], int, []string) { +func (n *Tree[V]) findNode(path string, matcher Matcher[V]) (*Tree[V], int, []string) { var ( - found *node[V] + found *Tree[V] params []string idx int value V @@ -379,7 +398,7 @@ func (n *node[V]) findNode(path string, matcher Matcher[V]) (*node[V], int, []st return nil, 0, nil } -func (n *node[V]) splitCommonPrefix(existingNodeIndex int, path string) (*node[V], int) { +func (n *Tree[V]) splitCommonPrefix(existingNodeIndex int, path string) (*Tree[V], int) { childNode := n.staticChildren[existingNodeIndex] if strings.HasPrefix(path, childNode.path) { @@ -399,19 +418,19 @@ func (n *node[V]) splitCommonPrefix(existingNodeIndex int, path string) (*node[V // Create a new intermediary node in the place of the existing node, with // the existing node as a child. - newNode := &node[V]{ + 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: []*node[V]{childNode}, + staticChildren: []*Tree[V]{childNode}, } n.staticChildren[existingNodeIndex] = newNode return newNode, i } -func (n *node[V]) Add(path string, value V) error { +func (n *Tree[V]) Add(path string, value V) error { res, err := n.addNode(path, nil, false) if err != nil { return err @@ -426,7 +445,7 @@ func (n *node[V]) Add(path string, value V) error { return nil } -func (n *node[V]) Find(path string, matcher Matcher[V]) (*Entry[V], error) { +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) @@ -452,7 +471,7 @@ func (n *node[V]) Find(path string, matcher Matcher[V]) (*Entry[V], error) { return entry, nil } -func (n *node[V]) Delete(path string, matcher Matcher[V]) error { +func (n *Tree[V]) Delete(path string, matcher Matcher[V]) error { if !n.delNode(path, matcher) { return fmt.Errorf("%w: %s", ErrFailedToDelete, path) } @@ -460,7 +479,7 @@ func (n *node[V]) Delete(path string, matcher Matcher[V]) error { return nil } -func (n *node[V]) Update(path string, value V, matcher Matcher[V]) error { +func (n *Tree[V]) Update(path string, value V, matcher Matcher[V]) error { found, idx, _ := n.findNode(path, matcher) if found == nil { return fmt.Errorf("%w: %s", ErrFailedToUpdate, path) @@ -471,6 +490,48 @@ func (n *node[V]) Update(path string, value V, matcher Matcher[V]) error { return nil } -func (n *node[V]) Empty() bool { +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/node_benchmark_test.go b/internal/x/radixtree/tree_benchmark_test.go similarity index 51% rename from internal/x/radixtree/node_benchmark_test.go rename to internal/x/radixtree/tree_benchmark_test.go index 3025554d4..55b95756a 100644 --- a/internal/x/radixtree/node_benchmark_test.go +++ b/internal/x/radixtree/tree_benchmark_test.go @@ -1,6 +1,8 @@ package radixtree import ( + "math/rand" + "strings" "testing" "github.com/stretchr/testify/require" @@ -8,7 +10,7 @@ import ( func BenchmarkNodeSearchNoPaths(b *testing.B) { tm := testMatcher[string](true) - tree := &node[string]{ + tree := &Tree[string]{ path: "/", canAdd: func(_ []string, _ string) bool { return true }, } @@ -23,7 +25,7 @@ func BenchmarkNodeSearchNoPaths(b *testing.B) { func BenchmarkNodeSearchRoot(b *testing.B) { tm := testMatcher[string](true) - tree := &node[string]{ + tree := &Tree[string]{ path: "/", canAdd: func(_ []string, _ string) bool { return true }, } @@ -38,7 +40,7 @@ func BenchmarkNodeSearchRoot(b *testing.B) { func BenchmarkNodeSearchOneStaticPath(b *testing.B) { tm := testMatcher[string](true) - tree := &node[string]{ + tree := &Tree[string]{ path: "/", canAdd: func(_ []string, _ string) bool { return true }, } @@ -55,7 +57,7 @@ func BenchmarkNodeSearchOneStaticPath(b *testing.B) { func BenchmarkNodeSearchOneLongStaticPath(b *testing.B) { tm := testMatcher[string](true) - tree := &node[string]{ + tree := &Tree[string]{ path: "/", canAdd: func(_ []string, _ string) bool { return true }, } @@ -72,7 +74,7 @@ func BenchmarkNodeSearchOneLongStaticPath(b *testing.B) { func BenchmarkNodeSearchOneWildcardPath(b *testing.B) { tm := testMatcher[string](true) - tree := &node[string]{ + tree := &Tree[string]{ path: "/", canAdd: func(_ []string, _ string) bool { return true }, } @@ -89,7 +91,7 @@ func BenchmarkNodeSearchOneWildcardPath(b *testing.B) { func BenchmarkNodeSearchOneLongWildcards(b *testing.B) { tm := testMatcher[string](true) - tree := &node[string]{ + tree := &Tree[string]{ path: "/", canAdd: func(_ []string, _ string) bool { return true }, } @@ -106,7 +108,7 @@ func BenchmarkNodeSearchOneLongWildcards(b *testing.B) { func BenchmarkNodeSearchOneFreeWildcard(b *testing.B) { tm := testMatcher[string](true) - tree := &node[string]{ + tree := &Tree[string]{ path: "/", canAdd: func(_ []string, _ string) bool { return true }, } @@ -120,3 +122,96 @@ func BenchmarkNodeSearchOneFreeWildcard(b *testing.B) { tree.findNode("foo", tm) } } + +func BenchmarkNodeSearchRandomPathInBigTree(b *testing.B) { + tm := testMatcher[string](true) + tree := &Tree[string]{ + path: "/", + canAdd: func(_ []string, _ string) bool { return true }, + } + + paths := make([]string, 0, 1000) + + for range cap(paths) { + builder := strings.Builder{} + builder.WriteString("/") + builder.WriteString(randStringBytes(5)) + + for range 4 { + builder.WriteString("/") + builder.WriteString(randStringBytes(5)) + } + + path := builder.String() + paths = append(paths, path) + + require.NoError(b, tree.Add(path, path)) + } + + b.ReportAllocs() + b.ResetTimer() + + for range b.N { + path := paths[rand.Intn(len(paths))] + tree.findNode(path, tm) + } +} + +func BenchmarkNodeCloneSmallTree(b *testing.B) { + tree := &Tree[string]{ + path: "/", + canAdd: func(_ []string, _ string) bool { return true }, + } + + for _, path := range []string{ + "/abc/abc", "/abb/abc", "/abd/abc", "/bbc/abc", + } { + require.NoError(b, tree.Add(path, path)) + } + + b.ReportAllocs() + b.ResetTimer() + + for range b.N { + tree.Clone() + } +} + +func BenchmarkNodeCloneBigTree(b *testing.B) { + tree := &Tree[string]{ + path: "/", + canAdd: func(_ []string, _ string) bool { return true }, + } + + for range 1000 { + builder := strings.Builder{} + builder.WriteString("/") + builder.WriteString(randStringBytes(5)) + + for range 4 { + builder.WriteString("/") + builder.WriteString(randStringBytes(5)) + } + + path := builder.String() + + require.NoError(b, tree.Add(path, path)) + } + + b.ReportAllocs() + b.ResetTimer() + + for range b.N { + tree.Clone() + } +} + +const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + +func randStringBytes(n int) string { + b := make([]byte, n) + for i := range b { + b[i] = letterBytes[rand.Intn(len(letterBytes))] + } + return string(b) +} diff --git a/internal/x/radixtree/node_test.go b/internal/x/radixtree/tree_test.go similarity index 100% rename from internal/x/radixtree/node_test.go rename to internal/x/radixtree/tree_test.go From c2251991d34c2867e9ff14d62db124d083ad0d96 Mon Sep 17 00:00:00 2001 From: Dimitrij Drus Date: Tue, 23 Apr 2024 14:55:00 +0200 Subject: [PATCH 32/76] benchmark tests used for orientation only removed --- internal/x/radixtree/tree_benchmark_test.go | 95 --------------------- 1 file changed, 95 deletions(-) diff --git a/internal/x/radixtree/tree_benchmark_test.go b/internal/x/radixtree/tree_benchmark_test.go index 55b95756a..93556eb3e 100644 --- a/internal/x/radixtree/tree_benchmark_test.go +++ b/internal/x/radixtree/tree_benchmark_test.go @@ -1,8 +1,6 @@ package radixtree import ( - "math/rand" - "strings" "testing" "github.com/stretchr/testify/require" @@ -122,96 +120,3 @@ func BenchmarkNodeSearchOneFreeWildcard(b *testing.B) { tree.findNode("foo", tm) } } - -func BenchmarkNodeSearchRandomPathInBigTree(b *testing.B) { - tm := testMatcher[string](true) - tree := &Tree[string]{ - path: "/", - canAdd: func(_ []string, _ string) bool { return true }, - } - - paths := make([]string, 0, 1000) - - for range cap(paths) { - builder := strings.Builder{} - builder.WriteString("/") - builder.WriteString(randStringBytes(5)) - - for range 4 { - builder.WriteString("/") - builder.WriteString(randStringBytes(5)) - } - - path := builder.String() - paths = append(paths, path) - - require.NoError(b, tree.Add(path, path)) - } - - b.ReportAllocs() - b.ResetTimer() - - for range b.N { - path := paths[rand.Intn(len(paths))] - tree.findNode(path, tm) - } -} - -func BenchmarkNodeCloneSmallTree(b *testing.B) { - tree := &Tree[string]{ - path: "/", - canAdd: func(_ []string, _ string) bool { return true }, - } - - for _, path := range []string{ - "/abc/abc", "/abb/abc", "/abd/abc", "/bbc/abc", - } { - require.NoError(b, tree.Add(path, path)) - } - - b.ReportAllocs() - b.ResetTimer() - - for range b.N { - tree.Clone() - } -} - -func BenchmarkNodeCloneBigTree(b *testing.B) { - tree := &Tree[string]{ - path: "/", - canAdd: func(_ []string, _ string) bool { return true }, - } - - for range 1000 { - builder := strings.Builder{} - builder.WriteString("/") - builder.WriteString(randStringBytes(5)) - - for range 4 { - builder.WriteString("/") - builder.WriteString(randStringBytes(5)) - } - - path := builder.String() - - require.NoError(b, tree.Add(path, path)) - } - - b.ReportAllocs() - b.ResetTimer() - - for range b.N { - tree.Clone() - } -} - -const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" - -func randStringBytes(n int) string { - b := make([]byte, n) - for i := range b { - b[i] = letterBytes[rand.Intn(len(letterBytes))] - } - return string(b) -} From 3b3d992bb3c381f6b071fa1dff041b1931189789 Mon Sep 17 00:00:00 2001 From: Dimitrij Drus Date: Tue, 23 Apr 2024 15:00:26 +0200 Subject: [PATCH 33/76] linter earnings resolved --- cmd/validate/ruleset.go | 4 ++-- internal/rules/repository_impl.go | 30 ++++++++++++++++-------------- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/cmd/validate/ruleset.go b/cmd/validate/ruleset.go index 99a104cd0..b1b5cfbf4 100644 --- a/cmd/validate/ruleset.go +++ b/cmd/validate/ruleset.go @@ -18,17 +18,17 @@ package validate import ( "context" - "github.com/dadrus/heimdall/internal/heimdall" - "github.com/dadrus/heimdall/internal/rules/rule" "os" "github.com/rs/zerolog" "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/mechanisms" "github.com/dadrus/heimdall/internal/rules/provider/filesystem" + "github.com/dadrus/heimdall/internal/rules/rule" ) // NewValidateRulesCommand represents the "validate rules" command. diff --git a/internal/rules/repository_impl.go b/internal/rules/repository_impl.go index edad649cb..17852c93c 100644 --- a/internal/rules/repository_impl.go +++ b/internal/rules/repository_impl.go @@ -148,18 +148,7 @@ func (r *repository) UpdateRuleSet(srcID string, rules []rule.Rule) error { tmp := r.index.Clone() - // remove deleted rules - if err := r.removeRulesFrom(tmp, deletedRules); err != nil { - return err - } - - // replace updated rules - if err := r.replaceRulesIn(tmp, updatedRules); err != nil { - return err - } - - // add new rules - if err := r.addRulesTo(tmp, newRules); err != nil { + if err := r.updateRulesIn(tmp, newRules, updatedRules, deletedRules); err != nil { return err } @@ -236,8 +225,16 @@ func (r *repository) removeRulesFrom(tree *radixtree.Tree[rule.Rule], tbdRules [ return nil } -func (r *repository) replaceRulesIn(tree *radixtree.Tree[rule.Rule], rules []rule.Rule) error { - for _, updated := range rules { +func (r *repository) updateRulesIn( + tree *radixtree.Tree[rule.Rule], newRules, updatedRules, deletedRules []rule.Rule, +) error { + // remove deleted rules + if err := r.removeRulesFrom(tree, deletedRules); err != nil { + return err + } + + // replace updated rules + for _, updated := range updatedRules { if err := tree.Update( updated.PathExpression(), updated, @@ -250,5 +247,10 @@ func (r *repository) replaceRulesIn(tree *radixtree.Tree[rule.Rule], rules []rul } } + // add new rules + if err := r.addRulesTo(tree, newRules); err != nil { + return err + } + return nil } From 99d5e47af202ee1b6a5f34f322704a6a55bc9155 Mon Sep 17 00:00:00 2001 From: Dimitrij Drus Date: Tue, 23 Apr 2024 15:27:41 +0200 Subject: [PATCH 34/76] package comment moved --- internal/x/radixtree/package.go | 7 +++++++ internal/x/radixtree/tree.go | 6 ------ 2 files changed, 7 insertions(+), 6 deletions(-) create mode 100644 internal/x/radixtree/package.go 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 index 6fd2942c6..5f931cce5 100644 --- a/internal/x/radixtree/tree.go +++ b/internal/x/radixtree/tree.go @@ -1,9 +1,3 @@ -/* -Package radixtree implements a tree lookup for values associated to -paths. - -This package is a fork of https://github.com/dimfeld/httptreemux. -*/ package radixtree import ( From 3b86421731b57505bf6fbaecbd3c5c5c2500b4d0 Mon Sep 17 00:00:00 2001 From: Dimitrij Drus Date: Tue, 23 Apr 2024 15:27:58 +0200 Subject: [PATCH 35/76] more tests for radix tree --- internal/x/radixtree/tree_test.go | 63 ++++++++++++++++++++++++++++--- 1 file changed, 57 insertions(+), 6 deletions(-) diff --git a/internal/x/radixtree/tree_test.go b/internal/x/radixtree/tree_test.go index ee08de3ab..46c95e266 100644 --- a/internal/x/radixtree/tree_test.go +++ b/internal/x/radixtree/tree_test.go @@ -1,6 +1,7 @@ package radixtree import ( + "golang.org/x/exp/maps" "slices" "testing" @@ -10,7 +11,7 @@ import ( func testMatcher[V any](matches bool) MatcherFunc[V] { return func(_ V) bool { return matches } } -func TestNodeSearch(t *testing.T) { +func TestTreeSearch(t *testing.T) { t.Parallel() // Setup & populate tree @@ -142,7 +143,29 @@ func TestNodeSearch(t *testing.T) { } } -func TestNodeAddPathDuplicates(t *testing.T) { +func TestTreeSearchWithBacktracking(t *testing.T) { + t.Parallel() + + // GIVEN + tree := New[string]() + + err := tree.Add("/date/:year/abc", "first") + require.NoError(t, err) + + err = tree.Add("/date/:year", "second") + require.NoError(t, err) + + // WHEN + entry, err := tree.Find("/date/2024", MatcherFunc[string](func(value string) bool { + return value != "first" + })) + + // THEN + require.NoError(t, err) + assert.Equal(t, "second", entry.Value) +} + +func TestTreeAddPathDuplicates(t *testing.T) { t.Parallel() tree := New[string]() @@ -169,7 +192,7 @@ func TestNodeAddPathDuplicates(t *testing.T) { assert.Equal(t, map[string]string{"year": "2024", "month": "04"}, entry.Parameters) } -func TestNodeAddPath(t *testing.T) { +func TestTreeAddPath(t *testing.T) { t.Parallel() for _, tc := range []struct { @@ -214,7 +237,7 @@ func TestNodeAddPath(t *testing.T) { } } -func TestNodeDeleteStaticPaths(t *testing.T) { +func TestTreeDeleteStaticPaths(t *testing.T) { t.Parallel() paths := []string{ @@ -245,7 +268,7 @@ func TestNodeDeleteStaticPaths(t *testing.T) { } } -func TestNodeDeleteStaticAndWildcardPaths(t *testing.T) { +func TestTreeDeleteStaticAndWildcardPaths(t *testing.T) { t.Parallel() paths := []string{ @@ -293,7 +316,7 @@ func TestNodeDeleteStaticAndWildcardPaths(t *testing.T) { } } -func TestNodeDeleteMixedPaths(t *testing.T) { +func TestTreeDeleteMixedPaths(t *testing.T) { t.Parallel() paths := []string{ @@ -335,3 +358,31 @@ func TestNodeDeleteMixedPaths(t *testing.T) { 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) + } +} From 371bd44e9e655d5a65de0da7d17dd77fd1ccec1f Mon Sep 17 00:00:00 2001 From: Dimitrij Drus Date: Tue, 23 Apr 2024 15:29:04 +0200 Subject: [PATCH 36/76] radix tree update won't work as implemented - removed --- internal/x/radixtree/tree.go | 37 +++++++++++++----------------------- 1 file changed, 13 insertions(+), 24 deletions(-) diff --git a/internal/x/radixtree/tree.go b/internal/x/radixtree/tree.go index 5f931cce5..a9b5e5902 100644 --- a/internal/x/radixtree/tree.go +++ b/internal/x/radixtree/tree.go @@ -424,21 +424,6 @@ func (n *Tree[V]) splitCommonPrefix(existingNodeIndex int, path string) (*Tree[V return newNode, i } -func (n *Tree[V]) Add(path string, value V) error { - res, err := n.addNode(path, nil, false) - if err != nil { - return err - } - - if !n.canAdd(res.values, value) { - return fmt.Errorf("%w: %s", ErrConstraintsViolation, path) - } - - res.values = append(res.values, value) - - return nil -} - func (n *Tree[V]) Find(path string, matcher Matcher[V]) (*Entry[V], error) { found, idx, params := n.findNode(path, matcher) if found == nil { @@ -465,22 +450,26 @@ func (n *Tree[V]) Find(path string, matcher Matcher[V]) (*Entry[V], error) { return entry, nil } -func (n *Tree[V]) Delete(path string, matcher Matcher[V]) error { - if !n.delNode(path, matcher) { - return fmt.Errorf("%w: %s", ErrFailedToDelete, path) +func (n *Tree[V]) Add(path string, value V) error { + res, err := n.addNode(path, nil, false) + if err != nil { + return err + } + + if !n.canAdd(res.values, value) { + return fmt.Errorf("%w: %s", ErrConstraintsViolation, path) } + res.values = append(res.values, value) + return nil } -func (n *Tree[V]) Update(path string, value V, matcher Matcher[V]) error { - found, idx, _ := n.findNode(path, matcher) - if found == nil { - return fmt.Errorf("%w: %s", ErrFailedToUpdate, path) +func (n *Tree[V]) Delete(path string, matcher Matcher[V]) error { + if !n.delNode(path, matcher) { + return fmt.Errorf("%w: %s", ErrFailedToDelete, path) } - found.values[idx] = value - return nil } From 2fa845e0b41d14422d848f5d6614eaa599cb5e21 Mon Sep 17 00:00:00 2001 From: Dimitrij Drus Date: Tue, 23 Apr 2024 15:40:49 +0200 Subject: [PATCH 37/76] linter warnings resolved --- internal/x/radixtree/tree_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/x/radixtree/tree_test.go b/internal/x/radixtree/tree_test.go index 46c95e266..9a8ad7a7e 100644 --- a/internal/x/radixtree/tree_test.go +++ b/internal/x/radixtree/tree_test.go @@ -1,12 +1,12 @@ package radixtree import ( - "golang.org/x/exp/maps" "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 } } From 97d9c796663c776d650ec210f105de97cceae65f Mon Sep 17 00:00:00 2001 From: Dimitrij Drus Date: Tue, 23 Apr 2024 15:41:06 +0200 Subject: [PATCH 38/76] repository impl updated --- internal/rules/repository_impl.go | 95 +++++++++---------------------- 1 file changed, 26 insertions(+), 69 deletions(-) diff --git a/internal/rules/repository_impl.go b/internal/rules/repository_impl.go index 17852c93c..6eae7c984 100644 --- a/internal/rules/repository_impl.go +++ b/internal/rules/repository_impl.go @@ -104,69 +104,56 @@ func (r *repository) UpdateRuleSet(srcID string, rules []rule.Rule) error { // find all rules for the given src id applicable := slicex.Filter(r.knownRules, func(r rule.Rule) bool { return r.SrcID() == srcID }) - // find new rules - these are completely new ones, as well as those, which have their path expressions - // updated, so that the old ones must be removed and the updated ones must be inserted into the tree. - newRules := slicex.Filter(rules, func(newRule rule.Rule) bool { + // 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 + ruleIsNew := !slices.ContainsFunc(applicable, func(existingRule rule.Rule) bool { return existingRule.ID() == newRule.ID() }) - pathExpressionChanged := slices.ContainsFunc(applicable, func(existingRule rule.Rule) bool { - return existingRule.ID() == newRule.ID() && existingRule.PathExpression() != newRule.PathExpression() + ruleChanged := slices.ContainsFunc(applicable, func(existingRule rule.Rule) bool { + existing := existingRule.(*ruleImpl) //nolint: forcetypeassert + + return existing.ID() == candidate.ID() && !bytes.Equal(existing.hash, candidate.hash) }) - return ruleIsNew || pathExpressionChanged + return ruleIsNew || ruleChanged }) - // find updated rules - those, which have the same ID and same path expression. These can be just updated - // in the tree without the need to remove the old ones first and insert the updated ones afterward. - updatedRules := slicex.Filter(rules, func(r rule.Rule) bool { - loaded := r.(*ruleImpl) // nolint: forcetypeassert - - return slices.ContainsFunc(applicable, func(existing rule.Rule) bool { - known := existing.(*ruleImpl) // nolint: forcetypeassert - - return known.id == loaded.id && // same id - !bytes.Equal(known.hash, loaded.hash) && // different hash - known.pathExpression == loaded.pathExpression // same path expressions - }) - }) + // 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 - // find deleted rules - those, which are gone, or still present, but have a different path - // expression. Latter means, the old ones needs to be removed and the updated ones inserted - deletedRules := slicex.Filter(applicable, func(existingRule rule.Rule) bool { ruleGone := !slices.ContainsFunc(rules, func(newRule rule.Rule) bool { return newRule.ID() == existingRule.ID() }) - pathExpressionChanged := slices.ContainsFunc(rules, func(newRule rule.Rule) bool { - return existingRule.ID() == newRule.ID() && existingRule.PathExpression() != newRule.PathExpression() + ruleChanged := slices.ContainsFunc(rules, func(newRule rule.Rule) bool { + candidate := newRule.(*ruleImpl) //nolint: forcetypeassert + + return existing.ID() == candidate.ID() && !bytes.Equal(existing.hash, candidate.hash) }) - return ruleGone || pathExpressionChanged + return ruleGone || ruleChanged }) tmp := r.index.Clone() - if err := r.updateRulesIn(tmp, newRules, updatedRules, deletedRules); err != nil { + // delete rules + if err := r.removeRulesFrom(tmp, toBeDeleted); err != nil { return err } - r.knownRules = slices.DeleteFunc(r.knownRules, func(loaded rule.Rule) bool { - return slices.Contains(deletedRules, loaded) - }) - - for idx, existing := range r.knownRules { - for _, updated := range updatedRules { - if updated.SameAs(existing) { - r.knownRules[idx] = updated - - break - } - } + // add rules + if err := r.addRulesTo(tmp, toBeAdded); err != nil { + return err } - r.knownRules = append(r.knownRules, newRules...) + r.knownRules = slices.DeleteFunc(r.knownRules, func(loaded rule.Rule) bool { + return slices.Contains(toBeDeleted, loaded) + }) + r.knownRules = append(r.knownRules, toBeAdded...) r.rulesTreeMutex.Lock() r.index = tmp @@ -224,33 +211,3 @@ func (r *repository) removeRulesFrom(tree *radixtree.Tree[rule.Rule], tbdRules [ return nil } - -func (r *repository) updateRulesIn( - tree *radixtree.Tree[rule.Rule], newRules, updatedRules, deletedRules []rule.Rule, -) error { - // remove deleted rules - if err := r.removeRulesFrom(tree, deletedRules); err != nil { - return err - } - - // replace updated rules - for _, updated := range updatedRules { - if err := tree.Update( - updated.PathExpression(), - updated, - radixtree.MatcherFunc[rule.Rule](func(existing rule.Rule) bool { - return existing.SameAs(updated) - }), - ); err != nil { - return errorchain.NewWithMessagef(heimdall.ErrInternal, "failed replacing rule ID='%s'", updated.ID()). - CausedBy(err) - } - } - - // add new rules - if err := r.addRulesTo(tree, newRules); err != nil { - return err - } - - return nil -} From d522eda8019374b40e84cad34c9c9d2e4e7c720d Mon Sep 17 00:00:00 2001 From: Dimitrij Drus Date: Tue, 23 Apr 2024 15:47:33 +0200 Subject: [PATCH 39/76] path prefix check removed as it doesn't make sense any more --- internal/rules/config/rule_set.go | 15 ---- internal/rules/config/rule_set_test.go | 55 -------------- .../provider/cloudblob/ruleset_endpoint.go | 4 -- .../cloudblob/ruleset_endpoint_test.go | 44 ------------ .../provider/httpendpoint/ruleset_endpoint.go | 4 -- .../httpendpoint/ruleset_endpoint_test.go | 71 +------------------ 6 files changed, 1 insertion(+), 192 deletions(-) delete mode 100644 internal/rules/config/rule_set_test.go diff --git a/internal/rules/config/rule_set.go b/internal/rules/config/rule_set.go index 3ecf341a1..e488ce882 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 { @@ -37,14 +33,3 @@ type RuleSet struct { 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.Matcher.Path.Expression, prefix) { - return errorchain.NewWithMessage(heimdall.ErrConfiguration, - "path prefix validation failed for rule ID=%s") - } - } - - return nil -} diff --git a/internal/rules/config/rule_set_test.go b/internal/rules/config/rule_set_test.go deleted file mode 100644 index 9518ebe6c..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}, - } { - t.Run(tc.uc, func(t *testing.T) { - // GIVEN - rs := RuleSet{ - Rules: []Rule{ - {Matcher: Matcher{Path: Path{Expression: 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/provider/cloudblob/ruleset_endpoint.go b/internal/rules/provider/cloudblob/ruleset_endpoint.go index 34e16cea2..6560f08f5 100644 --- a/internal/rules/provider/cloudblob/ruleset_endpoint.go +++ b/internal/rules/provider/cloudblob/ruleset_endpoint.go @@ -125,10 +125,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 aec057416..1b3c2a6dc 100644 --- a/internal/rules/provider/cloudblob/ruleset_endpoint_test.go +++ b/internal/rules/provider/cloudblob/ruleset_endpoint_test.go @@ -169,50 +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", - }, - setup: func(t *testing.T) { - t.Helper() - - data := ` -{ - "version": "1", - "name": "test", - "rules": [{ - "id": "foobar", - "match": { - "scheme": "http", - "host_glob": "**", - "path": "/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{ diff --git a/internal/rules/provider/httpendpoint/ruleset_endpoint.go b/internal/rules/provider/httpendpoint/ruleset_endpoint.go index ecfacebb9..b783d73db 100644 --- a/internal/rules/provider/httpendpoint/ruleset_endpoint.go +++ b/internal/rules/provider/httpendpoint/ruleset_endpoint.go @@ -78,10 +78,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 39c58341c..170f68a18 100644 --- a/internal/rules/provider/httpendpoint/ruleset_endpoint_test.go +++ b/internal/rules/provider/httpendpoint/ruleset_endpoint_test.go @@ -233,76 +233,7 @@ rules: }, }, { - uc: "valid rule set with path only 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": { "path": "/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": { - "path": { - "expression": "/bar/foo/:*", - "glob": "/bar/foo/**" - }, - "host_glob": "moobar.local:9090" - } - } - ] -}`)) - 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", + uc: "valid rule set with full url glob", ep: &ruleSetEndpoint{ Endpoint: endpoint.Endpoint{ URL: srv.URL, From c4c9705e8d5c5a9a6b5bdf22c2bb46a01a6ac208 Mon Sep 17 00:00:00 2001 From: Dimitrij Drus Date: Tue, 23 Apr 2024 15:53:55 +0200 Subject: [PATCH 40/76] the actual config for prefix checks removed --- internal/rules/provider/cloudblob/provider_test.go | 1 - internal/rules/provider/cloudblob/ruleset_endpoint.go | 5 ++--- internal/rules/provider/cloudblob/ruleset_endpoint_test.go | 1 - internal/rules/provider/httpendpoint/ruleset_endpoint.go | 2 -- .../rules/provider/httpendpoint/ruleset_endpoint_test.go | 1 - 5 files changed, 2 insertions(+), 8 deletions(-) diff --git a/internal/rules/provider/cloudblob/provider_test.go b/internal/rules/provider/cloudblob/provider_test.go index 9fc55d8b1..e5311660c 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() diff --git a/internal/rules/provider/cloudblob/ruleset_endpoint.go b/internal/rules/provider/cloudblob/ruleset_endpoint.go index 6560f08f5..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 { diff --git a/internal/rules/provider/cloudblob/ruleset_endpoint_test.go b/internal/rules/provider/cloudblob/ruleset_endpoint_test.go index 1b3c2a6dc..104677a88 100644 --- a/internal/rules/provider/cloudblob/ruleset_endpoint_test.go +++ b/internal/rules/provider/cloudblob/ruleset_endpoint_test.go @@ -177,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() diff --git a/internal/rules/provider/httpendpoint/ruleset_endpoint.go b/internal/rules/provider/httpendpoint/ruleset_endpoint.go index b783d73db..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 } diff --git a/internal/rules/provider/httpendpoint/ruleset_endpoint_test.go b/internal/rules/provider/httpendpoint/ruleset_endpoint_test.go index 170f68a18..be1458f3c 100644 --- a/internal/rules/provider/httpendpoint/ruleset_endpoint_test.go +++ b/internal/rules/provider/httpendpoint/ruleset_endpoint_test.go @@ -239,7 +239,6 @@ rules: URL: srv.URL, Method: http.MethodGet, }, - RulesPathPrefix: "/foo/bar", }, writeResponse: func(t *testing.T, w http.ResponseWriter) { t.Helper() From 03291a97e004a47ecc60ddfbf32cea07a8aabd8d Mon Sep 17 00:00:00 2001 From: Dimitrij Drus Date: Wed, 24 Apr 2024 09:06:50 +0200 Subject: [PATCH 41/76] backtracking test fixed --- internal/x/radixtree/tree_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/x/radixtree/tree_test.go b/internal/x/radixtree/tree_test.go index 9a8ad7a7e..90ec9f021 100644 --- a/internal/x/radixtree/tree_test.go +++ b/internal/x/radixtree/tree_test.go @@ -152,11 +152,11 @@ func TestTreeSearchWithBacktracking(t *testing.T) { err := tree.Add("/date/:year/abc", "first") require.NoError(t, err) - err = tree.Add("/date/:year", "second") + err = tree.Add("/date/**", "second") require.NoError(t, err) // WHEN - entry, err := tree.Find("/date/2024", MatcherFunc[string](func(value string) bool { + entry, err := tree.Find("/date/2024/abc", MatcherFunc[string](func(value string) bool { return value != "first" })) From e7f476d4e05f33dd1f34219179920fcf963683f4 Mon Sep 17 00:00:00 2001 From: Dimitrij Drus Date: Wed, 24 Apr 2024 14:24:18 +0200 Subject: [PATCH 42/76] made backtracking configurable in the radix tree implementation --- internal/x/radixtree/options.go | 8 ++++++ internal/x/radixtree/tree.go | 48 ++++++++++++++++++++----------- internal/x/radixtree/tree_test.go | 23 +++++++++++++++ 3 files changed, 62 insertions(+), 17 deletions(-) diff --git a/internal/x/radixtree/options.go b/internal/x/radixtree/options.go index 53863b468..a7f1eb82d 100644 --- a/internal/x/radixtree/options.go +++ b/internal/x/radixtree/options.go @@ -9,3 +9,11 @@ func WithValuesConstraints[V any](constraints ConstraintsFunc[V]) Option[V] { } } } + +type AddOption[V any] func(n *Tree[V]) + +func WithoutBacktracking[V any](flag bool) AddOption[V] { + return func(n *Tree[V]) { + n.backtrackingDisabled = flag + } +} diff --git a/internal/x/radixtree/tree.go b/internal/x/radixtree/tree.go index a9b5e5902..59274e979 100644 --- a/internal/x/radixtree/tree.go +++ b/internal/x/radixtree/tree.go @@ -44,7 +44,11 @@ type ( values []V wildcardKeys []string + // global options canAdd ConstraintsFunc[V] + + // node local options + backtrackingDisabled bool } ) @@ -310,7 +314,7 @@ func (n *Tree[V]) delEdge(token byte) { } //nolint:funlen,gocognit,cyclop -func (n *Tree[V]) findNode(path string, matcher Matcher[V]) (*Tree[V], int, []string) { +func (n *Tree[V]) findNode(path string, matcher Matcher[V]) (*Tree[V], int, []string, bool) { var ( found *Tree[V] params []string @@ -318,19 +322,21 @@ func (n *Tree[V]) findNode(path string, matcher Matcher[V]) (*Tree[V], int, []st value V ) + backtrack := true + pathLen := len(path) if pathLen == 0 { if len(n.values) == 0 { - return nil, 0, nil + return nil, 0, nil, true } for idx, value = range n.values { if match := matcher.Match(value); match { - return n, idx, nil + return n, idx, nil, false } } - return nil, 0, nil + return nil, 0, nil, !n.backtrackingDisabled } // First see if this matches a static token. @@ -342,15 +348,15 @@ func (n *Tree[V]) findNode(path string, matcher Matcher[V]) (*Tree[V], int, []st if pathLen >= childPathLen && child.path == path[:childPathLen] { nextPath := path[childPathLen:] - found, idx, params = child.findNode(nextPath, matcher) + found, idx, params, backtrack = child.findNode(nextPath, matcher) } break } } - if found != nil { - return found, idx, params + if found != nil || !backtrack { + return found, idx, params, backtrack } if n.wildcardChild != nil { //nolint:nestif @@ -360,7 +366,7 @@ func (n *Tree[V]) findNode(path string, matcher Matcher[V]) (*Tree[V], int, []st nextToken := path[nextSeparator:] if len(thisToken) > 0 { // Don't match on empty tokens. - found, idx, params = n.wildcardChild.findNode(nextToken, matcher) + 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 @@ -368,7 +374,11 @@ func (n *Tree[V]) findNode(path string, matcher Matcher[V]) (*Tree[V], int, []st params = make([]string, 0, 3) //nolint:gomnd } - return found, idx, append(params, thisToken) + return found, idx, append(params, thisToken), backtrack + } + + if !backtrack { + return nil, 0, nil, false } } } @@ -382,14 +392,14 @@ func (n *Tree[V]) findNode(path string, matcher Matcher[V]) (*Tree[V], int, []st params = make([]string, 1, 3) //nolint:gomnd params[0] = path - return n.catchAllChild, idx, params + return n.catchAllChild, idx, params, false } } - return nil, 0, nil + return nil, 0, nil, !n.backtrackingDisabled } - return nil, 0, nil + return nil, 0, nil, true } func (n *Tree[V]) splitCommonPrefix(existingNodeIndex int, path string) (*Tree[V], int) { @@ -425,7 +435,7 @@ func (n *Tree[V]) splitCommonPrefix(existingNodeIndex int, path string) (*Tree[V } func (n *Tree[V]) Find(path string, matcher Matcher[V]) (*Entry[V], error) { - found, idx, params := n.findNode(path, matcher) + found, idx, params, _ := n.findNode(path, matcher) if found == nil { return nil, fmt.Errorf("%w: %s", ErrNotFound, path) } @@ -450,17 +460,21 @@ func (n *Tree[V]) Find(path string, matcher Matcher[V]) (*Entry[V], error) { return entry, nil } -func (n *Tree[V]) Add(path string, value V) error { - res, err := n.addNode(path, nil, false) +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(res.values, value) { + if !n.canAdd(node.values, value) { return fmt.Errorf("%w: %s", ErrConstraintsViolation, path) } - res.values = append(res.values, value) + for _, apply := range opts { + apply(node) + } + + node.values = append(node.values, value) return nil } diff --git a/internal/x/radixtree/tree_test.go b/internal/x/radixtree/tree_test.go index 90ec9f021..16e3702ce 100644 --- a/internal/x/radixtree/tree_test.go +++ b/internal/x/radixtree/tree_test.go @@ -165,6 +165,29 @@ func TestTreeSearchWithBacktracking(t *testing.T) { assert.Equal(t, "second", entry.Value) } +func TestTreeSearchWithoutBacktracking(t *testing.T) { + t.Parallel() + + // GIVEN + tree := New[string]() + + err := tree.Add("/date/:year/abc", "first", WithoutBacktracking[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.Error(t, err) + require.ErrorIs(t, err, ErrNotFound) + require.Nil(t, entry) +} + func TestTreeAddPathDuplicates(t *testing.T) { t.Parallel() From b74f31e92f34988ef7d27a5d31e0a437bb64ef0f Mon Sep 17 00:00:00 2001 From: Dimitrij Drus Date: Wed, 24 Apr 2024 18:18:40 +0200 Subject: [PATCH 43/76] rule config updated to implement the new matching config api. More use of validation to simplify and reduce code --- .../invalid-ruleset-for-proxy-usage.yaml | 6 +- cmd/validate/test_data/valid-ruleset.yaml | 7 +- internal/rules/config/backend.go | 4 +- internal/rules/config/decoder.go | 1 - internal/rules/config/mapstructure_decoder.go | 34 -- .../rules/config/mapstructure_decoder_test.go | 107 ------ internal/rules/config/matcher.go | 39 +- internal/rules/config/matcher_test.go | 108 ------ internal/rules/config/parser_test.go | 223 +++++++++-- internal/rules/config/rule.go | 8 +- internal/rules/config/rule_set.go | 4 +- internal/rules/config/rule_test.go | 56 +-- .../rules/provider/cloudblob/provider_test.go | 24 +- .../cloudblob/ruleset_endpoint_test.go | 39 +- .../provider/filesystem/provider_test.go | 21 ++ .../provider/httpendpoint/provider_test.go | 42 +++ .../httpendpoint/ruleset_endpoint_test.go | 19 +- .../admissioncontroller/controller_test.go | 12 +- .../kubernetes/api/v1alpha4/client_test.go | 22 +- .../provider/kubernetes/provider_test.go | 78 ++-- internal/rules/rule_factory_impl.go | 113 ++---- internal/rules/rule_factory_impl_test.go | 347 ++---------------- 22 files changed, 497 insertions(+), 817 deletions(-) delete mode 100644 internal/rules/config/mapstructure_decoder.go delete mode 100644 internal/rules/config/mapstructure_decoder_test.go delete mode 100644 internal/rules/config/matcher_test.go 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 750e61043..e7c4e410a 100644 --- a/cmd/validate/test_data/invalid-ruleset-for-proxy-usage.yaml +++ b/cmd/validate/test_data/invalid-ruleset-for-proxy-usage.yaml @@ -3,9 +3,11 @@ name: test-rule-set rules: - id: rule:foo match: - scheme: http - host_glob: foo.bar path: /** + methods: [ ALL ] + with: + scheme: http + host_glob: foo.bar 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 4a6f6300d..ad472b6af 100644 --- a/cmd/validate/test_data/valid-ruleset.yaml +++ b/cmd/validate/test_data/valid-ruleset.yaml @@ -3,9 +3,12 @@ name: test-rule-set rules: - id: rule:foo match: - scheme: http path: /** - host_glob: foo.bar + methods: + - ALL + with: + scheme: http + host_glob: foo.bar forward_to: host: bar.foo rewrite: diff --git a/internal/rules/config/backend.go b/internal/rules/config/backend.go index 424a428a1..8ae9e645f 100644 --- a/internal/rules/config/backend.go +++ b/internal/rules/config/backend.go @@ -23,8 +23,8 @@ 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 { diff --git a/internal/rules/config/decoder.go b/internal/rules/config/decoder.go index 02369a2b4..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( - pathExpressionDecodeHookFunc, mapstructure.StringToTimeDurationHookFunc(), ), Result: output, diff --git a/internal/rules/config/mapstructure_decoder.go b/internal/rules/config/mapstructure_decoder.go deleted file mode 100644 index f82931c04..000000000 --- a/internal/rules/config/mapstructure_decoder.go +++ /dev/null @@ -1,34 +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 ( - "reflect" -) - -func pathExpressionDecodeHookFunc(from reflect.Type, to reflect.Type, data any) (any, error) { - if to != reflect.TypeOf(Path{}) { - return data, nil - } - - if from.Kind() != reflect.String { - return data, nil - } - - //nolint: forcetypeassert - return Path{Expression: data.(string)}, nil -} diff --git a/internal/rules/config/mapstructure_decoder_test.go b/internal/rules/config/mapstructure_decoder_test.go deleted file mode 100644 index 44b7bd6ac..000000000 --- a/internal/rules/config/mapstructure_decoder_test.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 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 { - Path Path `json:"path"` - } - - for _, tc := range []struct { - uc string - config []byte - assert func(t *testing.T, err error, path *Path) - }{ - { - uc: "specified as string", - config: []byte(`path: foo.bar`), - assert: func(t *testing.T, err error, path *Path) { - t.Helper() - - require.NoError(t, err) - assert.Equal(t, "foo.bar", path.Expression) - assert.Empty(t, path.Glob) - assert.Empty(t, path.Regex) - }, - }, - { - uc: "specified as structured type without path expression", - config: []byte(` -path: - glob: foo -`), - assert: func(t *testing.T, err error, _ *Path) { - t.Helper() - - require.Error(t, err) - assert.Contains(t, err.Error(), "'path'.'expression' is a required field") - }, - }, - { - uc: "specified as structured type with bad url type", - config: []byte(` -path: - expression: 1 -`), - assert: func(t *testing.T, err error, _ *Path) { - t.Helper() - - require.Error(t, err) - assert.Contains(t, err.Error(), "unconvertible type 'int'") - }, - }, - { - uc: "specified as structured type with unsupported property", - config: []byte(` -path: - expression: foo.bar - strategy: true -`), - assert: func(t *testing.T, err error, _ *Path) { - t.Helper() - - require.Error(t, err) - assert.Contains(t, err.Error(), "invalid keys: 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.Path) - }) - } -} diff --git a/internal/rules/config/matcher.go b/internal/rules/config/matcher.go index 246b70df7..ea5967a0b 100644 --- a/internal/rules/config/matcher.go +++ b/internal/rules/config/matcher.go @@ -18,41 +18,20 @@ package config import ( "slices" - - "github.com/goccy/go-json" - - "github.com/dadrus/heimdall/internal/x/stringx" ) -type Path struct { - Expression string `json:"expression" yaml:"expression" validate:"required"` //nolint:tagalign - Glob string `json:"glob" yaml:"glob"` - Regex string `json:"regex" yaml:"regex"` -} - -func (p *Path) UnmarshalJSON(data []byte) error { - if data[0] == '"' { - // data contains just the path expression - p.Expression = stringx.ToString(data[1 : len(data)-1]) - - return nil - } - - var rawData map[string]any - - if err := json.Unmarshal(data, &rawData); err != nil { - return err - } - - return DecodeConfig(rawData, p) +type MatcherConstraints struct { + Scheme string `json:"scheme" yaml:"scheme" validate:"omitempty,oneof=http https"` //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 } type Matcher struct { - Scheme string `json:"scheme" yaml:"scheme"` - Methods []string `json:"methods" yaml:"methods"` - HostGlob string `json:"host_glob" yaml:"host_glob"` - HostRegex string `json:"host_regex" yaml:"host_regex"` - Path Path `json:"path" yaml:"path"` + Path string `json:"path" yaml:"path" validate:"required"` //nolint:tagalign + Methods []string `json:"methods" yaml:"methods" validate:"gt=0,dive,required"` //nolint:tagalign + With MatcherConstraints `json:"with" yaml:"with"` } func (m *Matcher) DeepCopyInto(out *Matcher) { diff --git a/internal/rules/config/matcher_test.go b/internal/rules/config/matcher_test.go deleted file mode 100644 index abf510ca5..000000000 --- a/internal/rules/config/matcher_test.go +++ /dev/null @@ -1,108 +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/goccy/go-json" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestPathUnmarshalJSON(t *testing.T) { - t.Parallel() - - type Typ struct { - Path Path `json:"path"` - } - - for _, tc := range []struct { - uc string - config []byte - assert func(t *testing.T, err error, path *Path) - }{ - { - uc: "specified as string", - config: []byte(`{ "path": "foo.bar" }`), - assert: func(t *testing.T, err error, path *Path) { - t.Helper() - - require.NoError(t, err) - assert.Equal(t, "foo.bar", path.Expression) - assert.Empty(t, path.Glob) - assert.Empty(t, path.Regex) - }, - }, - { - uc: "specified as structured type with invalid json structure", - config: []byte(`{ - "path": { - expression: foo - } -}`), - assert: func(t *testing.T, err error, _ *Path) { - t.Helper() - - require.Error(t, err) - assert.Contains(t, err.Error(), "invalid character") - }, - }, - { - uc: "specified as structured type without expression", - config: []byte(`{ - "path": { - "regex": "foo" - } -}`), - assert: func(t *testing.T, err error, _ *Path) { - t.Helper() - - require.Error(t, err) - assert.Contains(t, err.Error(), "'expression' is a required field") - }, - }, - { - uc: "specified as structured type with everything specified", - config: []byte(`{ - "path": { - "expression": "foo.bar", - "glob": "**.css", - "regex": ".*\\.css" - } -}`), - assert: func(t *testing.T, err error, path *Path) { - t.Helper() - - require.NoError(t, err) - assert.Equal(t, "foo.bar", path.Expression) - assert.Equal(t, "**.css", path.Glob) - assert.Equal(t, ".*\\.css", path.Regex) - }, - }, - } { - t.Run(tc.uc, func(t *testing.T) { - var typ Typ - - // WHEN - err := json.Unmarshal(tc.config, &typ) - - // THEN - tc.assert(t, err, &typ.Path) - }) - } -} diff --git a/internal/rules/config/parser_test.go b/internal/rules/config/parser_test.go index beedc6139..bd8c2130a 100644 --- a/internal/rules/config/parser_test.go +++ b/internal/rules/config/parser_test.go @@ -53,69 +53,235 @@ 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: "JSON content type and not empty contents", + 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 rule set without rules", contentType: "application/json", content: []byte(`{ "version": "1", "name": "foo", -"rules": [{"id": "bar", "match": {"path": "foobar"}}] +"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 rule set with a rule without required elements", + contentType: "application/json", + content: []byte(`{ +"version": "1", +"name": "foo", +"rules": [{"forward_to": {"host":"foo.bar"}}] +}`), + assert: func(t *testing.T, err error, ruleSet *RuleSet) { + t.Helper() - rul := ruleSet.Rules[0] - require.NotNil(t, rul) - assert.Equal(t, "bar", rul.ID) - assert.Equal(t, "foobar", rul.Matcher.Path.Expression) + 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 with validation error", + 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": "bar", "allow_encoded_slashes": "foo"}] +"rules": [{"id": "foo", "match":{"with": {"host_glob":"**"}}, "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'.'path' is a required field") + require.Contains(t, err.Error(), "'rules'[0].'match'.'methods' 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 contains conflicting fields for host matching", contentType: "application/json", + content: []byte(`{ +"version": "1", +"name": "foo", +"rules": [ + { + "id": "foo", + "match":{"path":"/foo/bar", "methods":["ALL"], "with": {"host_glob":"**", "host_regex":"**"}}, + "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'.'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", "methods":["ALL"], "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", "methods":["ALL"], "with": {"scheme":"foo"}}, + "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", "methods":["ALL"]}, + "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", "methods":["ALL"]}, + "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: "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", "methods":["ALL"]}, + "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.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: no_decode match: - path: foo + path: /foo/bar + 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() @@ -125,12 +291,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.Methods) assert.Equal(t, EncodedSlashesOnNoDecode, rul.EncodedSlashesHandling) - assert.Equal(t, "foo", rul.Matcher.Path.Expression) + assert.Len(t, rul.Execute, 1) + assert.Equal(t, "test", rul.Execute[0]["authenticator"]) }, }, { @@ -206,6 +374,9 @@ rules: - id: bar match: path: foo + methods: [ ALL ] + execute: + - authenticator: test `), assert: func(t *testing.T, err error, ruleSet *RuleSet) { t.Helper() @@ -219,7 +390,7 @@ rules: rul := ruleSet.Rules[0] require.NotNil(t, rul) assert.Equal(t, "bar", rul.ID) - assert.Equal(t, "foo", rul.Matcher.Path.Expression) + assert.Equal(t, "foo", rul.Matcher.Path) }, }, { @@ -250,6 +421,9 @@ rules: - id: bar match: path: foo + methods: [ ALL ] + execute: + - authenticator: test `), assert: func(t *testing.T, err error, ruleSet *RuleSet) { t.Helper() @@ -263,7 +437,7 @@ rules: rul := ruleSet.Rules[0] require.NotNil(t, rul) assert.Equal(t, "bar", rul.ID) - assert.Equal(t, "foo", rul.Matcher.Path.Expression) + assert.Equal(t, "foo", rul.Matcher.Path) }, }, { @@ -275,6 +449,9 @@ rules: - id: bar match: path: foo + methods: [ ALL ] + execute: + - authenticator: test `), assert: func(t *testing.T, err error, ruleSet *RuleSet) { t.Helper() @@ -288,7 +465,7 @@ rules: rul := ruleSet.Rules[0] require.NotNil(t, rul) assert.Equal(t, "bar", rul.ID) - assert.Equal(t, "foo", rul.Matcher.Path.Expression) + assert.Equal(t, "foo", rul.Matcher.Path) }, }, } { diff --git a/internal/rules/config/rule.go b/internal/rules/config/rule.go index f809783c2..45d4f8391 100644 --- a/internal/rules/config/rule.go +++ b/internal/rules/config/rule.go @@ -29,11 +29,11 @@ const ( ) 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 - Matcher Matcher `json:"match" yaml:"match"` - Backend *Backend `json:"forward_to" yaml:"forward_to"` - 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"` } diff --git a/internal/rules/config/rule_set.go b/internal/rules/config/rule_set.go index e488ce882..d52f73d47 100644 --- a/internal/rules/config/rule_set.go +++ b/internal/rules/config/rule_set.go @@ -29,7 +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"` + Rules []Rule `json:"rules" yaml:"rules" validate:"gt=0,dive,required"` //nolint:tagalign } diff --git a/internal/rules/config/rule_test.go b/internal/rules/config/rule_test.go index 575d2b3ec..43b042abf 100644 --- a/internal/rules/config/rule_test.go +++ b/internal/rules/config/rule_test.go @@ -34,14 +34,14 @@ func TestRuleConfigDeepCopyInto(t *testing.T) { in := Rule{ ID: "foo", Matcher: Matcher{ - Scheme: "https", - HostGlob: "**.example.com", - HostRegex: ".*\\.example.com", - Methods: []string{"GET", "PATCH"}, - Path: Path{ - Expression: "bar", - Regex: ".*\\.css", - Glob: "**.css", + Path: "bar", + Methods: []string{"GET", "PATCH"}, + With: MatcherConstraints{ + Scheme: "https", + HostGlob: "**.example.com", + HostRegex: ".*\\.example.com", + PathGlob: "**.css", + PathRegex: ".*\\.css", }, }, Backend: &Backend{ @@ -62,13 +62,13 @@ func TestRuleConfigDeepCopyInto(t *testing.T) { // THEN assert.Equal(t, in.ID, out.ID) - assert.Equal(t, in.Matcher.Scheme, out.Matcher.Scheme) - assert.Equal(t, in.Matcher.HostGlob, out.Matcher.HostGlob) - assert.Equal(t, in.Matcher.HostRegex, out.Matcher.HostRegex) + assert.Equal(t, in.Matcher.Path, out.Matcher.Path) assert.Equal(t, in.Matcher.Methods, out.Matcher.Methods) - assert.Equal(t, in.Matcher.Path.Expression, out.Matcher.Path.Expression) - assert.Equal(t, in.Matcher.Path.Glob, out.Matcher.Path.Glob) - assert.Equal(t, in.Matcher.Path.Regex, out.Matcher.Path.Regex) + 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.Execute, out.Execute) assert.Equal(t, in.ErrorHandler, out.ErrorHandler) @@ -81,14 +81,14 @@ func TestRuleConfigDeepCopy(t *testing.T) { in := Rule{ ID: "foo", Matcher: Matcher{ - Scheme: "https", - HostGlob: "**.example.com", - HostRegex: ".*\\.example.com", - Methods: []string{"GET", "PATCH"}, - Path: Path{ - Expression: "bar", - Regex: ".*\\.css", - Glob: "**.css", + Path: "bar", + Methods: []string{"GET", "PATCH"}, + With: MatcherConstraints{ + Scheme: "https", + HostGlob: "**.example.com", + HostRegex: ".*\\.example.com", + PathGlob: "**.css", + PathRegex: ".*\\.css", }, }, Backend: &Backend{ @@ -113,13 +113,13 @@ func TestRuleConfigDeepCopy(t *testing.T) { // but same contents assert.Equal(t, in.ID, out.ID) - assert.Equal(t, in.Matcher.Scheme, out.Matcher.Scheme) - assert.Equal(t, in.Matcher.HostGlob, out.Matcher.HostGlob) - assert.Equal(t, in.Matcher.HostRegex, out.Matcher.HostRegex) + assert.Equal(t, in.Matcher.Path, out.Matcher.Path) assert.Equal(t, in.Matcher.Methods, out.Matcher.Methods) - assert.Equal(t, in.Matcher.Path.Expression, out.Matcher.Path.Expression) - assert.Equal(t, in.Matcher.Path.Glob, out.Matcher.Path.Glob) - assert.Equal(t, in.Matcher.Path.Regex, out.Matcher.Path.Regex) + 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.Execute, out.Execute) assert.Equal(t, in.ErrorHandler, out.ErrorHandler) diff --git a/internal/rules/provider/cloudblob/provider_test.go b/internal/rules/provider/cloudblob/provider_test.go index e5311660c..75fcbbf17 100644 --- a/internal/rules/provider/cloudblob/provider_test.go +++ b/internal/rules/provider/cloudblob/provider_test.go @@ -244,6 +244,9 @@ rules: - id: foo match: path: /foo + methods: [ GET ] + execute: + - authenticator: test ` _, err := backend.PutObject(bucketName, "test-rule", @@ -290,6 +293,9 @@ rules: - id: foo match: path: /foo + methods: [ GET ] + execute: + - authenticator: test ` _, err := backend.PutObject(bucketName, "test-rule", @@ -341,6 +347,9 @@ rules: - id: foo match: path: /foo + methods: [ GET ] + execute: + - authenticator: test ` _, err := backend.PutObject(bucketName, "test-rule1", @@ -357,6 +366,9 @@ rules: - id: bar match: path: /bar + methods: [ GET ] + execute: + - authenticator: test ` _, err := backend.PutObject(bucketName, "test-rule2", @@ -431,8 +443,10 @@ rules: - id: foo match: path: /foo + methods: [ GET ] + execute: + - authenticator: test ` - _, err := backend.PutObject(bucketName, "test-rule", map[string]string{"Content-Type": "application/yaml"}, strings.NewReader(data), int64(len(data))) @@ -445,8 +459,10 @@ rules: - id: bar match: path: /bar + methods: [ GET ] + execute: + - authenticator: test ` - _, err := backend.PutObject(bucketName, "test-rule", map[string]string{"Content-Type": "application/yaml"}, strings.NewReader(data), int64(len(data))) @@ -459,8 +475,10 @@ rules: - id: baz match: path: /baz + methods: [ GET ] + 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_test.go b/internal/rules/provider/cloudblob/ruleset_endpoint_test.go index 104677a88..3e68e0e00 100644 --- a/internal/rules/provider/cloudblob/ruleset_endpoint_test.go +++ b/internal/rules/provider/cloudblob/ruleset_endpoint_test.go @@ -188,10 +188,12 @@ func TestFetchRuleSets(t *testing.T) { "rules": [{ "id": "foobar", "match": { - "scheme": "http", - "host_glob": "**", "path": "/foo/bar/api1", - "methods": ["GET", "POST"] + "methods": ["GET", "POST"], + "with": { + "scheme": "http", + "host_glob": "**" + } }, "execute": [ { "authenticator": "foobar" } @@ -204,13 +206,14 @@ version: "1" name: test2 rules: - id: barfoo - match: - scheme: http - host_glob: "**" + match: path: /foo/bar/api2 methods: - GET - POST + with: + scheme: http + host_glob: "**" execute: - authenticator: barfoo ` @@ -261,10 +264,12 @@ rules: "rules": [{ "id": "foobar", "match": { - "scheme": "http", - "host_glob": "**", "path": "/foo/bar/api1", - "methods": ["GET", "POST"] + "methods": ["GET", "POST"], + "with": { + "scheme": "http", + "host_glob": "**" + } }, "execute": [ { "authenticator": "foobar" } @@ -277,10 +282,12 @@ rules: "rules": [{ "id": "barfoo", "match": { - "scheme": "http", - "host_glob": "**", "path": "/foo/bar/api2", - "methods": ["GET", "POST"] + "methods": ["GET", "POST"], + "with": { + "scheme": "http", + "host_glob": "**" + } }, "execute": [ { "authenticator": "barfoo" } @@ -375,10 +382,12 @@ rules: "rules": [{ "id": "foobar", "match": { - "scheme": "http", - "host_glob": "**", "path": "/foo/bar/api1", - "methods": ["GET", "POST"] + "methods": ["GET", "POST"], + "with": { + "scheme": "http", + "host_glob": "**" + } }, "execute": [ { "authenticator": "foobar" } diff --git a/internal/rules/provider/filesystem/provider_test.go b/internal/rules/provider/filesystem/provider_test.go index b2e857dac..88d9fc0e5 100644 --- a/internal/rules/provider/filesystem/provider_test.go +++ b/internal/rules/provider/filesystem/provider_test.go @@ -204,6 +204,9 @@ rules: - id: foo match: path: /foo/bar + methods: [ GET ] + execute: + - authenticator: test `) require.NoError(t, err) @@ -255,6 +258,9 @@ rules: - id: foo match: path: /foo/bar + methods: [ GET ] + execute: + - authenticator: test `) require.NoError(t, err) @@ -296,6 +302,9 @@ rules: - id: foo match: path: /foo/bar + methods: [ GET ] + execute: + - authenticator: test `) require.NoError(t, err) @@ -330,6 +339,9 @@ rules: - id: foo match: path: /foo/bar + methods: [ GET ] + execute: + - authenticator: test `) require.NoError(t, err) @@ -379,6 +391,9 @@ rules: - id: foo match: path: /foo + methods: [ GET ] + execute: + - authenticator: test `) require.NoError(t, err) @@ -393,6 +408,9 @@ rules: - id: foo match: path: /foo + methods: [ GET ] + execute: + - authenticator: test `) require.NoError(t, err) @@ -407,6 +425,9 @@ rules: - id: bar match: path: /bar + methods: [ GET ] + 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 b11038532..570690873 100644 --- a/internal/rules/provider/httpendpoint/provider_test.go +++ b/internal/rules/provider/httpendpoint/provider_test.go @@ -264,6 +264,9 @@ rules: - id: foo match: path: /foo + methods: [ "GET" ] + execute: + - authenticator: test `)) require.NoError(t, err) }, @@ -308,6 +311,9 @@ rules: - id: bar match: path: /bar + methods: [ "GET" ] + execute: + - authenticator: test `)) require.NoError(t, err) }, @@ -357,6 +363,9 @@ rules: - id: foo match: path: /foo + methods: [ GET ] + execute: + - authenticator: test `)) require.NoError(t, err) case 2: @@ -370,6 +379,9 @@ rules: - id: bar match: path: /bar + methods: [ GET ] + execute: + - authenticator: test `)) require.NoError(t, err) } @@ -437,6 +449,9 @@ rules: - id: bar match: path: /bar + methods: [ GET ] + execute: + - authenticator: test `)) require.NoError(t, err) case 2: @@ -448,6 +463,9 @@ rules: - id: baz match: path: /baz + methods: [ GET ] + execute: + - authenticator: test `)) require.NoError(t, err) case 3: @@ -459,6 +477,9 @@ rules: - id: foo match: path: /foo + methods: [ GET ] + execute: + - authenticator: test `)) require.NoError(t, err) default: @@ -470,6 +491,9 @@ rules: - id: foz match: path: /foz + methods: [ GET ] + execute: + - authenticator: test `)) require.NoError(t, err) } @@ -542,6 +566,9 @@ rules: - id: bar match: path: /bar + methods: [ GET ] + execute: + - authenticator: test `)) require.NoError(t, err) }, @@ -589,6 +616,9 @@ rules: - id: bar match: path: /bar + methods: [ GET ] + execute: + - authenticator: test `)) require.NoError(t, err) }, @@ -634,6 +664,9 @@ rules: - id: foo match: path: /foo + methods: [ GET ] + execute: + - authenticator: test `)) require.NoError(t, err) }, @@ -673,6 +706,9 @@ rules: - id: bar match: path: /bar + methods: [ GET ] + execute: + - authenticator: test `)) require.NoError(t, err) } else { @@ -684,6 +720,9 @@ rules: - id: baz match: path: /baz + methods: [ GET ] + execute: + - authenticator: test `)) require.NoError(t, err) } @@ -728,6 +767,9 @@ rules: - id: bar match: path: /bar + methods: [ GET ] + execute: + - authenticator: test `)) require.NoError(t, err) } else { diff --git a/internal/rules/provider/httpendpoint/ruleset_endpoint_test.go b/internal/rules/provider/httpendpoint/ruleset_endpoint_test.go index be1458f3c..6665aaeba 100644 --- a/internal/rules/provider/httpendpoint/ruleset_endpoint_test.go +++ b/internal/rules/provider/httpendpoint/ruleset_endpoint_test.go @@ -186,6 +186,9 @@ rules: - id: foo match: path: /foo + methods: [ GET ] + execute: + - authenticator: test `)) require.NoError(t, err) }, @@ -216,7 +219,7 @@ rules: "version": "1", "name": "test", "rules": [ - { "id": "foo", "match": { "path": "/foo"} } + { "id": "foo", "match": { "path": "/foo", "methods" : ["GET"] }, "execute": [{ "authenticator": "test"}] } ] }`)) require.NoError(t, err) @@ -251,12 +254,14 @@ rules: { "id": "foo", "match": { - "path": { - "expression": "/foo/bar/:*", - "glob": "/foo/bar/**" - }, - "host_glob": "moobar.local:9090" - } + "path": "/foo/bar/:*", + "methods": [ "GET" ], + "with": { + "host_glob": "moobar.local:9090", + "path_glob": "/foo/bar/**" + } + }, + "execute": [{ "authenticator": "test"}] } ] }`)) diff --git a/internal/rules/provider/kubernetes/admissioncontroller/controller_test.go b/internal/rules/provider/kubernetes/admissioncontroller/controller_test.go index 834552446..c2ac00cb5 100644 --- a/internal/rules/provider/kubernetes/admissioncontroller/controller_test.go +++ b/internal/rules/provider/kubernetes/admissioncontroller/controller_test.go @@ -272,9 +272,11 @@ func TestControllerLifecycle(t *testing.T) { { ID: "test", Matcher: config2.Matcher{ - Scheme: "http", - Path: config2.Path{Expression: "/foo.bar"}, + Path: "/foo.bar", Methods: []string{http.MethodGet}, + With: config2.MatcherConstraints{ + Scheme: "http", + }, }, Backend: &config2.Backend{ Host: "baz", @@ -365,9 +367,11 @@ func TestControllerLifecycle(t *testing.T) { { ID: "test", Matcher: config2.Matcher{ - Scheme: "http", - Path: config2.Path{Expression: "/foo.bar"}, + Path: "/foo.bar", Methods: []string{http.MethodGet}, + With: config2.MatcherConstraints{ + Scheme: "http", + }, }, Backend: &config2.Backend{ Host: "baz", diff --git a/internal/rules/provider/kubernetes/api/v1alpha4/client_test.go b/internal/rules/provider/kubernetes/api/v1alpha4/client_test.go index d9aadf893..3143ff941 100644 --- a/internal/rules/provider/kubernetes/api/v1alpha4/client_test.go +++ b/internal/rules/provider/kubernetes/api/v1alpha4/client_test.go @@ -57,13 +57,13 @@ const response = `{ ], "id": "test:rule", "match": { - "scheme": "http", - "host_glob": "127.0.0.1:*", - "path": { - "expression": "/foobar/:*", - "glob": "/foobar/foos*" - }, - "methods": ["GET", "POST"] + "path": "/foobar/:*", + "methods": ["GET", "POST"], + "with": { + "scheme": "http", + "host_glob": "127.0.0.1:*", + "path_glob": "/foobar/foos*" + } }, "forward_to": { "host": "foo.bar", @@ -141,10 +141,10 @@ func verifyRuleSetList(t *testing.T, rls *RuleSetList) { rule := ruleSet.Spec.Rules[0] assert.Equal(t, "test:rule", rule.ID) - assert.Equal(t, "http", rule.Matcher.Scheme) - assert.Equal(t, "127.0.0.1:*", rule.Matcher.HostGlob) - assert.Equal(t, "/foobar/:*", rule.Matcher.Path.Expression) - assert.Equal(t, "/foobar/foos*", rule.Matcher.Path.Glob) + 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.Methods, []string{"GET", "POST"}) assert.Empty(t, rule.ErrorHandler) assert.Equal(t, "https://foo.bar/baz/bar?foo=bar", rule.Backend.CreateURL(&url.URL{ diff --git a/internal/rules/provider/kubernetes/provider_test.go b/internal/rules/provider/kubernetes/provider_test.go index 1a5e4d506..b59a94fca 100644 --- a/internal/rules/provider/kubernetes/provider_test.go +++ b/internal/rules/provider/kubernetes/provider_test.go @@ -211,10 +211,12 @@ func (h *RuleSetResourceHandler) writeListResponse(t *testing.T, w http.Response { ID: "test", Matcher: config2.Matcher{ - Scheme: "http", - HostGlob: "foo.bar", - Methods: []string{http.MethodGet}, - Path: config2.Path{Expression: "/"}, + Path: "/", + Methods: []string{http.MethodGet}, + With: config2.MatcherConstraints{ + Scheme: "http", + HostGlob: "foo.bar", + }, }, Backend: &config2.Backend{ Host: "baz", @@ -368,9 +370,9 @@ func TestProviderLifecycle(t *testing.T) { rule := ruleSet.Rules[0] assert.Equal(t, "test", rule.ID) - assert.Equal(t, "http", rule.Matcher.Scheme) - assert.Equal(t, "foo.bar", rule.Matcher.HostGlob) - assert.Equal(t, "/", rule.Matcher.Path.Expression) + 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.Methods, 1) assert.Contains(t, rule.Matcher.Methods, http.MethodGet) assert.Equal(t, "baz", rule.Backend.Host) @@ -468,9 +470,9 @@ func TestProviderLifecycle(t *testing.T) { createdRule := ruleSet.Rules[0] assert.Equal(t, "test", createdRule.ID) - assert.Equal(t, "http", createdRule.Matcher.Scheme) - assert.Equal(t, "foo.bar", createdRule.Matcher.HostGlob) - assert.Equal(t, "/", createdRule.Matcher.Path.Expression) + 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.Methods, 1) assert.Contains(t, createdRule.Matcher.Methods, http.MethodGet) assert.Equal(t, "baz", createdRule.Backend.Host) @@ -532,9 +534,9 @@ func TestProviderLifecycle(t *testing.T) { createdRule := ruleSet.Rules[0] assert.Equal(t, "test", createdRule.ID) - assert.Equal(t, "http", createdRule.Matcher.Scheme) - assert.Equal(t, "foo.bar", createdRule.Matcher.HostGlob) - assert.Equal(t, "/", createdRule.Matcher.Path.Expression) + 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.Methods, 1) assert.Contains(t, createdRule.Matcher.Methods, http.MethodGet) assert.Equal(t, "baz", createdRule.Backend.Host) @@ -600,9 +602,9 @@ func TestProviderLifecycle(t *testing.T) { createdRule := ruleSet.Rules[0] assert.Equal(t, "test", createdRule.ID) - assert.Equal(t, "http", createdRule.Matcher.Scheme) - assert.Equal(t, "foo.bar", createdRule.Matcher.HostGlob) - assert.Equal(t, "/", createdRule.Matcher.Path.Expression) + 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.Methods, 1) assert.Contains(t, createdRule.Matcher.Methods, http.MethodGet) assert.Equal(t, "baz", createdRule.Backend.Host) @@ -678,10 +680,12 @@ func TestProviderLifecycle(t *testing.T) { { ID: "test", Matcher: config2.Matcher{ - Scheme: "http", - HostGlob: "foo.bar", - Path: config2.Path{Expression: "/"}, - Methods: []string{http.MethodGet}, + Path: "/", + Methods: []string{http.MethodGet}, + With: config2.MatcherConstraints{ + Scheme: "http", + HostGlob: "foo.bar", + }, }, Backend: &config2.Backend{ Host: "bar", @@ -730,9 +734,9 @@ func TestProviderLifecycle(t *testing.T) { createdRule := ruleSet.Rules[0] assert.Equal(t, "test", createdRule.ID) - assert.Equal(t, "http", createdRule.Matcher.Scheme) - assert.Equal(t, "foo.bar", createdRule.Matcher.HostGlob) - assert.Equal(t, "/", createdRule.Matcher.Path.Expression) + 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.Methods, 1) assert.Contains(t, createdRule.Matcher.Methods, http.MethodGet) assert.Equal(t, "baz", createdRule.Backend.Host) @@ -749,9 +753,9 @@ func TestProviderLifecycle(t *testing.T) { updatedRule := ruleSet.Rules[0] assert.Equal(t, "test", updatedRule.ID) - assert.Equal(t, "http", createdRule.Matcher.Scheme) - assert.Equal(t, "foo.bar", createdRule.Matcher.HostGlob) - assert.Equal(t, "/", createdRule.Matcher.Path.Expression) + 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.Methods, 1) assert.Contains(t, createdRule.Matcher.Methods, http.MethodGet) assert.Equal(t, "bar", updatedRule.Backend.Host) @@ -821,9 +825,9 @@ func TestProviderLifecycle(t *testing.T) { createdRule := ruleSet.Rules[0] assert.Equal(t, "test", createdRule.ID) - assert.Equal(t, "http", createdRule.Matcher.Scheme) - assert.Equal(t, "foo.bar", createdRule.Matcher.HostGlob) - assert.Equal(t, "/", createdRule.Matcher.Path.Expression) + 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.Methods, 1) assert.Contains(t, createdRule.Matcher.Methods, http.MethodGet) assert.Equal(t, "baz", createdRule.Backend.Host) @@ -840,9 +844,9 @@ func TestProviderLifecycle(t *testing.T) { deleteRule := ruleSet.Rules[0] assert.Equal(t, "test", deleteRule.ID) - assert.Equal(t, "http", createdRule.Matcher.Scheme) - assert.Equal(t, "foo.bar", createdRule.Matcher.HostGlob) - assert.Equal(t, "/", createdRule.Matcher.Path.Expression) + 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.Methods, 1) assert.Contains(t, createdRule.Matcher.Methods, http.MethodGet) assert.Equal(t, "baz", deleteRule.Backend.Host) @@ -878,10 +882,12 @@ func TestProviderLifecycle(t *testing.T) { { ID: "test", Matcher: config2.Matcher{ - Scheme: "http", - Methods: []string{http.MethodGet}, - HostGlob: "foo.bar", - Path: config2.Path{Expression: "/"}, + Path: "/", + Methods: []string{http.MethodGet}, + With: config2.MatcherConstraints{ + Scheme: "http", + HostGlob: "foo.bar", + }, }, Backend: &config2.Backend{ Host: "bar", diff --git a/internal/rules/rule_factory_impl.go b/internal/rules/rule_factory_impl.go index 91ba3e12c..45cd85db1 100644 --- a/internal/rules/rule_factory_impl.go +++ b/internal/rules/rule_factory_impl.go @@ -37,6 +37,9 @@ import ( "github.com/dadrus/heimdall/internal/x/slicex" ) +// nolint: gochecknoglobals +var spaceReplacer = strings.NewReplacer("\t", "", "\n", "", "\v", "", "\f", "", "\r", "", " ", "") + type alwaysMatcher struct{} func (alwaysMatcher) Match(_ string) bool { return true } @@ -157,74 +160,23 @@ 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.NewWithMessage(heimdall.ErrConfiguration, "no ID defined") - } - - if len(ruleConfig.Matcher.Path.Expression) == 0 { - return nil, errorchain.NewWithMessage(heimdall.ErrConfiguration, - "no path matching expression defined") - } - - if len(ruleConfig.Matcher.HostGlob) != 0 && len(ruleConfig.Matcher.HostRegex) != 0 { - return nil, errorchain.NewWithMessage(heimdall.ErrConfiguration, - "host glob and regex expressions are defined") - } - - if len(ruleConfig.Matcher.Path.Glob) != 0 && len(ruleConfig.Matcher.Path.Regex) != 0 { - return nil, errorchain.NewWithMessage(heimdall.ErrConfiguration, - "path glob and regex expressions are defined") - } - - if f.mode == config.ProxyMode { - if err := checkProxyModeApplicability(ruleConfig); err != nil { - return nil, err - } - } - - var ( - hostMatcher PatternMatcher - pathMatcher PatternMatcher - err error - ) - - spaceReplacer := strings.NewReplacer("\t", "", "\n", "", "\v", "", "\f", "", "\r", "", " ", "") - - hostGlob := spaceReplacer.Replace(ruleConfig.Matcher.HostGlob) - hostRegex := spaceReplacer.Replace(ruleConfig.Matcher.HostRegex) - pathGlob := spaceReplacer.Replace(ruleConfig.Matcher.Path.Glob) - pathRegex := spaceReplacer.Replace(ruleConfig.Matcher.Path.Regex) - - switch { - case len(hostGlob) != 0: - hostMatcher, err = newGlobMatcher(hostGlob, '.') - case len(hostRegex) != 0: - hostMatcher, err = newRegexMatcher(hostRegex) - default: - hostMatcher = alwaysMatcher{} +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") } + hostMatcher, err := f.createPatternMatcher( + ruleConfig.Matcher.With.HostGlob, '.', ruleConfig.Matcher.With.HostRegex) if err != nil { return nil, errorchain.NewWithMessage(heimdall.ErrConfiguration, - "filed to compile host pattern defined").CausedBy(err) - } - - switch { - case len(pathGlob) != 0: - pathMatcher, err = newGlobMatcher(pathGlob, '/') - case len(pathRegex) != 0: - pathMatcher, err = newRegexMatcher(pathRegex) - default: - pathMatcher = alwaysMatcher{} + "filed to compile host expression").CausedBy(err) } + pathMatcher, err := f.createPatternMatcher( + ruleConfig.Matcher.With.PathGlob, '/', ruleConfig.Matcher.With.PathRegex) if err != nil { return nil, errorchain.NewWithMessage(heimdall.ErrConfiguration, - "filed to compile path pattern defined").CausedBy(err) + "filed to compile path expression").CausedBy(err) } authenticators, subHandlers, finalizers, err := f.createExecutePipeline(version, ruleConfig.Execute) @@ -248,17 +200,12 @@ func (f *ruleFactory) CreateRule(version, srcID string, ruleConfig config2.Rule) 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.allowedMethods) } if len(authenticators) == 0 { return nil, errorchain.NewWithMessage(heimdall.ErrConfiguration, "no authenticator defined") } - if len(methods) == 0 { - return nil, errorchain.NewWithMessage(heimdall.ErrConfiguration, "no methods defined") - } - hash, err := f.createHash(ruleConfig) if err != nil { return nil, errorchain.NewWithMessage(heimdall.ErrConfiguration, "failed to create hash") @@ -273,11 +220,11 @@ func (f *ruleFactory) CreateRule(version, srcID string, ruleConfig config2.Rule) ruleConfig.EncodedSlashesHandling, config2.EncodedSlashesOff, ), - allowedScheme: ruleConfig.Matcher.Scheme, + allowedScheme: ruleConfig.Matcher.With.Scheme, allowedMethods: methods, hostMatcher: hostMatcher, pathMatcher: pathMatcher, - pathExpression: ruleConfig.Matcher.Path.Expression, + pathExpression: ruleConfig.Matcher.Path, backend: ruleConfig.Backend, hash: hash, sc: authenticators, @@ -287,28 +234,20 @@ func (f *ruleFactory) CreateRule(version, srcID string, ruleConfig config2.Rule) }, nil } -func checkProxyModeApplicability(ruleConfig config2.Rule) error { - if ruleConfig.Backend == nil { - return errorchain.NewWithMessage(heimdall.ErrConfiguration, "proxy mode requires forward_to definition") - } +func (f *ruleFactory) createPatternMatcher( + globExpression string, globSeparator rune, regexExpression string, +) (PatternMatcher, error) { + glob := spaceReplacer.Replace(globExpression) + regex := spaceReplacer.Replace(regexExpression) - if len(ruleConfig.Backend.Host) == 0 { - return errorchain.NewWithMessage(heimdall.ErrConfiguration, "missing host definition in forward_to") - } - - 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.NewWithMessage(heimdall.ErrConfiguration, "rewrite is defined in forward_to, but is empty") + switch { + case len(glob) != 0: + return newGlobMatcher(glob, globSeparator) + case len(regex) != 0: + return newRegexMatcher(regex) + default: + return alwaysMatcher{}, nil } - - return nil } func (f *ruleFactory) createHash(ruleConfig config2.Rule) ([]byte, error) { diff --git a/internal/rules/rule_factory_impl_test.go b/internal/rules/rule_factory_impl_test.go index e1ab122d4..7a582c398 100644 --- a/internal/rules/rule_factory_impl_test.go +++ b/internal/rules/rule_factory_impl_test.go @@ -543,68 +543,12 @@ func TestRuleFactoryCreateRule(t *testing.T) { configureMocks func(t *testing.T, mhf *mocks3.FactoryMock) assert func(t *testing.T, err error, rul *ruleImpl) }{ - { - uc: "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: "without match path expression", - 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 path matching expression") - }, - }, - { - uc: "with both glob and regex host patterns configured", - config: config2.Rule{ - ID: "foobar", - Matcher: config2.Matcher{ - Path: config2.Path{Expression: "/foo/bar"}, - HostRegex: ".*", - HostGlob: "**", - }, - }, - 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(), "host glob and regex") - }, - }, - { - uc: "with both glob and regex path patterns configured", - config: config2.Rule{ - ID: "foobar", - Matcher: config2.Matcher{ - Path: config2.Path{Expression: "/foo/bar", Regex: ".*", Glob: "**"}, - }, - }, - 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(), "path glob and regex") - }, - }, { uc: "in proxy mode without forward_to definition", opMode: config.ProxyMode, config: config2.Rule{ ID: "foobar", - Matcher: config2.Matcher{Path: config2.Path{Expression: "/foo/bar"}}, + Matcher: config2.Matcher{Path: "/foo/bar"}, }, assert: func(t *testing.T, err error, _ *ruleImpl) { t.Helper() @@ -615,30 +559,12 @@ func TestRuleFactoryCreateRule(t *testing.T) { }, }, { - uc: "in proxy mode and empty forward_to definition", - opMode: config.ProxyMode, + uc: "with bad host expression", config: config2.Rule{ - ID: "foobar", - Matcher: config2.Matcher{Path: config2.Path{Expression: "/foo/bar"}}, - 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") - }, - }, - { - uc: "in proxy mode, with forward_to.host, but empty rewrite definition", - opMode: config.ProxyMode, - config: config2.Rule{ - ID: "foobar", - Matcher: config2.Matcher{Path: config2.Path{Expression: "/foo/bar"}}, - Backend: &config2.Backend{ - Host: "foo.bar", - URLRewriter: &config2.URLRewriter{}, + ID: "foobar", + Matcher: config2.Matcher{ + Path: "/foo/bar", + With: config2.MatcherConstraints{HostRegex: "?>?<*??"}, }, }, assert: func(t *testing.T, err error, _ *ruleImpl) { @@ -646,16 +572,16 @@ func TestRuleFactoryCreateRule(t *testing.T) { require.Error(t, err) require.ErrorIs(t, err, heimdall.ErrConfiguration) - assert.Contains(t, err.Error(), "rewrite is defined") + assert.Contains(t, err.Error(), "filed to compile host expression") }, }, { - uc: "with bad host pattern", + uc: "with bad path expression", config: config2.Rule{ ID: "foobar", Matcher: config2.Matcher{ - HostRegex: "?>?<*??", - Path: config2.Path{Expression: "/foo/bar"}, + Path: "/foo/bar", + With: config2.MatcherConstraints{PathGlob: "!*][)(*"}, }, }, assert: func(t *testing.T, err error, _ *ruleImpl) { @@ -663,28 +589,14 @@ func TestRuleFactoryCreateRule(t *testing.T) { require.Error(t, err) require.ErrorIs(t, err, heimdall.ErrConfiguration) - assert.Contains(t, err.Error(), "filed to compile host pattern") - }, - }, - { - uc: "with bad path pattern", - config: config2.Rule{ - ID: "foobar", - Matcher: config2.Matcher{Path: config2.Path{Expression: "/foo/bar", Glob: "!*][)(*"}}, - }, - 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(), "filed to compile path pattern") + assert.Contains(t, err.Error(), "filed to compile path expression") }, }, { uc: "with error while creating execute pipeline", config: config2.Rule{ ID: "foobar", - Matcher: config2.Matcher{Path: config2.Path{Expression: "/foo/bar"}}, + Matcher: config2.Matcher{Path: "/foo/bar"}, Execute: []config.MechanismConfig{{"authenticator": "foo"}}, }, configureMocks: func(t *testing.T, mhf *mocks3.FactoryMock) { @@ -703,7 +615,7 @@ func TestRuleFactoryCreateRule(t *testing.T) { uc: "with error while creating on_error pipeline", config: config2.Rule{ ID: "foobar", - Matcher: config2.Matcher{Path: config2.Path{Expression: "/foo/bar"}}, + Matcher: config2.Matcher{Path: "/foo/bar"}, ErrorHandler: []config.MechanismConfig{{"error_handler": "foo"}}, }, configureMocks: func(t *testing.T, mhf *mocks3.FactoryMock) { @@ -722,7 +634,7 @@ func TestRuleFactoryCreateRule(t *testing.T) { uc: "without default rule and without any execute configuration", config: config2.Rule{ ID: "foobar", - Matcher: config2.Matcher{Path: config2.Path{Expression: "/foo/bar"}}, + Matcher: config2.Matcher{Path: "/foo/bar"}, }, assert: func(t *testing.T, err error, _ *ruleImpl) { t.Helper() @@ -733,80 +645,10 @@ func TestRuleFactoryCreateRule(t *testing.T) { }, }, { - uc: "without default rule and with only authenticator configured", - config: config2.Rule{ - ID: "foobar", - Matcher: config2.Matcher{Path: config2.Path{Expression: "/foo/bar"}}, - 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", - Matcher: config2.Matcher{Path: config2.Path{Expression: "/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", + uc: "without default rule and with authenticator and finalizer configured, with error while expanding methods", config: config2.Rule{ ID: "foobar", - Matcher: config2.Matcher{Path: config2.Path{Expression: "/foo/bar"}}, - 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", - Matcher: config2.Matcher{Path: config2.Path{Expression: "/foo/bar"}, Methods: []string{"FOO", ""}}, + Matcher: config2.Matcher{Path: "/foo/bar", Methods: []string{"FOO", ""}}, Execute: []config.MechanismConfig{ {"authenticator": "foo"}, {"finalizer": "bar"}, @@ -826,35 +668,11 @@ func TestRuleFactoryCreateRule(t *testing.T) { 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", - Matcher: config2.Matcher{Path: config2.Path{Expression: "/foo/bar"}}, - 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", - Matcher: config2.Matcher{Path: config2.Path{Expression: "/foo/bar"}, Methods: []string{"FOO", "BAR"}}, + Matcher: config2.Matcher{Path: "/foo/bar", Methods: []string{"FOO", "BAR"}}, Execute: []config.MechanismConfig{ {"authenticator": "foo"}, }, @@ -891,7 +709,7 @@ func TestRuleFactoryCreateRule(t *testing.T) { config: config2.Rule{ ID: "foobar", Backend: &config2.Backend{Host: "foo.bar"}, - Matcher: config2.Matcher{Path: config2.Path{Expression: "/foo/bar"}, Methods: []string{"FOO", "BAR"}}, + Matcher: config2.Matcher{Path: "/foo/bar", Methods: []string{"FOO", "BAR"}}, Execute: []config.MechanismConfig{ {"authenticator": "foo"}, }, @@ -927,7 +745,7 @@ func TestRuleFactoryCreateRule(t *testing.T) { uc: "with default rule and with id and path expression only", config: config2.Rule{ ID: "foobar", - Matcher: config2.Matcher{Path: config2.Path{Expression: "/foo/bar"}}, + Matcher: config2.Matcher{Path: "/foo/bar"}, }, defaultRule: &ruleImpl{ allowedMethods: []string{"FOO"}, @@ -949,7 +767,7 @@ func TestRuleFactoryCreateRule(t *testing.T) { assert.Equal(t, "/foo/bar", rul.PathExpression()) assert.IsType(t, alwaysMatcher{}, rul.hostMatcher) assert.IsType(t, alwaysMatcher{}, rul.pathMatcher) - assert.ElementsMatch(t, rul.allowedMethods, []string{"FOO"}) + assert.Empty(t, rul.allowedMethods) assert.Len(t, rul.sc, 1) assert.Len(t, rul.sh, 1) assert.Len(t, rul.fi, 1) @@ -961,12 +779,12 @@ func TestRuleFactoryCreateRule(t *testing.T) { config: config2.Rule{ ID: "foobar", Matcher: config2.Matcher{ - Scheme: "https", - HostGlob: "**.example.com", - Methods: []string{"BAR", "BAZ"}, - Path: config2.Path{ - Expression: "/foo/:resource", - Regex: "^/foo/(bar|baz)", + Path: "/foo/:resource", + Methods: []string{"BAR", "BAZ"}, + With: config2.MatcherConstraints{ + Scheme: "https", + HostGlob: "**.example.com", + PathRegex: "^/foo/(bar|baz)", }, }, EncodedSlashesHandling: config2.EncodedSlashesOnNoDecode, @@ -1038,12 +856,12 @@ func TestRuleFactoryCreateRule(t *testing.T) { config: config2.Rule{ ID: "foobar", Matcher: config2.Matcher{ - Scheme: "https", - HostGlob: "**.example.com", - Methods: []string{"BAR", "BAZ"}, - Path: config2.Path{ - Expression: "/foo/:resource", - Regex: "^/foo/(bar|baz)", + Path: "/foo/:resource", + Methods: []string{"BAR", "BAZ"}, + With: config2.MatcherConstraints{ + Scheme: "https", + HostGlob: "**.example.com", + PathRegex: "^/foo/(bar|baz)", }, }, EncodedSlashesHandling: config2.EncodedSlashesOn, @@ -1129,7 +947,7 @@ func TestRuleFactoryCreateRule(t *testing.T) { uc: "with conditional execution configuration type error", config: config2.Rule{ ID: "foobar", - Matcher: config2.Matcher{Path: config2.Path{Expression: "/foo/bar"}, Methods: []string{"FOO"}}, + Matcher: config2.Matcher{Path: "/foo/bar", Methods: []string{"FOO"}}, Execute: []config.MechanismConfig{ {"authenticator": "foo"}, {"finalizer": "bar", "if": 1}, @@ -1152,7 +970,7 @@ func TestRuleFactoryCreateRule(t *testing.T) { uc: "with empty conditional execution configuration", config: config2.Rule{ ID: "foobar", - Matcher: config2.Matcher{Path: config2.Path{Expression: "/foo/bar"}, Methods: []string{"FOO"}}, + Matcher: config2.Matcher{Path: "/foo/bar", Methods: []string{"FOO"}}, Execute: []config.MechanismConfig{ {"authenticator": "foo"}, {"finalizer": "bar", "if": ""}, @@ -1175,7 +993,7 @@ func TestRuleFactoryCreateRule(t *testing.T) { uc: "with conditional execution for some mechanisms", config: config2.Rule{ ID: "foobar", - Matcher: config2.Matcher{Path: config2.Path{Expression: "/foo/bar"}, Methods: []string{"FOO"}}, + Matcher: config2.Matcher{Path: "/foo/bar", Methods: []string{"FOO"}}, Execute: []config.MechanismConfig{ {"authenticator": "foo"}, {"authorizer": "bar", "if": "false"}, @@ -1243,7 +1061,7 @@ func TestRuleFactoryCreateRule(t *testing.T) { uc: "with conditional execution for error handler", config: config2.Rule{ ID: "foobar", - Matcher: config2.Matcher{Path: config2.Path{Expression: "/foo/bar"}, Methods: []string{"FOO"}}, + Matcher: config2.Matcher{Path: "/foo/bar", Methods: []string{"FOO"}}, Execute: []config.MechanismConfig{ {"authenticator": "foo"}, {"authorizer": "bar"}, @@ -1379,99 +1197,6 @@ 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(tc.ruleConfig) - - // THEN - if tc.shouldError { - require.Error(t, err) - } else { - require.NoError(t, err) - } - }) - } -} - func TestExpandHTTPMethods(t *testing.T) { t.Parallel() From a9b79c22a3a53e660e4330276489263aca1c0adb Mon Sep 17 00:00:00 2001 From: Dimitrij Drus Date: Wed, 24 Apr 2024 18:29:29 +0200 Subject: [PATCH 44/76] examle rule updated --- example_rules.yaml | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/example_rules.yaml b/example_rules.yaml index 5dfe52936..92ccc4f49 100644 --- a/example_rules.yaml +++ b/example_rules.yaml @@ -1,15 +1,17 @@ -version: "1alpha3" +version: "1alpha4" name: test-rule-set rules: - id: rule:foo match: - url: http://foo.bar/<**> - strategy: glob + path: /** + methods: + - GET + - POST + with: + host_glob: foo.bar + scheme: http forward_to: host: bar.foo -# methods: # reuses default -# - GET -# - POST execute: - authenticator: unauthorized_authenticator - authenticator: jwt_authenticator From bc3ef9585fefab275f5f9b1a5bd359f821937fd5 Mon Sep 17 00:00:00 2001 From: Dimitrij Drus Date: Wed, 24 Apr 2024 18:29:57 +0200 Subject: [PATCH 45/76] helm chart updated --- charts/heimdall/crds/ruleset.yaml | 54 +++++++++---------- charts/heimdall/templates/demo/test-rule.yaml | 10 ++-- 2 files changed, 32 insertions(+), 32 deletions(-) diff --git a/charts/heimdall/crds/ruleset.yaml b/charts/heimdall/crds/ruleset.yaml index 538ef4a6b..6e049d581 100644 --- a/charts/heimdall/crds/ruleset.yaml +++ b/charts/heimdall/crds/ruleset.yaml @@ -78,36 +78,10 @@ spec: - path - methods properties: - 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: description: The path to match - type: object - required: - - expression - properties: - expression: - description: The actual path expression to match. Simple and free (named) wildcards can be used - type: string - maxLength: 256 - glob: - description: Additional glob expression the matched path should be matched against. Mutual exclusive with 'regex'. - type: string - maxLength: 256 - regex: - description: Additional regular expression the matched path should be matched against. Mutual exclusive with 'glob' - type: string - maxLength: 256 + type: string + maxLength: 256 methods: description: The HTTP methods to match type: array @@ -135,6 +109,30 @@ spec: - "TRACE" - "!TRACE" - "ALL" + with: + description: Additional constraints during request matching + type: object + properties: + 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 diff --git a/charts/heimdall/templates/demo/test-rule.yaml b/charts/heimdall/templates/demo/test-rule.yaml index 984e471ae..3b6f2ecc8 100644 --- a/charts/heimdall/templates/demo/test-rule.yaml +++ b/charts/heimdall/templates/demo/test-rule.yaml @@ -26,8 +26,9 @@ spec: rules: - id: public-access match: - path: - expression: /pub/** + path: /pub/** + methods: + - ALL forward_to: host: {{ include "heimdall.demo.fullname" . }}.heimdall-demo.svc.cluster.local:8080 execute: @@ -36,8 +37,9 @@ spec: - finalizer: noop_finalizer - id: anonymous-access match: - path: - expression: /anon/** + path: /anon/** + methods: + - ALL forward_to: host: {{ include "heimdall.demo.fullname" . }}.heimdall-demo.svc.cluster.local:8080 execute: From 00eea4fedce934dbcd28e15fdefe2fa94e4d3fb2 Mon Sep 17 00:00:00 2001 From: Dimitrij Drus Date: Wed, 24 Apr 2024 18:36:35 +0200 Subject: [PATCH 46/76] rules in examples updated to use the new config api --- .../docker-compose/quickstarts/upstream-rules.yaml | 5 ++++- .../kubernetes/quickstarts/demo-app/base/rules.yaml | 12 ++++++------ .../quickstarts/proxy-demo/heimdall-rules.yaml | 12 ++++++------ 3 files changed, 16 insertions(+), 13 deletions(-) diff --git a/examples/docker-compose/quickstarts/upstream-rules.yaml b/examples/docker-compose/quickstarts/upstream-rules.yaml index a784d31f5..095259645 100644 --- a/examples/docker-compose/quickstarts/upstream-rules.yaml +++ b/examples/docker-compose/quickstarts/upstream-rules.yaml @@ -3,6 +3,7 @@ rules: - id: demo:public match: path: /public + methods: [ GET, POST ] forward_to: host: upstream:8081 execute: @@ -12,7 +13,9 @@ rules: - id: demo:protected match: path: /:user - regex: ^/(user|admin) + methods: [ GET, POST ] + with: + path_regex: ^/(user|admin) forward_to: host: upstream:8081 execute: diff --git a/examples/kubernetes/quickstarts/demo-app/base/rules.yaml b/examples/kubernetes/quickstarts/demo-app/base/rules.yaml index 20e218caa..297c43f51 100644 --- a/examples/kubernetes/quickstarts/demo-app/base/rules.yaml +++ b/examples/kubernetes/quickstarts/demo-app/base/rules.yaml @@ -9,16 +9,16 @@ spec: rules: - id: public-access match: - path: - expression: /pub/** + path: /pub/** + methods: [ ALL ] 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: - path: - expression: /anon/** + path: /anon/** + methods: [ ALL ] forward_to: # only required for proxy operation mode host: echo-app.quickstarts.svc.cluster.local:8080 execute: @@ -26,8 +26,8 @@ spec: - finalizer: create_jwt - id: redirect match: - path: - expression: /redir/** + path: /redir/** + methods: [ ALL ] forward_to: # only required for proxy operation mode host: echo-app.quickstarts.svc.cluster.local:8080 execute: diff --git a/examples/kubernetes/quickstarts/proxy-demo/heimdall-rules.yaml b/examples/kubernetes/quickstarts/proxy-demo/heimdall-rules.yaml index 9360e6a52..92c093bfe 100644 --- a/examples/kubernetes/quickstarts/proxy-demo/heimdall-rules.yaml +++ b/examples/kubernetes/quickstarts/proxy-demo/heimdall-rules.yaml @@ -12,8 +12,8 @@ data: rules: - id: public-access match: - path: - expression: /pub/<**> + path: /pub/** + methods: [ ALL ] forward_to: host: localhost:8080 rewrite: @@ -23,8 +23,8 @@ data: - id: anonymous-access match: - path: - expression: /anon/<**> + path: /anon/** + methods: [ ALL ] forward_to: host: localhost:8080 rewrite: @@ -35,8 +35,8 @@ data: - id: redirect match: - path: - expression: /redir/<**> + path: /redir/** + methods: [ ALL ] forward_to: host: localhost:8080 rewrite: From f4ef1b450b04276359427f3d631d372de885680c Mon Sep 17 00:00:00 2001 From: Dimitrij Drus Date: Sat, 27 Apr 2024 19:00:40 +0200 Subject: [PATCH 47/76] implementing the config api described in the PR --- cmd/validate/test_data/config.yaml | 3 - .../invalid-ruleset-for-proxy-usage.yaml | 2 +- cmd/validate/test_data/valid-ruleset.yaml | 5 +- internal/config/default_rule.go | 1 - internal/config/test_data/test_config.yaml | 3 - internal/rules/config/backend.go | 16 +- .../rules/config/encoded_slash_handling.go | 9 + internal/rules/config/matcher.go | 24 +- internal/rules/config/matcher_constraints.go | 46 +++ .../rules/config/matcher_constraints_test.go | 88 ++++ internal/rules/config/matcher_test.go | 60 +++ .../rules/config/mocks/request_matcher.go | 81 ++++ internal/rules/config/parser_test.go | 24 +- internal/rules/config/pattern_matcher.go | 61 +++ internal/rules/config/pattern_matcher_test.go | 136 ++++++ internal/rules/config/request_matcher.go | 167 ++++++++ internal/rules/config/request_matcher_test.go | 389 ++++++++++++++++++ internal/rules/config/rule.go | 48 ++- internal/rules/config/rule_test.go | 16 +- internal/rules/glob_matcher.go | 46 --- internal/rules/mocks/pattern_matcher.go | 2 +- internal/rules/pattern_matcher.go | 7 - .../rules/provider/cloudblob/provider_test.go | 7 - .../cloudblob/ruleset_endpoint_test.go | 22 +- .../provider/filesystem/provider_test.go | 7 - .../provider/httpendpoint/provider_test.go | 22 +- .../httpendpoint/ruleset_endpoint_test.go | 9 +- .../admissioncontroller/controller_test.go | 16 +- .../kubernetes/api/v1alpha4/client_test.go | 6 +- .../provider/kubernetes/provider_test.go | 50 +-- internal/rules/regex_matcher.go | 45 -- internal/rules/repository_impl_test.go | 123 +----- internal/rules/rule_factory_impl.go | 150 ++----- internal/rules/rule_factory_impl_test.go | 338 ++------------- internal/rules/rule_impl.go | 83 ++-- internal/rules/rule_impl_test.go | 145 ++----- schema/config.schema.json | 13 - 37 files changed, 1312 insertions(+), 958 deletions(-) create mode 100644 internal/rules/config/encoded_slash_handling.go create mode 100644 internal/rules/config/matcher_constraints.go create mode 100644 internal/rules/config/matcher_constraints_test.go create mode 100644 internal/rules/config/matcher_test.go create mode 100644 internal/rules/config/mocks/request_matcher.go create mode 100644 internal/rules/config/pattern_matcher.go create mode 100644 internal/rules/config/pattern_matcher_test.go create mode 100644 internal/rules/config/request_matcher.go create mode 100644 internal/rules/config/request_matcher_test.go delete mode 100644 internal/rules/glob_matcher.go delete mode 100644 internal/rules/pattern_matcher.go delete mode 100644 internal/rules/regex_matcher.go diff --git a/cmd/validate/test_data/config.yaml b/cmd/validate/test_data/config.yaml index 424aae07d..132541ad6 100644 --- a/cmd/validate/test_data/config.yaml +++ b/cmd/validate/test_data/config.yaml @@ -171,9 +171,6 @@ mechanisms: to: http://127.0.0.1:4433/self-service/login/browser?return_to={{ .Request.URL | urlenc }} default_rule: - methods: - - GET - - POST execute: - authenticator: anonymous_authenticator - finalizer: jwt 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 e7c4e410a..ca8616f88 100644 --- a/cmd/validate/test_data/invalid-ruleset-for-proxy-usage.yaml +++ b/cmd/validate/test_data/invalid-ruleset-for-proxy-usage.yaml @@ -4,10 +4,10 @@ rules: - id: rule:foo match: path: /** - methods: [ ALL ] 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 ad472b6af..c44393c19 100644 --- a/cmd/validate/test_data/valid-ruleset.yaml +++ b/cmd/validate/test_data/valid-ruleset.yaml @@ -4,11 +4,12 @@ rules: - id: rule:foo match: path: /** - methods: - - ALL with: scheme: http host_glob: foo.bar + methods: + - POST + - PUT forward_to: host: bar.foo rewrite: diff --git a/internal/config/default_rule.go b/internal/config/default_rule.go index e905435bd..3de763898 100644 --- a/internal/config/default_rule.go +++ b/internal/config/default_rule.go @@ -17,7 +17,6 @@ package config type DefaultRule struct { - Methods []string `koanf:"methods"` Execute []MechanismConfig `koanf:"execute"` ErrorHandler []MechanismConfig `koanf:"on_error"` } diff --git a/internal/config/test_data/test_config.yaml b/internal/config/test_data/test_config.yaml index 26060a256..d7d28e720 100644 --- a/internal/config/test_data/test_config.yaml +++ b/internal/config/test_data/test_config.yaml @@ -472,9 +472,6 @@ mechanisms: to: http://127.0.0.1:4433/self-service/login/browser?return_to={{ .Request.URL | urlenc }} default_rule: - methods: - - GET - - POST execute: - authenticator: anonymous_authenticator - finalizer: jwt diff --git a/internal/rules/config/backend.go b/internal/rules/config/backend.go index 8ae9e645f..4ca0df1e6 100644 --- a/internal/rules/config/backend.go +++ b/internal/rules/config/backend.go @@ -27,28 +27,24 @@ type Backend struct { 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/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/matcher.go b/internal/rules/config/matcher.go index ea5967a0b..c0ac243d6 100644 --- a/internal/rules/config/matcher.go +++ b/internal/rules/config/matcher.go @@ -16,25 +16,17 @@ package config -import ( - "slices" -) - -type MatcherConstraints struct { - Scheme string `json:"scheme" yaml:"scheme" validate:"omitempty,oneof=http https"` //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 -} - type Matcher struct { - Path string `json:"path" yaml:"path" validate:"required"` //nolint:tagalign - Methods []string `json:"methods" yaml:"methods" validate:"gt=0,dive,required"` //nolint:tagalign - With MatcherConstraints `json:"with" yaml:"with"` + Path string `json:"path" yaml:"path" validate:"required"` //nolint:tagalign + With *MatcherConstraints `json:"with" yaml:"with" validate:"omitnil,required"` //nolint:tagalign } func (m *Matcher) DeepCopyInto(out *Matcher) { *out = *m - out.Methods = slices.Clone(m.Methods) + + if m.With != nil { + in, out := m.With, out.With + + in.DeepCopyInto(out) + } } 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 new file mode 100644 index 000000000..d8851a261 --- /dev/null +++ b/internal/rules/config/matcher_test.go @@ -0,0 +1,60 @@ +package config + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMatcherDeepCopyInto(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + uc string + in *Matcher + assert func(t *testing.T, out *Matcher) + }{ + { + uc: "with path only", + in: &Matcher{Path: "/foo/bar"}, + assert: func(t *testing.T, out *Matcher) { + t.Helper() + + assert.Equal(t, "/foo/bar", out.Path) + assert.Nil(t, out.With) + }, + }, + { + uc: "with path and simple constraints", + in: &Matcher{Path: "/foo/bar", With: &MatcherConstraints{Scheme: "http"}}, + assert: func(t *testing.T, out *Matcher) { + t.Helper() + + assert.Equal(t, "/foo/bar", out.Path) + require.NotNil(t, out.With) + assert.Equal(t, "http", out.With.Scheme) + }, + }, + { + 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() + + 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) { + out := new(Matcher) + + tc.in.DeepCopyInto(out) + + 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 bd8c2130a..fa6ef9627 100644 --- a/internal/rules/config/parser_test.go +++ b/internal/rules/config/parser_test.go @@ -118,7 +118,6 @@ func TestParseRules(t *testing.T) { require.Error(t, err) require.ErrorIs(t, err, heimdall.ErrConfiguration) require.Contains(t, err.Error(), "'rules'[0].'match'.'path' is a required field") - require.Contains(t, err.Error(), "'rules'[0].'match'.'methods' must contain more than 0 items") require.Nil(t, ruleSet) }, }, @@ -131,7 +130,7 @@ func TestParseRules(t *testing.T) { "rules": [ { "id": "foo", - "match":{"path":"/foo/bar", "methods":["ALL"], "with": {"host_glob":"**", "host_regex":"**"}}, + "match":{"path":"/foo/bar", "with": {"host_glob":"**", "host_regex":"**"}}, "execute": [{"authenticator":"test"}] }] }`), @@ -154,7 +153,7 @@ func TestParseRules(t *testing.T) { "rules": [ { "id": "foo", - "match":{"path":"/foo/bar", "methods":["ALL"], "with": {"path_glob":"**", "path_regex":"**"}}, + "match":{"path":"/foo/bar", "with": {"path_glob":"**", "path_regex":"**"}}, "execute": [{"authenticator":"test"}] }] }`), @@ -177,7 +176,7 @@ func TestParseRules(t *testing.T) { "rules": [ { "id": "foo", - "match":{"path":"/foo/bar", "methods":["ALL"], "with": {"scheme":"foo"}}, + "match":{"path":"/foo/bar", "with": {"scheme":"foo", "methods":["ALL"]}}, "execute": [{"authenticator":"test"}] }] }`), @@ -199,7 +198,7 @@ func TestParseRules(t *testing.T) { "rules": [ { "id": "foo", - "match":{"path":"/foo/bar", "methods":["ALL"]}, + "match":{"path":"/foo/bar"}, "execute": [{"authenticator":"test"}], "forward_to": { "rewrite": {"scheme": "http"}} }] @@ -222,7 +221,7 @@ func TestParseRules(t *testing.T) { "rules": [ { "id": "foo", - "match":{"path":"/foo/bar", "methods":["ALL"]}, + "match":{"path":"/foo/bar"}, "allow_encoded_slashes": "foo", "execute": [{"authenticator":"test"}] }] @@ -244,7 +243,7 @@ func TestParseRules(t *testing.T) { "rules": [ { "id": "foo", - "match":{"path":"/foo/bar", "methods":["ALL"]}, + "match":{"path":"/foo/bar", "with": { "methods": ["ALL"] }}, "execute": [{"authenticator":"test"}] }] }`), @@ -261,7 +260,7 @@ func TestParseRules(t *testing.T) { 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.Methods) + assert.ElementsMatch(t, []string{"ALL"}, rul.Matcher.With.Methods) assert.Len(t, rul.Execute, 1) assert.Equal(t, "test", rul.Execute[0]["authenticator"]) }, @@ -276,7 +275,9 @@ rules: - id: bar match: path: /foo/bar - methods: [ "GET" ] + with: + methods: + - GET forward_to: host: test allow_encoded_slashes: no_decode @@ -295,7 +296,7 @@ rules: 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.Methods) + 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"]) @@ -374,7 +375,6 @@ rules: - id: bar match: path: foo - methods: [ ALL ] execute: - authenticator: test `), @@ -421,7 +421,6 @@ rules: - id: bar match: path: foo - methods: [ ALL ] execute: - authenticator: test `), @@ -449,7 +448,6 @@ rules: - id: bar match: path: foo - methods: [ ALL ] execute: - authenticator: test `), 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 45d4f8391..ed1e9b10e 100644 --- a/internal/rules/config/rule.go +++ b/internal/rules/config/rule.go @@ -17,15 +17,13 @@ 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" - EncodedSlashesOnNoDecode EncodedSlashesHandling = "no_decode" + "github.com/dadrus/heimdall/internal/config" + "github.com/dadrus/heimdall/internal/heimdall" ) type Rule struct { @@ -37,20 +35,32 @@ type Rule struct { ErrorHandler []config.MechanismConfig `json:"on_error" yaml:"on_error"` } -func (in *Rule) DeepCopyInto(out *Rule) { - *out = *in +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) + } + + md := crypto.SHA256.New() + md.Write(rawRuleConfig) + + return md.Sum(nil), nil +} + +func (r *Rule) DeepCopyInto(out *Rule) { + *out = *r - inm, outm := &in.Matcher, &out.Matcher + inm, outm := &r.Matcher, &out.Matcher inm.DeepCopyInto(outm) - if in.Backend != nil { - in, out := in.Backend, out.Backend + 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 { @@ -58,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 { @@ -68,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_test.go b/internal/rules/config/rule_test.go index 43b042abf..e97f955a2 100644 --- a/internal/rules/config/rule_test.go +++ b/internal/rules/config/rule_test.go @@ -34,9 +34,9 @@ func TestRuleConfigDeepCopyInto(t *testing.T) { in := Rule{ ID: "foo", Matcher: Matcher{ - Path: "bar", - Methods: []string{"GET", "PATCH"}, - With: MatcherConstraints{ + Path: "bar", + With: &MatcherConstraints{ + Methods: []string{"GET", "PATCH"}, Scheme: "https", HostGlob: "**.example.com", HostRegex: ".*\\.example.com", @@ -63,7 +63,7 @@ func TestRuleConfigDeepCopyInto(t *testing.T) { // THEN assert.Equal(t, in.ID, out.ID) assert.Equal(t, in.Matcher.Path, out.Matcher.Path) - assert.Equal(t, in.Matcher.Methods, out.Matcher.Methods) + 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) @@ -81,9 +81,9 @@ func TestRuleConfigDeepCopy(t *testing.T) { in := Rule{ ID: "foo", Matcher: Matcher{ - Path: "bar", - Methods: []string{"GET", "PATCH"}, - With: MatcherConstraints{ + Path: "bar", + With: &MatcherConstraints{ + Methods: []string{"GET", "PATCH"}, Scheme: "https", HostGlob: "**.example.com", HostRegex: ".*\\.example.com", @@ -114,7 +114,7 @@ func TestRuleConfigDeepCopy(t *testing.T) { // but same contents assert.Equal(t, in.ID, out.ID) assert.Equal(t, in.Matcher.Path, out.Matcher.Path) - assert.Equal(t, in.Matcher.Methods, out.Matcher.Methods) + 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) diff --git a/internal/rules/glob_matcher.go b/internal/rules/glob_matcher.go deleted file mode 100644 index bed283213..000000000 --- a/internal/rules/glob_matcher.go +++ /dev/null @@ -1,46 +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 rules - -import ( - "errors" - - "github.com/gobwas/glob" -) - -var 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, 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 -} diff --git a/internal/rules/mocks/pattern_matcher.go b/internal/rules/mocks/pattern_matcher.go index 37fd43aa9..82b834990 100644 --- a/internal/rules/mocks/pattern_matcher.go +++ b/internal/rules/mocks/pattern_matcher.go @@ -4,7 +4,7 @@ package mocks import mock "github.com/stretchr/testify/mock" -// PatternMatcherMock is an autogenerated mock type for the PatternMatcher type +// PatternMatcherMock is an autogenerated mock type for the patternMatcher type type PatternMatcherMock struct { mock.Mock } diff --git a/internal/rules/pattern_matcher.go b/internal/rules/pattern_matcher.go deleted file mode 100644 index 75a96c401..000000000 --- a/internal/rules/pattern_matcher.go +++ /dev/null @@ -1,7 +0,0 @@ -package rules - -//go:generate mockery --name PatternMatcher --structname PatternMatcherMock - -type PatternMatcher interface { - Match(pattern string) bool -} diff --git a/internal/rules/provider/cloudblob/provider_test.go b/internal/rules/provider/cloudblob/provider_test.go index 75fcbbf17..2e8db962b 100644 --- a/internal/rules/provider/cloudblob/provider_test.go +++ b/internal/rules/provider/cloudblob/provider_test.go @@ -244,7 +244,6 @@ rules: - id: foo match: path: /foo - methods: [ GET ] execute: - authenticator: test ` @@ -293,7 +292,6 @@ rules: - id: foo match: path: /foo - methods: [ GET ] execute: - authenticator: test ` @@ -347,7 +345,6 @@ rules: - id: foo match: path: /foo - methods: [ GET ] execute: - authenticator: test ` @@ -366,7 +363,6 @@ rules: - id: bar match: path: /bar - methods: [ GET ] execute: - authenticator: test ` @@ -443,7 +439,6 @@ rules: - id: foo match: path: /foo - methods: [ GET ] execute: - authenticator: test ` @@ -459,7 +454,6 @@ rules: - id: bar match: path: /bar - methods: [ GET ] execute: - authenticator: test ` @@ -475,7 +469,6 @@ rules: - id: baz match: path: /baz - methods: [ GET ] execute: - authenticator: test ` diff --git a/internal/rules/provider/cloudblob/ruleset_endpoint_test.go b/internal/rules/provider/cloudblob/ruleset_endpoint_test.go index 3e68e0e00..aac46b338 100644 --- a/internal/rules/provider/cloudblob/ruleset_endpoint_test.go +++ b/internal/rules/provider/cloudblob/ruleset_endpoint_test.go @@ -189,10 +189,10 @@ func TestFetchRuleSets(t *testing.T) { "id": "foobar", "match": { "path": "/foo/bar/api1", - "methods": ["GET", "POST"], "with": { "scheme": "http", - "host_glob": "**" + "host_glob": "**", + "methods": ["GET", "POST"] } }, "execute": [ @@ -208,12 +208,12 @@ rules: - id: barfoo match: path: /foo/bar/api2 - methods: - - GET - - POST with: scheme: http host_glob: "**" + methods: + - GET + - POST execute: - authenticator: barfoo ` @@ -265,10 +265,10 @@ rules: "id": "foobar", "match": { "path": "/foo/bar/api1", - "methods": ["GET", "POST"], "with": { "scheme": "http", - "host_glob": "**" + "host_glob": "**", + "methods": ["GET", "POST"] } }, "execute": [ @@ -283,10 +283,10 @@ rules: "id": "barfoo", "match": { "path": "/foo/bar/api2", - "methods": ["GET", "POST"], "with": { "scheme": "http", - "host_glob": "**" + "host_glob": "**", + "methods": ["GET", "POST"] } }, "execute": [ @@ -383,10 +383,10 @@ rules: "id": "foobar", "match": { "path": "/foo/bar/api1", - "methods": ["GET", "POST"], "with": { "scheme": "http", - "host_glob": "**" + "host_glob": "**", + "methods": ["GET", "POST"] } }, "execute": [ diff --git a/internal/rules/provider/filesystem/provider_test.go b/internal/rules/provider/filesystem/provider_test.go index 88d9fc0e5..f06c23f43 100644 --- a/internal/rules/provider/filesystem/provider_test.go +++ b/internal/rules/provider/filesystem/provider_test.go @@ -204,7 +204,6 @@ rules: - id: foo match: path: /foo/bar - methods: [ GET ] execute: - authenticator: test `) @@ -258,7 +257,6 @@ rules: - id: foo match: path: /foo/bar - methods: [ GET ] execute: - authenticator: test `) @@ -302,7 +300,6 @@ rules: - id: foo match: path: /foo/bar - methods: [ GET ] execute: - authenticator: test `) @@ -339,7 +336,6 @@ rules: - id: foo match: path: /foo/bar - methods: [ GET ] execute: - authenticator: test `) @@ -391,7 +387,6 @@ rules: - id: foo match: path: /foo - methods: [ GET ] execute: - authenticator: test `) @@ -408,7 +403,6 @@ rules: - id: foo match: path: /foo - methods: [ GET ] execute: - authenticator: test `) @@ -425,7 +419,6 @@ rules: - id: bar match: path: /bar - methods: [ GET ] execute: - authenticator: test `) diff --git a/internal/rules/provider/httpendpoint/provider_test.go b/internal/rules/provider/httpendpoint/provider_test.go index 570690873..8d175c961 100644 --- a/internal/rules/provider/httpendpoint/provider_test.go +++ b/internal/rules/provider/httpendpoint/provider_test.go @@ -264,7 +264,8 @@ rules: - id: foo match: path: /foo - methods: [ "GET" ] + with: + methods: [ "GET" ] execute: - authenticator: test `)) @@ -311,7 +312,8 @@ rules: - id: bar match: path: /bar - methods: [ "GET" ] + with: + methods: [ "GET" ] execute: - authenticator: test `)) @@ -363,7 +365,8 @@ rules: - id: foo match: path: /foo - methods: [ GET ] + with: + methods: [ GET ] execute: - authenticator: test `)) @@ -379,7 +382,8 @@ rules: - id: bar match: path: /bar - methods: [ GET ] + with: + methods: [ GET ] execute: - authenticator: test `)) @@ -449,7 +453,6 @@ rules: - id: bar match: path: /bar - methods: [ GET ] execute: - authenticator: test `)) @@ -463,7 +466,6 @@ rules: - id: baz match: path: /baz - methods: [ GET ] execute: - authenticator: test `)) @@ -477,7 +479,6 @@ rules: - id: foo match: path: /foo - methods: [ GET ] execute: - authenticator: test `)) @@ -491,7 +492,6 @@ rules: - id: foz match: path: /foz - methods: [ GET ] execute: - authenticator: test `)) @@ -566,7 +566,6 @@ rules: - id: bar match: path: /bar - methods: [ GET ] execute: - authenticator: test `)) @@ -616,7 +615,6 @@ rules: - id: bar match: path: /bar - methods: [ GET ] execute: - authenticator: test `)) @@ -664,7 +662,6 @@ rules: - id: foo match: path: /foo - methods: [ GET ] execute: - authenticator: test `)) @@ -706,7 +703,6 @@ rules: - id: bar match: path: /bar - methods: [ GET ] execute: - authenticator: test `)) @@ -720,7 +716,6 @@ rules: - id: baz match: path: /baz - methods: [ GET ] execute: - authenticator: test `)) @@ -767,7 +762,6 @@ rules: - id: bar match: path: /bar - methods: [ GET ] execute: - authenticator: test `)) diff --git a/internal/rules/provider/httpendpoint/ruleset_endpoint_test.go b/internal/rules/provider/httpendpoint/ruleset_endpoint_test.go index 6665aaeba..3f5e83d64 100644 --- a/internal/rules/provider/httpendpoint/ruleset_endpoint_test.go +++ b/internal/rules/provider/httpendpoint/ruleset_endpoint_test.go @@ -186,7 +186,8 @@ rules: - id: foo match: path: /foo - methods: [ GET ] + with: + methods: [ GET ] execute: - authenticator: test `)) @@ -219,7 +220,7 @@ rules: "version": "1", "name": "test", "rules": [ - { "id": "foo", "match": { "path": "/foo", "methods" : ["GET"] }, "execute": [{ "authenticator": "test"}] } + { "id": "foo", "match": { "path": "/foo", "with": { "methods" : ["GET"] }}, "execute": [{ "authenticator": "test"}] } ] }`)) require.NoError(t, err) @@ -255,10 +256,10 @@ rules: "id": "foo", "match": { "path": "/foo/bar/:*", - "methods": [ "GET" ], "with": { "host_glob": "moobar.local:9090", - "path_glob": "/foo/bar/**" + "path_glob": "/foo/bar/**", + "methods": [ "GET" ] } }, "execute": [{ "authenticator": "test"}] diff --git a/internal/rules/provider/kubernetes/admissioncontroller/controller_test.go b/internal/rules/provider/kubernetes/admissioncontroller/controller_test.go index c2ac00cb5..89eb684f1 100644 --- a/internal/rules/provider/kubernetes/admissioncontroller/controller_test.go +++ b/internal/rules/provider/kubernetes/admissioncontroller/controller_test.go @@ -272,10 +272,10 @@ func TestControllerLifecycle(t *testing.T) { { ID: "test", Matcher: config2.Matcher{ - Path: "/foo.bar", - Methods: []string{http.MethodGet}, - With: config2.MatcherConstraints{ - Scheme: "http", + Path: "/foo.bar", + With: &config2.MatcherConstraints{ + Scheme: "http", + Methods: []string{http.MethodGet}, }, }, Backend: &config2.Backend{ @@ -367,10 +367,10 @@ func TestControllerLifecycle(t *testing.T) { { ID: "test", Matcher: config2.Matcher{ - Path: "/foo.bar", - Methods: []string{http.MethodGet}, - With: config2.MatcherConstraints{ - Scheme: "http", + Path: "/foo.bar", + With: &config2.MatcherConstraints{ + Scheme: "http", + Methods: []string{http.MethodGet}, }, }, Backend: &config2.Backend{ diff --git a/internal/rules/provider/kubernetes/api/v1alpha4/client_test.go b/internal/rules/provider/kubernetes/api/v1alpha4/client_test.go index 3143ff941..cd39dface 100644 --- a/internal/rules/provider/kubernetes/api/v1alpha4/client_test.go +++ b/internal/rules/provider/kubernetes/api/v1alpha4/client_test.go @@ -58,11 +58,11 @@ const response = `{ "id": "test:rule", "match": { "path": "/foobar/:*", - "methods": ["GET", "POST"], "with": { "scheme": "http", "host_glob": "127.0.0.1:*", - "path_glob": "/foobar/foos*" + "path_glob": "/foobar/foos*", + "methods": ["GET", "POST"] } }, "forward_to": { @@ -145,7 +145,7 @@ func verifyRuleSetList(t *testing.T, rls *RuleSetList) { 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.Methods, []string{"GET", "POST"}) + 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/provider_test.go b/internal/rules/provider/kubernetes/provider_test.go index b59a94fca..c05f646da 100644 --- a/internal/rules/provider/kubernetes/provider_test.go +++ b/internal/rules/provider/kubernetes/provider_test.go @@ -211,11 +211,11 @@ func (h *RuleSetResourceHandler) writeListResponse(t *testing.T, w http.Response { ID: "test", Matcher: config2.Matcher{ - Path: "/", - Methods: []string{http.MethodGet}, - With: config2.MatcherConstraints{ + Path: "/", + With: &config2.MatcherConstraints{ Scheme: "http", HostGlob: "foo.bar", + Methods: []string{http.MethodGet}, }, }, Backend: &config2.Backend{ @@ -373,8 +373,8 @@ func TestProviderLifecycle(t *testing.T) { 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.Methods, 1) - assert.Contains(t, rule.Matcher.Methods, http.MethodGet) + 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.Empty(t, rule.ErrorHandler) assert.Len(t, rule.Execute, 2) @@ -473,8 +473,8 @@ func TestProviderLifecycle(t *testing.T) { 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.Methods, 1) - assert.Contains(t, createdRule.Matcher.Methods, http.MethodGet) + 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.Empty(t, createdRule.ErrorHandler) assert.Len(t, createdRule.Execute, 2) @@ -537,8 +537,8 @@ func TestProviderLifecycle(t *testing.T) { 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.Methods, 1) - assert.Contains(t, createdRule.Matcher.Methods, http.MethodGet) + 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.Empty(t, createdRule.ErrorHandler) assert.Len(t, createdRule.Execute, 2) @@ -605,8 +605,8 @@ func TestProviderLifecycle(t *testing.T) { 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.Methods, 1) - assert.Contains(t, createdRule.Matcher.Methods, http.MethodGet) + 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.Empty(t, createdRule.ErrorHandler) assert.Len(t, createdRule.Execute, 2) @@ -680,11 +680,11 @@ func TestProviderLifecycle(t *testing.T) { { ID: "test", Matcher: config2.Matcher{ - Path: "/", - Methods: []string{http.MethodGet}, - With: config2.MatcherConstraints{ + Path: "/", + With: &config2.MatcherConstraints{ Scheme: "http", HostGlob: "foo.bar", + Methods: []string{http.MethodGet}, }, }, Backend: &config2.Backend{ @@ -737,8 +737,8 @@ func TestProviderLifecycle(t *testing.T) { 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.Methods, 1) - assert.Contains(t, createdRule.Matcher.Methods, http.MethodGet) + 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.Empty(t, createdRule.ErrorHandler) assert.Len(t, createdRule.Execute, 2) @@ -756,8 +756,8 @@ func TestProviderLifecycle(t *testing.T) { 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.Methods, 1) - assert.Contains(t, createdRule.Matcher.Methods, http.MethodGet) + 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.Empty(t, updatedRule.ErrorHandler) assert.Len(t, updatedRule.Execute, 2) @@ -828,8 +828,8 @@ func TestProviderLifecycle(t *testing.T) { 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.Methods, 1) - assert.Contains(t, createdRule.Matcher.Methods, http.MethodGet) + 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.Empty(t, createdRule.ErrorHandler) assert.Len(t, createdRule.Execute, 2) @@ -847,8 +847,8 @@ func TestProviderLifecycle(t *testing.T) { 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.Methods, 1) - assert.Contains(t, createdRule.Matcher.Methods, http.MethodGet) + 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.Empty(t, deleteRule.ErrorHandler) assert.Len(t, deleteRule.Execute, 2) @@ -882,11 +882,11 @@ func TestProviderLifecycle(t *testing.T) { { ID: "test", Matcher: config2.Matcher{ - Path: "/", - Methods: []string{http.MethodGet}, - With: config2.MatcherConstraints{ + Path: "/", + With: &config2.MatcherConstraints{ Scheme: "http", HostGlob: "foo.bar", + Methods: []string{http.MethodGet}, }, }, Backend: &config2.Backend{ diff --git a/internal/rules/regex_matcher.go b/internal/rules/regex_matcher.go deleted file mode 100644 index ac1a44641..000000000 --- a/internal/rules/regex_matcher.go +++ /dev/null @@ -1,45 +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 rules - -import ( - "errors" - "regexp" -) - -var ErrNoRegexPatternDefined = errors.New("no regex pattern defined") - -type regexpMatcher struct { - compiled *regexp.Regexp -} - -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 -} - -func (m *regexpMatcher) Match(matchAgainst string) bool { - return m.compiled.MatchString(matchAgainst) -} diff --git a/internal/rules/repository_impl_test.go b/internal/rules/repository_impl_test.go index 05c2210be..af7e8f957 100644 --- a/internal/rules/repository_impl_test.go +++ b/internal/rules/repository_impl_test.go @@ -23,20 +23,19 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "github.com/dadrus/heimdall/internal/heimdall" 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" ) -type testMatcher bool - -func (m testMatcher) Match(_ string) bool { return bool(m) } - func TestRepositoryAddRuleSetWithoutViolation(t *testing.T) { t.Parallel() @@ -114,31 +113,15 @@ func TestRepositoryRemoveRulesFromDifferentRuleSets(t *testing.T) { repo := newRepository(&ruleFactory{}).(*repository) //nolint: forcetypeassert rules1 := []rule.Rule{ - &ruleImpl{ - id: "1", srcID: "bar", pathExpression: "/bar/1", - hostMatcher: testMatcher(true), pathMatcher: testMatcher(true), allowedMethods: []string{http.MethodGet}, - }, - - &ruleImpl{ - id: "3", srcID: "bar", pathExpression: "/bar/3", - hostMatcher: testMatcher(true), pathMatcher: testMatcher(true), allowedMethods: []string{http.MethodGet}, - }, - &ruleImpl{ - id: "4", srcID: "bar", pathExpression: "/bar/4", - hostMatcher: testMatcher(true), pathMatcher: testMatcher(true), allowedMethods: []string{http.MethodGet}, - }, + &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", - hostMatcher: testMatcher(true), pathMatcher: testMatcher(true), allowedMethods: []string{http.MethodGet}, - }, + &ruleImpl{id: "2", srcID: "baz", pathExpression: "/baz/2"}, } rules3 := []rule.Rule{ - &ruleImpl{ - id: "4", srcID: "foo", pathExpression: "/foo/4", - hostMatcher: testMatcher(true), pathMatcher: testMatcher(true), allowedMethods: []string{http.MethodGet}, - }, + &ruleImpl{id: "4", srcID: "foo", pathExpression: "/foo/4"}, } // WHEN @@ -203,43 +186,19 @@ func TestRepositoryUpdateRuleSet(t *testing.T) { repo := newRepository(&ruleFactory{}).(*repository) //nolint: forcetypeassert initialRules := []rule.Rule{ - &ruleImpl{ - id: "1", srcID: "1", pathExpression: "/bar/1", hash: []byte{1}, - hostMatcher: testMatcher(true), pathMatcher: testMatcher(true), allowedMethods: []string{http.MethodGet}, - }, - &ruleImpl{ - id: "2", srcID: "1", pathExpression: "/bar/2", hash: []byte{1}, - hostMatcher: testMatcher(true), pathMatcher: testMatcher(true), allowedMethods: []string{http.MethodGet}, - }, - &ruleImpl{ - id: "3", srcID: "1", pathExpression: "/bar/3", hash: []byte{1}, - hostMatcher: testMatcher(true), pathMatcher: testMatcher(true), allowedMethods: []string{http.MethodGet}, - }, - &ruleImpl{ - id: "4", srcID: "1", pathExpression: "/bar/4", hash: []byte{1}, - hostMatcher: testMatcher(true), pathMatcher: testMatcher(true), allowedMethods: []string{http.MethodGet}, - }, + &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{ - // changed - id: "1", srcID: "1", pathExpression: "/bar/1", hash: []byte{2}, - hostMatcher: testMatcher(true), pathMatcher: testMatcher(true), allowedMethods: []string{http.MethodGet}, - }, + &ruleImpl{id: "1", srcID: "1", pathExpression: "/bar/1", hash: []byte{2}}, // changed // rule with id 2 is deleted - &ruleImpl{ - // changed and path expression changed - id: "3", srcID: "1", pathExpression: "/foo/3", hash: []byte{2}, - hostMatcher: testMatcher(true), pathMatcher: testMatcher(true), allowedMethods: []string{http.MethodGet}, - }, - &ruleImpl{ - // same as before - id: "4", srcID: "1", pathExpression: "/bar/4", hash: []byte{1}, - hostMatcher: testMatcher(true), pathMatcher: testMatcher(true), allowedMethods: []string{http.MethodGet}, - }, + &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 @@ -309,7 +268,7 @@ func TestRepositoryFindRule(t *testing.T) { }, }, { - uc: "matches upstream rule having path without escaped parts", + 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() @@ -319,55 +278,17 @@ func TestRepositoryFindRule(t *testing.T) { addRules: func(t *testing.T, repo *repository) { t.Helper() - fooBarMatcher, err := newGlobMatcher("foo.bar", '.') - require.NoError(t, err) - - err = repo.AddRuleSet("baz", []rule.Rule{ + err := repo.AddRuleSet("baz", []rule.Rule{ &ruleImpl{ id: "test2", srcID: "baz", pathExpression: "/baz/bar", - hostMatcher: fooBarMatcher, - pathMatcher: testMatcher(true), - allowedMethods: []string{http.MethodGet}, - }, - }) - require.NoError(t, err) - }, - assert: func(t *testing.T, err error, rul rule.Rule) { - t.Helper() + matcher: func() config.RequestMatcher { + rm := mocks3.NewRequestMatcherMock(t) + rm.EXPECT().Matches(mock.Anything).Return(nil) - require.NoError(t, err) - - impl, ok := rul.(*ruleImpl) - require.True(t, ok) - - require.Equal(t, "test2", impl.id) - require.Equal(t, "baz", impl.srcID) - }, - }, - { - uc: "matches upstream rule having path with escaped parts", - requestURL: &url.URL{Scheme: "http", Host: "foo.bar", Path: "/baz/bar", RawPath: "/baz%2Fbar"}, - configureFactory: func(t *testing.T, factory *mocks.FactoryMock) { - t.Helper() - - factory.EXPECT().HasDefaultRule().Return(false) - }, - addRules: func(t *testing.T, repo *repository) { - t.Helper() - - fooBarMatcher, err := newGlobMatcher("foo.bar", '.') - require.NoError(t, err) - - err = repo.AddRuleSet("baz", []rule.Rule{ - &ruleImpl{ - id: "test2", - srcID: "baz", - pathExpression: "/baz%2Fbar", - hostMatcher: fooBarMatcher, - pathMatcher: testMatcher(true), - allowedMethods: []string{http.MethodGet}, + return rm + }(), }, }) require.NoError(t, err) diff --git a/internal/rules/rule_factory_impl.go b/internal/rules/rule_factory_impl.go index 45cd85db1..cc07f1965 100644 --- a/internal/rules/rule_factory_impl.go +++ b/internal/rules/rule_factory_impl.go @@ -17,14 +17,9 @@ 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" @@ -34,16 +29,8 @@ import ( "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" ) -// nolint: gochecknoglobals -var spaceReplacer = strings.NewReplacer("\t", "", "\n", "", "\v", "", "\f", "", "\r", "", " ", "") - -type alwaysMatcher struct{} - -func (alwaysMatcher) Match(_ string) bool { return true } - func NewRuleFactory( hf mechanisms.Factory, conf *config.Configuration, @@ -165,18 +152,15 @@ func (f *ruleFactory) CreateRule(version, srcID string, ruleConfig config2.Rule) return nil, errorchain.NewWithMessage(heimdall.ErrConfiguration, "proxy mode requires forward_to definition") } - hostMatcher, err := f.createPatternMatcher( - ruleConfig.Matcher.With.HostGlob, '.', ruleConfig.Matcher.With.HostRegex) - if err != nil { - return nil, errorchain.NewWithMessage(heimdall.ErrConfiguration, - "filed to compile host expression").CausedBy(err) - } + slashesHandling := x.IfThenElse( + len(ruleConfig.EncodedSlashesHandling) != 0, + ruleConfig.EncodedSlashesHandling, + config2.EncodedSlashesOff, + ) - pathMatcher, err := f.createPatternMatcher( - ruleConfig.Matcher.With.PathGlob, '/', ruleConfig.Matcher.With.PathRegex) + matcher, err := ruleConfig.Matcher.With.ToRequestMatcher(slashesHandling) if err != nil { - return nil, errorchain.NewWithMessage(heimdall.ErrConfiguration, - "filed to compile path expression").CausedBy(err) + return nil, err } authenticators, subHandlers, finalizers, err := f.createExecutePipeline(version, ruleConfig.Execute) @@ -189,12 +173,6 @@ func (f *ruleFactory) CreateRule(version, srcID string, ruleConfig config2.Rule) return nil, err } - methods, err := expandHTTPMethods(ruleConfig.Matcher.Methods) - if err != nil { - return nil, errorchain.NewWithMessage(heimdall.ErrConfiguration, - "failed to expand allowed HTTP methods").CausedBy(err) - } - if f.defaultRule != nil { authenticators = x.IfThenElse(len(authenticators) != 0, authenticators, f.defaultRule.sc) subHandlers = x.IfThenElse(len(subHandlers) != 0, subHandlers, f.defaultRule.sh) @@ -206,62 +184,27 @@ func (f *ruleFactory) CreateRule(version, srcID string, ruleConfig config2.Rule) return nil, errorchain.NewWithMessage(heimdall.ErrConfiguration, "no authenticator defined") } - hash, err := f.createHash(ruleConfig) + hash, err := ruleConfig.Hash() if err != nil { - return nil, errorchain.NewWithMessage(heimdall.ErrConfiguration, "failed to create hash") + return nil, err } return &ruleImpl{ - id: ruleConfig.ID, - srcID: srcID, - isDefault: false, - encodedSlashesHandling: x.IfThenElse( - len(ruleConfig.EncodedSlashesHandling) != 0, - ruleConfig.EncodedSlashesHandling, - config2.EncodedSlashesOff, - ), - allowedScheme: ruleConfig.Matcher.With.Scheme, - allowedMethods: methods, - hostMatcher: hostMatcher, - pathMatcher: pathMatcher, - pathExpression: ruleConfig.Matcher.Path, - backend: ruleConfig.Backend, - hash: hash, - sc: authenticators, - sh: subHandlers, - fi: finalizers, - eh: errorHandlers, + id: ruleConfig.ID, + srcID: srcID, + isDefault: false, + 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) 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 - } -} - -func (f *ruleFactory) createHash(ruleConfig config2.Rule) ([]byte, error) { - rawRuleConfig, err := json.Marshal(ruleConfig) - if err != nil { - return nil, err - } - - md := crypto.SHA256.New() - md.Write(rawRuleConfig) - - return md.Sum(nil), nil -} - func (f *ruleFactory) createOnErrorPipeline( version string, ehConfigs []config.MechanismConfig, @@ -324,26 +267,15 @@ 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, - allowedMethods: methods, - srcID: "config", - isDefault: true, - sc: authenticators, - sh: subHandlers, - fi: finalizers, - eh: errorHandlers, + id: "default", + slashesHandling: config2.EncodedSlashesOff, + srcID: "config", + isDefault: true, + sc: authenticators, + sh: subHandlers, + fi: finalizers, + eh: errorHandlers, } f.hasDefaultRule = true @@ -351,30 +283,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 7a582c398..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.allowedMethods, []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.allowedMethods, []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) @@ -559,12 +433,12 @@ func TestRuleFactoryCreateRule(t *testing.T) { }, }, { - uc: "with bad host expression", + uc: "with error while creating request matcher", config: config2.Rule{ ID: "foobar", Matcher: config2.Matcher{ Path: "/foo/bar", - With: config2.MatcherConstraints{HostRegex: "?>?<*??"}, + With: &config2.MatcherConstraints{HostRegex: "?>?<*??"}, }, }, assert: func(t *testing.T, err error, _ *ruleImpl) { @@ -575,23 +449,6 @@ func TestRuleFactoryCreateRule(t *testing.T) { assert.Contains(t, err.Error(), "filed to compile host expression") }, }, - { - uc: "with bad path expression", - config: config2.Rule{ - ID: "foobar", - Matcher: config2.Matcher{ - Path: "/foo/bar", - With: config2.MatcherConstraints{PathGlob: "!*][)(*"}, - }, - }, - 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(), "filed to compile path expression") - }, - }, { uc: "with error while creating execute pipeline", config: config2.Rule{ @@ -645,34 +502,10 @@ func TestRuleFactoryCreateRule(t *testing.T) { }, }, { - uc: "without default rule and with authenticator and finalizer configured, with error while expanding methods", + uc: "without default rule and minimum required configuration in decision mode", config: config2.Rule{ ID: "foobar", - Matcher: config2.Matcher{Path: "/foo/bar", Methods: []string{"FOO", ""}}, - 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, "failed to expand") - }, - }, - { - uc: "without default rule but with minimum required configuration in decision mode", - config: config2.Rule{ - ID: "foobar", - Matcher: config2.Matcher{Path: "/foo/bar", Methods: []string{"FOO", "BAR"}}, + Matcher: config2.Matcher{Path: "/foo/bar"}, Execute: []config.MechanismConfig{ {"authenticator": "foo"}, }, @@ -691,12 +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.Empty(t, rul.allowedScheme) + assert.Equal(t, config2.EncodedSlashesOff, rul.slashesHandling) + assert.NotNil(t, rul.matcher) assert.Equal(t, "/foo/bar", rul.PathExpression()) - assert.IsType(t, alwaysMatcher{}, rul.hostMatcher) - assert.IsType(t, alwaysMatcher{}, rul.pathMatcher) - assert.ElementsMatch(t, rul.allowedMethods, []string{"FOO", "BAR"}) assert.Len(t, rul.sc, 1) assert.Empty(t, rul.sh) assert.Empty(t, rul.fi) @@ -704,12 +534,12 @@ 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"}, - Matcher: config2.Matcher{Path: "/foo/bar", Methods: []string{"FOO", "BAR"}}, + Matcher: config2.Matcher{Path: "/foo/bar"}, Execute: []config.MechanismConfig{ {"authenticator": "foo"}, }, @@ -728,12 +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.Empty(t, rul.allowedScheme) + assert.Equal(t, config2.EncodedSlashesOff, rul.slashesHandling) + assert.NotNil(t, rul.matcher) assert.Equal(t, "/foo/bar", rul.PathExpression()) - assert.IsType(t, alwaysMatcher{}, rul.hostMatcher) - assert.IsType(t, alwaysMatcher{}, rul.pathMatcher) - assert.ElementsMatch(t, rul.allowedMethods, []string{"FOO", "BAR"}) assert.Len(t, rul.sc, 1) assert.Empty(t, rul.sh) assert.Empty(t, rul.fi) @@ -748,11 +575,10 @@ func TestRuleFactoryCreateRule(t *testing.T) { Matcher: config2.Matcher{Path: "/foo/bar"}, }, defaultRule: &ruleImpl{ - allowedMethods: []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() @@ -763,11 +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.Empty(t, rul.allowedScheme) + assert.NotNil(t, rul.matcher) assert.Equal(t, "/foo/bar", rul.PathExpression()) - assert.IsType(t, alwaysMatcher{}, rul.hostMatcher) - assert.IsType(t, alwaysMatcher{}, rul.pathMatcher) - assert.Empty(t, rul.allowedMethods) assert.Len(t, rul.sc, 1) assert.Len(t, rul.sh, 1) assert.Len(t, rul.fi, 1) @@ -779,12 +602,12 @@ func TestRuleFactoryCreateRule(t *testing.T) { config: config2.Rule{ ID: "foobar", Matcher: config2.Matcher{ - Path: "/foo/:resource", - Methods: []string{"BAR", "BAZ"}, - With: config2.MatcherConstraints{ + Path: "/foo/:resource", + With: &config2.MatcherConstraints{ Scheme: "https", HostGlob: "**.example.com", PathRegex: "^/foo/(bar|baz)", + Methods: []string{"BAR", "BAZ"}, }, }, EncodedSlashesHandling: config2.EncodedSlashesOnNoDecode, @@ -799,11 +622,10 @@ func TestRuleFactoryCreateRule(t *testing.T) { }, }, defaultRule: &ruleImpl{ - allowedMethods: []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() @@ -828,14 +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.EncodedSlashesOnNoDecode, rul.encodedSlashesHandling) - assert.Equal(t, "https", rul.allowedScheme) + assert.Equal(t, config2.EncodedSlashesOnNoDecode, rul.slashesHandling) assert.Equal(t, "/foo/:resource", rul.PathExpression()) - require.IsType(t, &globMatcher{}, rul.hostMatcher) - assert.True(t, rul.hostMatcher.Match("foo.example.com")) - require.IsType(t, ®expMatcher{}, rul.pathMatcher) - assert.True(t, rul.pathMatcher.Match("/foo/bar")) - assert.ElementsMatch(t, rul.allowedMethods, []string{"BAR", "BAZ"}) + assert.NotNil(t, rul.matcher) // nil checks above mean the responses from the mockHandlerFactory are used // and not the values from the default rule @@ -856,12 +673,12 @@ func TestRuleFactoryCreateRule(t *testing.T) { config: config2.Rule{ ID: "foobar", Matcher: config2.Matcher{ - Path: "/foo/:resource", - Methods: []string{"BAR", "BAZ"}, - With: config2.MatcherConstraints{ + Path: "/foo/:resource", + With: &config2.MatcherConstraints{ Scheme: "https", HostGlob: "**.example.com", PathRegex: "^/foo/(bar|baz)", + Methods: []string{"BAR", "BAZ"}, }, }, EncodedSlashesHandling: config2.EncodedSlashesOn, @@ -885,11 +702,10 @@ func TestRuleFactoryCreateRule(t *testing.T) { }, }, defaultRule: &ruleImpl{ - allowedMethods: []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() @@ -914,14 +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.Equal(t, "https", rul.allowedScheme) + assert.Equal(t, config2.EncodedSlashesOn, rul.slashesHandling) assert.Equal(t, "/foo/:resource", rul.PathExpression()) - require.IsType(t, &globMatcher{}, rul.hostMatcher) - assert.True(t, rul.hostMatcher.Match("foo.example.com")) - require.IsType(t, ®expMatcher{}, rul.pathMatcher) - assert.True(t, rul.pathMatcher.Match("/foo/bar")) - assert.ElementsMatch(t, rul.allowedMethods, []string{"BAR", "BAZ"}) + 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", @@ -947,7 +758,7 @@ func TestRuleFactoryCreateRule(t *testing.T) { uc: "with conditional execution configuration type error", config: config2.Rule{ ID: "foobar", - Matcher: config2.Matcher{Path: "/foo/bar", Methods: []string{"FOO"}}, + Matcher: config2.Matcher{Path: "/foo/bar"}, Execute: []config.MechanismConfig{ {"authenticator": "foo"}, {"finalizer": "bar", "if": 1}, @@ -970,7 +781,7 @@ func TestRuleFactoryCreateRule(t *testing.T) { uc: "with empty conditional execution configuration", config: config2.Rule{ ID: "foobar", - Matcher: config2.Matcher{Path: "/foo/bar", Methods: []string{"FOO"}}, + Matcher: config2.Matcher{Path: "/foo/bar"}, Execute: []config.MechanismConfig{ {"authenticator": "foo"}, {"finalizer": "bar", "if": ""}, @@ -993,7 +804,7 @@ func TestRuleFactoryCreateRule(t *testing.T) { uc: "with conditional execution for some mechanisms", config: config2.Rule{ ID: "foobar", - Matcher: config2.Matcher{Path: "/foo/bar", Methods: []string{"FOO"}}, + Matcher: config2.Matcher{Path: "/foo/bar"}, Execute: []config.MechanismConfig{ {"authenticator": "foo"}, {"authorizer": "bar", "if": "false"}, @@ -1023,11 +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.Empty(t, rul.allowedScheme) assert.Equal(t, "/foo/bar", rul.PathExpression()) - assert.IsType(t, alwaysMatcher{}, rul.hostMatcher) - assert.IsType(t, alwaysMatcher{}, rul.pathMatcher) - assert.ElementsMatch(t, rul.allowedMethods, []string{"FOO"}) + assert.NotNil(t, rul.matcher) require.Len(t, rul.sc, 1) assert.NotNil(t, rul.sc[0]) @@ -1061,7 +869,7 @@ func TestRuleFactoryCreateRule(t *testing.T) { uc: "with conditional execution for error handler", config: config2.Rule{ ID: "foobar", - Matcher: config2.Matcher{Path: "/foo/bar", Methods: []string{"FOO"}}, + Matcher: config2.Matcher{Path: "/foo/bar"}, Execute: []config.MechanismConfig{ {"authenticator": "foo"}, {"authorizer": "bar"}, @@ -1097,11 +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.Empty(t, rul.allowedScheme) assert.Equal(t, "/foo/bar", rul.PathExpression()) - assert.IsType(t, alwaysMatcher{}, rul.hostMatcher) - assert.IsType(t, alwaysMatcher{}, rul.pathMatcher) - assert.ElementsMatch(t, rul.allowedMethods, []string{"FOO"}) + assert.NotNil(t, rul.matcher) require.Len(t, rul.sc, 1) assert.NotNil(t, rul.sc[0]) @@ -1196,64 +1001,3 @@ func TestRuleFactoryConfigExtraction(t *testing.T) { }) } } - -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 4569c174d..0b4b91289 100644 --- a/internal/rules/rule_impl.go +++ b/internal/rules/rule_impl.go @@ -18,7 +18,6 @@ package rules import ( "net/url" - "slices" "strings" "github.com/rs/zerolog" @@ -26,24 +25,22 @@ import ( "github.com/dadrus/heimdall/internal/heimdall" "github.com/dadrus/heimdall/internal/rules/config" "github.com/dadrus/heimdall/internal/rules/rule" + "github.com/dadrus/heimdall/internal/x/errorchain" ) type ruleImpl struct { - id string - encodedSlashesHandling config.EncodedSlashesHandling - pathExpression string - allowedScheme string - hostMatcher PatternMatcher - pathMatcher PatternMatcher - allowedMethods []string - backend *config.Backend - 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 + slashesHandling config.EncodedSlashesHandling + backend *config.Backend + sc compositeSubjectCreator + sh compositeSubjectHandler + fi compositeSubjectHandler + eh compositeErrorHandler } func (r *ruleImpl) Execute(ctx heimdall.Context) (rule.Backend, error) { @@ -60,12 +57,18 @@ func (r *ruleImpl) Execute(ctx heimdall.Context) (rule.Backend, error) { // unescape captures captures := request.URL.Captures for k, v := range captures { - captures[k] = unescape(v, r.encodedSlashesHandling) + captures[k] = unescape(v, r.slashesHandling) } - // unescape path - if r.encodedSlashesHandling == config.EncodedSlashesOn { + 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") + } } // authenticators @@ -101,46 +104,8 @@ func (r *ruleImpl) Matches(ctx heimdall.Context) bool { logger.Debug().Msg("Matching rule") - // fastest checks first - // match scheme - if len(r.allowedScheme) != 0 && r.allowedScheme != request.URL.Scheme { - logger.Debug().Msg("Allowed scheme mismatch") - - return false - } - - // match methods - if !slices.Contains(r.allowedMethods, request.Method) { - logger.Debug().Msg("Allowed method mismatch") - - return false - } - - // check encoded slash handling - if r.encodedSlashesHandling == config.EncodedSlashesOff && strings.Contains(request.URL.RawPath, "%2F") { - logger.Debug().Msg("Path contains encoded slashes, which is not allowed") - - return false - } - - // match host - if !r.hostMatcher.Match(request.URL.Host) { - logger.Debug().Msg("Host does not satisfy configured expression") - - return false - } - - // match path - var path string - if len(request.URL.RawPath) == 0 || r.encodedSlashesHandling == config.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 !r.pathMatcher.Match(path) { - logger.Debug().Msgf("Path %s does not satisfy configured expression", request.URL.Path) + if err := r.matcher.Matches(request); err != nil { + logger.Debug().Err(err).Msg("Request does not satisfy matching conditions") return false } diff --git a/internal/rules/rule_impl_test.go b/internal/rules/rule_impl_test.go index 053d9938c..1202c9faa 100644 --- a/internal/rules/rule_impl_test.go +++ b/internal/rules/rule_impl_test.go @@ -18,16 +18,19 @@ 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/rule" @@ -45,102 +48,29 @@ func TestRuleMatches(t *testing.T) { matches bool }{ { - uc: "doesn't match scheme", + uc: "doesn't match", rule: &ruleImpl{ - hostMatcher: testMatcher(true), - pathMatcher: testMatcher(true), - allowedScheme: "https", - allowedMethods: []string{http.MethodGet}, - encodedSlashesHandling: config.EncodedSlashesOn, - }, - toMatch: &heimdall.Request{Method: http.MethodGet, URL: &heimdall.URL{}}, - matches: false, - }, - { - uc: "doesn't match method", - rule: &ruleImpl{ - hostMatcher: testMatcher(true), - pathMatcher: testMatcher(true), - allowedMethods: []string{http.MethodGet}, - encodedSlashesHandling: config.EncodedSlashesOn, - }, - toMatch: &heimdall.Request{Method: http.MethodPost, URL: &heimdall.URL{}}, - matches: false, - }, - { - uc: "doesn't match due to not allowed encoded slash", - rule: &ruleImpl{ - hostMatcher: testMatcher(true), - pathMatcher: testMatcher(true), - allowedMethods: []string{http.MethodGet}, - encodedSlashesHandling: config.EncodedSlashesOff, - }, - toMatch: &heimdall.Request{Method: http.MethodGet, URL: &heimdall.URL{URL: url.URL{Path: "/foo/bar", RawPath: "/foo%2Fbar"}}}, - matches: false, - }, - { - uc: "doesn't match host", - rule: &ruleImpl{ - hostMatcher: testMatcher(false), - pathMatcher: testMatcher(true), - allowedMethods: []string{http.MethodGet}, - encodedSlashesHandling: config.EncodedSlashesOn, - }, - toMatch: &heimdall.Request{Method: http.MethodGet, URL: &heimdall.URL{}}, - matches: false, - }, - { - uc: "doesn't match path with allowed encoded slashes", - rule: &ruleImpl{ - hostMatcher: testMatcher(true), - pathMatcher: testMatcher(false), - allowedMethods: []string{http.MethodGet}, - encodedSlashesHandling: config.EncodedSlashesOn, - }, - toMatch: &heimdall.Request{Method: http.MethodGet, URL: &heimdall.URL{}}, - matches: false, - }, - { - uc: "doesn't match path with encoded slash with allowed encoded slashes without decoding them", - rule: &ruleImpl{ - hostMatcher: testMatcher(true), - pathMatcher: testMatcher(false), - allowedMethods: []string{http.MethodGet}, - encodedSlashesHandling: config.EncodedSlashesOnNoDecode, - }, - toMatch: &heimdall.Request{Method: http.MethodGet, URL: &heimdall.URL{URL: url.URL{Path: "/foo/bar", RawPath: "/foo%2Fbar"}}}, - matches: false, - }, - { - uc: "match path with encoded slash with allowed encoded slashes without decoding them", - rule: &ruleImpl{ - hostMatcher: testMatcher(true), - pathMatcher: func() PatternMatcher { - matcher := &mocks.PatternMatcherMock{} - matcher.EXPECT().Match("/foo%2Fbar").Return(true) + matcher: func() config.RequestMatcher { + rm := mocks2.NewRequestMatcherMock(t) + rm.EXPECT().Matches(mock.Anything).Return(errors.New("test error")) - return matcher + return rm }(), - allowedMethods: []string{http.MethodGet}, - encodedSlashesHandling: config.EncodedSlashesOnNoDecode, }, - toMatch: &heimdall.Request{Method: http.MethodGet, URL: &heimdall.URL{URL: url.URL{Path: "/foo/bar", RawPath: "/foo%2Fbar"}}}, - matches: true, + toMatch: &heimdall.Request{Method: http.MethodGet, URL: &heimdall.URL{}}, + matches: false, }, { - uc: "match path with encoded slash with allowed encoded slashes with decoding them", + uc: "matches", rule: &ruleImpl{ - hostMatcher: testMatcher(true), - pathMatcher: func() PatternMatcher { - matcher := &mocks.PatternMatcherMock{} - matcher.EXPECT().Match("/foo/bar").Return(true) + matcher: func() config.RequestMatcher { + rm := mocks2.NewRequestMatcherMock(t) + rm.EXPECT().Matches(mock.Anything).Return(nil) - return matcher + return rm }(), - allowedMethods: []string{http.MethodGet}, - encodedSlashesHandling: config.EncodedSlashesOn, }, - toMatch: &heimdall.Request{Method: http.MethodGet, URL: &heimdall.URL{URL: url.URL{Path: "/foo/bar", RawPath: "/foo%2Fbar"}}}, + toMatch: &heimdall.Request{Method: http.MethodPost, URL: &heimdall.URL{}}, matches: true, }, } { @@ -325,41 +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") + 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", "second": "v1", "third": "foo%5Bid%5D"}, + Captures: map[string]string{"first": "api%2Fv1", "second": "foo%5Bid%5D"}, }, }) }, - assert: func(t *testing.T, err error, backend rule.Backend, captures map[string]string) { + 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()) - - assert.Equal(t, "api", captures["first"]) - assert.Equal(t, "v1", captures["second"]) - assert.Equal(t, "foo[id]", captures["third"]) + require.Error(t, err) + require.ErrorIs(t, err, heimdall.ErrArgument) + require.ErrorContains(t, err, "path contains encoded slash") }, }, { @@ -519,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) diff --git a/schema/config.schema.json b/schema/config.schema.json index c7e08b0e9..f30142154 100644 --- a/schema/config.schema.json +++ b/schema/config.schema.json @@ -2523,19 +2523,6 @@ "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" - ] - }, "execute": { "description": "The mechanisms to execute (authenticators, authorizers, etc)", "type": "array", From d4a94b96ef20ddfe6e98ef45eaf34bce3a77bf6b Mon Sep 17 00:00:00 2001 From: Dimitrij Drus Date: Sat, 27 Apr 2024 19:29:39 +0200 Subject: [PATCH 48/76] helm chart updated --- charts/heimdall/Chart.yaml | 3 +- charts/heimdall/crds/ruleset.yaml | 55 +++++++++---------- charts/heimdall/templates/demo/configmap.yaml | 3 - charts/heimdall/templates/demo/test-rule.yaml | 4 -- 4 files changed, 28 insertions(+), 37 deletions(-) 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 6e049d581..7677c13fc 100644 --- a/charts/heimdall/crds/ruleset.yaml +++ b/charts/heimdall/crds/ruleset.yaml @@ -76,43 +76,42 @@ spec: type: object required: - path - - methods properties: path: description: The path to match type: string maxLength: 256 - 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" 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 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 3b6f2ecc8..cccdeaa58 100644 --- a/charts/heimdall/templates/demo/test-rule.yaml +++ b/charts/heimdall/templates/demo/test-rule.yaml @@ -27,8 +27,6 @@ spec: - id: public-access match: path: /pub/** - methods: - - ALL forward_to: host: {{ include "heimdall.demo.fullname" . }}.heimdall-demo.svc.cluster.local:8080 execute: @@ -38,8 +36,6 @@ spec: - id: anonymous-access match: path: /anon/** - methods: - - ALL forward_to: host: {{ include "heimdall.demo.fullname" . }}.heimdall-demo.svc.cluster.local:8080 execute: From a162f600b57ce89bc38d9341fab60bb61afeef89 Mon Sep 17 00:00:00 2001 From: Dimitrij Drus Date: Sat, 27 Apr 2024 19:30:02 +0200 Subject: [PATCH 49/76] examples updated --- examples/docker-compose/quickstarts/upstream-rules.yaml | 5 +++-- examples/kubernetes/quickstarts/demo-app/base/rules.yaml | 3 --- examples/kubernetes/quickstarts/heimdall/config.yaml | 3 --- examples/kubernetes/quickstarts/heimdall1/config.yaml | 3 --- .../kubernetes/quickstarts/proxy-demo/heimdall-config.yaml | 3 --- .../kubernetes/quickstarts/proxy-demo/heimdall-rules.yaml | 3 --- 6 files changed, 3 insertions(+), 17 deletions(-) diff --git a/examples/docker-compose/quickstarts/upstream-rules.yaml b/examples/docker-compose/quickstarts/upstream-rules.yaml index 095259645..1c3380372 100644 --- a/examples/docker-compose/quickstarts/upstream-rules.yaml +++ b/examples/docker-compose/quickstarts/upstream-rules.yaml @@ -3,7 +3,8 @@ rules: - id: demo:public match: path: /public - methods: [ GET, POST ] + with: + methods: [ GET, POST ] forward_to: host: upstream:8081 execute: @@ -13,9 +14,9 @@ rules: - id: demo:protected match: path: /:user - methods: [ GET, POST ] with: path_regex: ^/(user|admin) + methods: [ GET, POST ] forward_to: host: upstream:8081 execute: diff --git a/examples/kubernetes/quickstarts/demo-app/base/rules.yaml b/examples/kubernetes/quickstarts/demo-app/base/rules.yaml index 297c43f51..ad08590ed 100644 --- a/examples/kubernetes/quickstarts/demo-app/base/rules.yaml +++ b/examples/kubernetes/quickstarts/demo-app/base/rules.yaml @@ -10,7 +10,6 @@ spec: - id: public-access match: path: /pub/** - methods: [ ALL ] forward_to: # only required for proxy operation mode host: echo-app.quickstarts.svc.cluster.local:8080 execute: @@ -18,7 +17,6 @@ spec: - id: anonymous-access match: path: /anon/** - methods: [ ALL ] forward_to: # only required for proxy operation mode host: echo-app.quickstarts.svc.cluster.local:8080 execute: @@ -27,7 +25,6 @@ spec: - id: redirect match: path: /redir/** - methods: [ ALL ] 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..0ad4220da 100644 --- a/examples/kubernetes/quickstarts/heimdall/config.yaml +++ b/examples/kubernetes/quickstarts/heimdall/config.yaml @@ -35,9 +35,6 @@ mechanisms: 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..22dbf869c 100644 --- a/examples/kubernetes/quickstarts/heimdall1/config.yaml +++ b/examples/kubernetes/quickstarts/heimdall1/config.yaml @@ -35,9 +35,6 @@ mechanisms: 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 92c093bfe..6c0eae814 100644 --- a/examples/kubernetes/quickstarts/proxy-demo/heimdall-rules.yaml +++ b/examples/kubernetes/quickstarts/proxy-demo/heimdall-rules.yaml @@ -13,7 +13,6 @@ data: - id: public-access match: path: /pub/** - methods: [ ALL ] forward_to: host: localhost:8080 rewrite: @@ -24,7 +23,6 @@ data: - id: anonymous-access match: path: /anon/** - methods: [ ALL ] forward_to: host: localhost:8080 rewrite: @@ -36,7 +34,6 @@ data: - id: redirect match: path: /redir/** - methods: [ ALL ] forward_to: host: localhost:8080 rewrite: From 57df03f904c0d64661690adde389da6627439a72 Mon Sep 17 00:00:00 2001 From: Dimitrij Drus Date: Sat, 27 Apr 2024 19:30:18 +0200 Subject: [PATCH 50/76] example rule and config updated --- example_config.yaml | 3 --- example_rules.yaml | 4 ++-- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/example_config.yaml b/example_config.yaml index 4d859f586..d4312b0f1 100644 --- a/example_config.yaml +++ b/example_config.yaml @@ -177,9 +177,6 @@ mechanisms: to: http://127.0.0.1:4433/self-service/login/browser?origin={{ .Request.URL | urlenc }} default_rule: - methods: - - GET - - POST execute: - authenticator: anonymous_authenticator - finalizer: jwt diff --git a/example_rules.yaml b/example_rules.yaml index 92ccc4f49..49edb5f25 100644 --- a/example_rules.yaml +++ b/example_rules.yaml @@ -4,10 +4,10 @@ rules: - id: rule:foo match: path: /** - methods: + with: + methods: - GET - POST - with: host_glob: foo.bar scheme: http forward_to: From 5a685b94a1f0fea066045b1ffc144ae9dd679c14 Mon Sep 17 00:00:00 2001 From: Dimitrij Drus Date: Sun, 28 Apr 2024 23:30:00 +0200 Subject: [PATCH 51/76] admission controller settings in helm chart updated to use the proper version --- charts/heimdall/templates/heimdall/admissioncontroller.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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" From ef65689d59de28ae231e029b11ff62afc71860b0 Mon Sep 17 00:00:00 2001 From: Dimitrij Drus Date: Sun, 28 Apr 2024 23:30:40 +0200 Subject: [PATCH 52/76] version string updated in fortgotten places --- .../admissioncontroller/controller_test.go | 4 +- .../admissioncontroller/validator.go | 4 +- .../kubernetes/api/v1alpha4/mocks/client.go | 14 +++--- .../api/v1alpha4/mocks/rule_set_repository.go | 50 +++++++++---------- .../rules/provider/kubernetes/provider.go | 4 +- .../provider/kubernetes/provider_test.go | 18 +++---- 6 files changed, 47 insertions(+), 47 deletions(-) diff --git a/internal/rules/provider/kubernetes/admissioncontroller/controller_test.go b/internal/rules/provider/kubernetes/admissioncontroller/controller_test.go index 89eb684f1..ce31272c8 100644 --- a/internal/rules/provider/kubernetes/admissioncontroller/controller_test.go +++ b/internal/rules/provider/kubernetes/admissioncontroller/controller_test.go @@ -312,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) { @@ -407,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 e46deecc3..9d294b2b9 100644 --- a/internal/rules/provider/kubernetes/admissioncontroller/validator.go +++ b/internal/rules/provider/kubernetes/admissioncontroller/validator.go @@ -100,6 +100,6 @@ func (rv *rulesetValidator) ruleSetFrom(req *admission.Request) (*v1alpha4.RuleS } func (rv *rulesetValidator) mapVersion(_ string) string { - // currently the only possible version is v1alpha4, 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/v1alpha4/mocks/client.go b/internal/rules/provider/kubernetes/api/v1alpha4/mocks/client.go index f13364c78..398d164f9 100644 --- a/internal/rules/provider/kubernetes/api/v1alpha4/mocks/client.go +++ b/internal/rules/provider/kubernetes/api/v1alpha4/mocks/client.go @@ -3,7 +3,7 @@ package mocks import ( - v1alpha3 "github.com/dadrus/heimdall/internal/rules/provider/kubernetes/api/v1alpha4" + v1alpha4 "github.com/dadrus/heimdall/internal/rules/provider/kubernetes/api/v1alpha4" mock "github.com/stretchr/testify/mock" ) @@ -21,19 +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) if len(ret) == 0 { panic("no return value specified for RuleSetRepository") } - var r0 v1alpha3.RuleSetRepository - if rf, ok := ret.Get(0).(func(string) v1alpha3.RuleSetRepository); ok { + 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) } } @@ -58,12 +58,12 @@ 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 } diff --git a/internal/rules/provider/kubernetes/api/v1alpha4/mocks/rule_set_repository.go b/internal/rules/provider/kubernetes/api/v1alpha4/mocks/rule_set_repository.go index dd15ed7f4..43e1a2c83 100644 --- a/internal/rules/provider/kubernetes/api/v1alpha4/mocks/rule_set_repository.go +++ b/internal/rules/provider/kubernetes/api/v1alpha4/mocks/rule_set_repository.go @@ -10,7 +10,7 @@ import ( v1 "k8s.io/apimachinery/pkg/apis/meta/v1" - v1alpha3 "github.com/dadrus/heimdall/internal/rules/provider/kubernetes/api/v1alpha4" + v1alpha4 "github.com/dadrus/heimdall/internal/rules/provider/kubernetes/api/v1alpha4" watch "k8s.io/apimachinery/pkg/watch" ) @@ -29,23 +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) if len(ret) == 0 { panic("no return value specified for Get") } - var r0 *v1alpha3.RuleSet + 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) } } @@ -78,34 +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) if len(ret) == 0 { panic("no return value specified for List") } - var r0 *v1alpha3.RuleSetList + 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) } } @@ -137,38 +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) if len(ret) == 0 { panic("no return value specified for PatchStatus") } - var r0 *v1alpha3.RuleSet + 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) @@ -190,19 +190,19 @@ func (_e *RuleSetRepositoryMock_Expecter) PatchStatus(ctx interface{}, patch int 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 } diff --git a/internal/rules/provider/kubernetes/provider.go b/internal/rules/provider/kubernetes/provider.go index 444afc569..6a9b61d1b 100644 --- a/internal/rules/provider/kubernetes/provider.go +++ b/internal/rules/provider/kubernetes/provider.go @@ -340,8 +340,8 @@ func (p *provider) toRuleSetConfiguration(rs *v1alpha4.RuleSet) *config2.RuleSet } func (p *provider) mapVersion(_ string) string { - // currently the only possible version is v1alpha4, 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( diff --git a/internal/rules/provider/kubernetes/provider_test.go b/internal/rules/provider/kubernetes/provider_test.go index c05f646da..00d6b40d6 100644 --- a/internal/rules/provider/kubernetes/provider_test.go +++ b/internal/rules/provider/kubernetes/provider_test.go @@ -364,7 +364,7 @@ func TestProviderLifecycle(t *testing.T) { 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) @@ -464,7 +464,7 @@ func TestProviderLifecycle(t *testing.T) { 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) @@ -483,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) @@ -528,7 +528,7 @@ func TestProviderLifecycle(t *testing.T) { 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) @@ -596,7 +596,7 @@ func TestProviderLifecycle(t *testing.T) { 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) @@ -728,7 +728,7 @@ func TestProviderLifecycle(t *testing.T) { 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) @@ -747,7 +747,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, ruleSet.Rules, 1) @@ -819,7 +819,7 @@ func TestProviderLifecycle(t *testing.T) { 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) @@ -838,7 +838,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, ruleSet.Rules, 1) From f59094530477666a66339c7c1922c49870f2943c Mon Sep 17 00:00:00 2001 From: Dimitrij Drus Date: Sun, 28 Apr 2024 23:31:19 +0200 Subject: [PATCH 53/76] DockerHub readme updated --- DockerHub-README.md | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) 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: From 9d4bbe79bc6c5a1d860771e2f5e75ce5c3b1ce9d Mon Sep 17 00:00:00 2001 From: Dimitrij Drus Date: Sun, 28 Apr 2024 23:32:02 +0200 Subject: [PATCH 54/76] docs updated; to be continued --- docs/content/_index.adoc | 8 +- .../docs/concepts/operating_modes.adoc | 16 +- docs/content/docs/concepts/rules.adoc | 40 ++--- .../docs/getting_started/protect_an_app.adoc | 8 +- .../docs/mechanisms/evaluation_objects.adoc | 15 +- docs/content/docs/rules/default_rule.adoc | 13 +- docs/content/docs/rules/providers.adoc | 8 +- docs/content/docs/rules/regular_rule.adoc | 156 +++++++++++++++--- docs/content/docs/rules/rule_sets.adoc | 26 +-- docs/content/guides/authz/openfga.adoc | 20 +-- 10 files changed, 215 insertions(+), 95 deletions(-) diff --git a/docs/content/_index.adoc b/docs/content/_index.adoc index 26df73ee7..d8f9a5810 100644 --- a/docs/content/_index.adoc +++ b/docs/content/_index.adoc @@ -23,10 +23,10 @@ spec: rules: - id: my_api_rule match: - scheme: http - host_glob: 127.0.0.1:9090 - path: - expression: /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/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..2ae44e030 100644 --- a/docs/content/docs/mechanisms/evaluation_objects.adoc +++ b/docs/content/docs/mechanisms/evaluation_objects.adoc @@ -72,6 +72,7 @@ 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: @@ -79,22 +80,31 @@ The URL of the matched request. This object has the following properties and met ** *`Scheme`*: _string_ + The HTTP scheme part of the url + ** *`Host`*: _string_ + The host part of the url + ** *`Path`*: _string_ + The path part of the url + ** *`RawQuery`*: _string_ + The raw query 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. +** *`Captures`*: _map_ ++ +Allows accessing of the values captured by the named wildcards used in the matching path expression of the rule. + * *`ClientIPAddresses`*: _string array_ + The list of IP addresses the request passed through with the first entry being the ultimate client of the request. Only available if heimdall is configured to trust the client, sending this information, e.g. in the `X-Forwarded-From` header (see e.g. Decision Service link:{{< relref "/docs/services/decision.adoc#_trusted_proxies" >}}[trusted_proxies] configuration for more details). @@ -152,8 +162,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"] } diff --git a/docs/content/docs/rules/default_rule.adoc b/docs/content/docs/rules/default_rule.adoc index 8ec82ac69..d80aa72cd 100644 --- a/docs/content/docs/rules/default_rule.adoc +++ b/docs/content/docs/rules/default_rule.adoc @@ -18,10 +18,6 @@ The configuration of the default rule can be done by making use of the `default_ 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. -* *`methods`*: _string array_ (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. - * *`execute`*: _link:{{< relref "regular_rule.adoc#_authentication_authorization_pipeline" >}}[Authentication & Authorization Pipeline]_ (mandatory) + Which mechanisms to use for authentication, authorization and finalization stages of the pipeline. At least the authentication stage with at least one link:{{< relref "/docs/mechanisms/authenticators.adoc" >}}[authenticator] must be defined. A specific rule (see also link:{{< relref "regular_rule.adoc" >}}[Regular Rule]) can omit the definition of that stage, if it wants to reuse it from in the default rule. Same is true for other stages (See also link:{{< relref "/docs/concepts/rules.adoc#_default_rule_inheritance" >}}[Rule Inheritance]). @@ -35,9 +31,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 +40,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 +48,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..3ee2d7ebe 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 ---- diff --git a/docs/content/docs/rules/regular_rule.adoc b/docs/content/docs/rules/regular_rule.adoc index 2e7149376..81f6a6191 100644 --- a/docs/content/docs/rules/regular_rule.adoc +++ b/docs/content/docs/rules/regular_rule.adoc @@ -24,41 +24,43 @@ 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): -** *`scheme`*: _string_ (optional) +** *`path`*: _link:{{< relref "#_path_expression" >}}[PathExpression]_ (mandatory) + -Which HTTP scheme is allowed. If not specified, both http and https are accepted. +The path expression describing the paths of incoming requests this rule is supposed to match. Supports usage of simple and free (named) wildcards. -** *`host_glob`*: _string_ (optional) +** *`backtracking_enabled`*: _boolean_ (optional) + -Glob expression to match the host. Used after the rule is matched with the `path` definition (see below). Mutually exclusive with `host_regex`. +Whether to allow backtracking if a request is matched based on the `path` expression, but the additional matching conditions (see below) are not satisfied. Defaults to `false`. + +** *`with`*: _MatchConditions_ (optional) + -Head over to https://github.com/gobwas/glob[gobwas/glob] to get more insights about possible options. +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. -** *`host_regex`*: _string_ (optional) +*** *`path_glob`*: _string_ (optional) + -Regular expression to match the host. Used after the rule is matched with the `path` definition (see below). Mutually exclusive with `host_glob`. +A https://github.com/gobwas/glob[glob expression], which should be satisfied by the path of the incoming request. Mutually exclusive with `path_regex`. -** *`path`*: _PathExpression_ (mandatory) +*** *`path_regex`*: _string_ (optional) + -Definitions on how to match the path. +A regular expression, which should be satisfied by the path of the incoming request. Mutually exclusive with `path_glob`. -*** *`expression`*: _string_ (mandatory) +*** *`scheme`*: _string_ (optional) + -The actual matching expression. Simple and free (named and unnamed) wildcards can be used +Expected HTTP scheme. If not specified, both http and https are accepted. -*** *`glob`*: _string_ (optional) +*** *`host_glob`*: _string_ (optional) + -An additional glob expression, which should be satisfied after the request has been matched by the `expression` value. Mutually exclusive with `regex`. +A https://github.com/gobwas/glob[glob expression] which should be satisfied by the host of the incoming request. Mutually exclusive with `host_regex`. -*** *`regex`*: _string_ (optional) +*** *`host_regex`*: _string_ (optional) + -An additional regular expression, which should be satisfied after the request has been matched by the `expression` value. Mutually exclusive with `glob`. +Regular expression to match the host. Mutually exclusive with `host_glob`. -** *`methods`*: _string array_ (optional) +*** *`methods`*: _string array_ (optional) + -Which HTTP methods (`GET`, `POST`, `PATCH`, etc) are allowed for the matched URL. If not specified, no rule will feel responsible resulting in `404 Not Found` 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 ==== @@ -85,8 +87,8 @@ methods: 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. +** *`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. @@ -135,16 +137,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 @@ -166,6 +170,108 @@ 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. + +Same path expressions with different conditions may be defined by multiple rules in the same rule set. + +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. If there are multiple rules with the same path expressions, their additional condition statements are executed in a sequence until one rule matches. 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 555f8929e..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) + @@ -49,18 +49,20 @@ name: my-rule-set rules: - id: rule:1 match: - scheme: https - host_glob: my-service1.local path: /** - methods: [ "GET" ] + with: + methods: [ "GET" ] + scheme: https + host_glob: my-service1.local execute: - authorizer: foobar - id: rule:2 match: - scheme: https - host_glob: my-service2.local path: /** - methods: [ "GET" ] + with: + scheme: https + host_glob: my-service2.local + methods: [ "GET" ] execute: - authorizer: barfoo ---- @@ -74,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) + @@ -121,10 +123,10 @@ spec: rules: - id: "" match: - scheme: https - host_glob: 127.0.0.1:9090 - path: - expression: /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` From e70c98804f69ca1530e45ebcc17f15d0f6d57370 Mon Sep 17 00:00:00 2001 From: Dimitrij Drus Date: Mon, 29 Apr 2024 10:23:46 +0200 Subject: [PATCH 55/76] more docs updates --- .../docs/mechanisms/evaluation_objects.adoc | 34 ++++++++++++------- docs/content/docs/rules/regular_rule.adoc | 32 ++++++++--------- 2 files changed, 35 insertions(+), 31 deletions(-) diff --git a/docs/content/docs/mechanisms/evaluation_objects.adoc b/docs/content/docs/mechanisms/evaluation_objects.adoc index 2ae44e030..56158df7e 100644 --- a/docs/content/docs/mechanisms/evaluation_objects.adoc +++ b/docs/content/docs/mechanisms/evaluation_objects.adoc @@ -77,33 +77,41 @@ The HTTP method used, like `GET`, `POST`, etc. + 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. -** *`Path`*: _string_ +** *`Hostname()`*: _method_ + -The path part of the url +This method returns the host name stripping any valid port number if present. -** *`RawQuery`*: _string_ +** *`Port()`*: _method_ + -The raw query part of the url. +Returns the port part of the `Host`, without the leading colon. If `Host` doesn't contain a valid numeric port, returns an empty string. -** *`String()`*: _method_ +** *`Path`*: _string_ + -This method returns the URL as valid URL string of a form `scheme:host/path?query`. +The path part of the url. ** *`Query()`*: _method_ + The parsed query with each key-value pair being a string to array of strings mapping. -** *`Captures`*: _map_ +** *`RawQuery`*: _string_ + -Allows accessing of the values captured by the named wildcards used in the matching path expression of the rule. +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`. * *`ClientIPAddresses`*: _string array_ + @@ -273,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: @@ -288,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/regular_rule.adoc b/docs/content/docs/rules/regular_rule.adoc index 81f6a6191..618f72555 100644 --- a/docs/content/docs/rules/regular_rule.adoc +++ b/docs/content/docs/rules/regular_rule.adoc @@ -38,49 +38,45 @@ Whether to allow backtracking if a request is matched based on the `path` expres + 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. -*** *`path_glob`*: _string_ (optional) +*** *`host_glob`*: _string_ (optional) + -A https://github.com/gobwas/glob[glob expression], which should be satisfied by the path of the incoming request. Mutually exclusive with `path_regex`. +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`. -*** *`path_regex`*: _string_ (optional) +*** *`host_regex`*: _string_ (optional) + -A regular expression, which should be satisfied by the path of the incoming request. Mutually exclusive with `path_glob`. +Regular expression to match the host. Mutually exclusive with `host_glob`. *** *`scheme`*: _string_ (optional) + Expected HTTP scheme. If not specified, both http and https are accepted. -*** *`host_glob`*: _string_ (optional) -+ -A https://github.com/gobwas/glob[glob expression] which should be satisfied by the host of the incoming request. Mutually exclusive with `host_regex`. - -*** *`host_regex`*: _string_ (optional) -+ -Regular expression to match the host. Mutually exclusive with `host_glob`. - *** *`methods`*: _string array_ (optional) + 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) + From dc3f1bfa87a9c4b067b68d16a1e33e57f1d03a9b Mon Sep 17 00:00:00 2001 From: Dimitrij Drus Date: Mon, 29 Apr 2024 13:13:09 +0200 Subject: [PATCH 56/76] json schema updated - suport for rule_path_match_prefix dropped --- schema/config.schema.json | 83 +-------------------------------------- 1 file changed, 1 insertion(+), 82 deletions(-) diff --git a/schema/config.schema.json b/schema/config.schema.json index f30142154..ffaab3d72 100644 --- a/schema/config.schema.json +++ b/schema/config.schema.json @@ -716,80 +716,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 +1953,7 @@ "type": "array", "additionalItems": false, "items": { - "$ref": "#/definitions/ruleSetEndpointConfiguration" + "$ref": "#/definitions/endpointConfiguration" } }, "watch_interval": { @@ -2078,13 +2004,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" - ] } } } From 18ade03f250c7267f159538188ff7e8521d5ef17 Mon Sep 17 00:00:00 2001 From: Dimitrij Drus Date: Mon, 29 Apr 2024 13:13:51 +0200 Subject: [PATCH 57/76] description of rule_path_match_prefix removed from the documentation of affected providers --- docs/content/docs/rules/providers.adoc | 27 ++++++++------------------ 1 file changed, 8 insertions(+), 19 deletions(-) diff --git a/docs/content/docs/rules/providers.adoc b/docs/content/docs/rules/providers.adoc index 3ee2d7ebe..a33b07e07 100644 --- a/docs/content/docs/rules/providers.adoc +++ b/docs/content/docs/rules/providers.adoc @@ -101,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 @@ -130,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] ---- @@ -140,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 @@ -185,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: @@ -224,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. From da6787e1f7b2a09d18f36f08de6640d96eb6378c Mon Sep 17 00:00:00 2001 From: Dimitrij Drus Date: Mon, 29 Apr 2024 13:19:41 +0200 Subject: [PATCH 58/76] config reference updated to reflect the changes introduced in this PR --- docs/content/docs/configuration/reference.adoc | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) 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: From 212865e05f3e9276374d018a86085108602c0aac Mon Sep 17 00:00:00 2001 From: Dimitrij Drus Date: Mon, 29 Apr 2024 13:28:14 +0200 Subject: [PATCH 59/76] more descriptions about rule matching and backtracking --- docs/content/docs/rules/default_rule.adoc | 4 ++++ docs/content/docs/rules/regular_rule.adoc | 6 ++---- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/content/docs/rules/default_rule.adoc b/docs/content/docs/rules/default_rule.adoc index d80aa72cd..1454a83d3 100644 --- a/docs/content/docs/rules/default_rule.adoc +++ b/docs/content/docs/rules/default_rule.adoc @@ -18,6 +18,10 @@ The configuration of the default rule can be done by making use of the `default_ 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. +* *`backtracking_enabled`*: _boolean_ (optional) ++ +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) + Which mechanisms to use for authentication, authorization and finalization stages of the pipeline. At least the authentication stage with at least one link:{{< relref "/docs/mechanisms/authenticators.adoc" >}}[authenticator] must be defined. A specific rule (see also link:{{< relref "regular_rule.adoc" >}}[Regular Rule]) can omit the definition of that stage, if it wants to reuse it from in the default rule. Same is true for other stages (See also link:{{< relref "/docs/concepts/rules.adoc#_default_rule_inheritance" >}}[Rule Inheritance]). diff --git a/docs/content/docs/rules/regular_rule.adoc b/docs/content/docs/rules/regular_rule.adoc index 618f72555..37726317a 100644 --- a/docs/content/docs/rules/regular_rule.adoc +++ b/docs/content/docs/rules/regular_rule.adoc @@ -32,7 +32,7 @@ The path expression describing the paths of incoming requests this rule is suppo ** *`backtracking_enabled`*: _boolean_ (optional) + -Whether to allow backtracking if a request is matched based on the `path` expression, but the additional matching conditions (see below) are not satisfied. Defaults to `false`. +Whether to allow backtracking if a request is matched based on the `path` expression, but the additional matching conditions (see below) are not satisfied. Inherited from the default rule and defaults to the settings in that rule. ** *`with`*: _MatchConditions_ (optional) + @@ -221,9 +221,7 @@ match: 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. -Same path expressions with different conditions may be defined by multiple rules in the same rule set. - -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. If there are multiple rules with the same path expressions, their additional condition statements are executed in a sequence until one rule matches. 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 +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. If there are multiple rules with the same path expressions, their additional condition statements are executed in a sequence until one rule matches. If there are multiple matching rules, the first one 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. From 5c248efdc7b77a25228ad3c1cd7a541c1d796c4f Mon Sep 17 00:00:00 2001 From: Dimitrij Drus Date: Mon, 29 Apr 2024 13:31:31 +0200 Subject: [PATCH 60/76] json schema updated - backtracking_enabled added to the default_rule --- schema/config.schema.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/schema/config.schema.json b/schema/config.schema.json index ffaab3d72..0968db1a3 100644 --- a/schema/config.schema.json +++ b/schema/config.schema.json @@ -2442,6 +2442,11 @@ "type": "object", "additionalProperties": false, "properties": { + "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)", "type": "array", From 127d4dfb8a2a8c32c09c5da81d6c1ff7a7ea2fa6 Mon Sep 17 00:00:00 2001 From: Dimitrij Drus Date: Mon, 29 Apr 2024 15:54:53 +0200 Subject: [PATCH 61/76] radixtree implementation updated to allow switching on or off backtrcking --- internal/x/radixtree/options.go | 4 ++-- internal/x/radixtree/tree.go | 34 ++++++++++++++++++++++--------- internal/x/radixtree/tree_test.go | 8 +++----- 3 files changed, 29 insertions(+), 17 deletions(-) diff --git a/internal/x/radixtree/options.go b/internal/x/radixtree/options.go index a7f1eb82d..b3f85ba77 100644 --- a/internal/x/radixtree/options.go +++ b/internal/x/radixtree/options.go @@ -12,8 +12,8 @@ func WithValuesConstraints[V any](constraints ConstraintsFunc[V]) Option[V] { type AddOption[V any] func(n *Tree[V]) -func WithoutBacktracking[V any](flag bool) AddOption[V] { +func WithBacktracking[V any](flag bool) AddOption[V] { return func(n *Tree[V]) { - n.backtrackingDisabled = flag + n.backtrackingEnabled = flag } } diff --git a/internal/x/radixtree/tree.go b/internal/x/radixtree/tree.go index 59274e979..a3355b0ba 100644 --- a/internal/x/radixtree/tree.go +++ b/internal/x/radixtree/tree.go @@ -11,7 +11,6 @@ var ( ErrInvalidPath = errors.New("invalid path") ErrNotFound = errors.New("not found") ErrFailedToDelete = errors.New("failed to delete") - ErrFailedToUpdate = errors.New("failed to update") ErrConstraintsViolation = errors.New("constraints violation") ) @@ -48,7 +47,7 @@ type ( canAdd ConstraintsFunc[V] // node local options - backtrackingDisabled bool + backtrackingEnabled bool } ) @@ -120,7 +119,7 @@ func (n *Tree[V]) addNode(path string, wildcardKeys []string, inStaticToken bool remainingPath := path[tokenEnd:] - if !inStaticToken { + if !inStaticToken { //nolint:nestif switch token { case '*': thisToken = thisToken[1:] @@ -134,6 +133,10 @@ func (n *Tree[V]) addNode(path string, wildcardKeys []string, inStaticToken bool path: thisToken, isCatchAll: true, } + + if len(n.values) == 0 { + n.backtrackingEnabled = true + } } if path[1:] != n.catchAllChild.path { @@ -148,6 +151,10 @@ func (n *Tree[V]) addNode(path string, wildcardKeys []string, inStaticToken bool 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) @@ -189,12 +196,16 @@ func (n *Tree[V]) addNode(path string, wildcardKeys []string, inStaticToken bool 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 +//nolint:cyclop,funlen func (n *Tree[V]) delNode(path string, matcher Matcher[V]) bool { pathLen := len(path) if pathLen == 0 { @@ -204,8 +215,13 @@ func (n *Tree[V]) delNode(path string, matcher Matcher[V]) bool { oldSize := len(n.values) n.values = slices.DeleteFunc(n.values, matcher.Match) + newSize := len(n.values) + + if newSize == 0 { + n.backtrackingEnabled = true + } - return oldSize != len(n.values) + return oldSize != newSize } var ( @@ -336,7 +352,7 @@ func (n *Tree[V]) findNode(path string, matcher Matcher[V]) (*Tree[V], int, []st } } - return nil, 0, nil, !n.backtrackingDisabled + return nil, 0, nil, n.backtrackingEnabled } // First see if this matches a static token. @@ -375,9 +391,7 @@ func (n *Tree[V]) findNode(path string, matcher Matcher[V]) (*Tree[V], int, []st } return found, idx, append(params, thisToken), backtrack - } - - if !backtrack { + } else if !backtrack { return nil, 0, nil, false } } @@ -396,7 +410,7 @@ func (n *Tree[V]) findNode(path string, matcher Matcher[V]) (*Tree[V], int, []st } } - return nil, 0, nil, !n.backtrackingDisabled + return nil, 0, nil, n.backtrackingEnabled } return nil, 0, nil, true diff --git a/internal/x/radixtree/tree_test.go b/internal/x/radixtree/tree_test.go index 16e3702ce..283d207e0 100644 --- a/internal/x/radixtree/tree_test.go +++ b/internal/x/radixtree/tree_test.go @@ -149,16 +149,14 @@ func TestTreeSearchWithBacktracking(t *testing.T) { // GIVEN tree := New[string]() - err := tree.Add("/date/:year/abc", "first") + 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" - })) + entry, err := tree.Find("/date/2024/abc", MatcherFunc[string](func(value string) bool { return value != "first" })) // THEN require.NoError(t, err) @@ -171,7 +169,7 @@ func TestTreeSearchWithoutBacktracking(t *testing.T) { // GIVEN tree := New[string]() - err := tree.Add("/date/:year/abc", "first", WithoutBacktracking[string](true)) + err := tree.Add("/date/:year/abc", "first") require.NoError(t, err) err = tree.Add("/date/**", "second") From eab6827335ce83f9d7ff9a5ce6aaabbf64d4400a Mon Sep 17 00:00:00 2001 From: Dimitrij Drus Date: Mon, 29 Apr 2024 15:56:14 +0200 Subject: [PATCH 62/76] CRD updated to allow usage of the new backtracking_enabled property --- charts/heimdall/crds/ruleset.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/charts/heimdall/crds/ruleset.yaml b/charts/heimdall/crds/ruleset.yaml index 7677c13fc..87b3552d9 100644 --- a/charts/heimdall/crds/ruleset.yaml +++ b/charts/heimdall/crds/ruleset.yaml @@ -81,6 +81,9 @@ spec: description: The path to match type: string 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 From 115b96e5f0b262da244758eeae62217d82143522 Mon Sep 17 00:00:00 2001 From: Dimitrij Drus Date: Mon, 29 Apr 2024 15:56:42 +0200 Subject: [PATCH 63/76] default rule impl updated to support the new property --- internal/config/default_rule.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/config/default_rule.go b/internal/config/default_rule.go index 3de763898..d7bf87e42 100644 --- a/internal/config/default_rule.go +++ b/internal/config/default_rule.go @@ -17,6 +17,7 @@ package config type DefaultRule struct { - Execute []MechanismConfig `koanf:"execute"` - ErrorHandler []MechanismConfig `koanf:"on_error"` + BacktrackingEnabled bool `koanf:"backtracking_enabled"` + Execute []MechanismConfig `koanf:"execute"` + ErrorHandler []MechanismConfig `koanf:"on_error"` } From 07ec621989af8b59d412d79023a13fbaa6404288 Mon Sep 17 00:00:00 2001 From: Dimitrij Drus Date: Mon, 29 Apr 2024 15:57:25 +0200 Subject: [PATCH 64/76] upstream rule config impl updated to allow usage of the new property --- internal/rules/config/matcher.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/rules/config/matcher.go b/internal/rules/config/matcher.go index c0ac243d6..1701af64c 100644 --- a/internal/rules/config/matcher.go +++ b/internal/rules/config/matcher.go @@ -17,8 +17,9 @@ package config type Matcher struct { - Path string `json:"path" yaml:"path" validate:"required"` //nolint:tagalign - With *MatcherConstraints `json:"with" yaml:"with" validate:"omitnil,required"` //nolint:tagalign + Path string `json:"path" yaml:"path" validate:"required"` //nolint:tagalign + BacktrackingEnabled *bool `json:"backtracking_enabled" yaml:"backtracking_enabled"` + With *MatcherConstraints `json:"with" yaml:"with" validate:"omitnil,required"` //nolint:lll,tagalign } func (m *Matcher) DeepCopyInto(out *Matcher) { From b371752e4eef952771ef3d58946bb2ce5a541d72 Mon Sep 17 00:00:00 2001 From: Dimitrij Drus Date: Mon, 29 Apr 2024 15:58:17 +0200 Subject: [PATCH 65/76] making use of the new backtracking_enabled property in rule implementation --- internal/rules/repository_impl.go | 5 ++- internal/rules/rule/mocks/rule.go | 45 ++++++++++++++++++++++++++ internal/rules/rule/rule.go | 1 + internal/rules/rule_factory_impl.go | 49 +++++++++++++++++------------ internal/rules/rule_impl.go | 27 +++++++++------- 5 files changed, 94 insertions(+), 33 deletions(-) diff --git a/internal/rules/repository_impl.go b/internal/rules/repository_impl.go index 6eae7c984..5bfafcb78 100644 --- a/internal/rules/repository_impl.go +++ b/internal/rules/repository_impl.go @@ -189,7 +189,10 @@ func (r *repository) DeleteRuleSet(srcID string) error { func (r *repository) addRulesTo(tree *radixtree.Tree[rule.Rule], rules []rule.Rule) error { for _, rul := range rules { - if err := tree.Add(rul.PathExpression(), rul); err != nil { + 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) } diff --git a/internal/rules/rule/mocks/rule.go b/internal/rules/rule/mocks/rule.go index d97e193fc..354cbfd71 100644 --- a/internal/rules/rule/mocks/rule.go +++ b/internal/rules/rule/mocks/rule.go @@ -22,6 +22,51 @@ func (_m *RuleMock) EXPECT() *RuleMock_Expecter { return &RuleMock_Expecter{mock: &_m.Mock} } +// 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) diff --git a/internal/rules/rule/rule.go b/internal/rules/rule/rule.go index 0ed441c80..e7539c431 100644 --- a/internal/rules/rule/rule.go +++ b/internal/rules/rule/rule.go @@ -28,5 +28,6 @@ type Rule interface { Execute(ctx heimdall.Context) (Backend, error) Matches(ctx heimdall.Context) bool PathExpression() string + BacktrackingEnabled() bool SameAs(other Rule) bool } diff --git a/internal/rules/rule_factory_impl.go b/internal/rules/rule_factory_impl.go index cc07f1965..a0b4bf595 100644 --- a/internal/rules/rule_factory_impl.go +++ b/internal/rules/rule_factory_impl.go @@ -173,11 +173,14 @@ func (f *ruleFactory) CreateRule(version, srcID string, ruleConfig config2.Rule) return nil, 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) + defaultBacktracking = f.defaultRule.allowsBacktracking } if len(authenticators) == 0 { @@ -189,19 +192,24 @@ func (f *ruleFactory) CreateRule(version, srcID string, ruleConfig config2.Rule) return nil, err } + allowsBacktracking := x.IfThenElseExec(ruleConfig.Matcher.BacktrackingEnabled != nil, + func() bool { return *ruleConfig.Matcher.BacktrackingEnabled }, + func() bool { return defaultBacktracking }) + return &ruleImpl{ - id: ruleConfig.ID, - srcID: srcID, - isDefault: false, - slashesHandling: slashesHandling, - matcher: matcher, - pathExpression: ruleConfig.Matcher.Path, - backend: ruleConfig.Backend, - hash: hash, - sc: authenticators, - sh: subHandlers, - fi: finalizers, - eh: errorHandlers, + 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 } @@ -268,14 +276,15 @@ func (f *ruleFactory) initWithDefaultRule(ruleConfig *config.DefaultRule, logger } f.defaultRule = &ruleImpl{ - id: "default", - slashesHandling: config2.EncodedSlashesOff, - 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 diff --git a/internal/rules/rule_impl.go b/internal/rules/rule_impl.go index 0b4b91289..b592378e0 100644 --- a/internal/rules/rule_impl.go +++ b/internal/rules/rule_impl.go @@ -29,18 +29,19 @@ import ( ) type ruleImpl struct { - id string - srcID string - isDefault bool - hash []byte - pathExpression string - matcher config.RequestMatcher - slashesHandling config.EncodedSlashesHandling - backend *config.Backend - 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) { @@ -121,6 +122,8 @@ 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() } From da8245881d656d150205b95d0c6e823042d946cd Mon Sep 17 00:00:00 2001 From: Dimitrij Drus Date: Mon, 29 Apr 2024 15:58:44 +0200 Subject: [PATCH 66/76] configs used for validation tests updated --- cmd/validate/test_data/config.yaml | 7 +++---- cmd/validate/test_data/valid-ruleset.yaml | 1 + internal/config/test_data/test_config.yaml | 6 ++---- 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/cmd/validate/test_data/config.yaml b/cmd/validate/test_data/config.yaml index 132541ad6..fabb54b2d 100644 --- a/cmd/validate/test_data/config.yaml +++ b/cmd/validate/test_data/config.yaml @@ -171,6 +171,7 @@ mechanisms: to: http://127.0.0.1:4433/self-service/login/browser?return_to={{ .Request.URL | urlenc }} default_rule: + backtracking_enabled: false execute: - authenticator: anonymous_authenticator - finalizer: jwt @@ -186,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 @@ -206,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/valid-ruleset.yaml b/cmd/validate/test_data/valid-ruleset.yaml index c44393c19..c13b97839 100644 --- a/cmd/validate/test_data/valid-ruleset.yaml +++ b/cmd/validate/test_data/valid-ruleset.yaml @@ -4,6 +4,7 @@ rules: - id: rule:foo match: path: /** + backtracking_enabled: true with: scheme: http host_glob: foo.bar diff --git a/internal/config/test_data/test_config.yaml b/internal/config/test_data/test_config.yaml index d7d28e720..8894084c2 100644 --- a/internal/config/test_data/test_config.yaml +++ b/internal/config/test_data/test_config.yaml @@ -488,8 +488,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 @@ -508,10 +508,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: From a8a6cbab6076b14879d291ecac7013a7ce9dc791 Mon Sep 17 00:00:00 2001 From: Dimitrij Drus Date: Mon, 29 Apr 2024 15:59:51 +0200 Subject: [PATCH 67/76] example config updated --- example_config.yaml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/example_config.yaml b/example_config.yaml index d4312b0f1..1ceff650c 100644 --- a/example_config.yaml +++ b/example_config.yaml @@ -177,6 +177,7 @@ mechanisms: to: http://127.0.0.1:4433/self-service/login/browser?origin={{ .Request.URL | urlenc }} default_rule: + backtracking_enabled: false execute: - authenticator: anonymous_authenticator - finalizer: jwt @@ -192,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 @@ -212,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: From e0e21c9c21c4f4e39d0c1bd87affc343229a75ad Mon Sep 17 00:00:00 2001 From: Dimitrij Drus Date: Mon, 29 Apr 2024 17:14:51 +0200 Subject: [PATCH 68/76] docker compose quickstarts heimdall config updated --- examples/docker-compose/quickstarts/heimdall-config.yaml | 3 --- 1 file changed, 3 deletions(-) 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 From 767f8ef6e9219c0ed374ff2b7c0f8206195e6c8b Mon Sep 17 00:00:00 2001 From: Dimitrij Drus Date: Mon, 29 Apr 2024 19:26:04 +0200 Subject: [PATCH 69/76] made configs better readable by adding empty lines; metallb config updated --- examples/kubernetes/metallb/configure.sh | 2 +- examples/kubernetes/quickstarts/heimdall/config.yaml | 1 + examples/kubernetes/quickstarts/heimdall1/config.yaml | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) 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/heimdall/config.yaml b/examples/kubernetes/quickstarts/heimdall/config.yaml index 0ad4220da..c69ce89de 100644 --- a/examples/kubernetes/quickstarts/heimdall/config.yaml +++ b/examples/kubernetes/quickstarts/heimdall/config.yaml @@ -34,6 +34,7 @@ mechanisms: if: type(Error) == authentication_error config: to: http://foo.bar?origin={{ .Request.URL | urlenc }} + default_rule: execute: - authenticator: anonymous_authenticator diff --git a/examples/kubernetes/quickstarts/heimdall1/config.yaml b/examples/kubernetes/quickstarts/heimdall1/config.yaml index 22dbf869c..e1f7e1294 100644 --- a/examples/kubernetes/quickstarts/heimdall1/config.yaml +++ b/examples/kubernetes/quickstarts/heimdall1/config.yaml @@ -34,6 +34,7 @@ mechanisms: if: type(Error) == authentication_error config: to: http://foo.bar?origin={{ .Request.URL | urlenc }} + default_rule: execute: - authenticator: anonymous_authenticator From b0d0d3d7e1ad2dd28d5029a4e6937fbab08c6a3b Mon Sep 17 00:00:00 2001 From: Dimitrij Drus Date: Mon, 29 Apr 2024 19:49:09 +0200 Subject: [PATCH 70/76] notes in examples readme added --- examples/README.md | 4 +++- examples/docker-compose/quickstarts/README.md | 3 +++ examples/kubernetes/README.md | 2 ++ 3 files changed, 8 insertions(+), 1 deletion(-) 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/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 From 7bd03a3a6623346cd30b45eb6495f5bb42ffae9c Mon Sep 17 00:00:00 2001 From: Dimitrij Drus Date: Tue, 30 Apr 2024 10:22:26 +0200 Subject: [PATCH 71/76] support for and documentation of the respond.with.method_error removed --- docs/content/docs/configuration/types.adoc | 1 - docs/content/guides/proxies/nginx.adoc | 2 -- internal/config/serve.go | 1 - internal/config/test_data/test_config.yaml | 2 -- internal/handler/decision/service.go | 1 - internal/handler/decision/service_test.go | 4 ++-- .../envoyextauth/grpcv3/handler_test.go | 6 ++--- .../handler/envoyextauth/grpcv3/service.go | 1 - .../middleware/grpc/errorhandler/defaults.go | 1 - .../grpc/errorhandler/interceptor.go | 2 -- .../grpc/errorhandler/interceptor_test.go | 22 ------------------- .../middleware/grpc/errorhandler/options.go | 9 -------- .../middleware/http/errorhandler/defaults.go | 1 - .../http/errorhandler/error_handler.go | 2 -- .../http/errorhandler/error_handler_test.go | 19 ---------------- .../middleware/http/errorhandler/options.go | 9 -------- internal/handler/proxy/service.go | 1 - internal/handler/proxy/service_test.go | 4 ++-- internal/heimdall/errors.go | 1 - schema/config.schema.json | 3 --- 20 files changed, 7 insertions(+), 85 deletions(-) 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/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/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 8894084c2..2d098819e 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: 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/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/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/schema/config.schema.json b/schema/config.schema.json index 0968db1a3..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" }, From 8622ea210c5f314edff0846c6d0bb417d04597ef Mon Sep 17 00:00:00 2001 From: Dimitrij Drus Date: Tue, 30 Apr 2024 12:00:48 +0200 Subject: [PATCH 72/76] more docu --- docs/content/docs/rules/default_rule.adoc | 8 ++++++- docs/content/docs/rules/regular_rule.adoc | 26 +++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/docs/content/docs/rules/default_rule.adoc b/docs/content/docs/rules/default_rule.adoc index 1454a83d3..cc151794a 100644 --- a/docs/content/docs/rules/default_rule.adoc +++ b/docs/content/docs/rules/default_rule.adoc @@ -16,7 +16,13 @@ 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. +==== * *`backtracking_enabled`*: _boolean_ (optional) + diff --git a/docs/content/docs/rules/regular_rule.adoc b/docs/content/docs/rules/regular_rule.adoc index 37726317a..c44610ca2 100644 --- a/docs/content/docs/rules/regular_rule.adoc +++ b/docs/content/docs/rules/regular_rule.adoc @@ -266,6 +266,32 @@ The request to `/files/team3/document.pdf` will be matched by the `rule3` as it 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. +[CAUTION] +==== +Since multiple rules with the same path expression might be present in a rule set, multiple rules could be matched based on their additional conditions definitions. Here an example: + +[source, yaml] +---- +- id: rule1 + match: + path: /articles/:id + with: + methods: [ POST ] + execute: + - + +- id: rule2 + match: + path: /articles/:id + with: + methods: [ POST ] + execute: + - +---- + +Such conflicting configurations cannot be avoided while loading a rule set and there might be valid reasons to have different rules with more specific additional conditions for the same path expression as well. For that reason, heimdall will use the first matching rule (the order is given by the placement of the rules in a rule set) when the incoming request is matched by multiple rules and emit a corresponding log statement. +==== + == 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. From 8b33ec74427189115664fc46c9af9f5702e2e672 Mon Sep 17 00:00:00 2001 From: Dimitrij Drus Date: Tue, 30 Apr 2024 19:27:17 +0200 Subject: [PATCH 73/76] made backtracking_enabled configurable together with additional conditions only --- internal/rules/config/matcher.go | 6 +++--- internal/rules/config/parser_test.go | 23 ++++++++++++++++++++++- internal/rules/rule_impl.go | 12 ++++++------ 3 files changed, 31 insertions(+), 10 deletions(-) diff --git a/internal/rules/config/matcher.go b/internal/rules/config/matcher.go index 1701af64c..2121525e6 100644 --- a/internal/rules/config/matcher.go +++ b/internal/rules/config/matcher.go @@ -17,9 +17,9 @@ package config type Matcher struct { - Path string `json:"path" yaml:"path" validate:"required"` //nolint:tagalign - BacktrackingEnabled *bool `json:"backtracking_enabled" yaml:"backtracking_enabled"` - With *MatcherConstraints `json:"with" yaml:"with" validate:"omitnil,required"` //nolint:lll,tagalign + 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) DeepCopyInto(out *Matcher) { diff --git a/internal/rules/config/parser_test.go b/internal/rules/config/parser_test.go index fa6ef9627..4e111f820 100644 --- a/internal/rules/config/parser_test.go +++ b/internal/rules/config/parser_test.go @@ -234,6 +234,27 @@ func TestParseRules(t *testing.T) { 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: "Valid JSON rule set", contentType: "application/json", @@ -243,7 +264,7 @@ func TestParseRules(t *testing.T) { "rules": [ { "id": "foo", - "match":{"path":"/foo/bar", "with": { "methods": ["ALL"] }}, + "match":{"path":"/foo/bar", "with": { "methods": ["ALL"] }, "backtracking_enabled": true }, "execute": [{"authenticator":"test"}] }] }`), diff --git a/internal/rules/rule_impl.go b/internal/rules/rule_impl.go index b592378e0..146d6bb65 100644 --- a/internal/rules/rule_impl.go +++ b/internal/rules/rule_impl.go @@ -55,12 +55,6 @@ func (r *ruleImpl) Execute(ctx heimdall.Context) (rule.Backend, error) { request := ctx.Request() - // unescape captures - captures := request.URL.Captures - for k, v := range captures { - captures[k] = unescape(v, r.slashesHandling) - } - switch r.slashesHandling { //nolint:exhaustive case config.EncodedSlashesOn: // unescape path @@ -72,6 +66,12 @@ func (r *ruleImpl) Execute(ctx heimdall.Context) (rule.Backend, error) { } } + // 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 { From 2fd38b043d284aac4ce3108fdca90e4329e12556 Mon Sep 17 00:00:00 2001 From: Dimitrij Drus Date: Tue, 30 Apr 2024 19:27:39 +0200 Subject: [PATCH 74/76] documentation updated --- docs/content/docs/rules/regular_rule.adoc | 34 ++++------------------- 1 file changed, 6 insertions(+), 28 deletions(-) diff --git a/docs/content/docs/rules/regular_rule.adoc b/docs/content/docs/rules/regular_rule.adoc index c44610ca2..c196f1012 100644 --- a/docs/content/docs/rules/regular_rule.adoc +++ b/docs/content/docs/rules/regular_rule.adoc @@ -32,7 +32,7 @@ The path expression describing the paths of incoming requests this rule is suppo ** *`backtracking_enabled`*: _boolean_ (optional) + -Whether to allow backtracking if a request is matched based on the `path` expression, but the additional matching conditions (see below) are not satisfied. Inherited from the default rule and defaults to the settings in that rule. +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. ** *`with`*: _MatchConditions_ (optional) + @@ -221,7 +221,11 @@ match: 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. If there are multiple rules with the same path expressions, their additional condition statements are executed in a sequence until one rule matches. If there are multiple matching rules, the first one 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 +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. @@ -266,32 +270,6 @@ The request to `/files/team3/document.pdf` will be matched by the `rule3` as it 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. -[CAUTION] -==== -Since multiple rules with the same path expression might be present in a rule set, multiple rules could be matched based on their additional conditions definitions. Here an example: - -[source, yaml] ----- -- id: rule1 - match: - path: /articles/:id - with: - methods: [ POST ] - execute: - - - -- id: rule2 - match: - path: /articles/:id - with: - methods: [ POST ] - execute: - - ----- - -Such conflicting configurations cannot be avoided while loading a rule set and there might be valid reasons to have different rules with more specific additional conditions for the same path expression as well. For that reason, heimdall will use the first matching rule (the order is given by the placement of the rules in a rule set) when the incoming request is matched by multiple rules and emit a corresponding log statement. -==== - == 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. From 92b368b4a46e879035f3fa8ac2097a33c502a87b Mon Sep 17 00:00:00 2001 From: Dimitrij Drus Date: Tue, 30 Apr 2024 19:29:33 +0200 Subject: [PATCH 75/76] example rule updated --- example_rules.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/example_rules.yaml b/example_rules.yaml index 49edb5f25..712a9851f 100644 --- a/example_rules.yaml +++ b/example_rules.yaml @@ -4,6 +4,7 @@ rules: - id: rule:foo match: path: /** + backtracking_enabled: false with: methods: - GET From 614709621ba9c8fa1d852c708fcea4207ecd6eb8 Mon Sep 17 00:00:00 2001 From: Dimitrij Drus Date: Tue, 30 Apr 2024 19:30:42 +0200 Subject: [PATCH 76/76] better config validation converade in tests --- internal/config/test_data/test_config.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/config/test_data/test_config.yaml b/internal/config/test_data/test_config.yaml index 2d098819e..194a9d78b 100644 --- a/internal/config/test_data/test_config.yaml +++ b/internal/config/test_data/test_config.yaml @@ -470,6 +470,7 @@ mechanisms: to: http://127.0.0.1:4433/self-service/login/browser?return_to={{ .Request.URL | urlenc }} default_rule: + backtracking_enabled: false execute: - authenticator: anonymous_authenticator - finalizer: jwt