parse odata query and build ldap filter

Signed-off-by: Jörn Friedrich Dreyer <jfd@butonic.de>
This commit is contained in:
Jörn Friedrich Dreyer
2020-06-15 16:02:55 +02:00
parent 2fd05e2b6f
commit 810d2b6b34
5 changed files with 276 additions and 4 deletions
+180
View File
@@ -0,0 +1,180 @@
package provider
import (
"fmt"
"strings"
"github.com/CiscoM31/godata"
"github.com/owncloud/ocis-accounts/pkg/config"
"gopkg.in/ldap.v2"
)
func init() {
// add (ap)prox filter
godata.GlobalFilterTokenizer = FilterTokenizer()
godata.GlobalFilterParser.DefineOperator("ap", 2, godata.OpAssociationLeft, 4, false)
}
// LDAPNodeMap is used to convert query tokens into ldap filters according to https://tools.ietf.org/search/rfc4515
var LDAPNodeMap = map[string]string{
// 11.2.6.1.1 Built-in Filter Operations according to http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-protocol.html#_Toc31358949
// Comparison Operators
"eq": "(%s=%s)", // -> LDAP equal
"ne": "(!(%s=%s))", // -> LDAP NOT equal
//"gt": "(&(%s>=%s)(!(%s=%s)))", // -> TODO can be constructed but requires more parameters
"ge": "(%s>=%s)", // -> LDAP greaterorequal
//"lt": "(&(%s<=%s)(!(%s=%s)))", // -> TODO can be constructed but requires more parameters
"le": "(%s<=%s)", // -> LDAP lessorequal
//"has": "(%s=*)", // -> TODO LDAP present but in odata has looks like "Style has Sales.Color'Yellow'"
//"in": "???", // TODO
// additional native LDAP Search String Filter Definition according to https://tools.ietf.org/search/rfc4515#section-3
"ap": "(%s~=%s)", // approx, TODO needs token in parser, odata uses $search instead of $filter for fuzzy search
// Logical Operators
// While LDAP understands logical filters like (&()()()()) we leave that as an optimization and use at max two params
"and": "(&%s%s)",
"or": "(|%s%s)",
"not": "(!%s)",
// Arithmetic operators
//"add": ""
//"sub": ""
//"mul": ""
//"div": ""
//"divby": ""
//"mod": ""
// Grouping operators
// 11.2.6.1.2 Built-in Query Functions according to http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-protocol.html#sec_BuiltinQueryFunctions
//String and Collection Functions
//"concat": "CONCAT(%s,%s)",
"contains": "(%s=*%s*)",
"endswith": "(%s=*%s)",
//"indexof": "LOCATE(%s)",
//"length": "LENGTH(%s)",
"startswith": "(%s=%s*)",
//"substring": "",
//
//"tolower": "LOWER(%s)",
//"toupper": "UPPER(%s)",
//"trim": "TRIM(%s)",
//"year": "YEAR(%s)",
//"month": "MONTH(%s)",
//"day": "DAY(%s)",
//"hour": "HOUR(%s)",
//"minute": "MINUTE(%s)",
//"second": "SECOND(%s)",
//"fractionalsecond": "MICROSECOND(%s)",
//"date": "DATE(%s)",
//"time": "TIME(%s)",
//"totaloffsetminutes": "",
//"now": "NOW()",
//"maxdatetime":"",
//"mindatetime":"",
//"totalseconds":"",
//"round": "ROUND(%s)",
//"floor": "FLOOR(%s)",
//"ceiling": "CEIL(%s)",
//"isof": "", // TODO objectclass=
//"cast": "",
//"geo.distance": "",
//"geo.intersects": "",
//"geo.length": "",
//"any": "",
//"all": "",
//"null": "NULL",
}
// BuildLDAPFilter converts a GoDataFilterQuery into an ldap filter
func BuildLDAPFilter(r *godata.GoDataFilterQuery, c *config.LDAPSchema) (string, error) {
return recursiveBuildFilter(r.Tree, c)
}
// Builds the filter recursively using DFS
func recursiveBuildFilter(n *godata.ParseNode, c *config.LDAPSchema) (string, error) {
if n.Token.Type == godata.FilterTokenLiteral {
switch n.Token.Value {
case "accountid":
return c.AccountID, nil
case "displayname":
return c.DisplayName, nil
case "username":
return c.Username, nil
case "mail":
return c.Mail, nil
case "groups":
// TODO groups
return "", godata.NotImplementedError(n.Token.Value + " is not implemented.")
case "identities":
// TODO identities
return "", godata.NotImplementedError(n.Token.Value + " is not implemented.")
}
return "", godata.BadRequestError("unknown property " + n.Token.Value)
}
if n.Token.Type == godata.FilterTokenString {
// without leading and ending ' required by odata
// encode LDAP safe
return strings.TrimSuffix(strings.TrimPrefix(ldap.EscapeFilter(n.Token.Value), "'"), "'"), nil
}
if n.Token.Type == godata.FilterTokenInteger {
return n.Token.Value, nil
}
if n.Token.Type == godata.FilterTokenFloat {
return n.Token.Value, nil
}
if v, ok := LDAPNodeMap[n.Token.Value]; ok {
children := []interface{}{}
// build each child first using DFS
for _, child := range n.Children {
f, err := recursiveBuildFilter(child, c)
if err != nil {
return "", err
}
children = append(children, f)
}
// merge together the children and the current node
result := fmt.Sprintf(v, children...)
return result, nil
}
return "", godata.NotImplementedError(n.Token.Value + " is not implemented.")
}
// FilterTokenizer creates a tokenizer capable of tokenizing filter statements
// TODO disable tokens we don't handle anyway
func FilterTokenizer() *godata.Tokenizer {
t := godata.Tokenizer{}
t.Add("^[0-9]{4,4}-[0-9]{2,2}-[0-9]{2,2}T[0-9]{2,2}:[0-9]{2,2}(:[0-9]{2,2}(.[0-9]+)?)?(Z|[+-][0-9]{2,2}:[0-9]{2,2})", godata.FilterTokenDateTime)
t.Add("^-?[0-9]{4,4}-[0-9]{2,2}-[0-9]{2,2}", godata.FilterTokenDate)
t.Add("^[0-9]{2,2}:[0-9]{2,2}(:[0-9]{2,2}(.[0-9]+)?)?", godata.FilterTokenTime)
t.Add("^\\(", godata.FilterTokenOpenParen)
t.Add("^\\)", godata.FilterTokenCloseParen)
t.Add("^/", godata.FilterTokenNav)
t.Add("^:", godata.FilterTokenColon)
t.Add("^,", godata.FilterTokenComma)
t.Add("^(geo.distance|geo.intersects|geo.length)", godata.FilterTokenFunc)
t.Add("^(substringof|substring|length|indexof)", godata.FilterTokenFunc)
// only change from the global tokenizer is the added ap
t.Add("^(eq|ne|gt|ge|lt|le|and|or|not|has|in|ap)", godata.FilterTokenLogical)
t.Add("^(add|sub|mul|divby|div|mod)", godata.FilterTokenOp)
t.Add("^(contains|endswith|startswith|tolower|toupper|"+
"trim|concat|year|month|day|hour|minute|second|fractionalseconds|date|"+
"time|totaloffsetminutes|now|maxdatetime|mindatetime|totalseconds|round|"+
"floor|ceiling|isof|cast)", godata.FilterTokenFunc)
t.Add("^(any|all)", godata.FilterTokenLambda)
t.Add("^null", godata.FilterTokenNull)
t.Add("^\\$it", godata.FilterTokenIt)
t.Add("^\\$root", godata.FilterTokenRoot)
t.Add("^-?[0-9]+\\.[0-9]+", godata.FilterTokenFloat)
t.Add("^-?[0-9]+", godata.FilterTokenInteger)
t.Add("^'(''|[^'])*'", godata.FilterTokenString)
t.Add("^(true|false)", godata.FilterTokenBoolean)
t.Add("^@*[a-zA-Z][a-zA-Z0-9_.]*", godata.FilterTokenLiteral) // The optional '@' character is used to identify parameter aliases
t.Ignore("^ ", godata.FilterTokenWhitespace)
return &t
}
+68
View File
@@ -0,0 +1,68 @@
package provider
import (
"testing"
"github.com/CiscoM31/godata"
"github.com/owncloud/ocis-accounts/pkg/config"
)
var c *config.LDAPSchema
func init() {
c = &config.LDAPSchema{
AccountID: "ownclouduuid",
Username: "uid",
Mail: "mail",
DisplayName: "displayname",
}
}
func TestEQ(t *testing.T) { testLDAPFilters(t, "accountid eq 'a-b-c-d'", "(ownclouduuid=a-b-c-d)") }
func TestNE(t *testing.T) { testLDAPFilters(t, "mail ne 'foo@bar.com'", "(!(mail=foo@bar.com))") }
func TestGE(t *testing.T) { testLDAPFilters(t, "displayname ge 'marie'", "(displayname>=marie)") }
func TestLE(t *testing.T) { testLDAPFilters(t, "username le 'marie'", "(uid<=marie)") }
//func TestHas(t *testing.T) { testLDAPFilters(t, "Style has Sales.Color'Yellow'", "(foo=*)") }
func TestAP(t *testing.T) {
testLDAPFilters(t, "displayname ap 'einstein'", "(displayname~=einstein)")
}
func TestAND(t *testing.T) {
testLDAPFilters(t, "accountid le 500000 and accountid ge 300000", "(&(ownclouduuid<=500000)(ownclouduuid>=300000))")
}
func TestOR(t *testing.T) {
testLDAPFilters(t, "accountid le 700000 or accountid ge 900000", "(|(ownclouduuid<=700000)(ownclouduuid>=900000))")
}
func TestNOT(t *testing.T) {
// not operator takes precedence over ap, so we need brackets
testLDAPFilters(t, "not ( displayname ap 'einstein' )", "(!(displayname~=einstein))")
}
func TestContains(t *testing.T) {
testLDAPFilters(t, "contains(username,'eins')", "(uid=*eins*)")
}
func TestStartsWith(t *testing.T) {
testLDAPFilters(t, "startswith(username,'eins')", "(uid=eins*)")
}
func TestEndsWith(t *testing.T) {
testLDAPFilters(t, "endswith(username,'eins')", "(uid=*eins)")
}
func TestEncoding(t *testing.T) {
testLDAPFilters(t, "displayname eq 'eins(*)tein'", "(displayname=eins\\28\\2a\\29tein)")
}
func testLDAPFilters(t *testing.T, have string, want string) {
var err error
var q *godata.GoDataFilterQuery
if q, err = godata.ParseFilterString(have); err != nil {
t.Error(err)
}
var filter string
if filter, err = BuildLDAPFilter(q, c); err != nil {
t.Error(err)
}
if filter != want {
t.Error("expected", want, "for", have, "but got", filter)
}
}
+25 -4
View File
@@ -6,10 +6,12 @@ import (
"errors"
"fmt"
"github.com/CiscoM31/godata"
"github.com/golang/protobuf/ptypes/empty"
mclient "github.com/micro/go-micro/v2/client"
"github.com/owncloud/ocis-accounts/pkg/config"
"github.com/owncloud/ocis-accounts/pkg/proto/v0"
"github.com/owncloud/ocis-accounts/pkg/provider"
olog "github.com/owncloud/ocis-pkg/v2/log"
settings "github.com/owncloud/ocis-settings/pkg/proto/v0"
"github.com/rs/zerolog/log"
@@ -31,9 +33,30 @@ type Service struct {
}
// ListAccounts implements the AccountsServiceHandler interface
func (s Service) ListAccounts(ctx context.Context, in *proto.ListAccountsRequest, res *proto.ListAccountsResponse) error {
func (s Service) ListAccounts(ctx context.Context, in *proto.ListAccountsRequest, res *proto.ListAccountsResponse) (err error) {
l, err := ldap.DialTLS("tcp", fmt.Sprintf("%s:%d", s.Config.LDAP.Hostname, s.Config.LDAP.Port), &tls.Config{InsecureSkipVerify: true})
log.Debug().Str("query", in.Query).Int32("page-size", in.PageSize).Str("page-token", in.PageToken).Msg("ListAccounts")
filter := "(&)" // see Absolute True and False Filters in https://tools.ietf.org/html/rfc4526#section-2
if in.Query != "" {
// parse the query like an odata filter
var q *godata.GoDataFilterQuery
if q, err = godata.ParseFilterString(in.Query); err != nil {
return err
}
// convert to ldap filter
filter, err = provider.BuildLDAPFilter(q, &s.Config.LDAP.Schema)
if err != nil {
return err
}
}
log.Debug().Str("filter", filter).Msg("using filter")
var l *ldap.Conn
l, err = ldap.DialTLS("tcp", fmt.Sprintf("%s:%d", s.Config.LDAP.Hostname, s.Config.LDAP.Port), &tls.Config{InsecureSkipVerify: true})
if err != nil {
return err
}
@@ -45,9 +68,7 @@ func (s Service) ListAccounts(ctx context.Context, in *proto.ListAccountsRequest
return err
}
// TODO parse query using https://github.com/araddon/qlbridge
// TODO combine the parsed query with a query filter from the config, eg. fmt.Sprintf(s.Config.LDAP.UserFilter, clientID)
filter := "(&)" // see Absolute True and False Filters in https://tools.ietf.org/html/rfc4526#section-2
// Search for the given clientID
searchRequest := ldap.NewSearchRequest(