From 34cc7b2e562a5200ac0f00308e8cd7a0d1abc74b Mon Sep 17 00:00:00 2001 From: Ralf Haferkamp Date: Thu, 19 Sep 2024 16:32:38 +0200 Subject: [PATCH] feat(graph): Add $filter support for lastSuccessfulSignInDateTime It is now possible to filter users based on the lastSuccessfulSignInDateTime attribute using query filter like: '$filter=signInActivity/lastSuccessfulSignInDateTime le 2021-09-01T00:00:00Z' Note: This does only work with LDAP servers actually supporting '<=' filters. The built-in LDAP server (idm) does not support this feature. --- changelog/unreleased/ldap-signin-timestamp.md | 9 +++ services/graph/pkg/identity/backend.go | 4 + services/graph/pkg/identity/cs3.go | 13 +++- services/graph/pkg/identity/ldap.go | 74 +++++++++++++++++-- services/graph/pkg/identity/mocks/backend.go | 60 +++++++++++++++ services/graph/pkg/service/v0/users_filter.go | 7 ++ 6 files changed, 157 insertions(+), 10 deletions(-) diff --git a/changelog/unreleased/ldap-signin-timestamp.md b/changelog/unreleased/ldap-signin-timestamp.md index a773597367..b74dc390d1 100644 --- a/changelog/unreleased/ldap-signin-timestamp.md +++ b/changelog/unreleased/ldap-signin-timestamp.md @@ -3,4 +3,13 @@ Enhancement: allow to maintain the last sign-in timestamp of a user When the LDAP identity backend is configured to have write access to the database we're now able to maintain the ocLastSignInTimestamp attribute for the users. +This attribute is return in the 'signinActivity/lastSuccessfulSignInDateTime' +properity of the user objects. It is also possible to $filter on this attribute. + +Use e.g. '$filter=signinActivity/lastSuccessfulSignInDateTime le 2023-12-31T00:00:00Z' +to search for users that have not signed in since 2023-12-31. +Note: To use this type of filter the underlying LDAP server must support the +'<=' filter. Which is currently not the case of the built-in LDAP server (idm). + https://github.com/owncloud/ocis/pull/9942 +https://github.com/owncloud/ocis/pull/10111 diff --git a/services/graph/pkg/identity/backend.go b/services/graph/pkg/identity/backend.go index a5ff196d07..8f3d09686c 100644 --- a/services/graph/pkg/identity/backend.go +++ b/services/graph/pkg/identity/backend.go @@ -19,6 +19,8 @@ var ( ErrReadOnly = errorcode.New(errorcode.NotAllowed, "server is configured read-only") // ErrNotFound signals that the requested resource was not found. ErrNotFound = errorcode.New(errorcode.ItemNotFound, "not found") + // ErrUnsupportedFilter signals that the requested filter is not supported by the backend. + ErrUnsupportedFilter = godata.NotImplementedError("unsupported filter") ) const ( @@ -37,6 +39,8 @@ type Backend interface { UpdateUser(ctx context.Context, nameOrID string, user libregraph.UserUpdate) (*libregraph.User, error) GetUser(ctx context.Context, nameOrID string, oreq *godata.GoDataRequest) (*libregraph.User, error) GetUsers(ctx context.Context, oreq *godata.GoDataRequest) ([]*libregraph.User, error) + // FilterUsers returns a list of users that match the filter + FilterUsers(ctx context.Context, oreq *godata.GoDataRequest, filter *godata.ParseNode) ([]*libregraph.User, error) UpdateLastSignInDate(ctx context.Context, userID string, timestamp time.Time) error // CreateGroup creates the supplied group in the identity backend. diff --git a/services/graph/pkg/identity/cs3.go b/services/graph/pkg/identity/cs3.go index c00bb541e8..e188b4adaa 100644 --- a/services/graph/pkg/identity/cs3.go +++ b/services/graph/pkg/identity/cs3.go @@ -95,12 +95,12 @@ func (i *CS3) GetUsers(ctx context.Context, oreq *godata.GoDataRequest) ([]*libr case err != nil: logger.Error().Str("backend", "cs3").Err(err).Str("search", search).Msg("error sending find users grpc request: transport error") return nil, errorcode.New(errorcode.ServiceNotAvailable, err.Error()) - case res.Status.Code != cs3rpc.Code_CODE_OK: - if res.Status.Code == cs3rpc.Code_CODE_NOT_FOUND { - return nil, errorcode.New(errorcode.ItemNotFound, res.Status.Message) + case res.GetStatus().GetCode() != cs3rpc.Code_CODE_OK: + if res.GetStatus().GetCode() == cs3rpc.Code_CODE_NOT_FOUND { + return nil, errorcode.New(errorcode.ItemNotFound, res.GetStatus().GetMessage()) } logger.Debug().Str("backend", "cs3").Err(err).Str("search", search).Msg("error sending find users grpc request") - return nil, errorcode.New(errorcode.GeneralException, res.Status.Message) + return nil, errorcode.New(errorcode.GeneralException, res.GetStatus().GetMessage()) } users := make([]*libregraph.User, 0, len(res.GetUsers())) @@ -112,6 +112,11 @@ func (i *CS3) GetUsers(ctx context.Context, oreq *godata.GoDataRequest) ([]*libr return users, nil } +// FilterUsers implements the Backend Interface. It's currently not supported for the CS3 backend +func (i *CS3) FilterUsers(_ context.Context, _ *godata.GoDataRequest, _ *godata.ParseNode) ([]*libregraph.User, error) { + return nil, errNotImplemented +} + // UpdateLastSignInDate implements the Backend Interface. It's currently not supported for the CS3 backend func (i *CS3) UpdateLastSignInDate(ctx context.Context, userID string, timestamp time.Time) error { return errNotImplemented diff --git a/services/graph/pkg/identity/ldap.go b/services/graph/pkg/identity/ldap.go index 0f890c50e5..9df8a3d1ed 100644 --- a/services/graph/pkg/identity/ldap.go +++ b/services/graph/pkg/identity/ldap.go @@ -510,7 +510,7 @@ func (i *LDAP) getLDAPUserByNameOrID(nameOrID string) (*ldap.Entry, error) { idString, err := filterEscapeUUID(i.userIDisOctetString, nameOrID) // err != nil just means that this is not an uuid, so we can skip the uuid filter part // and just filter by name - filter := "" + var filter string if err == nil { filter = fmt.Sprintf("(|(%s=%s)(%s=%s))", i.userAttributeMap.userName, ldap.EscapeFilter(nameOrID), i.userAttributeMap.id, idString) } else { @@ -564,9 +564,19 @@ func (i *LDAP) GetUser(ctx context.Context, nameOrID string, oreq *godata.GoData // GetUsers implements the Backend Interface. func (i *LDAP) GetUsers(ctx context.Context, oreq *godata.GoDataRequest) ([]*libregraph.User, error) { + return i.FilterUsers(ctx, oreq, nil) +} + +// FilterUsers implements the Backend Interface. +func (i *LDAP) FilterUsers(ctx context.Context, oreq *godata.GoDataRequest, filter *godata.ParseNode) ([]*libregraph.User, error) { logger := i.logger.SubloggerWithRequestID(ctx) logger.Debug().Str("backend", "ldap").Msg("GetUsers") + queryFilter, err := i.oDataFilterToLDAPFilter(filter) + if err != nil { + return nil, err + } + search, err := GetSearchValues(oreq.Query) if err != nil { return nil, err @@ -587,7 +597,7 @@ func (i *LDAP) GetUsers(ctx context.Context, oreq *godata.GoDataRequest) ([]*lib i.userAttributeMap.displayName, search, ) } - userFilter = fmt.Sprintf("(&%s(objectClass=%s)%s)", i.userFilter, i.userObjectClass, userFilter) + userFilter = fmt.Sprintf("(&%s(objectClass=%s)%s%s)", i.userFilter, i.userObjectClass, queryFilter, userFilter) searchRequest := ldap.NewSearchRequest( i.userBaseDN, i.userScope, ldap.NeverDerefAliases, 0, 0, false, userFilter, @@ -612,13 +622,16 @@ func (i *LDAP) GetUsers(ctx context.Context, oreq *godata.GoDataRequest) ([]*lib return nil, i.mapLDAPError(err, errMap) } - users := make([]*libregraph.User, 0, len(res.Entries)) - usersEnabledState, err := i.usersEnabledState(res.Entries) + return i.usersFromLDAPEntries(res.Entries, exp) +} + +func (i *LDAP) usersFromLDAPEntries(entries []*ldap.Entry, exp []string) ([]*libregraph.User, error) { + usersEnabledState, err := i.usersEnabledState(entries) if err != nil { return nil, err } - - for _, e := range res.Entries { + users := make([]*libregraph.User, 0, len(entries)) + for _, e := range entries { u := i.createUserModelFromLDAP(e) // Skip invalid LDAP users if u == nil { @@ -1286,6 +1299,55 @@ func (i *LDAP) getLastSignTime(e *ldap.Entry) (*time.Time, error) { return &t, nil } +func (i *LDAP) oDataFilterToLDAPFilter(filter *godata.ParseNode) (string, error) { + if filter == nil { + return "", nil + } + + if filter.Token.Type != godata.ExpressionTokenLogical { + return "", ErrUnsupportedFilter + } + + if filter.Token.Value != "le" { + return "", ErrUnsupportedFilter + } + + if !isLastSuccessFullSignInDateTimeFilter(filter.Children[0]) { + return "", ErrUnsupportedFilter + } + + if filter.Children[1].Token.Type != godata.ExpressionTokenDateTime { + return "", ErrUnsupportedFilter + } + parsed, err := time.Parse(time.RFC3339, filter.Children[1].Token.Value) + if err != nil { + return "", godata.BadRequestError("invalid date format") + } + + ldapDateTime := parsed.UTC().Format(ldapDateFormat) + return fmt.Sprintf("(%s<=%s)", i.userAttributeMap.lastSignIn, ldap.EscapeFilter(ldapDateTime)), nil +} + +func isLastSuccessFullSignInDateTimeFilter(node *godata.ParseNode) bool { + if node.Token.Type != godata.ExpressionTokenNav { + return false + } + + if len(node.Children) != 2 { + return false + } + + if node.Children[0].Token.Value != "signInActivity" { + return false + } + + if node.Children[1].Token.Value != "lastSuccessfulSignInDateTime" { + return false + } + + return true +} + func isUserEnabledUpdate(user libregraph.UserUpdate) bool { switch { case user.Id != nil, user.DisplayName != nil, diff --git a/services/graph/pkg/identity/mocks/backend.go b/services/graph/pkg/identity/mocks/backend.go index 35ce4cabd3..3c2752647c 100644 --- a/services/graph/pkg/identity/mocks/backend.go +++ b/services/graph/pkg/identity/mocks/backend.go @@ -289,6 +289,66 @@ func (_c *Backend_DeleteUser_Call) RunAndReturn(run func(context.Context, string return _c } +// FilterUsers provides a mock function with given fields: ctx, oreq, filter +func (_m *Backend) FilterUsers(ctx context.Context, oreq *godata.GoDataRequest, filter *godata.ParseNode) ([]*libregraph.User, error) { + ret := _m.Called(ctx, oreq, filter) + + if len(ret) == 0 { + panic("no return value specified for FilterUsers") + } + + var r0 []*libregraph.User + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *godata.GoDataRequest, *godata.ParseNode) ([]*libregraph.User, error)); ok { + return rf(ctx, oreq, filter) + } + if rf, ok := ret.Get(0).(func(context.Context, *godata.GoDataRequest, *godata.ParseNode) []*libregraph.User); ok { + r0 = rf(ctx, oreq, filter) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*libregraph.User) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, *godata.GoDataRequest, *godata.ParseNode) error); ok { + r1 = rf(ctx, oreq, filter) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Backend_FilterUsers_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FilterUsers' +type Backend_FilterUsers_Call struct { + *mock.Call +} + +// FilterUsers is a helper method to define mock.On call +// - ctx context.Context +// - oreq *godata.GoDataRequest +// - filter *godata.ParseNode +func (_e *Backend_Expecter) FilterUsers(ctx interface{}, oreq interface{}, filter interface{}) *Backend_FilterUsers_Call { + return &Backend_FilterUsers_Call{Call: _e.mock.On("FilterUsers", ctx, oreq, filter)} +} + +func (_c *Backend_FilterUsers_Call) Run(run func(ctx context.Context, oreq *godata.GoDataRequest, filter *godata.ParseNode)) *Backend_FilterUsers_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(*godata.GoDataRequest), args[2].(*godata.ParseNode)) + }) + return _c +} + +func (_c *Backend_FilterUsers_Call) Return(_a0 []*libregraph.User, _a1 error) *Backend_FilterUsers_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *Backend_FilterUsers_Call) RunAndReturn(run func(context.Context, *godata.GoDataRequest, *godata.ParseNode) ([]*libregraph.User, error)) *Backend_FilterUsers_Call { + _c.Call.Return(run) + return _c +} + // GetGroup provides a mock function with given fields: ctx, nameOrID, queryParam func (_m *Backend) GetGroup(ctx context.Context, nameOrID string, queryParam url.Values) (*libregraph.Group, error) { ret := _m.Called(ctx, nameOrID, queryParam) diff --git a/services/graph/pkg/service/v0/users_filter.go b/services/graph/pkg/service/v0/users_filter.go index 40b90e9ca6..04cba45c37 100644 --- a/services/graph/pkg/service/v0/users_filter.go +++ b/services/graph/pkg/service/v0/users_filter.go @@ -137,6 +137,8 @@ func (g Graph) applyFilterLogical(ctx context.Context, req *godata.GoDataRequest return g.applyFilterLogicalOr(ctx, req, root.Children[0], root.Children[1]) case "eq": return g.applyFilterEq(ctx, req, root.Children[0], root.Children[1]) + case "le": + return g.applyFilterLessOrEqual(ctx, req, root) } logger.Debug().Str("Token", root.Token.Value).Msg("unsupported logical filter") return users, unsupportedFilterError() @@ -245,6 +247,11 @@ func (g Graph) applyFilterEq(ctx context.Context, req *godata.GoDataRequest, ope return users, unsupportedFilterError() } +func (g Graph) applyFilterLessOrEqual(ctx context.Context, req *godata.GoDataRequest, filterRoot *godata.ParseNode) (users []*libregraph.User, err error) { + // Assume that our identity backend is able to apply this filter + return g.identityBackend.FilterUsers(ctx, req, filterRoot) +} + 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 {