Repository: owncloud/ocis
Pull request #2804 (2021-11-22 16:23:31)

Add (read-only) LDAP backend to Graph users/groups endpoints
## Description
This adds LDAP support to the Graph API's `/users` and `/groups` endpoints. In order to keep the existing CS3 based code working during the transition phase I created a simple configurable backend mechanism and move the CS3 implementation into it's own backend.

This is still just a read-only implemetation. Support of Create,Update, Delete will be implemented at a later point in time.

## How Has This Been Tested?
Manually using graph-explorer and an external LDAP Server
## Screenshots (if appropriate):

## Types of changes
<!--- What types of changes does your code introduce? Put an `x` in all the boxes that apply: -->
- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to change)
- [ ] Technical debt
- [ ] Tests only (no source changes)

## Checklist:
<!-- Tick the checkboxes when done. -->
<!-- Raise documentation ticket in owncloud.github.io/ -->
- [x] Code changes
- [x] Unit tests added
- [ ] Acceptance tests added
- [ ] Documentation ticket raised: <link> 


update-docs[bot]: Thanks for opening this pull request! The maintainers of this repository would appreciate it if you would create a [changelog](https://github.com/owncloud/ocis/blob/master/changelog/README.md) item based on your changes.

sonarcloud[bot]: SonarCloud Quality Gate failed.&nbsp; &nbsp; ![Quality Gate failed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/failed-16px.png 'Quality Gate failed')

[![Bug](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/bug-16px.png 'Bug')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=2804&resolved=false&types=BUG) [![C](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/C-16px.png 'C')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=2804&resolved=false&types=BUG) [6 Bugs](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=2804&resolved=false&types=BUG)  
[![Vulnerability](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/vulnerability-16px.png 'Vulnerability')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=2804&resolved=false&types=VULNERABILITY) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=2804&resolved=false&types=VULNERABILITY) [0 Vulnerabilities](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=2804&resolved=false&types=VULNERABILITY)  
[![Security Hotspot](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/security_hotspot-16px.png 'Security Hotspot')](https://sonarcloud.io/project/security_hotspots?id=owncloud_ocis&pullRequest=2804&resolved=false&types=SECURITY_HOTSPOT) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/security_hotspots?id=owncloud_ocis&pullRequest=2804&resolved=false&types=SECURITY_HOTSPOT) [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=owncloud_ocis&pullRequest=2804&resolved=false&types=SECURITY_HOTSPOT)  
[![Code Smell](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/code_smell-16px.png 'Code Smell')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=2804&resolved=false&types=CODE_SMELL) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=2804&resolved=false&types=CODE_SMELL) [14 Code Smells](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=2804&resolved=false&types=CODE_SMELL)

[![10.9%](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/CoverageChart/0-16px.png '10.9%')](https://sonarcloud.io/component_measures?id=owncloud_ocis&pullRequest=2804&metric=new_coverage&view=list) [10.9% Coverage](https://sonarcloud.io/component_measures?id=owncloud_ocis&pullRequest=2804&metric=new_coverage&view=list)  
[![11.8%](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/Duplications/20-16px.png '11.8%')](https://sonarcloud.io/component_measures?id=owncloud_ocis&pullRequest=2804&metric=new_duplicated_lines_density&view=list) [11.8% Duplication](https://sonarcloud.io/component_measures?id=owncloud_ocis&pullRequest=2804&metric=new_duplicated_lines_density&view=list)


sonarcloud[bot]: Kudos, SonarCloud Quality Gate passed!&nbsp; &nbsp; ![Quality Gate passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/passed-16px.png 'Quality Gate passed')

[![Bug](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/bug-16px.png 'Bug')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=2804&resolved=false&types=BUG) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=2804&resolved=false&types=BUG) [0 Bugs](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=2804&resolved=false&types=BUG)  
[![Vulnerability](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/vulnerability-16px.png 'Vulnerability')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=2804&resolved=false&types=VULNERABILITY) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=2804&resolved=false&types=VULNERABILITY) [0 Vulnerabilities](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=2804&resolved=false&types=VULNERABILITY)  
[![Security Hotspot](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/security_hotspot-16px.png 'Security Hotspot')](https://sonarcloud.io/project/security_hotspots?id=owncloud_ocis&pullRequest=2804&resolved=false&types=SECURITY_HOTSPOT) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/security_hotspots?id=owncloud_ocis&pullRequest=2804&resolved=false&types=SECURITY_HOTSPOT) [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=owncloud_ocis&pullRequest=2804&resolved=false&types=SECURITY_HOTSPOT)  
[![Code Smell](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/code_smell-16px.png 'Code Smell')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=2804&resolved=false&types=CODE_SMELL) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=2804&resolved=false&types=CODE_SMELL) [13 Code Smells](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=2804&resolved=false&types=CODE_SMELL)

[![8.8%](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/CoverageChart/0-16px.png '8.8%')](https://sonarcloud.io/component_measures?id=owncloud_ocis&pullRequest=2804&metric=new_coverage&view=list) [8.8% Coverage](https://sonarcloud.io/component_measures?id=owncloud_ocis&pullRequest=2804&metric=new_coverage&view=list)  
[![18.3%](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/Duplications/20-16px.png '18.3%')](https://sonarcloud.io/component_measures?id=owncloud_ocis&pullRequest=2804&metric=new_duplicated_lines_density&view=list) [18.3% Duplication](https://sonarcloud.io/component_measures?id=owncloud_ocis&pullRequest=2804&metric=new_duplicated_lines_density&view=list)



Patch:
diff --git a/go.mod b/go.mod
index 8a1c1b989ad..779b2dcd4aa 100644
--- a/go.mod
+++ b/go.mod
@@ -25,6 +25,7 @@ require (
 	github.com/go-chi/chi/v5 v5.0.7
 	github.com/go-chi/cors v1.2.0
 	github.com/go-chi/render v1.0.1
+	github.com/go-ldap/ldap/v3 v3.4.1
 	github.com/go-logr/logr v0.4.0
 	github.com/go-ozzo/ozzo-validation/v4 v4.3.0
 	github.com/gofrs/uuid v4.1.0+incompatible
@@ -132,7 +133,6 @@ require (
 	github.com/go-git/go-billy/v5 v5.3.1 // indirect
 	github.com/go-git/go-git/v5 v5.4.2 // indirect
 	github.com/go-kit/log v0.2.0 // indirect
-	github.com/go-ldap/ldap/v3 v3.4.1 // indirect
 	github.com/go-logfmt/logfmt v0.5.1 // indirect
 	github.com/go-sql-driver/mysql v1.6.0 // indirect
 	github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 // indirect
diff --git a/graph/pkg/config/config.go b/graph/pkg/config/config.go
index 71a52845518..0cf4a473599 100644
--- a/graph/pkg/config/config.go
+++ b/graph/pkg/config/config.go
@@ -52,6 +52,31 @@ type Spaces struct {
 	DefaultQuota string `ocisConfig:"default_quota"`
 }
 
+type LDAP struct {
+	URI          string `ocisConfig:"uri"`
+	BindDN       string `ocisConfig:"bind_dn"`
+	BindPassword string `ocisConfig:"bind_password"`
+
+	UserBaseDN               string `ocisConfig:"user_base_dn"`
+	UserSearchScope          string `ocisConfig:"user_search_scope"`
+	UserFilter               string `ocisConfig:"user_filter"`
+	UserEmailAttribute       string `ocisConfig:"user_mail_attribute"`
+	UserDisplayNameAttribute string `ocisConfig:"user_displayname_attribute"`
+	UserNameAttribute        string `ocisConfig:"user_name_attribute"`
+	UserIDAttribute          string `ocisConfig:"user_id_attribute"`
+
+	GroupBaseDN        string `ocisConfig:"group_base_dn"`
+	GroupSearchScope   string `ocisConfig:"group_search_scope"`
+	GroupFilter        string `ocisConfig:"group_filter"`
+	GroupNameAttribute string `ocisConfig:"group_name_attribute"`
+	GroupIDAttribute   string `ocisConfig:"group_id_attribute"`
+}
+
+type Identity struct {
+	Backend string `ocisConfig:"backend"`
+	LDAP    LDAP   `ocisConfig:"ldap"`
+}
+
 // Config combines all available configuration parts.
 type Config struct {
 	*shared.Commons
@@ -65,6 +90,7 @@ type Config struct {
 	Reva         Reva         `ocisConfig:"reva"`
 	TokenManager TokenManager `ocisConfig:"token_manager"`
 	Spaces       Spaces       `ocisConfig:"spaces"`
+	Identity     Identity     `ocisConfig:"identity"`
 
 	Context    context.Context
 	Supervised bool
@@ -103,5 +129,27 @@ func DefaultConfig() *Config {
 			WebDavPath:   "/dav/spaces/",
 			DefaultQuota: "1000000000",
 		},
+		Identity: Identity{
+			Backend: "cs3",
+			LDAP: LDAP{
+				URI:                      "ldap://localhost:9125",
+				BindDN:                   "",
+				BindPassword:             "",
+				UserBaseDN:               "ou=users,dc=ocis,dc=test",
+				UserSearchScope:          "sub",
+				UserFilter:               "(objectClass=posixaccount)",
+				UserEmailAttribute:       "mail",
+				UserDisplayNameAttribute: "displayName",
+				UserNameAttribute:        "uid",
+				// FIXME: switch this to some more widely available attribute by default
+				//        ideally this needs to	be constant for the lifetime of a users
+				UserIDAttribute:    "ownclouduuid",
+				GroupBaseDN:        "ou=groups,dc=ocis,dc=test",
+				GroupSearchScope:   "sub",
+				GroupFilter:        "(objectclass=groupOfNames)",
+				GroupNameAttribute: "cn",
+				GroupIDAttribute:   "cn",
+			},
+		},
 	}
 }
diff --git a/graph/pkg/config/mappings.go b/graph/pkg/config/mappings.go
index 2c74cc9f17b..6798e80687b 100644
--- a/graph/pkg/config/mappings.go
+++ b/graph/pkg/config/mappings.go
@@ -111,5 +111,69 @@ func structMappings(cfg *Config) []shared.EnvBinding {
 			EnvVars:     []string{"REVA_GATEWAY"},
 			Destination: &cfg.Reva.Address,
 		},
+		{
+			EnvVars:     []string{"GRAPH_IDENTITY_BACKEND"},
+			Destination: &cfg.Identity.Backend,
+		},
+		{
+			EnvVars:     []string{"GRAPH_LDAP_URI"},
+			Destination: &cfg.Identity.LDAP.URI,
+		},
+		{
+			EnvVars:     []string{"GRAPH_LDAP_BIND_DN"},
+			Destination: &cfg.Identity.LDAP.BindDN,
+		},
+		{
+			EnvVars:     []string{"GRAPH_LDAP_BIND_PASSWORD"},
+			Destination: &cfg.Identity.LDAP.BindPassword,
+		},
+		{
+			EnvVars:     []string{"GRAPH_LDAP_USER_BASE_DN"},
+			Destination: &cfg.Identity.LDAP.UserBaseDN,
+		},
+		{
+			EnvVars:     []string{"GRAPH_LDAP_USER_EMAIL_ATTRIBUTE"},
+			Destination: &cfg.Identity.LDAP.UserEmailAttribute,
+		},
+		{
+			EnvVars:     []string{"GRAPH_LDAP_USER_DISPLAYNAME_ATTRIBUTE"},
+			Destination: &cfg.Identity.LDAP.UserDisplayNameAttribute,
+		},
+		{
+			EnvVars:     []string{"GRAPH_LDAP_USER_NAME_ATTRIBUTE"},
+			Destination: &cfg.Identity.LDAP.UserNameAttribute,
+		},
+		{
+			EnvVars:     []string{"GRAPH_LDAP_USER_UID_ATTRIBUTE"},
+			Destination: &cfg.Identity.LDAP.UserIDAttribute,
+		},
+		{
+			EnvVars:     []string{"GRAPH_LDAP_USER_FILTER"},
+			Destination: &cfg.Identity.LDAP.UserFilter,
+		},
+		{
+			EnvVars:     []string{"GRAPH_LDAP_USER_SCOPE"},
+			Destination: &cfg.Identity.LDAP.UserSearchScope,
+		},
+		{
+			EnvVars:     []string{"GRAPH_LDAP_GROUP_BASE_DN"},
+			Destination: &cfg.Identity.LDAP.GroupBaseDN,
+		},
+		{
+			EnvVars:     []string{"GRAPH_LDAP_GROUP_SEARCH_SCOPE"},
+			Destination: &cfg.Identity.LDAP.GroupSearchScope,
+		},
+		{
+			EnvVars:     []string{"GRAPH_LDAP_GROUP_FILTER"},
+			Destination: &cfg.Identity.LDAP.GroupFilter,
+		},
+		{
+			EnvVars:     []string{"GRAPH_LDAP_GROUP_NAME_ATTRIBUTE"},
+			Destination: &cfg.Identity.LDAP.GroupNameAttribute,
+		},
+		{
+			EnvVars:     []string{"GRAPH_LDAP_GROUP_ID_ATTRIBUTE"},
+			Destination: &cfg.Identity.LDAP.GroupIDAttribute,
+		},
 	}
 }
diff --git a/graph/pkg/identity/backend.go b/graph/pkg/identity/backend.go
new file mode 100644
index 00000000000..b5e6620bf35
--- /dev/null
+++ b/graph/pkg/identity/backend.go
@@ -0,0 +1,40 @@
+package identity
+
+import (
+	"context"
+	"net/url"
+
+	cs3 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
+	msgraph "github.com/yaegashi/msgraph.go/beta"
+)
+
+type Backend interface {
+	GetUser(ctx context.Context, nameOrId string) (*msgraph.User, error)
+	GetUsers(ctx context.Context, queryParam url.Values) ([]*msgraph.User, error)
+
+	GetGroup(ctx context.Context, nameOrId string) (*msgraph.Group, error)
+	GetGroups(ctx context.Context, queryParam url.Values) ([]*msgraph.Group, error)
+}
+
+func CreateUserModelFromCS3(u *cs3.User) *msgraph.User {
+	if u.Id == nil {
+		u.Id = &cs3.UserId{}
+	}
+	return &msgraph.User{
+		DisplayName: &u.DisplayName,
+		Mail:        &u.Mail,
+		// TODO u.Groups are those ids or group names?
+		OnPremisesSamAccountName: &u.Username,
+		DirectoryObject: msgraph.DirectoryObject{
+			Entity: msgraph.Entity{
+				ID: &u.Id.OpaqueId,
+				Object: msgraph.Object{
+					AdditionalData: map[string]interface{}{
+						"uidnumber": u.UidNumber,
+						"gidnumber": u.GidNumber,
+					},
+				},
+			},
+		},
+	}
+}
diff --git a/graph/pkg/identity/cs3.go b/graph/pkg/identity/cs3.go
new file mode 100644
index 00000000000..b81f39fa505
--- /dev/null
+++ b/graph/pkg/identity/cs3.go
@@ -0,0 +1,169 @@
+package identity
+
+import (
+	"context"
+	"net/url"
+
+	cs3group "github.com/cs3org/go-cs3apis/cs3/identity/group/v1beta1"
+	cs3user "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
+	cs3rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
+	"github.com/cs3org/reva/pkg/rgrpc/todo/pool"
+	msgraph "github.com/yaegashi/msgraph.go/beta"
+
+	"github.com/owncloud/ocis/graph/pkg/config"
+	"github.com/owncloud/ocis/graph/pkg/service/v0/errorcode"
+	"github.com/owncloud/ocis/ocis-pkg/log"
+)
+
+type CS3 struct {
+	Config *config.Reva
+	Logger *log.Logger
+}
+
+func (i *CS3) GetUser(ctx context.Context, userID string) (*msgraph.User, error) {
+	client, err := pool.GetGatewayServiceClient(i.Config.Address)
+	if err != nil {
+		i.Logger.Error().Err(err).Msg("could not get client")
+		return nil, errorcode.New(errorcode.ServiceNotAvailable, err.Error())
+	}
+
+	res, err := client.GetUserByClaim(ctx, &cs3user.GetUserByClaimRequest{
+		Claim: "userid", // FIXME add consts to reva
+		Value: userID,
+	})
+
+	switch {
+	case err != nil:
+		i.Logger.Error().Err(err).Str("userid", userID).Msg("error sending get user by claim id grpc request")
+		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)
+		}
+		i.Logger.Error().Err(err).Str("userid", userID).Msg("error sending get user by claim id grpc request")
+		return nil, errorcode.New(errorcode.GeneralException, res.Status.Message)
+	}
+	return CreateUserModelFromCS3(res.User), nil
+}
+
+func (i *CS3) GetUsers(ctx context.Context, queryParam url.Values) ([]*msgraph.User, error) {
+	client, err := pool.GetGatewayServiceClient(i.Config.Address)
+	if err != nil {
+		i.Logger.Error().Err(err).Msg("could not get client")
+		return nil, errorcode.New(errorcode.ServiceNotAvailable, err.Error())
+	}
+
+	search := queryParam.Get("search")
+	if search == "" {
+		search = queryParam.Get("$search")
+	}
+
+	res, err := client.FindUsers(ctx, &cs3user.FindUsersRequest{
+		// FIXME presence match is currently not implemented, an empty search currently leads to
+		// Unwilling To Perform": Search Error: error parsing filter: (&(objectclass=posixAccount)(|(cn=*)(displayname=*)(mail=*))), error: Present filter match for cn not implemented
+		Filter: search,
+	})
+	switch {
+	case err != nil:
+		i.Logger.Error().Err(err).Str("search", search).Msg("error sending find users grpc request")
+		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)
+		}
+		i.Logger.Error().Err(err).Str("search", search).Msg("error sending find users grpc request")
+		return nil, errorcode.New(errorcode.GeneralException, res.Status.Message)
+	}
+
+	users := make([]*msgraph.User, 0, len(res.Users))
+
+	for _, user := range res.Users {
+		users = append(users, CreateUserModelFromCS3(user))
+	}
+
+	return users, nil
+}
+
+func (i *CS3) GetGroups(ctx context.Context, queryParam url.Values) ([]*msgraph.Group, error) {
+	client, err := pool.GetGatewayServiceClient(i.Config.Address)
+	if err != nil {
+		i.Logger.Error().Err(err).Msg("could not get client")
+		return nil, errorcode.New(errorcode.ServiceNotAvailable, err.Error())
+	}
+
+	search := queryParam.Get("search")
+	if search == "" {
+		search = queryParam.Get("$search")
+	}
+
+	res, err := client.FindGroups(ctx, &cs3group.FindGroupsRequest{
+		// FIXME presence match is currently not implemented, an empty search currently leads to
+		// Unwilling To Perform": Search Error: error parsing filter: (&(objectclass=posixAccount)(|(cn=*)(displayname=*)(mail=*))), error: Present filter match for cn not implemented
+		Filter: search,
+	})
+
+	switch {
+	case err != nil:
+		i.Logger.Error().Err(err).Str("search", search).Msg("error sending find groups grpc request")
+		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)
+		}
+		i.Logger.Error().Err(err).Str("search", search).Msg("error sending find groups grpc request")
+		return nil, errorcode.New(errorcode.GeneralException, res.Status.Message)
+	}
+
+	groups := make([]*msgraph.Group, 0, len(res.Groups))
+
+	for _, group := range res.Groups {
+		groups = append(groups, createGroupModelFromCS3(group))
+	}
+
+	return groups, nil
+}
+
+func (i *CS3) GetGroup(ctx context.Context, groupID string) (*msgraph.Group, error) {
+	client, err := pool.GetGatewayServiceClient(i.Config.Address)
+	if err != nil {
+		i.Logger.Error().Err(err).Msg("could not get client")
+		return nil, errorcode.New(errorcode.ServiceNotAvailable, err.Error())
+	}
+
+	res, err := client.GetGroupByClaim(ctx, &cs3group.GetGroupByClaimRequest{
+		Claim: "groupid", // FIXME add consts to reva
+		Value: groupID,
+	})
+
+	switch {
+	case err != nil:
+		i.Logger.Error().Err(err).Str("groupid", groupID).Msg("error sending get group by claim id grpc request")
+		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)
+		}
+		i.Logger.Error().Err(err).Str("groupid", groupID).Msg("error sending get group by claim id grpc request")
+		return nil, errorcode.New(errorcode.GeneralException, res.Status.Message)
+	}
+
+	return createGroupModelFromCS3(res.Group), nil
+}
+
+func createGroupModelFromCS3(g *cs3group.Group) *msgraph.Group {
+	if g.Id == nil {
+		g.Id = &cs3group.GroupId{}
+	}
+	return &msgraph.Group{
+		DirectoryObject: msgraph.DirectoryObject{
+			Entity: msgraph.Entity{
+				ID: &g.Id.OpaqueId,
+			},
+		},
+		OnPremisesDomainName:     &g.Id.Idp,
+		OnPremisesSamAccountName: &g.GroupName,
+		DisplayName:              &g.DisplayName,
+		Mail:                     &g.Mail,
+		// TODO when to fetch and expand memberof, usernames or ids?
+	}
+}
diff --git a/graph/pkg/identity/ldap.go b/graph/pkg/identity/ldap.go
new file mode 100644
index 00000000000..3114b008595
--- /dev/null
+++ b/graph/pkg/identity/ldap.go
@@ -0,0 +1,283 @@
+package identity
+
+import (
+	"context"
+	"fmt"
+	"net/url"
+
+	"github.com/go-ldap/ldap/v3"
+	msgraph "github.com/yaegashi/msgraph.go/beta"
+
+	"github.com/owncloud/ocis/graph/pkg/config"
+	"github.com/owncloud/ocis/graph/pkg/service/v0/errorcode"
+	"github.com/owncloud/ocis/ocis-pkg/log"
+)
+
+type LDAP struct {
+	userBaseDN       string
+	userFilter       string
+	userScope        int
+	userAttributeMap userAttributeMap
+
+	groupBaseDN       string
+	groupFilter       string
+	groupScope        int
+	groupAttributeMap groupAttributeMap
+
+	logger *log.Logger
+	conn   ldap.Client
+}
+
+type userAttributeMap struct {
+	displayName string
+	id          string
+	mail        string
+	userName    string
+}
+
+type groupAttributeMap struct {
+	name string
+	id   string
+}
+
+func NewLDAPBackend(lc ldap.Client, config config.LDAP, logger *log.Logger) (*LDAP, error) {
+	if config.UserDisplayNameAttribute == "" || config.UserIDAttribute == "" ||
+		config.UserEmailAttribute == "" || config.UserNameAttribute == "" {
+		return nil, fmt.Errorf("Invalid user attribute mappings")
+	}
+	uam := userAttributeMap{
+		displayName: config.UserDisplayNameAttribute,
+		id:          config.UserIDAttribute,
+		mail:        config.UserEmailAttribute,
+		userName:    config.UserNameAttribute,
+	}
+
+	if config.GroupNameAttribute == "" || config.GroupIDAttribute == "" {
+		return nil, fmt.Errorf("Invalid group attribute mappings")
+	}
+	gam := groupAttributeMap{
+		name: config.GroupNameAttribute,
+		id:   config.GroupIDAttribute,
+	}
+
+	var userScope, groupScope int
+	var err error
+	if userScope, err = stringToScope(config.UserSearchScope); err != nil {
+		return nil, fmt.Errorf("Error configuring user scope: %w", err)
+	}
+
+	if groupScope, err = stringToScope(config.GroupSearchScope); err != nil {
+		return nil, fmt.Errorf("Error configuring group scope: %w", err)
+	}
+
+	return &LDAP{
+		userBaseDN:        config.UserBaseDN,
+		userFilter:        config.UserFilter,
+		userScope:         userScope,
+		userAttributeMap:  uam,
+		groupBaseDN:       config.GroupBaseDN,
+		groupFilter:       config.GroupFilter,
+		groupScope:        groupScope,
+		groupAttributeMap: gam,
+		logger:            logger,
+		conn:              lc,
+	}, nil
+}
+
+func (i *LDAP) GetUser(ctx context.Context, userID string) (*msgraph.User, error) {
+	i.logger.Debug().Str("backend", "ldap").Msg("GetUser")
+	userID = ldap.EscapeFilter(userID)
+	searchRequest := ldap.NewSearchRequest(
+		i.userBaseDN, i.userScope, ldap.NeverDerefAliases, 1, 0, false,
+		fmt.Sprintf("(&%s(|(%s=%s)(%s=%s)))", i.userFilter, i.userAttributeMap.userName, userID, i.userAttributeMap.id, userID),
+		[]string{
+			i.userAttributeMap.displayName,
+			i.userAttributeMap.id,
+			i.userAttributeMap.mail,
+			i.userAttributeMap.userName,
+		},
+		nil,
+	)
+	i.logger.Debug().Str("backend", "ldap").Msgf("Search %s", i.userBaseDN)
+	res, err := i.conn.Search(searchRequest)
+
+	if err != nil {
+		var errmsg string
+		if lerr, ok := err.(*ldap.Error); ok {
+			if lerr.ResultCode == ldap.LDAPResultSizeLimitExceeded {
+				errmsg = fmt.Sprintf("too many results searching for user '%s'", userID)
+				i.logger.Debug().Str("backend", "ldap").Err(lerr).Msg(errmsg)
+			}
+		}
+		return nil, errorcode.New(errorcode.ItemNotFound, errmsg)
+	}
+	if len(res.Entries) == 0 {
+		return nil, errorcode.New(errorcode.ItemNotFound, "not found")
+	}
+
+	return i.createUserModelFromLDAP(res.Entries[0]), nil
+}
+
+func (i *LDAP) GetUsers(ctx context.Context, queryParam url.Values) ([]*msgraph.User, error) {
+	i.logger.Debug().Str("backend", "ldap").Msg("GetUsers")
+
+	search := queryParam.Get("search")
+	if search == "" {
+		search = queryParam.Get("$search")
+	}
+	userFilter := i.userFilter
+	if search != "" {
+		search = ldap.EscapeFilter(search)
+		userFilter = fmt.Sprintf(
+			"(&(%s)(|(%s=%s*)(%s=%s*)(%s=%s*)))",
+			userFilter,
+			i.userAttributeMap.userName, search,
+			i.userAttributeMap.mail, search,
+			i.userAttributeMap.displayName, search,
+		)
+	}
+	searchRequest := ldap.NewSearchRequest(
+		i.userBaseDN, i.userScope, ldap.NeverDerefAliases, 0, 0, false,
+		userFilter,
+		[]string{
+			i.userAttributeMap.displayName,
+			i.userAttributeMap.id,
+			i.userAttributeMap.mail,
+			i.userAttributeMap.userName,
+		},
+		nil,
+	)
+	i.logger.Debug().Str("backend", "ldap").Msgf("Search %s", i.userBaseDN)
+	res, err := i.conn.Search(searchRequest)
+	if err != nil {
+		return nil, errorcode.New(errorcode.ItemNotFound, err.Error())
+	}
+
+	users := make([]*msgraph.User, 0, len(res.Entries))
+
+	for _, e := range res.Entries {
+		users = append(users, i.createUserModelFromLDAP(e))
+	}
+	return users, nil
+}
+
+func (i *LDAP) GetGroup(ctx context.Context, groupID string) (*msgraph.Group, error) {
+	i.logger.Debug().Str("backend", "ldap").Msg("GetGroup")
+	groupID = ldap.EscapeFilter(groupID)
+	searchRequest := ldap.NewSearchRequest(
+		i.groupBaseDN, i.groupScope, ldap.NeverDerefAliases, 1, 0, false,
+		fmt.Sprintf("(&%s(|(%s=%s)(%s=%s)))", i.groupFilter, i.groupAttributeMap.name, groupID, i.groupAttributeMap.id, groupID),
+		[]string{
+			i.groupAttributeMap.name,
+			i.groupAttributeMap.id,
+		},
+		nil,
+	)
+	i.logger.Debug().Str("backend", "ldap").Msgf("Search %s", i.groupBaseDN)
+	res, err := i.conn.Search(searchRequest)
+
+	if err != nil {
+		var errmsg string
+		if lerr, ok := err.(*ldap.Error); ok {
+			if lerr.ResultCode == ldap.LDAPResultSizeLimitExceeded {
+				errmsg = fmt.Sprintf("too many results searching for group '%s'", groupID)
+				i.logger.Debug().Str("backend", "ldap").Err(lerr).Msg(errmsg)
+			}
+		}
+		return nil, errorcode.New(errorcode.ItemNotFound, errmsg)
+	}
+	if len(res.Entries) == 0 {
+		return nil, errorcode.New(errorcode.ItemNotFound, "not found")
+	}
+
+	return i.createGroupModelFromLDAP(res.Entries[0]), nil
+}
+
+func (i *LDAP) GetGroups(ctx context.Context, queryParam url.Values) ([]*msgraph.Group, error) {
+	i.logger.Debug().Str("backend", "ldap").Msg("GetGroups")
+
+	search := queryParam.Get("search")
+	if search == "" {
+		search = queryParam.Get("$search")
+	}
+	groupFilter := i.groupFilter
+	if search != "" {
+		search = ldap.EscapeFilter(search)
+		groupFilter = fmt.Sprintf(
+			"(&(%s)(|(%s=%s*)(%s=%s*)))",
+			groupFilter,
+			i.groupAttributeMap.name, search,
+			i.groupAttributeMap.id, search,
+		)
+	}
+	searchRequest := ldap.NewSearchRequest(
+		i.groupBaseDN, i.groupScope, ldap.NeverDerefAliases, 0, 0, false,
+		groupFilter,
+		[]string{
+			i.groupAttributeMap.name,
+			i.groupAttributeMap.id,
+		},
+		nil,
+	)
+	i.logger.Debug().Str("backend", "ldap").Str("Base", i.groupBaseDN).Str("filter", groupFilter).Msg("ldap search")
+	res, err := i.conn.Search(searchRequest)
+	if err != nil {
+		return nil, errorcode.New(errorcode.ItemNotFound, err.Error())
+	}
+
+	groups := make([]*msgraph.Group, 0, len(res.Entries))
+
+	for _, e := range res.Entries {
+		groups = append(groups, i.createGroupModelFromLDAP(e))
+	}
+	return groups, nil
+}
+
+func (i *LDAP) createUserModelFromLDAP(e *ldap.Entry) *msgraph.User {
+	if e == nil {
+		return nil
+	}
+	return &msgraph.User{
+		DisplayName:              pointerOrNil(e.GetEqualFoldAttributeValue(i.userAttributeMap.displayName)),
+		Mail:                     pointerOrNil(e.GetEqualFoldAttributeValue(i.userAttributeMap.mail)),
+		OnPremisesSamAccountName: pointerOrNil(e.GetEqualFoldAttributeValue(i.userAttributeMap.userName)),
+		DirectoryObject: msgraph.DirectoryObject{
+			Entity: msgraph.Entity{
+				ID: pointerOrNil(e.GetEqualFoldAttributeValue(i.userAttributeMap.id)),
+			},
+		},
+	}
+}
+
+func (i *LDAP) createGroupModelFromLDAP(e *ldap.Entry) *msgraph.Group {
+	return &msgraph.Group{
+		DisplayName:              pointerOrNil(e.GetEqualFoldAttributeValue(i.groupAttributeMap.name)),
+		OnPremisesSamAccountName: pointerOrNil(e.GetEqualFoldAttributeValue(i.groupAttributeMap.name)),
+		DirectoryObject: msgraph.DirectoryObject{
+			Entity: msgraph.Entity{
+				ID: pointerOrNil(e.GetEqualFoldAttributeValue(i.groupAttributeMap.id)),
+			},
+		},
+	}
+}
+func pointerOrNil(val string) *string {
+	if val == "" {
+		return nil
+	}
+	return &val
+}
+
+func stringToScope(scope string) (int, error) {
+	var s int
+	switch scope {
+	case "sub":
+		s = ldap.ScopeWholeSubtree
+	case "one":
+		s = ldap.ScopeSingleLevel
+	case "base":
+		s = ldap.ScopeBaseObject
+	default:
+		return 0, fmt.Errorf("Invalid Scope '%s'", scope)
+	}
+	return s, nil
+}
diff --git a/graph/pkg/identity/ldap/reconnect.go b/graph/pkg/identity/ldap/reconnect.go
new file mode 100644
index 00000000000..f929e57a560
--- /dev/null
+++ b/graph/pkg/identity/ldap/reconnect.go
@@ -0,0 +1,195 @@
+package ldap
+
+// LDAP automatic reconnection mechanism, inspired by:
+// https://gist.github.com/emsearcy/cba3295d1a06d4c432ab4f6173b65e4f#file-ldap_snippet-go
+
+import (
+	"crypto/tls"
+	"errors"
+	"fmt"
+	"time"
+
+	"github.com/go-ldap/ldap/v3"
+
+	"github.com/owncloud/ocis/ocis-pkg/log"
+)
+
+type ldapConnection struct {
+	Conn  *ldap.Conn
+	Error error
+}
+
+// Implements the ldap.CLient interface
+type ConnWithReconnect struct {
+	conn    chan ldapConnection
+	reset   chan *ldap.Conn
+	retries int
+	logger  *log.Logger
+}
+
+func NewLDAPWithReconnect(logger *log.Logger, ldapURI, bindDN, bindPassword string) ConnWithReconnect {
+	conn := ConnWithReconnect{
+		conn:    make(chan ldapConnection),
+		reset:   make(chan *ldap.Conn),
+		retries: 1,
+		logger:  logger,
+	}
+	go conn.ldapAutoConnect(ldapURI, bindDN, bindPassword)
+	return conn
+}
+
+func (c ConnWithReconnect) Search(sr *ldap.SearchRequest) (*ldap.SearchResult, error) {
+	conn, err := c.GetConnection()
+
+	if err != nil {
+		return nil, err
+	}
+
+	var res *ldap.SearchResult
+	for try := 0; try <= c.retries; try++ {
+		res, err = conn.Search(sr)
+		if !ldap.IsErrorWithCode(err, ldap.ErrorNetwork) {
+			// non network error, return it to the client
+			return res, err
+		}
+
+		c.logger.Debug().Msgf("Network Error. attempt %d", try)
+		conn, err = c.reconnect(conn)
+		if err != nil {
+			return nil, err
+		}
+		c.logger.Debug().Msg("retrying LDAP Search")
+	}
+	// if we get here we reached the maximum retries. So return an error
+	return nil, ldap.NewError(ldap.ErrorNetwork, errors.New("max retries"))
+}
+
+func (c ConnWithReconnect) GetConnection() (*ldap.Conn, error) {
+	conn := <-c.conn
+	if conn.Conn != nil && !ldap.IsErrorWithCode(conn.Error, ldap.ErrorNetwork) {
+		c.logger.Debug().Msg("using existing Connection")
+		return conn.Conn, conn.Error
+	}
+	return c.reconnect(conn.Conn)
+}
+
+func (c ConnWithReconnect) ldapAutoConnect(ldapURI, bindDN, bindPassword string) {
+	l, err := c.ldapConnect(ldapURI, bindDN, bindPassword)
+	if err != nil {
+		c.logger.Error().Err(err).Msg("autoconnect could not get ldap Connection")
+	}
+
+	for {
+		select {
+		case resConn := <-c.reset:
+			// Only close the connection and reconnect if the current
+			// connection, matches the one we got via the reset channel.
+			// If they differ we already reconnected
+			if l != nil && l == resConn {
+				c.logger.Debug().Msgf("closing connection %v", &l)
+				l.Close()
+			}
+			if l == resConn || l == nil {
+				c.logger.Debug().Msg("reconnecting to LDAP")
+				l, err = c.ldapConnect(ldapURI, bindDN, bindPassword)
+			} else {
+				c.logger.Debug().Msg("already reconnected")
+			}
+		case c.conn <- ldapConnection{l, err}:
+		}
+	}
+}
+
+func (c ConnWithReconnect) ldapConnect(ldapURI, bindDN, bindPassword string) (*ldap.Conn, error) {
+	c.logger.Debug().Msgf("Connecting to %s", ldapURI)
+	l, err := ldap.DialURL(ldapURI)
+	if err != nil {
+		c.logger.Error().Err(err).Msg("could not get ldap Connection")
+	} else {
+		c.logger.Debug().Msg("LDAP Connected")
+		if bindDN != "" {
+			c.logger.Debug().Msgf("Binding as %s", bindDN)
+			err = l.Bind(bindDN, bindPassword)
+			if err != nil {
+				c.logger.Error().Err(err).Msg("Bind failed")
+				l.Close()
+				return nil, err
+			}
+
+		}
+	}
+
+	return l, err
+}
+
+func (c ConnWithReconnect) reconnect(resetConn *ldap.Conn) (*ldap.Conn, error) {
+	c.logger.Debug().Msg("LDAP connection reset")
+	c.reset <- resetConn
+	c.logger.Debug().Msg("Waiting for new connection")
+	result := <-c.conn
+	return result.Conn, result.Error
+}
+
+// Remaining methods to fulfill ldap.Client interface
+
+func (c ConnWithReconnect) Start() {}
+
+func (c ConnWithReconnect) StartTLS(*tls.Config) error {
+	return ldap.NewError(ldap.LDAPResultNotSupported, fmt.Errorf("not implemented"))
+}
+
+func (c ConnWithReconnect) Close() {}
+
+func (c ConnWithReconnect) IsClosing() bool {
+	return false
+}
+
+func (c ConnWithReconnect) SetTimeout(time.Duration) {}
+
+func (c ConnWithReconnect) Bind(username, password string) error {
+	return ldap.NewError(ldap.LDAPResultNotSupported, fmt.Errorf("not implemented"))
+}
+
+func (c ConnWithReconnect) UnauthenticatedBind(username string) error {
+	return ldap.NewError(ldap.LDAPResultNotSupported, fmt.Errorf("not implemented"))
+}
+
+func (c ConnWithReconnect) SimpleBind(*ldap.SimpleBindRequest) (*ldap.SimpleBindResult, error) {
+	return nil, ldap.NewError(ldap.LDAPResultNotSupported, fmt.Errorf("not implemented"))
+}
+
+func (c ConnWithReconnect) ExternalBind() error {
+	return ldap.NewError(ldap.LDAPResultNotSupported, fmt.Errorf("not implemented"))
+}
+
+func (c ConnWithReconnect) Add(*ldap.AddRequest) error {
+	return ldap.NewError(ldap.LDAPResultNotSupported, fmt.Errorf("not implemented"))
+}
+
+func (c ConnWithReconnect) Del(*ldap.DelRequest) error {
+	return ldap.NewError(ldap.LDAPResultNotSupported, fmt.Errorf("not implemented"))
+}
+
+func (c ConnWithReconnect) Modify(*ldap.ModifyRequest) error {
+	return ldap.NewError(ldap.LDAPResultNotSupported, fmt.Errorf("not implemented"))
+}
+
+func (c ConnWithReconnect) ModifyDN(*ldap.ModifyDNRequest) error {
+	return ldap.NewError(ldap.LDAPResultNotSupported, fmt.Errorf("not implemented"))
+}
+
+func (c ConnWithReconnect) ModifyWithResult(*ldap.ModifyRequest) (*ldap.ModifyResult, error) {
+	return nil, ldap.NewError(ldap.LDAPResultNotSupported, fmt.Errorf("not implemented"))
+}
+
+func (c ConnWithReconnect) Compare(dn, attribute, value string) (bool, error) {
+	return false, ldap.NewError(ldap.LDAPResultNotSupported, fmt.Errorf("not implemented"))
+}
+
+func (c ConnWithReconnect) PasswordModify(*ldap.PasswordModifyRequest) (*ldap.PasswordModifyResult, error) {
+	return nil, ldap.NewError(ldap.LDAPResultNotSupported, fmt.Errorf("not implemented"))
+}
+
+func (c ConnWithReconnect) SearchWithPaging(searchRequest *ldap.SearchRequest, pagingSize uint32) (*ldap.SearchResult, error) {
+	return nil, ldap.NewError(ldap.LDAPResultNotSupported, fmt.Errorf("not implemented"))
+}
diff --git a/graph/pkg/service/v0/errorcode/errorcode.go b/graph/pkg/service/v0/errorcode/errorcode.go
index 108a1359856..23dc333e58b 100644
--- a/graph/pkg/service/v0/errorcode/errorcode.go
+++ b/graph/pkg/service/v0/errorcode/errorcode.go
@@ -12,6 +12,11 @@ import (
 // ErrorCode defines code as used in MS Graph - see https://docs.microsoft.com/en-us/graph/errors?context=graph%2Fapi%2F1.0&view=graph-rest-1.0
 type ErrorCode int
 
+type Error struct {
+	errorCode ErrorCode
+	msg       string
+}
+
 const (
 	// AccessDenied defines the error if the caller doesn't have permission to perform the action.
 	AccessDenied ErrorCode = iota
@@ -66,6 +71,13 @@ var errorCodes = [...]string{
 	"unauthenticated",
 }
 
+func New(e ErrorCode, msg string) Error {
+	return Error{
+		errorCode: e,
+		msg:       msg,
+	}
+}
+
 // Render writes an Graph ErrorObject to the response writer
 func (e ErrorCode) Render(w http.ResponseWriter, r *http.Request, status int, msg string) {
 	innererror := map[string]interface{}{
@@ -85,6 +97,18 @@ func (e ErrorCode) Render(w http.ResponseWriter, r *http.Request, status int, ms
 	render.JSON(w, r, resp)
 }
 
+func (e Error) Render(w http.ResponseWriter, r *http.Request) {
+	status := http.StatusInternalServerError
+	if e.errorCode == ItemNotFound {
+		status = http.StatusNotFound
+	}
+	e.errorCode.Render(w, r, status, e.msg)
+}
+
 func (e ErrorCode) String() string {
 	return errorCodes[e]
 }
+
+func (e Error) Error() string {
+	return errorCodes[e.errorCode]
+}
diff --git a/graph/pkg/service/v0/graph.go b/graph/pkg/service/v0/graph.go
index 51f5cc60420..251bd0edaa3 100644
--- a/graph/pkg/service/v0/graph.go
+++ b/graph/pkg/service/v0/graph.go
@@ -7,14 +7,16 @@ import (
 	"github.com/cs3org/reva/pkg/rgrpc/todo/pool"
 	"github.com/go-chi/chi/v5"
 	"github.com/owncloud/ocis/graph/pkg/config"
+	"github.com/owncloud/ocis/graph/pkg/identity"
 	"github.com/owncloud/ocis/ocis-pkg/log"
 )
 
 // Graph defines implements the business logic for Service.
 type Graph struct {
-	config *config.Config
-	mux    *chi.Mux
-	logger *log.Logger
+	config          *config.Config
+	mux             *chi.Mux
+	logger          *log.Logger
+	identityBackend identity.Backend
 }
 
 // ServeHTTP implements the Service interface.
@@ -27,15 +29,6 @@ func (g Graph) GetClient() (gateway.GatewayAPIClient, error) {
 	return pool.GetGatewayServiceClient(g.config.Reva.Address)
 }
 
-// The key type is unexported to prevent collisions with context keys defined in
-// other packages.
-type key int
-
-const (
-	userKey key = iota
-	groupKey
-)
-
 type listResponse struct {
 	Value interface{} `json:"value,omitempty"`
 }
diff --git a/graph/pkg/service/v0/groups.go b/graph/pkg/service/v0/groups.go
index 9677bdc08ae..01af9178ccc 100644
--- a/graph/pkg/service/v0/groups.go
+++ b/graph/pkg/service/v0/groups.go
@@ -1,129 +1,50 @@
 package svc
 
 import (
-	"context"
+	"errors"
 	"net/http"
 
-	cs3 "github.com/cs3org/go-cs3apis/cs3/identity/group/v1beta1"
-	cs3rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
 	"github.com/owncloud/ocis/graph/pkg/service/v0/errorcode"
 
 	"github.com/go-chi/chi/v5"
 	"github.com/go-chi/render"
-
 	//msgraph "github.com/owncloud/open-graph-api-go" // FIXME add groups to open graph, needs OnPremisesSamAccountName and OnPremisesDomainName
-	msgraph "github.com/yaegashi/msgraph.go/v1.0"
 )
 
-// GroupCtx middleware is used to load an User object from
-// the URL parameters passed through as the request. In case
-// the User could not be found, we stop here and return a 404.
-func (g Graph) GroupCtx(next http.Handler) http.Handler {
-	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		groupID := chi.URLParam(r, "groupID")
-		if groupID == "" {
-			errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing group id")
-			return
-		}
-
-		client, err := g.GetClient()
-		if err != nil {
-			g.logger.Error().Err(err).Msg("could not get client")
-			errorcode.ServiceNotAvailable.Render(w, r, http.StatusInternalServerError, err.Error())
-			return
-		}
-
-		res, err := client.GetGroupByClaim(r.Context(), &cs3.GetGroupByClaimRequest{
-			Claim: "groupid", // FIXME add consts to reva
-			Value: groupID,
-		})
-
-		switch {
-		case err != nil:
-			g.logger.Error().Err(err).Str("groupid", groupID).Msg("error sending get group by claim id grpc request")
-			errorcode.ServiceNotAvailable.Render(w, r, http.StatusInternalServerError, err.Error())
-			return
-		case res.Status.Code != cs3rpc.Code_CODE_OK:
-			if res.Status.Code == cs3rpc.Code_CODE_NOT_FOUND {
-				errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, res.Status.Message)
-				return
-			}
-			g.logger.Error().Err(err).Str("groupid", groupID).Msg("error sending get group by claim id grpc request")
-			errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, res.Status.Message)
-			return
-		}
-
-		ctx := context.WithValue(r.Context(), groupKey, res.Group)
-		next.ServeHTTP(w, r.WithContext(ctx))
-	})
-}
-
 // GetGroups implements the Service interface.
 func (g Graph) GetGroups(w http.ResponseWriter, r *http.Request) {
-	client, err := g.GetClient()
-	if err != nil {
-		g.logger.Error().Err(err).Msg("could not get client")
-		errorcode.ServiceNotAvailable.Render(w, r, http.StatusInternalServerError, err.Error())
-		return
-	}
-
-	search := r.URL.Query().Get("search")
-	if search == "" {
-		search = r.URL.Query().Get("$search")
-	}
+	groups, err := g.identityBackend.GetGroups(r.Context(), r.URL.Query())
 
-	res, err := client.FindGroups(r.Context(), &cs3.FindGroupsRequest{
-		// FIXME presence match is currently not implemented, an empty search currently leads to
-		// Unwilling To Perform": Search Error: error parsing filter: (&(objectclass=posixAccount)(|(cn=*)(displayname=*)(mail=*))), error: Present filter match for cn not implemented
-		Filter: search,
-	})
-	switch {
-	case err != nil:
-		g.logger.Error().Err(err).Str("search", search).Msg("error sending find groups grpc request")
-		errorcode.ServiceNotAvailable.Render(w, r, http.StatusInternalServerError, err.Error())
-		return
-	case res.Status.Code != cs3rpc.Code_CODE_OK:
-		if res.Status.Code == cs3rpc.Code_CODE_NOT_FOUND {
-			errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, res.Status.Message)
-			return
+	if err != nil {
+		var errcode errorcode.Error
+		if errors.As(err, &errcode) {
+			errcode.Render(w, r)
+		} else {
+			errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
 		}
-		g.logger.Error().Err(err).Str("search", search).Msg("error sending find groups grpc request")
-		errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, res.Status.Message)
-		return
-	}
-
-	groups := make([]*msgraph.Group, 0, len(res.Groups))
-
-	for _, group := range res.Groups {
-		groups = append(groups, createGroupModelFromCS3(group))
 	}
-
 	render.Status(r, http.StatusOK)
 	render.JSON(w, r, &listResponse{Value: groups})
 }
 
 // GetGroup implements the Service interface.
 func (g Graph) GetGroup(w http.ResponseWriter, r *http.Request) {
-	group := r.Context().Value(groupKey).(*cs3.Group)
-
-	render.Status(r, http.StatusOK)
-	render.JSON(w, r, createGroupModelFromCS3(group))
-}
-
-func createGroupModelFromCS3(g *cs3.Group) *msgraph.Group {
-	if g.Id == nil {
-		g.Id = &cs3.GroupId{}
+	groupID := chi.URLParam(r, "groupID")
+	if groupID == "" {
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing group id")
+		return
 	}
-	return &msgraph.Group{
-		DirectoryObject: msgraph.DirectoryObject{
-			Entity: msgraph.Entity{
-				ID: &g.Id.OpaqueId,
-			},
-		},
-		OnPremisesDomainName:     &g.Id.Idp,
-		OnPremisesSamAccountName: &g.GroupName,
-		DisplayName:              &g.DisplayName,
-		Mail:                     &g.Mail,
-		// TODO when to fetch and expand memberof, usernames or ids?
+
+	group, err := g.identityBackend.GetGroup(r.Context(), groupID)
+	if err != nil {
+		var errcode errorcode.Error
+		if errors.As(err, &errcode) {
+			errcode.Render(w, r)
+		} else {
+			errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
+		}
 	}
+
+	render.Status(r, http.StatusOK)
+	render.JSON(w, r, group)
 }
diff --git a/graph/pkg/service/v0/service.go b/graph/pkg/service/v0/service.go
index 192e97c8a61..e722d160b9c 100644
--- a/graph/pkg/service/v0/service.go
+++ b/graph/pkg/service/v0/service.go
@@ -5,6 +5,9 @@ import (
 
 	"github.com/go-chi/chi/v5"
 	"github.com/go-chi/chi/v5/middleware"
+
+	"github.com/owncloud/ocis/graph/pkg/identity"
+	"github.com/owncloud/ocis/graph/pkg/identity/ldap"
 	"github.com/owncloud/ocis/ocis-pkg/account"
 	opkgm "github.com/owncloud/ocis/ocis-pkg/middleware"
 )
@@ -24,10 +27,34 @@ func NewService(opts ...Option) Service {
 	m := chi.NewMux()
 	m.Use(options.Middleware...)
 
+	var backend identity.Backend
+	switch options.Config.Identity.Backend {
+	case "cs3":
+		backend = &identity.CS3{
+			Config: &options.Config.Reva,
+			Logger: &options.Logger,
+		}
+	case "ldap":
+		var err error
+		conn := ldap.NewLDAPWithReconnect(&options.Logger,
+			options.Config.Identity.LDAP.URI,
+			options.Config.Identity.LDAP.BindDN,
+			options.Config.Identity.LDAP.BindPassword,
+		)
+		if backend, err = identity.NewLDAPBackend(conn, options.Config.Identity.LDAP, &options.Logger); err != nil {
+			options.Logger.Error().Msgf("Error initializing LDAP Backend: '%s'", err)
+			return nil
+		}
+	default:
+		options.Logger.Error().Msgf("Unknown Identity Backend: '%s'", options.Config.Identity.Backend)
+		return nil
+	}
+
 	svc := Graph{
-		config: options.Config,
-		mux:    m,
-		logger: &options.Logger,
+		config:          options.Config,
+		mux:             m,
+		logger:          &options.Logger,
+		identityBackend: backend,
 	}
 
 	m.Route(options.Config.HTTP.Root, func(r chi.Router) {
@@ -41,14 +68,12 @@ func NewService(opts ...Option) Service {
 			r.Route("/users", func(r chi.Router) {
 				r.Get("/", svc.GetUsers)
 				r.Route("/{userID}", func(r chi.Router) {
-					r.Use(svc.UserCtx)
 					r.Get("/", svc.GetUser)
 				})
 			})
 			r.Route("/groups", func(r chi.Router) {
 				r.Get("/", svc.GetGroups)
 				r.Route("/{groupID}", func(r chi.Router) {
-					r.Use(svc.GroupCtx)
 					r.Get("/", svc.GetGroup)
 				})
 			})
diff --git a/graph/pkg/service/v0/users.go b/graph/pkg/service/v0/users.go
index 2567cb288d5..8c73736dc3b 100644
--- a/graph/pkg/service/v0/users.go
+++ b/graph/pkg/service/v0/users.go
@@ -1,65 +1,17 @@
 package svc
 
 import (
-	"context"
+	"errors"
 	"net/http"
 
-	cs3 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
-	cs3rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
 	revactx "github.com/cs3org/reva/pkg/ctx"
 	"github.com/go-chi/chi/v5"
 	"github.com/go-chi/render"
+	"github.com/owncloud/ocis/graph/pkg/identity"
 	"github.com/owncloud/ocis/graph/pkg/service/v0/errorcode"
-
 	//msgraph "github.com/owncloud/open-graph-api-go" // FIXME needs OnPremisesSamAccountName, OnPremisesDomainName and AdditionalData
-	msgraph "github.com/yaegashi/msgraph.go/v1.0"
 )
 
-// UserCtx middleware is used to load an User object from
-// the URL parameters passed through as the request. In case
-// the User could not be found, we stop here and return a 404.
-// TODO use cs3 api to look up user
-func (g Graph) UserCtx(next http.Handler) http.Handler {
-	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-
-		userID := chi.URLParam(r, "userID")
-		if userID == "" {
-			errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing user id")
-			return
-		}
-
-		client, err := g.GetClient()
-		if err != nil {
-			g.logger.Error().Err(err).Msg("could not get client")
-			errorcode.ServiceNotAvailable.Render(w, r, http.StatusInternalServerError, err.Error())
-			return
-		}
-
-		res, err := client.GetUserByClaim(r.Context(), &cs3.GetUserByClaimRequest{
-			Claim: "userid", // FIXME add consts to reva
-			Value: userID,
-		})
-
-		switch {
-		case err != nil:
-			g.logger.Error().Err(err).Str("userid", userID).Msg("error sending get user by claim id grpc request")
-			errorcode.ServiceNotAvailable.Render(w, r, http.StatusInternalServerError, err.Error())
-			return
-		case res.Status.Code != cs3rpc.Code_CODE_OK:
-			if res.Status.Code == cs3rpc.Code_CODE_NOT_FOUND {
-				errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, res.Status.Message)
-				return
-			}
-			g.logger.Error().Err(err).Str("userid", userID).Msg("error sending get user by claim id grpc request")
-			errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, res.Status.Message)
-			return
-		}
-
-		ctx := context.WithValue(r.Context(), userKey, res.User)
-		next.ServeHTTP(w, r.WithContext(ctx))
-	})
-}
-
 // GetMe implements the Service interface.
 func (g Graph) GetMe(w http.ResponseWriter, r *http.Request) {
 
@@ -72,7 +24,7 @@ func (g Graph) GetMe(w http.ResponseWriter, r *http.Request) {
 
 	g.logger.Info().Interface("user", u).Msg("User in /me")
 
-	me := createUserModelFromCS3(u)
+	me := identity.CreateUserModelFromCS3(u)
 
 	render.Status(r, http.StatusOK)
 	render.JSON(w, r, me)
@@ -81,76 +33,38 @@ func (g Graph) GetMe(w http.ResponseWriter, r *http.Request) {
 // GetUsers implements the Service interface.
 // TODO use cs3 api to look up user
 func (g Graph) GetUsers(w http.ResponseWriter, r *http.Request) {
-
-	client, err := g.GetClient()
+	users, err := g.identityBackend.GetUsers(r.Context(), r.URL.Query())
 	if err != nil {
-		g.logger.Error().Err(err).Msg("could not get client")
-		errorcode.ServiceNotAvailable.Render(w, r, http.StatusInternalServerError, err.Error())
-		return
-	}
-
-	search := r.URL.Query().Get("search")
-	if search == "" {
-		search = r.URL.Query().Get("$search")
-	}
-
-	res, err := client.FindUsers(r.Context(), &cs3.FindUsersRequest{
-		// FIXME presence match is currently not implemented, an empty search currently leads to
-		// Unwilling To Perform": Search Error: error parsing filter: (&(objectclass=posixAccount)(|(cn=*)(displayname=*)(mail=*))), error: Present filter match for cn not implemented
-		Filter: search,
-	})
-	switch {
-	case err != nil:
-		g.logger.Error().Err(err).Str("search", search).Msg("error sending find users grpc request")
-		errorcode.ServiceNotAvailable.Render(w, r, http.StatusInternalServerError, err.Error())
-		return
-	case res.Status.Code != cs3rpc.Code_CODE_OK:
-		if res.Status.Code == cs3rpc.Code_CODE_NOT_FOUND {
-			errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, res.Status.Message)
-			return
+		var errcode errorcode.Error
+		if errors.As(err, &errcode) {
+			errcode.Render(w, r)
+		} else {
+			errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
 		}
-		g.logger.Error().Err(err).Str("search", search).Msg("error sending find users grpc request")
-		errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, res.Status.Message)
-		return
-	}
-
-	users := make([]*msgraph.User, 0, len(res.Users))
-
-	for _, user := range res.Users {
-		users = append(users, createUserModelFromCS3(user))
 	}
-
 	render.Status(r, http.StatusOK)
 	render.JSON(w, r, &listResponse{Value: users})
 }
 
 // GetUser implements the Service interface.
 func (g Graph) GetUser(w http.ResponseWriter, r *http.Request) {
-	user := r.Context().Value(userKey).(*cs3.User)
+	userID := chi.URLParam(r, "userID")
+	if userID == "" {
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing user id")
+		return
+	}
 
-	render.Status(r, http.StatusOK)
-	render.JSON(w, r, createUserModelFromCS3(user))
-}
+	user, err := g.identityBackend.GetUser(r.Context(), userID)
 
-func createUserModelFromCS3(u *cs3.User) *msgraph.User {
-	if u.Id == nil {
-		u.Id = &cs3.UserId{}
-	}
-	return &msgraph.User{
-		DisplayName: &u.DisplayName,
-		Mail:        &u.Mail,
-		// TODO u.Groups are those ids or group names?
-		OnPremisesSamAccountName: &u.Username,
-		DirectoryObject: msgraph.DirectoryObject{
-			Entity: msgraph.Entity{
-				ID: &u.Id.OpaqueId,
-				Object: msgraph.Object{
-					AdditionalData: map[string]interface{}{
-						"uidnumber": u.UidNumber,
-						"gidnumber": u.GidNumber,
-					},
-				},
-			},
-		},
+	if err != nil {
+		var errcode errorcode.Error
+		if errors.As(err, &errcode) {
+			errcode.Render(w, r)
+		} else {
+			errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
+		}
 	}
+
+	render.Status(r, http.StatusOK)
+	render.JSON(w, r, user)
 }


Test patch:
diff --git a/graph/pkg/identity/ldap_test.go b/graph/pkg/identity/ldap_test.go
new file mode 100644
index 00000000000..41afac8a117
--- /dev/null
+++ b/graph/pkg/identity/ldap_test.go
@@ -0,0 +1,307 @@
+package identity
+
+import (
+	"context"
+	"crypto/tls"
+	"errors"
+	"fmt"
+	"net/url"
+	"testing"
+	"time"
+
+	"github.com/go-ldap/ldap/v3"
+	"github.com/owncloud/ocis/graph/pkg/config"
+	"github.com/owncloud/ocis/ocis-pkg/log"
+)
+
+// ldapMock implements the ldap.Client interfac
+type ldapMock struct {
+	SearchFunc *searchFunc
+}
+
+type searchFunc func(*ldap.SearchRequest) (*ldap.SearchResult, error)
+
+func getMockedBackend(sf *searchFunc, lc config.LDAP, logger *log.Logger) (*LDAP, error) {
+	// Mock a Sizelimit Error
+	lm := ldapMock{SearchFunc: sf}
+	return NewLDAPBackend(lm, lconfig, logger)
+}
+
+var lconfig = config.LDAP{
+	UserBaseDN:               "dc=test",
+	UserSearchScope:          "sub",
+	UserFilter:               "filter",
+	UserDisplayNameAttribute: "displayname",
+	UserIDAttribute:          "entryUUID",
+	UserEmailAttribute:       "mail",
+	UserNameAttribute:        "uid",
+
+	GroupBaseDN:        "dc=test",
+	GroupSearchScope:   "sub",
+	GroupFilter:        "filter",
+	GroupNameAttribute: "cn",
+	GroupIDAttribute:   "entryUUID",
+}
+
+var userEntry = ldap.NewEntry("uid=user",
+	map[string][]string{
+		"uid":         {"user"},
+		"displayname": {"DisplayName"},
+		"mail":        {"user@example"},
+		"entryuuid":   {"abcd-defg"},
+	})
+var groupEntry = ldap.NewEntry("cn=group",
+	map[string][]string{
+		"cn":        {"group"},
+		"entryuuid": {"abcd-defg"},
+	})
+
+var logger = log.NewLogger(log.Level("debug"))
+
+func TestNewLDAPBackend(t *testing.T) {
+
+	l := ldapMock{}
+
+	tc := lconfig
+	tc.UserDisplayNameAttribute = ""
+	if _, err := NewLDAPBackend(l, tc, &logger); err == nil {
+		t.Error("Should fail with incomplete user attr config")
+	}
+
+	tc = lconfig
+	tc.GroupIDAttribute = ""
+	if _, err := NewLDAPBackend(l, tc, &logger); err == nil {
+		t.Errorf("Should fail with incomplete group	config")
+	}
+
+	tc = lconfig
+	tc.UserSearchScope = ""
+	if _, err := NewLDAPBackend(l, tc, &logger); err == nil {
+		t.Errorf("Should fail with invalid user search scope")
+	}
+
+	tc = lconfig
+	tc.GroupSearchScope = ""
+	if _, err := NewLDAPBackend(l, tc, &logger); err == nil {
+		t.Errorf("Should fail with invalid group search scope")
+	}
+
+	if _, err := NewLDAPBackend(l, lconfig, &logger); err != nil {
+		t.Errorf("Should fail with invalid group search scope")
+	}
+
+}
+
+func TestCreateUserModelFromLDAP(t *testing.T) {
+	l := ldapMock{}
+	logger := log.NewLogger(log.Level("debug"))
+
+	b, _ := NewLDAPBackend(l, lconfig, &logger)
+	if user := b.createUserModelFromLDAP(nil); user != nil {
+		t.Errorf("createUserModelFromLDAP should return on nil Entry")
+	}
+	user := b.createUserModelFromLDAP(userEntry)
+	if user == nil {
+		t.Error("Converting a valid LDAP Entry should succeed")
+	} else {
+		if *user.OnPremisesSamAccountName != userEntry.GetEqualFoldAttributeValue(b.userAttributeMap.userName) {
+			t.Errorf("Error creating msGraph User from LDAP Entry: %s != %s", *user.OnPremisesSamAccountName, userEntry.GetEqualFoldAttributeValue(b.userAttributeMap.userName))
+		}
+		if *user.Mail != userEntry.GetEqualFoldAttributeValue(b.userAttributeMap.mail) {
+			t.Errorf("Error creating msGraph User from LDAP Entry: %s != %s", *user.Mail, userEntry.GetEqualFoldAttributeValue(b.userAttributeMap.mail))
+		}
+		if *user.DisplayName != userEntry.GetEqualFoldAttributeValue(b.userAttributeMap.displayName) {
+			t.Errorf("Error creating msGraph User from LDAP Entry: %s != %s", *user.DisplayName, userEntry.GetEqualFoldAttributeValue(b.userAttributeMap.displayName))
+		}
+		if *user.ID != userEntry.GetEqualFoldAttributeValue(b.userAttributeMap.id) {
+			t.Errorf("Error creating msGraph User from LDAP Entry: %s != %s", *user.ID, userEntry.GetEqualFoldAttributeValue(b.userAttributeMap.id))
+		}
+	}
+}
+
+func TestGetUser(t *testing.T) {
+	// Mock a Sizelimit Error
+	var sf searchFunc = func(*ldap.SearchRequest) (*ldap.SearchResult, error) {
+		return nil, ldap.NewError(ldap.LDAPResultSizeLimitExceeded, errors.New("mock"))
+	}
+	b, _ := getMockedBackend(&sf, lconfig, &logger)
+	_, err := b.GetUser(context.Background(), "fred")
+	if err == nil || err.Error() != "itemNotFound" {
+		t.Errorf("Expected 'itemNotFound' got '%s'", err.Error())
+	}
+
+	// Mock an empty Search Result
+	sf = func(*ldap.SearchRequest) (*ldap.SearchResult, error) {
+		return &ldap.SearchResult{}, nil
+	}
+	b, _ = getMockedBackend(&sf, lconfig, &logger)
+	_, err = b.GetUser(context.Background(), "fred")
+	if err == nil || err.Error() != "itemNotFound" {
+		t.Errorf("Expected 'itemNotFound' got '%s'", err.Error())
+	}
+
+	// Mock a valid	Search Result
+	sf = func(*ldap.SearchRequest) (*ldap.SearchResult, error) {
+		return &ldap.SearchResult{
+			Entries: []*ldap.Entry{userEntry},
+		}, nil
+	}
+	b, _ = getMockedBackend(&sf, lconfig, &logger)
+	u, err := b.GetUser(context.Background(), "user")
+	if err != nil {
+		t.Errorf("Expected GetUser to succeed. Got %s", err.Error())
+	} else if *u.ID != userEntry.GetEqualFoldAttributeValue(b.userAttributeMap.id) {
+		t.Errorf("Expected GetUser to return a valid user")
+	}
+}
+
+func TestGetUsers(t *testing.T) {
+	var sf searchFunc = func(*ldap.SearchRequest) (*ldap.SearchResult, error) {
+		return nil, ldap.NewError(ldap.LDAPResultOperationsError, errors.New("mock"))
+	}
+	b, _ := getMockedBackend(&sf, lconfig, &logger)
+	_, err := b.GetUsers(context.Background(), url.Values{})
+	if err == nil || err.Error() != "itemNotFound" {
+		t.Errorf("Expected 'itemNotFound' got '%s'", err.Error())
+	}
+
+	sf = func(*ldap.SearchRequest) (*ldap.SearchResult, error) {
+		return &ldap.SearchResult{}, nil
+	}
+	b, _ = getMockedBackend(&sf, lconfig, &logger)
+	g, err := b.GetUsers(context.Background(), url.Values{})
+	if err != nil {
+		t.Errorf("Expected success, got '%s'", err.Error())
+	} else if g == nil || len(g) != 0 {
+		t.Errorf("Expected zero length user slice")
+	}
+}
+
+func TestGetGroup(t *testing.T) {
+	// Mock a Sizelimit Error
+	var sf searchFunc = func(*ldap.SearchRequest) (*ldap.SearchResult, error) {
+		return nil, ldap.NewError(ldap.LDAPResultSizeLimitExceeded, errors.New("mock"))
+	}
+	b, _ := getMockedBackend(&sf, lconfig, &logger)
+	_, err := b.GetGroup(context.Background(), "group")
+	if err == nil || err.Error() != "itemNotFound" {
+		t.Errorf("Expected 'itemNotFound' got '%s'", err.Error())
+	}
+
+	// Mock an empty Search Result
+	sf = func(*ldap.SearchRequest) (*ldap.SearchResult, error) {
+		return &ldap.SearchResult{}, nil
+	}
+	b, _ = getMockedBackend(&sf, lconfig, &logger)
+	_, err = b.GetGroup(context.Background(), "group")
+	if err == nil || err.Error() != "itemNotFound" {
+		t.Errorf("Expected 'itemNotFound' got '%s'", err.Error())
+	}
+
+	// Mock a valid	Search Result
+	sf = func(*ldap.SearchRequest) (*ldap.SearchResult, error) {
+		return &ldap.SearchResult{
+			Entries: []*ldap.Entry{groupEntry},
+		}, nil
+	}
+	b, _ = getMockedBackend(&sf, lconfig, &logger)
+	g, err := b.GetGroup(context.Background(), "group")
+	if err != nil {
+		t.Errorf("Expected GetGroup to succeed. Got %s", err.Error())
+	} else if *g.ID != groupEntry.GetEqualFoldAttributeValue(b.groupAttributeMap.id) {
+		t.Errorf("Expected GetGroup to return a valid group")
+	}
+}
+
+func TestGetGroups(t *testing.T) {
+	var sf searchFunc = func(*ldap.SearchRequest) (*ldap.SearchResult, error) {
+		return nil, ldap.NewError(ldap.LDAPResultOperationsError, errors.New("mock"))
+	}
+	b, _ := getMockedBackend(&sf, lconfig, &logger)
+	_, err := b.GetGroups(context.Background(), url.Values{})
+	if err == nil || err.Error() != "itemNotFound" {
+		t.Errorf("Expected 'itemNotFound' got '%s'", err.Error())
+	}
+
+	sf = func(*ldap.SearchRequest) (*ldap.SearchResult, error) {
+		return &ldap.SearchResult{}, nil
+	}
+	b, _ = getMockedBackend(&sf, lconfig, &logger)
+	g, err := b.GetGroups(context.Background(), url.Values{})
+	if err != nil {
+		t.Errorf("Expected success, got '%s'", err.Error())
+	} else if g == nil || len(g) != 0 {
+		t.Errorf("Expected zero length user slice")
+	}
+}
+
+// below here ldap.Client interface method for ldapMock
+
+func (c ldapMock) Start() {}
+
+func (c ldapMock) StartTLS(*tls.Config) error {
+	return ldap.NewError(ldap.LDAPResultNotSupported, fmt.Errorf("not implemented"))
+}
+
+func (c ldapMock) Close() {}
+
+func (c ldapMock) IsClosing() bool {
+	return false
+}
+
+func (c ldapMock) SetTimeout(time.Duration) {}
+
+func (c ldapMock) Bind(username, password string) error {
+	return ldap.NewError(ldap.LDAPResultNotSupported, fmt.Errorf("not implemented"))
+}
+
+func (c ldapMock) UnauthenticatedBind(username string) error {
+	return ldap.NewError(ldap.LDAPResultNotSupported, fmt.Errorf("not implemented"))
+}
+
+func (c ldapMock) SimpleBind(*ldap.SimpleBindRequest) (*ldap.SimpleBindResult, error) {
+	return nil, ldap.NewError(ldap.LDAPResultNotSupported, fmt.Errorf("not implemented"))
+}
+
+func (c ldapMock) ExternalBind() error {
+	return ldap.NewError(ldap.LDAPResultNotSupported, fmt.Errorf("not implemented"))
+}
+
+func (c ldapMock) Add(*ldap.AddRequest) error {
+	return ldap.NewError(ldap.LDAPResultNotSupported, fmt.Errorf("not implemented"))
+}
+
+func (c ldapMock) Del(*ldap.DelRequest) error {
+	return ldap.NewError(ldap.LDAPResultNotSupported, fmt.Errorf("not implemented"))
+}
+
+func (c ldapMock) Modify(*ldap.ModifyRequest) error {
+	return ldap.NewError(ldap.LDAPResultNotSupported, fmt.Errorf("not implemented"))
+}
+
+func (c ldapMock) ModifyDN(*ldap.ModifyDNRequest) error {
+	return ldap.NewError(ldap.LDAPResultNotSupported, fmt.Errorf("not implemented"))
+}
+
+func (c ldapMock) ModifyWithResult(*ldap.ModifyRequest) (*ldap.ModifyResult, error) {
+	return nil, ldap.NewError(ldap.LDAPResultNotSupported, fmt.Errorf("not implemented"))
+}
+
+func (c ldapMock) Compare(dn, attribute, value string) (bool, error) {
+	return false, ldap.NewError(ldap.LDAPResultNotSupported, fmt.Errorf("not implemented"))
+}
+
+func (c ldapMock) PasswordModify(*ldap.PasswordModifyRequest) (*ldap.PasswordModifyResult, error) {
+	return nil, ldap.NewError(ldap.LDAPResultNotSupported, fmt.Errorf("not implemented"))
+}
+
+func (c ldapMock) Search(searchRequest *ldap.SearchRequest) (*ldap.SearchResult, error) {
+	if c.SearchFunc != nil {
+		return (*c.SearchFunc)(searchRequest)
+	}
+
+	return nil, ldap.NewError(ldap.LDAPResultNotSupported, fmt.Errorf("not implemented"))
+}
+func (c ldapMock) SearchWithPaging(searchRequest *ldap.SearchRequest, pagingSize uint32) (*ldap.SearchResult, error) {
+	return nil, ldap.NewError(ldap.LDAPResultNotSupported, fmt.Errorf("not implemented"))
+}