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.
This commit is contained in:
Ralf Haferkamp
2024-09-19 16:32:38 +02:00
committed by Ralf Haferkamp
parent 231128950f
commit 34cc7b2e56
6 changed files with 157 additions and 10 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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,

View File

@@ -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)

View File

@@ -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 {