diff --git a/services/graph/pkg/identity/backend.go b/services/graph/pkg/identity/backend.go index 4d2679773f..903dfc65b2 100644 --- a/services/graph/pkg/identity/backend.go +++ b/services/graph/pkg/identity/backend.go @@ -35,7 +35,8 @@ type Backend interface { DeleteGroup(ctx context.Context, id string) error GetGroup(ctx context.Context, nameOrID string, queryParam url.Values) (*libregraph.Group, error) GetGroups(ctx context.Context, queryParam url.Values) ([]*libregraph.Group, error) - GetGroupMembers(ctx context.Context, id string) ([]*libregraph.User, error) + // GetGroupMembers list all members of a group + GetGroupMembers(ctx context.Context, id string, oreq *godata.GoDataRequest) ([]*libregraph.User, error) // AddMembersToGroup adds new members (reference by a slice of IDs) to supplied group in the identity backend. AddMembersToGroup(ctx context.Context, groupID string, memberID []string) error // RemoveMemberFromGroup removes a single member (by ID) from a group diff --git a/services/graph/pkg/identity/cs3.go b/services/graph/pkg/identity/cs3.go index d6eab4029d..331ea1574f 100644 --- a/services/graph/pkg/identity/cs3.go +++ b/services/graph/pkg/identity/cs3.go @@ -193,7 +193,7 @@ func (i *CS3) DeleteGroup(ctx context.Context, id string) error { } // GetGroupMembers implements the Backend Interface. It's currently not supported for the CS3 backend -func (i *CS3) GetGroupMembers(ctx context.Context, groupID string) ([]*libregraph.User, error) { +func (i *CS3) GetGroupMembers(ctx context.Context, groupID string, _ *godata.GoDataRequest) ([]*libregraph.User, error) { return nil, errorcode.New(errorcode.NotSupported, "not implemented") } diff --git a/services/graph/pkg/identity/ldap_group.go b/services/graph/pkg/identity/ldap_group.go index e82e8d5682..786235cdc2 100644 --- a/services/graph/pkg/identity/ldap_group.go +++ b/services/graph/pkg/identity/ldap_group.go @@ -6,6 +6,7 @@ import ( "net/url" "strings" + "github.com/CiscoM31/godata" "github.com/go-ldap/ldap/v3" "github.com/gofrs/uuid" ldapdn "github.com/libregraph/idm/pkg/ldapdn" @@ -134,9 +135,15 @@ func (i *LDAP) GetGroups(ctx context.Context, queryParam url.Values) ([]*libregr } // GetGroupMembers implements the Backend Interface for the LDAP Backend -func (i *LDAP) GetGroupMembers(ctx context.Context, groupID string) ([]*libregraph.User, error) { +func (i *LDAP) GetGroupMembers(ctx context.Context, groupID string, req *godata.GoDataRequest) ([]*libregraph.User, error) { logger := i.logger.SubloggerWithRequestID(ctx) logger.Debug().Str("backend", "ldap").Msg("GetGroupMembers") + + exp, err := GetExpandValues(req.Query) + if err != nil { + return nil, err + } + e, err := i.getLDAPGroupByNameOrID(groupID, true) if err != nil { return nil, err @@ -149,6 +156,13 @@ func (i *LDAP) GetGroupMembers(ctx context.Context, groupID string) ([]*libregra } for _, member := range memberEntries { if u := i.createUserModelFromLDAP(member); u != nil { + if slices.Contains(exp, "memberOf") { + userGroups, err := i.getGroupsForUser(member.DN) + if err != nil { + return nil, err + } + u.MemberOf = i.groupsFromLDAPEntries(userGroups) + } result = append(result, u) } } diff --git a/services/graph/pkg/identity/mocks/backend.go b/services/graph/pkg/identity/mocks/backend.go index 9fcdcd8c19..89586889df 100644 --- a/services/graph/pkg/identity/mocks/backend.go +++ b/services/graph/pkg/identity/mocks/backend.go @@ -130,13 +130,13 @@ func (_m *Backend) GetGroup(ctx context.Context, nameOrID string, queryParam url return r0, r1 } -// GetGroupMembers provides a mock function with given fields: ctx, id -func (_m *Backend) GetGroupMembers(ctx context.Context, id string) ([]*libregraph.User, error) { - ret := _m.Called(ctx, id) +// GetGroupMembers provides a mock function with given fields: ctx, id, oreq +func (_m *Backend) GetGroupMembers(ctx context.Context, id string, oreq *godata.GoDataRequest) ([]*libregraph.User, error) { + ret := _m.Called(ctx, id, oreq) var r0 []*libregraph.User - if rf, ok := ret.Get(0).(func(context.Context, string) []*libregraph.User); ok { - r0 = rf(ctx, id) + if rf, ok := ret.Get(0).(func(context.Context, string, *godata.GoDataRequest) []*libregraph.User); ok { + r0 = rf(ctx, id, oreq) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*libregraph.User) @@ -144,8 +144,8 @@ func (_m *Backend) GetGroupMembers(ctx context.Context, id string) ([]*libregrap } var r1 error - if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { - r1 = rf(ctx, id) + if rf, ok := ret.Get(1).(func(context.Context, string, *godata.GoDataRequest) error); ok { + r1 = rf(ctx, id, oreq) } else { r1 = ret.Error(1) } diff --git a/services/graph/pkg/service/v0/groups.go b/services/graph/pkg/service/v0/groups.go index 65bf24bfe8..7ddfd995e2 100644 --- a/services/graph/pkg/service/v0/groups.go +++ b/services/graph/pkg/service/v0/groups.go @@ -259,6 +259,7 @@ func (g Graph) DeleteGroup(w http.ResponseWriter, r *http.Request) { func (g Graph) GetGroupMembers(w http.ResponseWriter, r *http.Request) { logger := g.logger.SubloggerWithRequestID(r.Context()) logger.Info().Msg("calling get group members") + sanitizedPath := strings.TrimPrefix(r.URL.Path, "/graph/v1.0/") groupID := chi.URLParam(r, "groupID") groupID, err := url.PathUnescape(groupID) if err != nil { @@ -273,8 +274,15 @@ func (g Graph) GetGroupMembers(w http.ResponseWriter, r *http.Request) { return } + odataReq, err := godata.ParseRequest(r.Context(), sanitizedPath, r.URL.Query()) + if err != nil { + logger.Debug().Err(err).Interface("query", r.URL.Query()).Msg("could not get users: query error") + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error()) + return + } + logger.Debug().Str("id", groupID).Msg("calling get group members on backend") - members, err := g.identityBackend.GetGroupMembers(r.Context(), groupID) + members, err := g.identityBackend.GetGroupMembers(r.Context(), groupID, odataReq) if err != nil { logger.Debug().Err(err).Msg("could not get group members: backend error") var errcode errorcode.Error diff --git a/services/graph/pkg/service/v0/users.go b/services/graph/pkg/service/v0/users.go index 2cdadf22bf..5d4beb6c46 100644 --- a/services/graph/pkg/service/v0/users.go +++ b/services/graph/pkg/service/v0/users.go @@ -201,13 +201,25 @@ func (g Graph) GetUsers(w http.ResponseWriter, r *http.Request) { } logger.Debug().Interface("query", r.URL.Query()).Msg("calling get users on backend") - users, err := g.identityBackend.GetUsers(r.Context(), odataReq) + + var users []*libregraph.User + + if odataReq.Query.Filter != nil { + users, err = g.applyUserFilter(r.Context(), odataReq, nil) + } else { + users, err = g.identityBackend.GetUsers(r.Context(), odataReq) + } + if err != nil { logger.Debug().Err(err).Interface("query", r.URL.Query()).Msg("could not get users from backend") var errcode errorcode.Error - if errors.As(err, &errcode) { + var godataerr *godata.GoDataError + switch { + case errors.As(err, &errcode): errcode.Render(w, r) - } else { + case errors.As(err, &godataerr): + errorcode.GeneralException.Render(w, r, godataerr.ResponseCode, err.Error()) + default: errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error()) } return diff --git a/services/graph/pkg/service/v0/users_filter.go b/services/graph/pkg/service/v0/users_filter.go new file mode 100644 index 0000000000..63a6579cd5 --- /dev/null +++ b/services/graph/pkg/service/v0/users_filter.go @@ -0,0 +1,153 @@ +package svc + +import ( + "context" + "strings" + + "github.com/CiscoM31/godata" + libregraph "github.com/owncloud/libre-graph-api-go" +) + +func invalidFilterError() error { + return godata.BadRequestError("invalid filter") +} + +func unsupportedFilterError() error { + return godata.NotImplementedError("unsupported filter") +} + +func (g Graph) applyUserFilter(ctx context.Context, req *godata.GoDataRequest, root *godata.ParseNode) (users []*libregraph.User, err error) { + logger := g.logger.SubloggerWithRequestID(ctx) + + if root == nil { + root = req.Query.Filter.Tree + } + + switch root.Token.Type { + case godata.ExpressionTokenLambdaNav: + return g.applyFilterLambda(ctx, req, root.Children) + case godata.ExpressionTokenLogical: + return g.applyFilterLogical(ctx, req, root) + } + logger.Debug().Str("filter", req.Query.Filter.RawValue).Msg("filter is not supported") + return users, unsupportedFilterError() +} + +func (g Graph) applyFilterLogical(ctx context.Context, req *godata.GoDataRequest, root *godata.ParseNode) (users []*libregraph.User, err error) { + logger := g.logger.SubloggerWithRequestID(ctx) + if root.Token.Type != godata.ExpressionTokenLogical { + return users, invalidFilterError() + } + + switch root.Token.Value { + case "and": + // 'and' needs 2 operands + if len(root.Children) != 2 { + return users, invalidFilterError() + } + return g.applyFilterLogicalAnd(ctx, req, root.Children[0], root.Children[1]) + } + logger.Debug().Str("Token", root.Token.Value).Msg("unsupported logical filter") + return users, unsupportedFilterError() +} + +func (g Graph) applyFilterLogicalAnd(ctx context.Context, req *godata.GoDataRequest, operand1 *godata.ParseNode, operand2 *godata.ParseNode) (users []*libregraph.User, err error) { + logger := g.logger.SubloggerWithRequestID(ctx) + results := make([][]*libregraph.User, 0, 2) + + for _, node := range []*godata.ParseNode{operand1, operand2} { + res, err := g.applyUserFilter(ctx, req, node) + if err != nil { + return users, err + } + logger.Debug().Interface("subfilter", res).Msg("result part") + results = append(results, res) + } + + // 'results' contains two slices of libregraph.Users now turn one of them + // into a map for efficiently getting the intersection of both slices + userSet := userSliceToMap(results[0]) + var filteredUsers []*libregraph.User + for _, user := range results[1] { + if _, found := userSet[user.GetId()]; found { + filteredUsers = append(filteredUsers, user) + } + } + return filteredUsers, nil +} + +func (g Graph) applyFilterLambda(ctx context.Context, req *godata.GoDataRequest, nodes []*godata.ParseNode) (users []*libregraph.User, err error) { + logger := g.logger.SubloggerWithRequestID(ctx) + if len(nodes) != 2 { + return users, invalidFilterError() + } + // We only support memberOf/any queries for now + if nodes[0].Token.Type != godata.ExpressionTokenLiteral || nodes[0].Token.Value != "memberOf" { + logger.Debug().Str("Token", nodes[0].Token.Value).Msg("unsupported relation for lambda filter") + return users, unsupportedFilterError() + } + if nodes[1].Token.Type != godata.ExpressionTokenLambda || nodes[1].Token.Value != "any" { + logger.Debug().Str("Token", nodes[1].Token.Value).Msg("unsupported lambda filter") + return users, unsupportedFilterError() + } + return g.applyLambdaMemberOfAny(ctx, req, nodes[1].Children) +} + +func (g Graph) applyLambdaMemberOfAny(ctx context.Context, req *godata.GoDataRequest, nodes []*godata.ParseNode) (users []*libregraph.User, err error) { + if len(nodes) != 2 { + return users, invalidFilterError() + } + + // First element is the "name" of the lambda function's parameter + if nodes[0].Token.Type != godata.ExpressionTokenLiteral { + return users, invalidFilterError() + } + + // We only support the 'eq' expression for now + if nodes[1].Token.Type != godata.ExpressionTokenLogical || nodes[1].Token.Value != "eq" { + return users, unsupportedFilterError() + } + return g.applyMemberOfEq(ctx, req, nodes[1].Children) +} + +func (g Graph) applyMemberOfEq(ctx context.Context, req *godata.GoDataRequest, nodes []*godata.ParseNode) (users []*libregraph.User, err error) { + logger := g.logger.SubloggerWithRequestID(ctx) + if len(nodes) != 2 { + return users, invalidFilterError() + } + + if nodes[0].Token.Type != godata.ExpressionTokenNav { + return users, invalidFilterError() + } + + if len(nodes[0].Children) != 2 { + return users, invalidFilterError() + } + + switch nodes[0].Children[1].Token.Value { + case "id": + var filterValue string + switch nodes[1].Token.Type { + case godata.ExpressionTokenGuid: + filterValue = nodes[1].Token.Value + case godata.ExpressionTokenString: + // unquote + filterValue = strings.Trim(nodes[1].Token.Value, "'") + default: + return users, unsupportedFilterError() + } + logger.Debug().Str("property", nodes[0].Children[1].Token.Value).Str("value", filterValue).Msg("Filtering memberOf by group id") + return g.identityBackend.GetGroupMembers(ctx, filterValue, req) + default: + return users, unsupportedFilterError() + } + +} + +func userSliceToMap(users []*libregraph.User) map[string]*libregraph.User { + resMap := make(map[string]*libregraph.User, len(users)) + for _, user := range users { + resMap[user.GetId()] = user + } + return resMap +} diff --git a/services/graph/pkg/service/v0/users_test.go b/services/graph/pkg/service/v0/users_test.go index 9f21aa1a19..870368d3da 100644 --- a/services/graph/pkg/service/v0/users_test.go +++ b/services/graph/pkg/service/v0/users_test.go @@ -7,6 +7,7 @@ import ( "io" "net/http" "net/http/httptest" + "net/url" userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" @@ -324,6 +325,43 @@ var _ = Describe("Users", func() { }) }) + DescribeTable("GetUsers handles unsupported or invalid filters", + func(filter string, status int) { + r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/users?$filter="+url.QueryEscape(filter), nil) + svc.GetUsers(rr, r) + + Expect(rr.Code).To(Equal(status)) + }, + Entry("with invalid filter", "invalid", http.StatusBadRequest), + Entry("with unsupported filter for user property", "mail eq 'unsupported'", http.StatusNotImplemented), + Entry("with unsupported filter operation", "mail add 10", http.StatusNotImplemented), + Entry("with unsupported logical operation", "memberOf/any(n:n/id eq 1) or memberOf/any(n:n/id eq 2)", http.StatusNotImplemented), + Entry("with unsupported lambda query ", `drives/any(n:n/id eq '1')`, http.StatusNotImplemented), + Entry("with unsupported lambda token ", "memberOf/all(n:n/id eq 1)", http.StatusNotImplemented), + Entry("with unsupported filter operation ", "memberOf/any(n:n/id ne 1)", http.StatusNotImplemented), + Entry("with unsupported filter operand type", "memberOf/any(n:n/id eq 1)", http.StatusNotImplemented), + Entry("with unsupported lambda filter property", "memberOf/any(n:n/name eq 'name')", http.StatusNotImplemented), + ) + + DescribeTable("With a valid memberOf filter", + func(filter string, status int) { + user := &libregraph.User{} + user.SetId("25cb7bc0-3168-4a0c-adbe-396f478ad494") + users := []*libregraph.User{user} + identityBackend.On("GetGroupMembers", mock.Anything, "25cb7bc0-3168-4a0c-adbe-396f478ad494", mock.Anything).Return(users, nil) + identityBackend.On("GetGroupMembers", mock.Anything, "2713f1d5-6822-42bd-ad56-9f6c55a3a8fa", mock.Anything).Return([]*libregraph.User{}, nil) + r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/users?$filter="+url.QueryEscape(filter), nil) + svc.GetUsers(rr, r) + + Expect(rr.Code).To(Equal(status)) + }, + Entry("with memberOf lambda filter with UUID", "memberOf/any(n:n/id eq 25cb7bc0-3168-4a0c-adbe-396f478ad494)", http.StatusOK), + Entry("with memberOf lambda filter with UUID string", "memberOf/any(n:n/id eq '25cb7bc0-3168-4a0c-adbe-396f478ad494')", http.StatusOK), + Entry("with two memberOf lambda filters", + "memberOf/any(n:n/id eq 25cb7bc0-3168-4a0c-adbe-396f478ad494) and memberOf/any(n:n/id eq 2713f1d5-6822-42bd-ad56-9f6c55a3a8fa)", + http.StatusOK), + ) + Describe("GetUser", func() { It("handles missing userids", func() { r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/users", nil) @@ -345,6 +383,7 @@ var _ = Describe("Users", func() { Expect(rr.Code).To(Equal(http.StatusOK)) data, err := io.ReadAll(rr.Body) + Expect(err).ToNot(HaveOccurred()) responseUser := &libregraph.User{} err = json.Unmarshal(data, &responseUser)