Repository: owncloud/ocis
Pull request #5385 (2023-01-12 10:25:42)

[full-ci] graph education/classes: Add LDAP backend support for Create,Get,Delete
## Description

This implements the LDAP backend pieces for the education/classes endpoints:

Currently working:
- creating classes
   ``` 
   curl -X 'POST' \
     'https://localhost:9200/graph/v1.0/education/classes' \
     -H 'Authorization: Bearer a-secret-token' \
     -d '{
     "displayName": "Mathematik",
     "classification": "course",
     "externalId": "1234Mathematik"
   }'
   ```
- deleting classes
   ```
   curl -X 'DELETE' \
      'https://localhost:9200/graph/v1.0/education/classes/86948e45-96a6-43df-b83d-46e92afd30de' \
      -H 'Authorization: Bearer a-secret-token'
   ```
- listing all classes
   ```
   curl -X 'GET' \
     'https://localhost:9200/graph/v1.0/education/classes' \
      -H 'Authorization: Bearer a-secret-token'
   ```
- reading a single class
   ```
   curl -X 'GET' \
     'https://localhost:9200/graph/v1.0/education/classes/86948e45-96a6-43df-b83d-46e92afd30de' \
      -H 'Authorization: Bearer a-secret-token'
   ```
- adding single members to a class
   ```
   curl -X 'POST' \
     'https://localhost:9200/graph/v1.0/education/classes/86948e45-96a6-43df-b83d-46e92afd30de/members/$ref' \
     -H 'Authorization: Bearer a-secret-token' \
     -d '{
        "@odata.id": "https://localhost:9200/graph/v1.0/education/users/90eedea1-dea1-90ee-a1de-ee90a1deee90"
      }'
   ```
- removing single members from a class
   ```
   curl -X 'DELETE' \
     'https://localhost:9200/graph/v1.0/education/classes/86948e45-96a6-43df-b83d-46e92afd30de/members/90eedea1-dea1-90ee-a1de-ee90a1deee90/$ref' \
     -H 'Authorization: Bearer a-secret-token'
   ```

- adding multiple members to a class
   ```
   curl -X 'GET' \
      'https://localhost:9200/graph/v1.0/education/classes/86948e45-96a6-43df-b83d-46e92afd30de' \
      -H 'Authorization: Bearer a-secret-token'
      -d '{
         "members@odata.bind": [
            "https://localhost:9200/graph/v1.0/education/users/90eedea1-dea1-90ee-a1de-ee90a1deee90",
            "https://localhost:9200/graph/v1.0/education/users/6363bb5b-5559-4125-832a-d1a1c1819136"
         ]
      }'
   ```

- listing all members of  a class
   ```
   curl -X 'GET' \
  'https://localhost:9200/graph/v1.0/education/classes/86948e45-96a6-43df-b83d-46e92afd30de/members' \
  -H 'accept: application/json' \
  -H 'Authorization: Bearer a-secret-token'
   ```

A couple of thing are still missing. I think it's best to tackle them in separate issues:
- updating class properties (displayName and externaId)
- adding/removing teachers to classes
- listing teachers of a class
- assigning/unassigning  a class to/from a school



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')](https://sonarcloud.io/dashboard?id=owncloud_ocis&pullRequest=5385)

[![Bug](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/bug-16px.png 'Bug')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5385&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=5385&resolved=false&types=BUG) [7 Bugs](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5385&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=5385&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=5385&resolved=false&types=VULNERABILITY) [0 Vulnerabilities](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5385&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=5385&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=5385&resolved=false&types=SECURITY_HOTSPOT) [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=owncloud_ocis&pullRequest=5385&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=5385&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=5385&resolved=false&types=CODE_SMELL) [24 Code Smells](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5385&resolved=false&types=CODE_SMELL)

[![62.3%](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/CoverageChart/60-16px.png '62.3%')](https://sonarcloud.io/component_measures?id=owncloud_ocis&pullRequest=5385&metric=new_coverage&view=list) [62.3% Coverage](https://sonarcloud.io/component_measures?id=owncloud_ocis&pullRequest=5385&metric=new_coverage&view=list)  
[![3.2%](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/Duplications/5-16px.png '3.2%')](https://sonarcloud.io/component_measures?id=owncloud_ocis&pullRequest=5385&metric=new_duplicated_lines_density&view=list) [3.2% Duplication](https://sonarcloud.io/component_measures?id=owncloud_ocis&pullRequest=5385&metric=new_duplicated_lines_density&view=list)


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')](https://sonarcloud.io/dashboard?id=owncloud_ocis&pullRequest=5385)

[![Bug](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/bug-16px.png 'Bug')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5385&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=5385&resolved=false&types=BUG) [1 Bug](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5385&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=5385&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=5385&resolved=false&types=VULNERABILITY) [0 Vulnerabilities](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5385&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=5385&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=5385&resolved=false&types=SECURITY_HOTSPOT) [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=owncloud_ocis&pullRequest=5385&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=5385&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=5385&resolved=false&types=CODE_SMELL) [24 Code Smells](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5385&resolved=false&types=CODE_SMELL)

[![62.2%](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/CoverageChart/60-16px.png '62.2%')](https://sonarcloud.io/component_measures?id=owncloud_ocis&pullRequest=5385&metric=new_coverage&view=list) [62.2% Coverage](https://sonarcloud.io/component_measures?id=owncloud_ocis&pullRequest=5385&metric=new_coverage&view=list)  
[![3.2%](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/Duplications/5-16px.png '3.2%')](https://sonarcloud.io/component_measures?id=owncloud_ocis&pullRequest=5385&metric=new_duplicated_lines_density&view=list) [3.2% Duplication](https://sonarcloud.io/component_measures?id=owncloud_ocis&pullRequest=5385&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')](https://sonarcloud.io/dashboard?id=owncloud_ocis&pullRequest=5385)

[![Bug](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/bug-16px.png 'Bug')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5385&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=5385&resolved=false&types=BUG) [0 Bugs](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5385&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=5385&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=5385&resolved=false&types=VULNERABILITY) [0 Vulnerabilities](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5385&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=5385&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=5385&resolved=false&types=SECURITY_HOTSPOT) [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=owncloud_ocis&pullRequest=5385&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=5385&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=5385&resolved=false&types=CODE_SMELL) [24 Code Smells](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5385&resolved=false&types=CODE_SMELL)

[![62.2%](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/CoverageChart/60-16px.png '62.2%')](https://sonarcloud.io/component_measures?id=owncloud_ocis&pullRequest=5385&metric=new_coverage&view=list) [62.2% Coverage](https://sonarcloud.io/component_measures?id=owncloud_ocis&pullRequest=5385&metric=new_coverage&view=list)  
[![3.2%](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/Duplications/5-16px.png '3.2%')](https://sonarcloud.io/component_measures?id=owncloud_ocis&pullRequest=5385&metric=new_duplicated_lines_density&view=list) [3.2% Duplication](https://sonarcloud.io/component_measures?id=owncloud_ocis&pullRequest=5385&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')](https://sonarcloud.io/dashboard?id=owncloud_ocis&pullRequest=5385)

[![Bug](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/bug-16px.png 'Bug')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5385&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=5385&resolved=false&types=BUG) [0 Bugs](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5385&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=5385&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=5385&resolved=false&types=VULNERABILITY) [0 Vulnerabilities](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5385&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=5385&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=5385&resolved=false&types=SECURITY_HOTSPOT) [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=owncloud_ocis&pullRequest=5385&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=5385&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=5385&resolved=false&types=CODE_SMELL) [24 Code Smells](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5385&resolved=false&types=CODE_SMELL)

[![62.2%](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/CoverageChart/60-16px.png '62.2%')](https://sonarcloud.io/component_measures?id=owncloud_ocis&pullRequest=5385&metric=new_coverage&view=list) [62.2% Coverage](https://sonarcloud.io/component_measures?id=owncloud_ocis&pullRequest=5385&metric=new_coverage&view=list)  
[![3.2%](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/Duplications/5-16px.png '3.2%')](https://sonarcloud.io/component_measures?id=owncloud_ocis&pullRequest=5385&metric=new_duplicated_lines_density&view=list) [3.2% Duplication](https://sonarcloud.io/component_measures?id=owncloud_ocis&pullRequest=5385&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')](https://sonarcloud.io/dashboard?id=owncloud_ocis&pullRequest=5385)

[![Bug](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/bug-16px.png 'Bug')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5385&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=5385&resolved=false&types=BUG) [0 Bugs](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5385&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=5385&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=5385&resolved=false&types=VULNERABILITY) [0 Vulnerabilities](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5385&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=5385&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=5385&resolved=false&types=SECURITY_HOTSPOT) [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=owncloud_ocis&pullRequest=5385&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=5385&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=5385&resolved=false&types=CODE_SMELL) [24 Code Smells](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5385&resolved=false&types=CODE_SMELL)

[![62.2%](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/CoverageChart/60-16px.png '62.2%')](https://sonarcloud.io/component_measures?id=owncloud_ocis&pullRequest=5385&metric=new_coverage&view=list) [62.2% Coverage](https://sonarcloud.io/component_measures?id=owncloud_ocis&pullRequest=5385&metric=new_coverage&view=list)  
[![3.2%](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/Duplications/5-16px.png '3.2%')](https://sonarcloud.io/component_measures?id=owncloud_ocis&pullRequest=5385&metric=new_duplicated_lines_density&view=list) [3.2% Duplication](https://sonarcloud.io/component_measures?id=owncloud_ocis&pullRequest=5385&metric=new_duplicated_lines_density&view=list)



Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 76881d415e0..dd36f488d4e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -21,6 +21,7 @@ The following sections list the changes for unreleased.
 * Enhancement - Extended search: [#5221](https://github.com/owncloud/ocis/pull/5221)
 * Enhancement - Resource tags: [#5227](https://github.com/owncloud/ocis/pull/5227)
 * Enhancement - Expiration Notifications: [#5330](https://github.com/owncloud/ocis/pull/5330)
+* Enhancement - Add expiration to user and group shares: [#5389](https://github.com/owncloud/ocis/pull/5389)
 
 # Changelog for [2.0.0] (2022-11-30)
 
diff --git a/changelog/unreleased/share-expiration.md b/changelog/unreleased/share-expiration.md
new file mode 100644
index 00000000000..66c54b66fc6
--- /dev/null
+++ b/changelog/unreleased/share-expiration.md
@@ -0,0 +1,5 @@
+Enhancement: Add expiration to user and group shares
+
+Added expiration to user and group shares.
+
+https://github.com/owncloud/ocis/pull/5389
diff --git a/docs/helpers/README.md b/docs/helpers/README.md
index 914ecd876f2..21835a9e7ee 100644
--- a/docs/helpers/README.md
+++ b/docs/helpers/README.md
@@ -1,44 +1,91 @@
 # Docs Helpers
 
-`docs/helpers` contains a small go program creating docs by extracting information from the code. It is manually started with `make docs-generate` or via the CI and has three main responsibilities:
-
-- Generate docs for envvars in config structs
-- Extract envvars that are not mentioned in config structs (aka "Rogue" envvars)
-- Generate docs for rogue envvars
-
-Output:
+   * [Introduction](#introduction)
+   * [Output Generated](#output)
+   * [Admin Doc Process](#admin-doc-process)
+   * [Branching](#branching)
+   * [Service-Dependent Output](#service-dependent-output)
+      * [Generate Envvar Docs for Config Structs](#generate-envvar-docs-for-config-structs)
+      * [Deprecation Process](#deprecation-process)
+   * [Global Envvars](#global-envvars)
+   * [Extended Envvars](#extended-envvars)
+      * [General Extended Envvars Info](#general-extended-envvars-info)
+      * [Extract Extended Envvars](#extract-extended-envvars)
+      * [Generate Extended Envvar Docs](#generate-extended-envvar-docs)
+   * [Backporting](#backporting)
+
+## Introduction
+
+`docs/helpers` contains small go programs creating docs by extracting information from the code. The `main.go` program is manually started with `make docs-generate` or via the CI. It calls the other required programs and has three main responsibilities:
+
+- Generate docs for envvars in config structs including deprecations if there are any.
+- Extract and generate docs for `extended` envvars that are not mentioned in config structs (aka "rogue" envvars).
+- Extract and generate docs for `global` envvars which occur in multiple services.
+
+## Output Generated
 
 - The generated yaml files can be found at: `docs/services/_includes` when running locally respectively in the `docs branch` after the CI has finished.
 - The generated adoc files can be found at: `docs/services/_includes/adoc` when running locally respectively in the `docs branch` after the CI has finished.
-- The file name for rouge envvars is named: `extended_configvars.adoc`.
+- The file name for global envvars is named: `global_configvars.adoc`.
+- The file name for extended envvars is named: `extended_configvars.adoc`.
 
-Admin doc process:
+## Admin Doc Process
 
-Whenever a build from the admin documentation is triggered, the files generated here are included into the build process and added in a proper manner defined by the admin documentation.
+Whenever a build from the [ocis admin](https://github.com/owncloud/docs-ocis) or any other related documentation is triggered, the files generated here are included into the build process and added in a proper manner defined by the admin documentation. The updated documentation will then show up on the [web](https://doc.owncloud.com/ocis/next/).
 
-Genreal info:
+## Branching
 
-"Rouge" envvars are variables that need to be present *before* the core or services are starting up as they depend on the info provided like path for config files etc. Therefore they are _not_ bound to services like other envvars do. 
+The following is valid for envvars and yaml files related to the doc process:
 
-It can happen that rouge envvars are found but do not need to be published as they are for internal use only. Those rouge envvars can be defined to be ignored for further processing.
+* When filing a pull request in the ocis master branch relating to docs, CI runs `make docs-generate` and copies the result into the `docs` branch of ocis. This branch is then taken as base for owncloud.dev and as reference for the [admin docs](https://doc.owncloud.com/ocis/next/).
+* When running `make docs-generate` locally, the same output is created as above but it stays in the same branch where the make command was issued.
 
-IMPORTANT:
+In both cases, `make docs-generate` removes files in the target folder `_includes` to avoid remnants. All content is recreated. 
 
-- Once a rouge envvar has been identified, it is added to the `extended_vars.yaml` file but never changed or touched by the process. There is one exception with respect to single/double quote usage. While you manually can (and will) define a text like: `"'/var/lib/ocis'"`, quotes are transformed by the process in the .yaml file to: `'''/var/lib/ocis'''`. There is no need to change this back, as the final step transforms this correctly to the adoc table.
+On a side note (unrelated to the `docs` branch), [deployment examples](https://github.com/owncloud/ocis/tree/master/deployments/examples) have their own branch related to an ocis stable version to keep the state consistent, which is necessary for the admin documentation.
 
-- Because rouge envvars do not have the same structural setup as "normal" envvars like type, description or defaults, these infos need to be provided manually one time - even if found multiple times. Any change on this info will be used on the next CI run and published on the next admin docs build.
+## Service-Dependent Output
 
-- Do not change the sort order of rouge envvar blocks as they are automatically reordered alphabetically.
+For each service available, a file named like `<service name>_configvars.adoc` is created containing a:
 
-## Generate Envvar docs for config structs
+* table on top defining deprecated envvars - if applicable
+* table containing all envvars with their name, type, default value and description
+
+The table with deprecations is always printed in the final adoc file even if there are none, but is rendered in the docs build process only if the `HasDeprecations` value is set. This value is automatically handed over via the adoc file. The template file can be found at `docs/templates/ADOC.tmpl`.
+
+### Generate Envvar Docs for Config Structs
 
 Generates docs from a template file, mainly extracting `"env"` and `"desc"` tags from the config structs.
 
 Templates can be found in `docs/helpers` folder. (Same as this `README`.) Check `.tmpl` files
 
-## Extract Rogue Envvars
+### Deprecation Process
+
+For details on deprecation see the [deprecating-variables](https://github.com/owncloud/ocis/blob/master/docs/ocis/development/deprecating-variables.md) documentation.
+
+## Global Envvars
+
+Global envvars are gathered by checking if the envvar is available in more than one service. The table created is similar to the service-dependent envvar table but additionally contains a column with all service names where this envvar occurs. The output is rendered in list form where each item is clickable and automatically points to the corresponding service page. The template file can be found at `docs/templates/ADOC_global.tmpl`.
+
+## Extended Envvars
+
+### General Extended Envvars Info
+
+"Extended" envvars are variables that need to be present *before* the core or services are starting up as they depend on the info provided like path for config files etc. Therefore they are _not_ bound to services like other envvars. 
 
-It `grep`s over the code, looking for `os.Getenv` and parses these contents to a yaml file along with the following information:
+It can happen that extended envvars are found but do not need to be published as they are for internal use only. Those envvars can be defined to be ignored for further processing.
+
+IMPORTANT:
+
+- Once a extended envvar has been identified, it is added to the `extended_vars.yaml` file found in this folder but never changed or touched by the process. There is one exception with respect to single/double quote usage. While you can (and will) manually define a text like: `"'/var/lib/ocis'"`, quotes are transformed by the process in the .yaml file to: `'''/var/lib/ocis'''`. There is no need to change this back, as the final step transforms this correctly for the adoc table.
+
+- Because extended envvars do not have the same structural setup as "normal" envvars (like type, description or defaults), this info needs to be provided manually once - even if found multiple times. Any change of this info will be noticed during the next CI run and published in the next admin docs build.
+
+- Do not change the sort order of extended envvar blocks as they are automatically reordered alphabetically.
+
+### Extract Extended Envvars
+
+The grep command parses the code, looking for `os.Getenv` and passes these contents to a yaml file along with the following information:
 ```golang
 // Variable contains all information about one rogue envvar
 type Variable struct {
@@ -70,8 +117,16 @@ This yaml file can later be manually edited to add descriptions, default values,
 
 IMPORTANT: `RawName`, `Path` and `FoundInCode` are automatically filled by the program. DO NOT EDIT THESE VALUES MANUALLY.
 
-## Generate Rogue Envvar docs
+### Generate Extended Envvar Docs
+
+The process further picks up the `yaml` file generated in the `Extract Rogue Envvars` step and renders it to an adoc file (a table is created) using a go template. The template file for this step can be found at `docs/templates/ADOC_extended.tmpl`.
+
+## Backporting
+
+The ocis repo contains branches which are necessary for the documentation. The `docs` branch is related to changes in master, necessary for owncloud.dev and the admin docs referencing master content when it comes to envvars and yaml files.
+
+When a new stable ocis release (branch) is published, like `stable-2.0`, an additional branch (including CI) is set up manually by the dev team for referencing docs content like `docs-stable-2.0` - related to envvars and yaml files only - and added to the CI.
 
-It picks up the `yaml` file generated in `Extract Rogue Envvars` step and renders it to a adoc file (table) using a go template.
+In case it is necessary to transport a change from master to a stable branch like `docs-stable-2.0`, you must backport the original changes that will create that file to the `stable-2.0` branch. The CI will then take care of creating the results in the target `docs-stable-2.0`.
 
-The adoc template file for this step can be found at `docs/templates/ADOC_global.tmpl`.
+Cases for a backport can be a typo in an envvar description you want to have fixed in a stable branch too or a file  was created after the stable branch was set up but needs to be available in that branch.
diff --git a/go.mod b/go.mod
index 2964562fce2..69f0e391d1b 100644
--- a/go.mod
+++ b/go.mod
@@ -11,7 +11,7 @@ require (
 	github.com/blevesearch/bleve/v2 v2.3.5
 	github.com/coreos/go-oidc/v3 v3.4.0
 	github.com/cs3org/go-cs3apis v0.0.0-20221012090518-ef2996678965
-	github.com/cs3org/reva/v2 v2.12.1-0.20230112100946-f8c784168ef7
+	github.com/cs3org/reva/v2 v2.12.1-0.20230112151625-e9554514d142
 	github.com/disintegration/imaging v1.6.2
 	github.com/ggwhite/go-masker v1.0.9
 	github.com/go-chi/chi/v5 v5.0.7
@@ -287,3 +287,5 @@ require (
 	gopkg.in/yaml.v3 v3.0.1 // indirect
 	stash.kopano.io/kgol/kcc-go/v5 v5.0.1 // indirect
 )
+
+replace github.com/cs3org/go-cs3apis => github.com/c0rby/go-cs3apis v0.0.0-20230110100311-5b424f1baa35
diff --git a/go.sum b/go.sum
index 351c458f36f..f1823398628 100644
--- a/go.sum
+++ b/go.sum
@@ -283,6 +283,8 @@ github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBW
 github.com/bwesterb/go-ristretto v1.2.0/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0=
 github.com/bwesterb/go-ristretto v1.2.1/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0=
 github.com/c-bata/go-prompt v0.2.5/go.mod h1:vFnjEGDIIA/Lib7giyE4E9c50Lvl8j0S+7FVlAwDAVw=
+github.com/c0rby/go-cs3apis v0.0.0-20230110100311-5b424f1baa35 h1:bbpRY/l4z5MTH+TRGZdkIqDM9JXQQewJdO1o+80zcok=
+github.com/c0rby/go-cs3apis v0.0.0-20230110100311-5b424f1baa35/go.mod h1:UXha4TguuB52H14EMoSsCqDj7k8a/t7g4gVP+bgY5LY=
 github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4=
 github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM=
 github.com/cenkalti/backoff/v4 v4.1.0/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw=
@@ -342,10 +344,8 @@ github.com/crewjam/httperr v0.2.0/go.mod h1:Jlz+Sg/XqBQhyMjdDiC+GNNRzZTD7x39Gu3p
 github.com/crewjam/saml v0.4.6/go.mod h1:ZBOXnNPFzB3CgOkRm7Nd6IVdkG+l/wF+0ZXLqD96t1A=
 github.com/crewjam/saml v0.4.9 h1:X2jDv4dv3IvfT9t+RhADavzNFAcq3fVxzTCIH3G605U=
 github.com/crewjam/saml v0.4.9/go.mod h1:9Zh6dWPtB3MSzTRt8fIFH60Z351QQ+s7hCU3J/tTlA4=
-github.com/cs3org/go-cs3apis v0.0.0-20221012090518-ef2996678965 h1:y4n2j68LLnvac+zw/al8MfPgO5aQiIwLmHM/JzYN8AM=
-github.com/cs3org/go-cs3apis v0.0.0-20221012090518-ef2996678965/go.mod h1:UXha4TguuB52H14EMoSsCqDj7k8a/t7g4gVP+bgY5LY=
-github.com/cs3org/reva/v2 v2.12.1-0.20230112100946-f8c784168ef7 h1:xULPUJdydKJbZ8z0MD4zkOqnz2oE+SXKw+3GzSYr+yY=
-github.com/cs3org/reva/v2 v2.12.1-0.20230112100946-f8c784168ef7/go.mod h1:GpocVB1w6yxeSr1VBsO9jztmt1SyNC4lCwudLwDzxHQ=
+github.com/cs3org/reva/v2 v2.12.1-0.20230112151625-e9554514d142 h1:4VNNpQoW1srvhg2uQO+1hU+MRFCJvkl+Ni4O+cmRk+s=
+github.com/cs3org/reva/v2 v2.12.1-0.20230112151625-e9554514d142/go.mod h1:u73Df9JAZsDj43GIjQIb3DO1PLJuPutZXkRqQH0oGXA=
 github.com/cubewise-code/go-mime v0.0.0-20200519001935-8c5762b177d8 h1:Z9lwXumT5ACSmJ7WGnFl+OMLLjpz5uR2fyz7dC255FI=
 github.com/cubewise-code/go-mime v0.0.0-20200519001935-8c5762b177d8/go.mod h1:4abs/jPXcmJzYoYGF91JF9Uq9s/KL5n1jvFDix8KcqY=
 github.com/cyberdelia/templates v0.0.0-20141128023046-ca7fffd4298c/go.mod h1:GyV+0YP4qX0UQ7r2MoYZ+AvYDp12OF5yg4q8rGnyNh4=
@@ -873,8 +873,6 @@ github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa02
 github.com/klauspost/cpuid/v2 v2.0.4/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
 github.com/klauspost/cpuid/v2 v2.1.0 h1:eyi1Ad2aNJMW95zcSbmGg7Cg6cq3ADwLpMAP96d8rF0=
 github.com/klauspost/cpuid/v2 v2.1.0/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
-github.com/kobergj/reva/v2 v2.0.0-20230112095039-1aba27929ce4 h1:jvN+oN9vHYXhrYv76OJ6bhXx7ldDpErblPZSwu0UzmA=
-github.com/kobergj/reva/v2 v2.0.0-20230112095039-1aba27929ce4/go.mod h1:GpocVB1w6yxeSr1VBsO9jztmt1SyNC4lCwudLwDzxHQ=
 github.com/kolo/xmlrpc v0.0.0-20200310150728-e0350524596b/go.mod h1:o03bZfuBwAXHetKXuInt4S7omeXUu62/A845kiycsSQ=
 github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
 github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
diff --git a/services/frontend/pkg/revaconfig/config.go b/services/frontend/pkg/revaconfig/config.go
index 283423e8d98..82926ab6a90 100644
--- a/services/frontend/pkg/revaconfig/config.go
+++ b/services/frontend/pkg/revaconfig/config.go
@@ -247,6 +247,9 @@ func FrontendConfigFromStruct(cfg *config.Config) (map[string]interface{}, error
 											"version": "1.0.0",
 										},
 									},
+									"expire_date": map[string]interface{}{
+										"enabled": true,
+									},
 								},
 								"user_enumeration": map[string]interface{}{
 									"enabled":            true,
diff --git a/services/graph/pkg/identity/ldap.go b/services/graph/pkg/identity/ldap.go
index 735f4ee6a4d..a7a34ec932d 100644
--- a/services/graph/pkg/identity/ldap.go
+++ b/services/graph/pkg/identity/ldap.go
@@ -9,7 +9,6 @@ import (
 
 	"github.com/go-ldap/ldap/v3"
 	"github.com/gofrs/uuid"
-	ldapdn "github.com/libregraph/idm/pkg/ldapdn"
 	libregraph "github.com/owncloud/libre-graph-api-go"
 	oldap "github.com/owncloud/ocis/v2/ocis-pkg/ldap"
 	"github.com/owncloud/ocis/v2/ocis-pkg/log"
@@ -55,13 +54,6 @@ type userAttributeMap struct {
 	surname     string
 }
 
-type groupAttributeMap struct {
-	name         string
-	id           string
-	member       string
-	memberSyntax string
-}
-
 type ldapAttributeValues map[string][]string
 
 func NewLDAPBackend(lc ldap.Client, config config.LDAP, logger *log.Logger) (*LDAP, error) {
@@ -188,7 +180,7 @@ func (i *LDAP) DeleteUser(ctx context.Context, nameOrID string) error {
 	for _, group := range groupEntries {
 		logger.Debug().Str("group", group.DN).Str("user", e.DN).Msg("Cleaning up group membership")
 
-		if mr, err := i.removeMemberFromGroupEntry(group, e.DN); err == nil && mr != nil {
+		if mr, err := i.removeMemberFromGroupEntry(group, e.DN); err == nil {
 			if err = i.conn.Modify(mr); err != nil {
 				// Errors when deleting the memberships are only logged as warnings but not returned
 				// to the user as we already successfully deleted the users itself
@@ -288,19 +280,6 @@ func (i *LDAP) getUserByDN(dn string) (*ldap.Entry, error) {
 	return i.getEntryByDN(dn, attrs, filter)
 }
 
-func (i *LDAP) getGroupByDN(dn string) (*ldap.Entry, error) {
-	attrs := []string{
-		i.groupAttributeMap.id,
-		i.groupAttributeMap.name,
-	}
-	filter := fmt.Sprintf("(objectClass=%s)", i.groupObjectClass)
-
-	if i.groupFilter != "" {
-		filter = fmt.Sprintf("(&%s(%s))", filter, i.groupFilter)
-	}
-	return i.getEntryByDN(dn, attrs, filter)
-}
-
 func (i *LDAP) getEntryByDN(dn string, attrs []string, filter string) (*ldap.Entry, error) {
 	if filter == "" {
 		filter = "(objectclass=*)"
@@ -489,435 +468,6 @@ func (i *LDAP) GetUsers(ctx context.Context, queryParam url.Values) ([]*libregra
 	return users, nil
 }
 
-func (i *LDAP) getGroupsForUser(dn string) ([]*ldap.Entry, error) {
-	groupFilter := fmt.Sprintf(
-		"(%s=%s)",
-		i.groupAttributeMap.member, dn,
-	)
-	userGroups, err := i.getLDAPGroupsByFilter(groupFilter, false, false)
-	if err != nil {
-		return nil, err
-	}
-	return userGroups, nil
-}
-
-func (i *LDAP) GetGroup(ctx context.Context, nameOrID string, queryParam url.Values) (*libregraph.Group, error) {
-	logger := i.logger.SubloggerWithRequestID(ctx)
-	logger.Debug().Str("backend", "ldap").Msg("GetGroup")
-	e, err := i.getLDAPGroupByNameOrID(nameOrID, true)
-	if err != nil {
-		return nil, err
-	}
-	sel := strings.Split(queryParam.Get("$select"), ",")
-	exp := strings.Split(queryParam.Get("$expand"), ",")
-	var g *libregraph.Group
-	if g = i.createGroupModelFromLDAP(e); g == nil {
-		return nil, errorcode.New(errorcode.ItemNotFound, "not found")
-	}
-	if slices.Contains(sel, "members") || slices.Contains(exp, "members") {
-		members, err := i.expandLDAPGroupMembers(ctx, e)
-		if err != nil {
-			return nil, err
-		}
-		if len(members) > 0 {
-			m := make([]libregraph.User, 0, len(members))
-			for _, ue := range members {
-				if u := i.createUserModelFromLDAP(ue); u != nil {
-					m = append(m, *u)
-				}
-			}
-			g.Members = m
-		}
-	}
-	return g, nil
-}
-
-func (i *LDAP) getLDAPGroupByID(id string, requestMembers bool) (*ldap.Entry, error) {
-	id = ldap.EscapeFilter(id)
-	filter := fmt.Sprintf("(%s=%s)", i.groupAttributeMap.id, id)
-	return i.getLDAPGroupByFilter(filter, requestMembers)
-}
-
-func (i *LDAP) getLDAPGroupByNameOrID(nameOrID string, requestMembers bool) (*ldap.Entry, error) {
-	nameOrID = ldap.EscapeFilter(nameOrID)
-	filter := fmt.Sprintf("(|(%s=%s)(%s=%s))", i.groupAttributeMap.name, nameOrID, i.groupAttributeMap.id, nameOrID)
-	return i.getLDAPGroupByFilter(filter, requestMembers)
-}
-
-func (i *LDAP) getLDAPGroupByFilter(filter string, requestMembers bool) (*ldap.Entry, error) {
-	e, err := i.getLDAPGroupsByFilter(filter, requestMembers, true)
-	if err != nil {
-		return nil, err
-	}
-	if len(e) == 0 {
-		return nil, errorcode.New(errorcode.ItemNotFound, "not found")
-	}
-
-	return e[0], nil
-}
-
-// Search for LDAP Groups matching the specified filter, if requestMembers is true the groupMemberShip
-// attribute will be part of the result attributes. The LDAP filter is combined with the configured groupFilter
-// resulting in a filter like "(&(LDAP.groupFilter)(objectClass=LDAP.groupObjectClass)(<filter_from_args>))"
-func (i *LDAP) getLDAPGroupsByFilter(filter string, requestMembers, single bool) ([]*ldap.Entry, error) {
-	attrs := []string{
-		i.groupAttributeMap.name,
-		i.groupAttributeMap.id,
-	}
-
-	if requestMembers {
-		attrs = append(attrs, i.groupAttributeMap.member)
-	}
-
-	sizelimit := 0
-	if single {
-		sizelimit = 1
-	}
-	searchRequest := ldap.NewSearchRequest(
-		i.groupBaseDN, i.groupScope, ldap.NeverDerefAliases, sizelimit, 0, false,
-		fmt.Sprintf("(&%s(objectClass=%s)%s)", i.groupFilter, i.groupObjectClass, filter),
-		attrs,
-		nil,
-	)
-	i.logger.Debug().Str("backend", "ldap").
-		Str("base", searchRequest.BaseDN).
-		Str("filter", searchRequest.Filter).
-		Int("scope", searchRequest.Scope).
-		Int("sizelimit", searchRequest.SizeLimit).
-		Interface("attributes", searchRequest.Attributes).
-		Msg("getLDAPGroupsByFilter")
-	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'", filter)
-				i.logger.Debug().Str("backend", "ldap").Err(lerr).Msg(errmsg)
-			}
-		}
-		return nil, errorcode.New(errorcode.ItemNotFound, errmsg)
-	}
-	return res.Entries, nil
-}
-
-// removeMemberFromGroupEntry creates an LDAP Modify request (not sending it)
-// that would update the supplied entry to remove the specified member from the
-// group
-func (i *LDAP) removeMemberFromGroupEntry(group *ldap.Entry, memberDN string) (*ldap.ModifyRequest, error) {
-	nOldMemberDN, err := ldapdn.ParseNormalize(memberDN)
-	if err != nil {
-		return nil, err
-	}
-	members := group.GetEqualFoldAttributeValues(i.groupAttributeMap.member)
-	found := false
-	for _, member := range members {
-		if member == "" {
-			continue
-		}
-		if nMember, err := ldapdn.ParseNormalize(member); err != nil {
-			// We couldn't parse the member value as a DN. Let's keep it
-			// as it is but log a warning
-			i.logger.Warn().Str("memberDN", member).Err(err).Msg("Couldn't parse DN")
-			continue
-		} else {
-			if nMember == nOldMemberDN {
-				found = true
-			}
-		}
-	}
-	if !found {
-		i.logger.Debug().Str("backend", "ldap").Str("groupdn", group.DN).Str("member", memberDN).
-			Msg("The target is not a member of the group")
-		return nil, nil
-	}
-
-	mr := ldap.ModifyRequest{DN: group.DN}
-	if len(members) == 1 {
-		mr.Add(i.groupAttributeMap.member, []string{""})
-	}
-	mr.Delete(i.groupAttributeMap.member, []string{memberDN})
-	return &mr, nil
-}
-
-func (i *LDAP) GetGroups(ctx context.Context, queryParam url.Values) ([]*libregraph.Group, error) {
-	logger := i.logger.SubloggerWithRequestID(ctx)
-	logger.Debug().Str("backend", "ldap").Msg("GetGroups")
-
-	search := queryParam.Get("search")
-	if search == "" {
-		search = queryParam.Get("$search")
-	}
-
-	var expandMembers bool
-	sel := strings.Split(queryParam.Get("$select"), ",")
-	exp := strings.Split(queryParam.Get("$expand"), ",")
-	if slices.Contains(sel, "members") || slices.Contains(exp, "members") {
-		expandMembers = true
-	}
-
-	var groupFilter string
-	if search != "" {
-		search = ldap.EscapeFilter(search)
-		groupFilter = fmt.Sprintf(
-			"(|(%s=%s*)(%s=%s*))",
-			i.groupAttributeMap.name, search,
-			i.groupAttributeMap.id, search,
-		)
-	}
-	groupFilter = fmt.Sprintf("(&%s(objectClass=%s)%s)", i.groupFilter, i.groupObjectClass, groupFilter)
-
-	groupAttrs := []string{
-		i.groupAttributeMap.name,
-		i.groupAttributeMap.id,
-	}
-	if expandMembers {
-		groupAttrs = append(groupAttrs, i.groupAttributeMap.member)
-	}
-
-	searchRequest := ldap.NewSearchRequest(
-		i.groupBaseDN, i.groupScope, ldap.NeverDerefAliases, 0, 0, false,
-		groupFilter,
-		groupAttrs,
-		nil,
-	)
-	logger.Debug().Str("backend", "ldap").
-		Str("base", searchRequest.BaseDN).
-		Str("filter", searchRequest.Filter).
-		Int("scope", searchRequest.Scope).
-		Int("sizelimit", searchRequest.SizeLimit).
-		Interface("attributes", searchRequest.Attributes).
-		Msg("GetGroups")
-	res, err := i.conn.Search(searchRequest)
-	if err != nil {
-		return nil, errorcode.New(errorcode.ItemNotFound, err.Error())
-	}
-
-	groups := make([]*libregraph.Group, 0, len(res.Entries))
-
-	var g *libregraph.Group
-	for _, e := range res.Entries {
-		if g = i.createGroupModelFromLDAP(e); g == nil {
-			continue
-		}
-		if expandMembers {
-			members, err := i.expandLDAPGroupMembers(ctx, e)
-			if err != nil {
-				return nil, err
-			}
-			if len(members) > 0 {
-				m := make([]libregraph.User, 0, len(members))
-				for _, ue := range members {
-					if u := i.createUserModelFromLDAP(ue); u != nil {
-						m = append(m, *u)
-					}
-				}
-				g.Members = m
-			}
-		}
-		groups = append(groups, g)
-	}
-	return groups, nil
-}
-
-// GetGroupMembers implements the Backend Interface for the LDAP Backend
-func (i *LDAP) GetGroupMembers(ctx context.Context, groupID string) ([]*libregraph.User, error) {
-	logger := i.logger.SubloggerWithRequestID(ctx)
-	logger.Debug().Str("backend", "ldap").Msg("GetGroupMembers")
-	e, err := i.getLDAPGroupByNameOrID(groupID, true)
-	if err != nil {
-		return nil, err
-	}
-
-	memberEntries, err := i.expandLDAPGroupMembers(ctx, e)
-	result := make([]*libregraph.User, 0, len(memberEntries))
-	if err != nil {
-		return nil, err
-	}
-	for _, member := range memberEntries {
-		if u := i.createUserModelFromLDAP(member); u != nil {
-			result = append(result, u)
-		}
-	}
-
-	return result, nil
-}
-
-func (i *LDAP) expandLDAPGroupMembers(ctx context.Context, e *ldap.Entry) ([]*ldap.Entry, error) {
-	logger := i.logger.SubloggerWithRequestID(ctx)
-	logger.Debug().Str("backend", "ldap").Msg("expandLDAPGroupMembers")
-	result := []*ldap.Entry{}
-
-	for _, memberDN := range e.GetEqualFoldAttributeValues(i.groupAttributeMap.member) {
-		if memberDN == "" {
-			continue
-		}
-		logger.Debug().Str("memberDN", memberDN).Msg("lookup")
-		ue, err := i.getUserByDN(memberDN)
-		if err != nil {
-			// Ignore errors when reading a specific member fails, just log them and continue
-			logger.Debug().Err(err).Str("member", memberDN).Msg("error reading group member")
-			continue
-		}
-		result = append(result, ue)
-	}
-
-	return result, nil
-}
-
-// CreateGroup implements the Backend Interface for the LDAP Backend
-// It is currently restricted to managing groups based on the "groupOfNames" ObjectClass.
-// As "groupOfNames" requires a "member" Attribute to be present. Empty Groups (groups
-// without a member) a represented by adding an empty DN as the single member.
-func (i *LDAP) CreateGroup(ctx context.Context, group libregraph.Group) (*libregraph.Group, error) {
-	logger := i.logger.SubloggerWithRequestID(ctx)
-	logger.Debug().Str("backend", "ldap").Msg("create group")
-	if !i.writeEnabled {
-		return nil, errorcode.New(errorcode.NotAllowed, "server is configured read-only")
-	}
-	ar := ldap.AddRequest{
-		DN: fmt.Sprintf("cn=%s,%s", oldap.EscapeDNAttributeValue(*group.DisplayName), i.groupBaseDN),
-		Attributes: []ldap.Attribute{
-			{
-				Type: i.groupAttributeMap.name,
-				Vals: []string{*group.DisplayName},
-			},
-			// This is a crutch to allow groups without members for LDAP Server's which
-			// that apply strict Schema checking. The RFCs define "member/uniqueMember"
-			// as required attribute for groupOfNames/groupOfUniqueNames. So we
-			// add an empty string (which is a valid DN) as the initial member.
-			// It will be replace once real members are added.
-			// We might wanna use the newer, but not so broadly used "groupOfMembers"
-			// objectclass (RFC2307bis-02) where "member" is optional.
-			{
-				Type: i.groupAttributeMap.member,
-				Vals: []string{""},
-			},
-		},
-	}
-
-	// TODO make group objectclass configurable to support e.g. posixGroup, groupOfUniqueNames, groupOfMembers?}
-	objectClasses := []string{"groupOfNames", "top"}
-
-	if !i.useServerUUID {
-		ar.Attribute("owncloudUUID", []string{uuid.Must(uuid.NewV4()).String()})
-		objectClasses = append(objectClasses, "owncloud")
-	}
-	ar.Attribute("objectClass", objectClasses)
-
-	if err := i.conn.Add(&ar); err != nil {
-		return nil, err
-	}
-
-	// Read	back group from LDAP to get the generated UUID
-	e, err := i.getGroupByDN(ar.DN)
-	if err != nil {
-		return nil, err
-	}
-	return i.createGroupModelFromLDAP(e), nil
-}
-
-// DeleteGroup implements the Backend Interface.
-func (i *LDAP) DeleteGroup(ctx context.Context, id string) error {
-	logger := i.logger.SubloggerWithRequestID(ctx)
-	logger.Debug().Str("backend", "ldap").Msg("DeleteGroup")
-	if !i.writeEnabled {
-		return errorcode.New(errorcode.NotAllowed, "server is configured read-only")
-	}
-	e, err := i.getLDAPGroupByID(id, false)
-	if err != nil {
-		return err
-	}
-	dr := ldap.DelRequest{DN: e.DN}
-	if err = i.conn.Del(&dr); err != nil {
-		return err
-	}
-	return nil
-}
-
-// AddMembersToGroup implements the Backend Interface for the LDAP backend.
-// Currently it is limited to adding Users as Group members. Adding other groups
-// as members is not yet implemented
-func (i *LDAP) AddMembersToGroup(ctx context.Context, groupID string, memberIDs []string) error {
-	logger := i.logger.SubloggerWithRequestID(ctx)
-	logger.Debug().Str("backend", "ldap").Msg("AddMembersToGroup")
-	ge, err := i.getLDAPGroupByID(groupID, true)
-	if err != nil {
-		return err
-	}
-
-	mr := ldap.ModifyRequest{DN: ge.DN}
-	// Handle empty groups (using the empty member attribute)
-	current := ge.GetEqualFoldAttributeValues(i.groupAttributeMap.member)
-	if len(current) == 1 && current[0] == "" {
-		mr.Delete(i.groupAttributeMap.member, []string{""})
-	}
-
-	// Create a Set of current members for faster lookups
-	currentSet := make(map[string]struct{}, len(current))
-	for _, currentMember := range current {
-		// We can ignore any empty member value here
-		if currentMember == "" {
-			continue
-		}
-		nCurrentMember, err := ldapdn.ParseNormalize(currentMember)
-		if err != nil {
-			// We couldn't parse the member value as a DN. Let's skip it, but log a warning
-			logger.Warn().Str("memberDN", currentMember).Err(err).Msg("Couldn't parse DN")
-			continue
-		}
-		currentSet[nCurrentMember] = struct{}{}
-	}
-
-	var newMemberDNs []string
-	for _, memberID := range memberIDs {
-		me, err := i.getLDAPUserByID(memberID)
-		if err != nil {
-			return err
-		}
-		nDN, err := ldapdn.ParseNormalize(me.DN)
-		if err != nil {
-			logger.Error().Str("new member", me.DN).Err(err).Msg("Couldn't parse DN")
-			return err
-		}
-		if _, present := currentSet[nDN]; !present {
-			newMemberDNs = append(newMemberDNs, me.DN)
-		} else {
-			logger.Debug().Str("memberDN", me.DN).Msg("Member already present in group. Skipping")
-		}
-	}
-
-	if len(newMemberDNs) > 0 {
-		mr.Add(i.groupAttributeMap.member, newMemberDNs)
-
-		if err := i.conn.Modify(&mr); err != nil {
-			return err
-		}
-	}
-	return nil
-}
-
-// RemoveMemberFromGroup implements the Backend Interface.
-func (i *LDAP) RemoveMemberFromGroup(ctx context.Context, groupID string, memberID string) error {
-	logger := i.logger.SubloggerWithRequestID(ctx)
-	logger.Debug().Str("backend", "ldap").Msg("RemoveMemberFromGroup")
-	ge, err := i.getLDAPGroupByID(groupID, true)
-	if err != nil {
-		logger.Debug().Str("backend", "ldap").Str("groupID", groupID).Msg("Error looking up group")
-		return err
-	}
-	me, err := i.getLDAPUserByID(memberID)
-	if err != nil {
-		logger.Debug().Str("backend", "ldap").Str("memberID", memberID).Msg("Error looking up group member")
-		return err
-	}
-	logger.Debug().Str("backend", "ldap").Str("groupdn", ge.DN).Str("member", me.DN).Msg("remove member")
-
-	if mr, err := i.removeMemberFromGroupEntry(ge, me.DN); err == nil && mr != nil {
-		return i.conn.Modify(mr)
-	}
-	return nil
-}
-
 func (i *LDAP) updateUserPassowrd(ctx context.Context, dn, password string) error {
 	logger := i.logger.SubloggerWithRequestID(ctx)
 	logger.Debug().Str("backend", "ldap").Msg("updateUserPassowrd")
@@ -964,30 +514,6 @@ func (i *LDAP) createUserModelFromLDAP(e *ldap.Entry) *libregraph.User {
 	return nil
 }
 
-func (i *LDAP) createGroupModelFromLDAP(e *ldap.Entry) *libregraph.Group {
-	name := e.GetEqualFoldAttributeValue(i.groupAttributeMap.name)
-	id := e.GetEqualFoldAttributeValue(i.groupAttributeMap.id)
-
-	if id != "" && name != "" {
-		return &libregraph.Group{
-			DisplayName: &name,
-			Id:          &id,
-		}
-	}
-	i.logger.Warn().Str("dn", e.DN).Msg("Group is missing name or id")
-	return nil
-}
-
-func (i *LDAP) groupsFromLDAPEntries(e []*ldap.Entry) []libregraph.Group {
-	groups := make([]libregraph.Group, 0, len(e))
-	for _, g := range e {
-		if grp := i.createGroupModelFromLDAP(g); grp != nil {
-			groups = append(groups, *grp)
-		}
-	}
-	return groups
-}
-
 func (i *LDAP) userToLDAPAttrValues(user libregraph.User) (map[string][]string, error) {
 	attrs := map[string][]string{
 		i.userAttributeMap.displayName: {user.GetDisplayName()},
@@ -1026,6 +552,20 @@ func (i *LDAP) userToLDAPAttrValues(user libregraph.User) (map[string][]string,
 	return attrs, nil
 }
 
+func (i *LDAP) getUserAttrTypes() []string {
+	return []string{
+		i.userAttributeMap.displayName,
+		i.userAttributeMap.userName,
+		i.userAttributeMap.mail,
+		i.userAttributeMap.surname,
+		i.userAttributeMap.givenName,
+		"objectClass",
+		"cn",
+		"owncloudUUID",
+		"userPassword",
+	}
+}
+
 func (i *LDAP) getUserLDAPDN(user libregraph.User) string {
 	return fmt.Sprintf("uid=%s,%s", oldap.EscapeDNAttributeValue(*user.OnPremisesSamAccountName), i.userBaseDN)
 }
@@ -1037,8 +577,11 @@ func (i *LDAP) userToAddRequest(user libregraph.User) (*ldap.AddRequest, error)
 	if err != nil {
 		return nil, err
 	}
-	for attrType, values := range attrMap {
-		ar.Attribute(attrType, values)
+
+	for _, attrType := range i.getUserAttrTypes() {
+		if values, ok := attrMap[attrType]; ok {
+			ar.Attribute(attrType, values)
+		}
 	}
 	return ar, nil
 }
diff --git a/services/graph/pkg/identity/ldap_education_class.go b/services/graph/pkg/identity/ldap_education_class.go
index 4fc3082bae2..93ba9b13764 100644
--- a/services/graph/pkg/identity/ldap_education_class.go
+++ b/services/graph/pkg/identity/ldap_education_class.go
@@ -2,32 +2,262 @@ package identity
 
 import (
 	"context"
+	"fmt"
 	"net/url"
 
+	"github.com/go-ldap/ldap/v3"
 	libregraph "github.com/owncloud/libre-graph-api-go"
+	oldap "github.com/owncloud/ocis/v2/ocis-pkg/ldap"
+	"github.com/owncloud/ocis/v2/services/graph/pkg/service/v0/errorcode"
 )
 
+type educationClassAttributeMap struct {
+	externalID     string
+	classification string
+}
+
+func newEducationClassAttributeMap() educationClassAttributeMap {
+	return educationClassAttributeMap{
+		externalID:     "ocEducationExternalId",
+		classification: "ocEducationClassType",
+	}
+}
+
 // GetEducationClasses implements the EducationBackend interface for the LDAP backend.
 func (i *LDAP) GetEducationClasses(ctx context.Context, queryParam url.Values) ([]*libregraph.EducationClass, error) {
-	return nil, errNotImplemented
+	logger := i.logger.SubloggerWithRequestID(ctx)
+	logger.Debug().Str("backend", "ldap").Msg("GetEducationClasses")
+
+	search := queryParam.Get("search")
+	if search == "" {
+		search = queryParam.Get("$search")
+	}
+
+	var classFilter string
+	if search != "" {
+		search = ldap.EscapeFilter(search)
+		classFilter = fmt.Sprintf(
+			"(|(%s=%s)(%s=%s*)(%s=%s*))",
+			i.educationConfig.classAttributeMap.externalID, search,
+			i.groupAttributeMap.name, search,
+			i.groupAttributeMap.id, search,
+		)
+	}
+	classFilter = fmt.Sprintf("(&%s(objectClass=%s)%s)", i.groupFilter, i.educationConfig.classObjectClass, classFilter)
+
+	classAttrs := i.getEducationClassAttrTypes(false)
+
+	searchRequest := ldap.NewSearchRequest(
+		i.groupBaseDN, i.groupScope, ldap.NeverDerefAliases, 0, 0, false,
+		classFilter,
+		classAttrs,
+		nil,
+	)
+	logger.Debug().Str("backend", "ldap").
+		Str("base", searchRequest.BaseDN).
+		Str("filter", searchRequest.Filter).
+		Int("scope", searchRequest.Scope).
+		Int("sizelimit", searchRequest.SizeLimit).
+		Interface("attributes", searchRequest.Attributes).
+		Msg("GetEducationClasses")
+	res, err := i.conn.Search(searchRequest)
+	if err != nil {
+		return nil, errorcode.New(errorcode.ItemNotFound, err.Error())
+	}
+
+	classes := make([]*libregraph.EducationClass, 0, len(res.Entries))
+
+	var c *libregraph.EducationClass
+	for _, e := range res.Entries {
+		if c = i.createEducationClassModelFromLDAP(e); c == nil {
+			continue
+		}
+		classes = append(classes, c)
+	}
+	return classes, nil
 }
 
 // CreateEducationClass implements the EducationBackend interface for the LDAP backend.
+// An EducationClass is mapped to an LDAP entry of the "groupOfNames" structural ObjectClass.
+// With a few additional Attributes added on top via the "ocEducationClass" auxiallary ObjectClass.
 func (i *LDAP) CreateEducationClass(ctx context.Context, class libregraph.EducationClass) (*libregraph.EducationClass, error) {
-	return nil, errNotImplemented
+	logger := i.logger.SubloggerWithRequestID(ctx)
+	logger.Debug().Str("backend", "ldap").Msg("create educationClass")
+	if !i.writeEnabled {
+		return nil, errorcode.New(errorcode.NotAllowed, "server is configured read-only")
+	}
+	ar, err := i.educationClassToAddRequest(class)
+	if err != nil {
+		return nil, err
+	}
+
+	if err := i.conn.Add(ar); err != nil {
+		return nil, err
+	}
+
+	// Read	back group from LDAP to get the generated UUID
+	e, err := i.getEducationClassByDN(ar.DN)
+	if err != nil {
+		return nil, err
+	}
+	return i.createEducationClassModelFromLDAP(e), nil
 }
 
 // GetEducationClass implements the EducationBackend interface for the LDAP backend.
-func (i *LDAP) GetEducationClass(ctx context.Context, namedOrID string, queryParam url.Values) (*libregraph.EducationClass, error) {
-	return nil, errNotImplemented
+func (i *LDAP) GetEducationClass(ctx context.Context, id string, queryParam url.Values) (*libregraph.EducationClass, error) {
+	logger := i.logger.SubloggerWithRequestID(ctx)
+	logger.Debug().Str("backend", "ldap").Msg("GetEducationClass")
+	e, err := i.getEducationClassByID(id, false)
+	if err != nil {
+		return nil, err
+	}
+	var class *libregraph.EducationClass
+	if class = i.createEducationClassModelFromLDAP(e); class == nil {
+		return nil, errorcode.New(errorcode.ItemNotFound, "not found")
+	}
+	return class, nil
 }
 
 // DeleteEducationClass implements the EducationBackend interface for the LDAP backend.
-func (i *LDAP) DeleteEducationClass(ctx context.Context, nameOrID string) error {
-	return errNotImplemented
+func (i *LDAP) DeleteEducationClass(ctx context.Context, id string) error {
+	logger := i.logger.SubloggerWithRequestID(ctx)
+	logger.Debug().Str("backend", "ldap").Msg("DeleteEducationClass")
+	if !i.writeEnabled {
+		return ErrReadOnly
+	}
+	e, err := i.getEducationClassByID(id, false)
+	if err != nil {
+		return err
+	}
+
+	dr := ldap.DelRequest{DN: e.DN}
+	if err = i.conn.Del(&dr); err != nil {
+		return err
+	}
+
+	// TODO update any users that are member of this school
+	return nil
 }
 
 // GetEducationClassMembers implements the EducationBackend interface for the LDAP backend.
-func (i *LDAP) GetEducationClassMembers(ctx context.Context, nameOrID string) ([]*libregraph.EducationUser, error) {
-	return nil, errNotImplemented
+func (i *LDAP) GetEducationClassMembers(ctx context.Context, id string) ([]*libregraph.EducationUser, error) {
+	logger := i.logger.SubloggerWithRequestID(ctx)
+	logger.Debug().Str("backend", "ldap").Msg("GetEducationClassMembers")
+	e, err := i.getEducationClassByID(id, true)
+	if err != nil {
+		return nil, err
+	}
+
+	memberEntries, err := i.expandLDAPGroupMembers(ctx, e)
+	result := make([]*libregraph.EducationUser, 0, len(memberEntries))
+	if err != nil {
+		return nil, err
+	}
+	for _, member := range memberEntries {
+		if u := i.createEducationUserModelFromLDAP(member); u != nil {
+			result = append(result, u)
+		}
+	}
+
+	return result, nil
+}
+
+func (i *LDAP) educationClassToAddRequest(class libregraph.EducationClass) (*ldap.AddRequest, error) {
+	plainGroup := i.educationClassToGroup(class)
+	ldapAttrs, err := i.groupToLDAPAttrValues(*plainGroup)
+	if err != nil {
+		return nil, err
+	}
+	ldapAttrs, err = i.educationClassToLDAPAttrValues(class, ldapAttrs)
+	if err != nil {
+		return nil, err
+	}
+
+	ar := ldap.NewAddRequest(i.getEducationClassLDAPDN(class), nil)
+
+	for attrType, values := range ldapAttrs {
+		ar.Attribute(attrType, values)
+	}
+	return ar, nil
+}
+
+func (i *LDAP) educationClassToGroup(class libregraph.EducationClass) *libregraph.Group {
+	group := libregraph.NewGroup()
+	group.SetDisplayName(class.DisplayName)
+
+	return group
+}
+
+func (i *LDAP) educationClassToLDAPAttrValues(class libregraph.EducationClass, attrs ldapAttributeValues) (ldapAttributeValues, error) {
+	if externalID, ok := class.GetExternalIdOk(); ok {
+		attrs[i.educationConfig.classAttributeMap.externalID] = []string{*externalID}
+	}
+	if classification, ok := class.GetClassificationOk(); ok {
+		attrs[i.educationConfig.classAttributeMap.classification] = []string{*classification}
+	}
+	attrs["objectClass"] = append(attrs["objectClass"], i.educationConfig.classObjectClass)
+	return attrs, nil
+}
+
+func (i *LDAP) getEducationClassAttrTypes(requestMembers bool) []string {
+	attrs := []string{
+		i.groupAttributeMap.name,
+		i.groupAttributeMap.id,
+		i.educationConfig.classAttributeMap.classification,
+		i.educationConfig.classAttributeMap.externalID,
+	}
+	if requestMembers {
+		attrs = append(attrs, i.groupAttributeMap.member)
+	}
+	return attrs
+}
+
+func (i *LDAP) getEducationClassByDN(dn string) (*ldap.Entry, error) {
+	filter := fmt.Sprintf("(objectClass=%s)", i.educationConfig.classObjectClass)
+
+	if i.groupFilter != "" {
+		filter = fmt.Sprintf("(&%s(%s))", filter, i.groupFilter)
+	}
+
+	return i.getEntryByDN(dn, i.getEducationClassAttrTypes(false), filter)
+}
+
+func (i *LDAP) createEducationClassModelFromLDAP(e *ldap.Entry) *libregraph.EducationClass {
+	group := i.createGroupModelFromLDAP(e)
+	return i.groupToEducationClass(*group, e)
+}
+
+func (i *LDAP) groupToEducationClass(group libregraph.Group, e *ldap.Entry) *libregraph.EducationClass {
+	class := libregraph.NewEducationClass(group.GetDisplayName(), "")
+	class.SetId(group.GetId())
+
+	if e != nil {
+		// Set the education User specific Attributes from the supplied LDAP Entry
+		if externalID := e.GetEqualFoldAttributeValue(i.educationConfig.classAttributeMap.externalID); externalID != "" {
+			class.SetExternalId(externalID)
+		}
+		if classification := e.GetEqualFoldAttributeValue(i.educationConfig.classAttributeMap.classification); classification != "" {
+			class.SetClassification(classification)
+		}
+	}
+
+	return class
+}
+
+func (i *LDAP) getEducationClassLDAPDN(class libregraph.EducationClass) string {
+	return fmt.Sprintf("ocEducationExternalId=%s,%s", oldap.EscapeDNAttributeValue(class.GetExternalId()), i.groupBaseDN)
+}
+
+// getEducationClassByID looks up a class by id (and be ID or externalID)
+func (i *LDAP) getEducationClassByID(id string, requestMembers bool) (*ldap.Entry, error) {
+	id = ldap.EscapeFilter(id)
+	filter := fmt.Sprintf("(|(%s=%s)(%s=%s))",
+		i.groupAttributeMap.id, id,
+		i.educationConfig.classAttributeMap.externalID, id)
+	return i.getEducationClassByFilter(filter, requestMembers)
+}
+
+func (i *LDAP) getEducationClassByFilter(filter string, requestMembers bool) (*ldap.Entry, error) {
+	filter = fmt.Sprintf("(&%s(objectClass=%s)%s)", i.groupFilter, i.educationConfig.classObjectClass, filter)
+	return i.searchLDAPEntryByFilter(i.groupBaseDN, i.getEducationClassAttrTypes(requestMembers), filter)
 }
diff --git a/services/graph/pkg/identity/ldap_education_school.go b/services/graph/pkg/identity/ldap_education_school.go
index a50f543fb67..77e1252ab61 100644
--- a/services/graph/pkg/identity/ldap_education_school.go
+++ b/services/graph/pkg/identity/ldap_education_school.go
@@ -26,6 +26,9 @@ type educationConfig struct {
 
 	userObjectClass  string
 	userAttributeMap educationUserAttributeMap
+
+	classObjectClass  string
+	classAttributeMap educationClassAttributeMap
 }
 
 type schoolAttributeMap struct {
@@ -52,6 +55,9 @@ func defaultEducationConfig() educationConfig {
 
 		userObjectClass:  "ocEducationUser",
 		userAttributeMap: newEducationUserAttributeMap(),
+
+		classObjectClass:  "ocEducationClass",
+		classAttributeMap: newEducationClassAttributeMap(),
 	}
 }
 
diff --git a/services/graph/pkg/identity/ldap_group.go b/services/graph/pkg/identity/ldap_group.go
new file mode 100644
index 00000000000..49a0d1e6399
--- /dev/null
+++ b/services/graph/pkg/identity/ldap_group.go
@@ -0,0 +1,504 @@
+package identity
+
+import (
+	"context"
+	"fmt"
+	"net/url"
+	"strings"
+
+	"github.com/go-ldap/ldap/v3"
+	"github.com/gofrs/uuid"
+	ldapdn "github.com/libregraph/idm/pkg/ldapdn"
+	libregraph "github.com/owncloud/libre-graph-api-go"
+	oldap "github.com/owncloud/ocis/v2/ocis-pkg/ldap"
+	"github.com/owncloud/ocis/v2/services/graph/pkg/service/v0/errorcode"
+	"golang.org/x/exp/slices"
+)
+
+type groupAttributeMap struct {
+	name         string
+	id           string
+	member       string
+	memberSyntax string
+}
+
+// GetGroup implements the Backend Interface for the LDAP Backend
+func (i *LDAP) GetGroup(ctx context.Context, nameOrID string, queryParam url.Values) (*libregraph.Group, error) {
+	logger := i.logger.SubloggerWithRequestID(ctx)
+	logger.Debug().Str("backend", "ldap").Msg("GetGroup")
+	e, err := i.getLDAPGroupByNameOrID(nameOrID, true)
+	if err != nil {
+		return nil, err
+	}
+	sel := strings.Split(queryParam.Get("$select"), ",")
+	exp := strings.Split(queryParam.Get("$expand"), ",")
+	var g *libregraph.Group
+	if g = i.createGroupModelFromLDAP(e); g == nil {
+		return nil, errorcode.New(errorcode.ItemNotFound, "not found")
+	}
+	if slices.Contains(sel, "members") || slices.Contains(exp, "members") {
+		members, err := i.expandLDAPGroupMembers(ctx, e)
+		if err != nil {
+			return nil, err
+		}
+		if len(members) > 0 {
+			m := make([]libregraph.User, 0, len(members))
+			for _, ue := range members {
+				if u := i.createUserModelFromLDAP(ue); u != nil {
+					m = append(m, *u)
+				}
+			}
+			g.Members = m
+		}
+	}
+	return g, nil
+}
+
+// GetGroups implements the Backend Interface for the LDAP Backend
+func (i *LDAP) GetGroups(ctx context.Context, queryParam url.Values) ([]*libregraph.Group, error) {
+	logger := i.logger.SubloggerWithRequestID(ctx)
+	logger.Debug().Str("backend", "ldap").Msg("GetGroups")
+
+	search := queryParam.Get("search")
+	if search == "" {
+		search = queryParam.Get("$search")
+	}
+
+	var expandMembers bool
+	sel := strings.Split(queryParam.Get("$select"), ",")
+	exp := strings.Split(queryParam.Get("$expand"), ",")
+	if slices.Contains(sel, "members") || slices.Contains(exp, "members") {
+		expandMembers = true
+	}
+
+	var groupFilter string
+	if search != "" {
+		search = ldap.EscapeFilter(search)
+		groupFilter = fmt.Sprintf(
+			"(|(%s=%s*)(%s=%s*))",
+			i.groupAttributeMap.name, search,
+			i.groupAttributeMap.id, search,
+		)
+	}
+	groupFilter = fmt.Sprintf("(&%s(objectClass=%s)%s)", i.groupFilter, i.groupObjectClass, groupFilter)
+
+	groupAttrs := []string{
+		i.groupAttributeMap.name,
+		i.groupAttributeMap.id,
+	}
+	if expandMembers {
+		groupAttrs = append(groupAttrs, i.groupAttributeMap.member)
+	}
+
+	searchRequest := ldap.NewSearchRequest(
+		i.groupBaseDN, i.groupScope, ldap.NeverDerefAliases, 0, 0, false,
+		groupFilter,
+		groupAttrs,
+		nil,
+	)
+	logger.Debug().Str("backend", "ldap").
+		Str("base", searchRequest.BaseDN).
+		Str("filter", searchRequest.Filter).
+		Int("scope", searchRequest.Scope).
+		Int("sizelimit", searchRequest.SizeLimit).
+		Interface("attributes", searchRequest.Attributes).
+		Msg("GetGroups")
+	res, err := i.conn.Search(searchRequest)
+	if err != nil {
+		return nil, errorcode.New(errorcode.ItemNotFound, err.Error())
+	}
+
+	groups := make([]*libregraph.Group, 0, len(res.Entries))
+
+	var g *libregraph.Group
+	for _, e := range res.Entries {
+		if g = i.createGroupModelFromLDAP(e); g == nil {
+			continue
+		}
+		if expandMembers {
+			members, err := i.expandLDAPGroupMembers(ctx, e)
+			if err != nil {
+				return nil, err
+			}
+			if len(members) > 0 {
+				m := make([]libregraph.User, 0, len(members))
+				for _, ue := range members {
+					if u := i.createUserModelFromLDAP(ue); u != nil {
+						m = append(m, *u)
+					}
+				}
+				g.Members = m
+			}
+		}
+		groups = append(groups, g)
+	}
+	return groups, nil
+}
+
+// GetGroupMembers implements the Backend Interface for the LDAP Backend
+func (i *LDAP) GetGroupMembers(ctx context.Context, groupID string) ([]*libregraph.User, error) {
+	logger := i.logger.SubloggerWithRequestID(ctx)
+	logger.Debug().Str("backend", "ldap").Msg("GetGroupMembers")
+	e, err := i.getLDAPGroupByNameOrID(groupID, true)
+	if err != nil {
+		return nil, err
+	}
+
+	memberEntries, err := i.expandLDAPGroupMembers(ctx, e)
+	result := make([]*libregraph.User, 0, len(memberEntries))
+	if err != nil {
+		return nil, err
+	}
+	for _, member := range memberEntries {
+		if u := i.createUserModelFromLDAP(member); u != nil {
+			result = append(result, u)
+		}
+	}
+
+	return result, nil
+}
+
+// CreateGroup implements the Backend Interface for the LDAP Backend
+// It is currently restricted to managing groups based on the "groupOfNames" ObjectClass.
+// As "groupOfNames" requires a "member" Attribute to be present. Empty Groups (groups
+// without a member) a represented by adding an empty DN as the single member.
+func (i *LDAP) CreateGroup(ctx context.Context, group libregraph.Group) (*libregraph.Group, error) {
+	logger := i.logger.SubloggerWithRequestID(ctx)
+	logger.Debug().Str("backend", "ldap").Msg("create group")
+	if !i.writeEnabled {
+		return nil, errorcode.New(errorcode.NotAllowed, "server is configured read-only")
+	}
+	ar, err := i.groupToAddRequest(group)
+	if err != nil {
+		return nil, err
+	}
+
+	if err := i.conn.Add(ar); err != nil {
+		return nil, err
+	}
+
+	// Read	back group from LDAP to get the generated UUID
+	e, err := i.getGroupByDN(ar.DN)
+	if err != nil {
+		return nil, err
+	}
+	return i.createGroupModelFromLDAP(e), nil
+}
+
+// DeleteGroup implements the Backend Interface.
+func (i *LDAP) DeleteGroup(ctx context.Context, id string) error {
+	logger := i.logger.SubloggerWithRequestID(ctx)
+	logger.Debug().Str("backend", "ldap").Msg("DeleteGroup")
+	if !i.writeEnabled {
+		return errorcode.New(errorcode.NotAllowed, "server is configured read-only")
+	}
+	e, err := i.getLDAPGroupByID(id, false)
+	if err != nil {
+		return err
+	}
+	dr := ldap.DelRequest{DN: e.DN}
+	if err = i.conn.Del(&dr); err != nil {
+		return err
+	}
+	return nil
+}
+
+// AddMembersToGroup implements the Backend Interface for the LDAP backend.
+// Currently it is limited to adding Users as Group members. Adding other groups
+// as members is not yet implemented
+func (i *LDAP) AddMembersToGroup(ctx context.Context, groupID string, memberIDs []string) error {
+	logger := i.logger.SubloggerWithRequestID(ctx)
+	logger.Debug().Str("backend", "ldap").Msg("AddMembersToGroup")
+	ge, err := i.getLDAPGroupByID(groupID, true)
+	if err != nil {
+		return err
+	}
+
+	mr := ldap.ModifyRequest{DN: ge.DN}
+	// Handle empty groups (using the empty member attribute)
+	current := ge.GetEqualFoldAttributeValues(i.groupAttributeMap.member)
+	if len(current) == 1 && current[0] == "" {
+		mr.Delete(i.groupAttributeMap.member, []string{""})
+	}
+
+	// Create a Set of current members for faster lookups
+	currentSet := make(map[string]struct{}, len(current))
+	for _, currentMember := range current {
+		// We can ignore any empty member value here
+		if currentMember == "" {
+			continue
+		}
+		nCurrentMember, err := ldapdn.ParseNormalize(currentMember)
+		if err != nil {
+			// We couldn't parse the member value as a DN. Let's skip it, but log a warning
+			logger.Warn().Str("memberDN", currentMember).Err(err).Msg("Couldn't parse DN")
+			continue
+		}
+		currentSet[nCurrentMember] = struct{}{}
+	}
+
+	var newMemberDN []string
+	for _, memberID := range memberIDs {
+		me, err := i.getLDAPUserByID(memberID)
+		if err != nil {
+			return err
+		}
+		nDN, err := ldapdn.ParseNormalize(me.DN)
+		if err != nil {
+			logger.Error().Str("new member", me.DN).Err(err).Msg("Couldn't parse DN")
+			return err
+		}
+		if _, present := currentSet[nDN]; !present {
+			newMemberDN = append(newMemberDN, me.DN)
+		} else {
+			logger.Debug().Str("memberDN", me.DN).Msg("Member already present in group. Skipping")
+		}
+	}
+
+	if len(newMemberDN) > 0 {
+		mr.Add(i.groupAttributeMap.member, newMemberDN)
+
+		if err := i.conn.Modify(&mr); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+// RemoveMemberFromGroup implements the Backend Interface.
+func (i *LDAP) RemoveMemberFromGroup(ctx context.Context, groupID string, memberID string) error {
+	logger := i.logger.SubloggerWithRequestID(ctx)
+	logger.Debug().Str("backend", "ldap").Msg("RemoveMemberFromGroup")
+	ge, err := i.getLDAPGroupByID(groupID, true)
+	if err != nil {
+		logger.Debug().Str("backend", "ldap").Str("groupID", groupID).Msg("Error looking up group")
+		return err
+	}
+	me, err := i.getLDAPUserByID(memberID)
+	if err != nil {
+		logger.Debug().Str("backend", "ldap").Str("memberID", memberID).Msg("Error looking up group member")
+		return err
+	}
+	logger.Debug().Str("backend", "ldap").Str("groupdn", ge.DN).Str("member", me.DN).Msg("remove member")
+
+	if mr, err := i.removeMemberFromGroupEntry(ge, me.DN); err == nil {
+		return i.conn.Modify(mr)
+	}
+	return nil
+}
+
+func (i *LDAP) groupToAddRequest(group libregraph.Group) (*ldap.AddRequest, error) {
+	ar := ldap.NewAddRequest(i.getGroupLDAPDN(group), nil)
+
+	attrMap, err := i.groupToLDAPAttrValues(group)
+	if err != nil {
+		return nil, err
+	}
+	for attrType, values := range attrMap {
+		ar.Attribute(attrType, values)
+	}
+	return ar, nil
+}
+
+func (i *LDAP) getGroupLDAPDN(group libregraph.Group) string {
+	return fmt.Sprintf("cn=%s,%s", oldap.EscapeDNAttributeValue(group.GetDisplayName()), i.groupBaseDN)
+}
+
+func (i *LDAP) groupToLDAPAttrValues(group libregraph.Group) (map[string][]string, error) {
+	attrs := map[string][]string{
+		i.groupAttributeMap.name: {group.GetDisplayName()},
+		"objectClass":            {"groupOfNames", "top"},
+		// This is a crutch to allow groups without members for LDAP servers
+		// that apply strict Schema checking. The RFCs define "member/uniqueMember"
+		// as required attribute for groupOfNames/groupOfUniqueNames. So we
+		// add an empty string (which is a valid DN) as the initial member.
+		// It will be replaced once real members are added.
+		// We might wanna use the newer, but not so broadly used "groupOfMembers"
+		// objectclass (RFC2307bis-02) where "member" is optional.
+		i.groupAttributeMap.member: {""},
+	}
+
+	if !i.useServerUUID {
+		attrs["owncloudUUID"] = []string{uuid.Must(uuid.NewV4()).String()}
+		attrs["objectClass"] = append(attrs["objectClass"], "owncloud")
+	}
+	return attrs, nil
+}
+
+func (i *LDAP) expandLDAPGroupMembers(ctx context.Context, e *ldap.Entry) ([]*ldap.Entry, error) {
+	logger := i.logger.SubloggerWithRequestID(ctx)
+	logger.Debug().Str("backend", "ldap").Msg("expandLDAPGroupMembers")
+	result := []*ldap.Entry{}
+
+	for _, memberDN := range e.GetEqualFoldAttributeValues(i.groupAttributeMap.member) {
+		if memberDN == "" {
+			continue
+		}
+		logger.Debug().Str("memberDN", memberDN).Msg("lookup")
+		ue, err := i.getUserByDN(memberDN)
+		if err != nil {
+			// Ignore errors when reading a specific member fails, just log them and continue
+			logger.Debug().Err(err).Str("member", memberDN).Msg("error reading group member")
+			continue
+		}
+		result = append(result, ue)
+	}
+
+	return result, nil
+}
+
+func (i *LDAP) getLDAPGroupByID(id string, requestMembers bool) (*ldap.Entry, error) {
+	id = ldap.EscapeFilter(id)
+	filter := fmt.Sprintf("(%s=%s)", i.groupAttributeMap.id, id)
+	return i.getLDAPGroupByFilter(filter, requestMembers)
+}
+
+func (i *LDAP) getLDAPGroupByNameOrID(nameOrID string, requestMembers bool) (*ldap.Entry, error) {
+	nameOrID = ldap.EscapeFilter(nameOrID)
+	filter := fmt.Sprintf("(|(%s=%s)(%s=%s))", i.groupAttributeMap.name, nameOrID, i.groupAttributeMap.id, nameOrID)
+	return i.getLDAPGroupByFilter(filter, requestMembers)
+}
+
+func (i *LDAP) getLDAPGroupByFilter(filter string, requestMembers bool) (*ldap.Entry, error) {
+	e, err := i.getLDAPGroupsByFilter(filter, requestMembers, true)
+	if err != nil {
+		return nil, err
+	}
+	if len(e) == 0 {
+		return nil, errorcode.New(errorcode.ItemNotFound, "not found")
+	}
+
+	return e[0], nil
+}
+
+// Search for LDAP Groups matching the specified filter, if requestMembers is true the groupMemberShip
+// attribute will be part of the result attributes. The LDAP filter is combined with the configured groupFilter
+// resulting in a filter like "(&(LDAP.groupFilter)(objectClass=LDAP.groupObjectClass)(<filter_from_args>))"
+func (i *LDAP) getLDAPGroupsByFilter(filter string, requestMembers, single bool) ([]*ldap.Entry, error) {
+	attrs := []string{
+		i.groupAttributeMap.name,
+		i.groupAttributeMap.id,
+	}
+
+	if requestMembers {
+		attrs = append(attrs, i.groupAttributeMap.member)
+	}
+
+	sizelimit := 0
+	if single {
+		sizelimit = 1
+	}
+	searchRequest := ldap.NewSearchRequest(
+		i.groupBaseDN, i.groupScope, ldap.NeverDerefAliases, sizelimit, 0, false,
+		fmt.Sprintf("(&%s(objectClass=%s)%s)", i.groupFilter, i.groupObjectClass, filter),
+		attrs,
+		nil,
+	)
+	i.logger.Debug().Str("backend", "ldap").
+		Str("base", searchRequest.BaseDN).
+		Str("filter", searchRequest.Filter).
+		Int("scope", searchRequest.Scope).
+		Int("sizelimit", searchRequest.SizeLimit).
+		Interface("attributes", searchRequest.Attributes).
+		Msg("getLDAPGroupsByFilter")
+	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'", filter)
+				i.logger.Debug().Str("backend", "ldap").Err(lerr).Msg(errmsg)
+			}
+		}
+		return nil, errorcode.New(errorcode.ItemNotFound, errmsg)
+	}
+	return res.Entries, nil
+}
+
+// removeMemberFromGroupEntry creates an LDAP Modify request (not sending it)
+// that would update the supplied entry to remove the specified member from the
+// group
+func (i *LDAP) removeMemberFromGroupEntry(group *ldap.Entry, memberDN string) (*ldap.ModifyRequest, error) {
+	nOldMemberDN, err := ldapdn.ParseNormalize(memberDN)
+	if err != nil {
+		return nil, err
+	}
+	members := group.GetEqualFoldAttributeValues(i.groupAttributeMap.member)
+	found := false
+	for _, member := range members {
+		if member == "" {
+			continue
+		}
+		if nMember, err := ldapdn.ParseNormalize(member); err != nil {
+			// We couldn't parse the member value as a DN. Let's keep it
+			// as it is but log a warning
+			i.logger.Warn().Str("memberDN", member).Err(err).Msg("Couldn't parse DN")
+			continue
+		} else {
+			if nMember == nOldMemberDN {
+				found = true
+			}
+		}
+	}
+	if !found {
+		i.logger.Debug().Str("backend", "ldap").Str("groupdn", group.DN).Str("member", memberDN).
+			Msg("The target is not a member of the group")
+		return nil, ErrNotFound
+	}
+
+	mr := ldap.ModifyRequest{DN: group.DN}
+	if len(members) == 1 {
+		mr.Add(i.groupAttributeMap.member, []string{""})
+	}
+	mr.Delete(i.groupAttributeMap.member, []string{memberDN})
+	return &mr, nil
+}
+
+func (i *LDAP) getGroupByDN(dn string) (*ldap.Entry, error) {
+	attrs := []string{
+		i.groupAttributeMap.id,
+		i.groupAttributeMap.name,
+	}
+	filter := fmt.Sprintf("(objectClass=%s)", i.groupObjectClass)
+
+	if i.groupFilter != "" {
+		filter = fmt.Sprintf("(&%s(%s))", filter, i.groupFilter)
+	}
+	return i.getEntryByDN(dn, attrs, filter)
+}
+
+func (i *LDAP) getGroupsForUser(dn string) ([]*ldap.Entry, error) {
+	groupFilter := fmt.Sprintf(
+		"(%s=%s)",
+		i.groupAttributeMap.member, dn,
+	)
+	userGroups, err := i.getLDAPGroupsByFilter(groupFilter, false, false)
+	if err != nil {
+		return nil, err
+	}
+	return userGroups, nil
+}
+
+func (i *LDAP) createGroupModelFromLDAP(e *ldap.Entry) *libregraph.Group {
+	name := e.GetEqualFoldAttributeValue(i.groupAttributeMap.name)
+	id := e.GetEqualFoldAttributeValue(i.groupAttributeMap.id)
+
+	if id != "" && name != "" {
+		return &libregraph.Group{
+			DisplayName: &name,
+			Id:          &id,
+		}
+	}
+	i.logger.Warn().Str("dn", e.DN).Msg("Group is missing name or id")
+	return nil
+}
+
+func (i *LDAP) groupsFromLDAPEntries(e []*ldap.Entry) []libregraph.Group {
+	groups := make([]libregraph.Group, 0, len(e))
+	for _, g := range e {
+		if grp := i.createGroupModelFromLDAP(g); grp != nil {
+			groups = append(groups, *grp)
+		}
+	}
+	return groups
+}
diff --git a/services/sharing/pkg/revaconfig/config.go b/services/sharing/pkg/revaconfig/config.go
index eb24939c873..8af76974c55 100644
--- a/services/sharing/pkg/revaconfig/config.go
+++ b/services/sharing/pkg/revaconfig/config.go
@@ -69,6 +69,12 @@ func SharingConfigFromStruct(cfg *config.Config) map[string]interface{} {
 							"service_user_idp":    cfg.UserSharingDrivers.JSONCS3.SystemUserIDP,
 							"machine_auth_apikey": cfg.UserSharingDrivers.JSONCS3.SystemUserAPIKey,
 							"ttl":                 cfg.UserSharingDrivers.JSONCS3.CacheTTL,
+							"events": map[string]interface{}{
+								"natsaddress":          cfg.Events.Addr,
+								"natsclusterid":        cfg.Events.ClusterID,
+								"tlsinsecure":          cfg.Events.TLSInsecure,
+								"tlsrootcacertificate": cfg.Events.TLSRootCaCertPath,
+							},
 						},
 					},
 				},


Test patch:
diff --git a/services/graph/pkg/identity/ldap_education_class_test.go b/services/graph/pkg/identity/ldap_education_class_test.go
new file mode 100644
index 00000000000..f7271302153
--- /dev/null
+++ b/services/graph/pkg/identity/ldap_education_class_test.go
@@ -0,0 +1,302 @@
+package identity
+
+import (
+	"context"
+	"errors"
+	"net/url"
+	"testing"
+
+	"github.com/go-ldap/ldap/v3"
+	libregraph "github.com/owncloud/libre-graph-api-go"
+	"github.com/owncloud/ocis/v2/services/graph/mocks"
+	"github.com/test-go/testify/assert"
+	"github.com/test-go/testify/mock"
+)
+
+var classEntry = ldap.NewEntry("ocEducationExternalId=Math0123",
+	map[string][]string{
+		"cn":                    {"Math"},
+		"ocEducationExternalId": {"Math0123"},
+		"ocEducationClassType":  {"course"},
+		"entryUUID":             {"abcd-defg"},
+	})
+var classEntryWithMember = ldap.NewEntry("ocEducationExternalId=Math0123",
+	map[string][]string{
+		"cn":                    {"Math"},
+		"ocEducationExternalId": {"Math0123"},
+		"ocEducationClassType":  {"course"},
+		"entryUUID":             {"abcd-defg"},
+		"member":                {"uid=user"},
+	})
+
+func TestCreateEducationClass(t *testing.T) {
+	lm := &mocks.Client{}
+	lm.On("Add", mock.Anything).
+		Return(nil)
+
+	lm.On("Search", mock.Anything).
+		Return(
+			&ldap.SearchResult{
+				Entries: []*ldap.Entry{classEntry},
+			},
+			nil)
+
+	b, err := getMockedBackend(lm, eduConfig, &logger)
+	assert.Nil(t, err)
+	assert.NotEqual(t, "", b.educationConfig.classObjectClass)
+	class := libregraph.NewEducationClass("Math", "course")
+	class.SetExternalId("Math0123")
+	class.SetId("abcd-defg")
+	res_class, err := b.CreateEducationClass(context.Background(), *class)
+	lm.AssertNumberOfCalls(t, "Add", 1)
+	lm.AssertNumberOfCalls(t, "Search", 1)
+	assert.Nil(t, err)
+	assert.NotNil(t, res_class)
+	assert.Equal(t, res_class.GetDisplayName(), class.GetDisplayName())
+	assert.Equal(t, res_class.GetId(), class.GetId())
+	assert.Equal(t, res_class.GetExternalId(), class.GetExternalId())
+	assert.Equal(t, res_class.GetClassification(), class.GetClassification())
+}
+
+func TestGetEducationClasses(t *testing.T) {
+	lm := &mocks.Client{}
+	lm.On("Search", mock.Anything).Return(nil, ldap.NewError(ldap.LDAPResultOperationsError, errors.New("mock")))
+	b, _ := getMockedBackend(lm, lconfig, &logger)
+	_, err := b.GetEducationClasses(context.Background(), url.Values{})
+	if err == nil || err.Error() != "itemNotFound" {
+		t.Errorf("Expected 'itemNotFound' got '%s'", err.Error())
+	}
+
+	lm = &mocks.Client{}
+	lm.On("Search", mock.Anything).Return(&ldap.SearchResult{}, nil)
+	b, _ = getMockedBackend(lm, lconfig, &logger)
+	g, err := b.GetEducationClasses(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")
+	}
+
+	lm = &mocks.Client{}
+	lm.On("Search", mock.Anything).Return(&ldap.SearchResult{
+		Entries: []*ldap.Entry{classEntry},
+	}, nil)
+	b, _ = getMockedBackend(lm, lconfig, &logger)
+	g, err = b.GetEducationClasses(context.Background(), url.Values{})
+	if err != nil {
+		t.Errorf("Expected GetEducationClasses to succeed. Got %s", err.Error())
+	} else if *g[0].Id != classEntry.GetEqualFoldAttributeValue(b.groupAttributeMap.id) {
+		t.Errorf("Expected GetEducationClasses to return a valid group")
+	}
+}
+
+func TestGetEducationClass(t *testing.T) {
+	tests := []struct {
+		name                 string
+		id                   string
+		filter               string
+		expectedItemNotFound bool
+	}{
+		{
+			name:                 "Test search class using id",
+			id:                   "abcd-defg",
+			filter:               "(&(objectClass=ocEducationClass)(|(entryUUID=abcd-defg)(ocEducationExternalId=abcd-defg)))",
+			expectedItemNotFound: false,
+		},
+		{
+			name:                 "Test search class using unknown Id",
+			id:                   "xxxx-xxxx",
+			filter:               "(&(objectClass=ocEducationClass)(|(entryUUID=xxxx-xxxx)(ocEducationExternalId=xxxx-xxxx)))",
+			expectedItemNotFound: true,
+		},
+		{
+			name:                 "Test search class using external ID",
+			id:                   "Math0123",
+			filter:               "(&(objectClass=ocEducationClass)(|(entryUUID=Math0123)(ocEducationExternalId=Math0123)))",
+			expectedItemNotFound: false,
+		},
+		{
+			name:                 "Test search school using unknown externalID",
+			id:                   "Unknown3210",
+			filter:               "(&(objectClass=ocEducationClass)(|(entryUUID=Unknown3210)(ocEducationExternalId=Unknown3210)))",
+			expectedItemNotFound: true,
+		},
+	}
+
+	for _, tt := range tests {
+		lm := &mocks.Client{}
+		sr := &ldap.SearchRequest{
+			BaseDN:     "ou=groups,dc=test",
+			Scope:      2,
+			SizeLimit:  1,
+			Filter:     tt.filter,
+			Attributes: []string{"cn", "entryUUID", "ocEducationClassType", "ocEducationExternalId"},
+			Controls:   []ldap.Control(nil),
+		}
+		if tt.expectedItemNotFound {
+			lm.On("Search", sr).Return(&ldap.SearchResult{Entries: []*ldap.Entry{}}, nil)
+		} else {
+			lm.On("Search", sr).Return(&ldap.SearchResult{Entries: []*ldap.Entry{classEntry}}, nil)
+		}
+
+		b, err := getMockedBackend(lm, eduConfig, &logger)
+		assert.Nil(t, err)
+
+		class, err := b.GetEducationClass(context.Background(), tt.id, nil)
+		lm.AssertNumberOfCalls(t, "Search", 1)
+
+		if tt.expectedItemNotFound {
+			assert.NotNil(t, err)
+			assert.Equal(t, "itemNotFound", err.Error())
+		} else {
+			assert.Nil(t, err)
+			assert.Equal(t, "Math", class.GetDisplayName())
+			assert.Equal(t, "abcd-defg", class.GetId())
+			assert.Equal(t, "Math0123", class.GetExternalId())
+		}
+	}
+}
+
+func TestDeleteEducationClass(t *testing.T) {
+	tests := []struct {
+		name                 string
+		id                   string
+		filter               string
+		expectedItemNotFound bool
+	}{
+		{
+			name:                 "Test search class using id",
+			id:                   "abcd-defg",
+			filter:               "(&(objectClass=ocEducationClass)(|(entryUUID=abcd-defg)(ocEducationExternalId=abcd-defg)))",
+			expectedItemNotFound: false,
+		},
+		{
+			name:                 "Test search class using unknown Id",
+			id:                   "xxxx-xxxx",
+			filter:               "(&(objectClass=ocEducationClass)(|(entryUUID=xxxx-xxxx)(ocEducationExternalId=xxxx-xxxx)))",
+			expectedItemNotFound: true,
+		},
+		{
+			name:                 "Test search class using external ID",
+			id:                   "Math0123",
+			filter:               "(&(objectClass=ocEducationClass)(|(entryUUID=Math0123)(ocEducationExternalId=Math0123)))",
+			expectedItemNotFound: false,
+		},
+		{
+			name:                 "Test search school using unknown externalID",
+			id:                   "Unknown3210",
+			filter:               "(&(objectClass=ocEducationClass)(|(entryUUID=Unknown3210)(ocEducationExternalId=Unknown3210)))",
+			expectedItemNotFound: true,
+		},
+	}
+
+	for _, tt := range tests {
+		lm := &mocks.Client{}
+		sr := &ldap.SearchRequest{
+			BaseDN:     "ou=groups,dc=test",
+			Scope:      2,
+			SizeLimit:  1,
+			Filter:     tt.filter,
+			Attributes: []string{"cn", "entryUUID", "ocEducationClassType", "ocEducationExternalId"},
+			Controls:   []ldap.Control(nil),
+		}
+		if tt.expectedItemNotFound {
+			lm.On("Search", sr).Return(&ldap.SearchResult{Entries: []*ldap.Entry{}}, nil)
+		} else {
+			lm.On("Search", sr).Return(&ldap.SearchResult{Entries: []*ldap.Entry{classEntry}}, nil)
+		}
+		dr := &ldap.DelRequest{
+			DN: "ocEducationExternalId=Math0123",
+		}
+		lm.On("Del", dr).Return(nil)
+
+		b, err := getMockedBackend(lm, eduConfig, &logger)
+		assert.Nil(t, err)
+
+		err = b.DeleteEducationClass(context.Background(), tt.id)
+		lm.AssertNumberOfCalls(t, "Search", 1)
+
+		if tt.expectedItemNotFound {
+			lm.AssertNumberOfCalls(t, "Del", 0)
+			assert.NotNil(t, err)
+			assert.Equal(t, "itemNotFound", err.Error())
+		} else {
+			assert.Nil(t, err)
+		}
+	}
+}
+
+func TestGetEducationClassMembers(t *testing.T) {
+	tests := []struct {
+		name                 string
+		id                   string
+		filter               string
+		expectedItemNotFound bool
+	}{
+		{
+			name:                 "Test search class using id",
+			id:                   "abcd-defg",
+			filter:               "(&(objectClass=ocEducationClass)(|(entryUUID=abcd-defg)(ocEducationExternalId=abcd-defg)))",
+			expectedItemNotFound: false,
+		},
+		{
+			name:                 "Test search class using unknown Id",
+			id:                   "xxxx-xxxx",
+			filter:               "(&(objectClass=ocEducationClass)(|(entryUUID=xxxx-xxxx)(ocEducationExternalId=xxxx-xxxx)))",
+			expectedItemNotFound: true,
+		},
+		{
+			name:                 "Test search class using external ID",
+			id:                   "Math0123",
+			filter:               "(&(objectClass=ocEducationClass)(|(entryUUID=Math0123)(ocEducationExternalId=Math0123)))",
+			expectedItemNotFound: false,
+		},
+		{
+			name:                 "Test search school using unknown externalID",
+			id:                   "Unknown3210",
+			filter:               "(&(objectClass=ocEducationClass)(|(entryUUID=Unknown3210)(ocEducationExternalId=Unknown3210)))",
+			expectedItemNotFound: true,
+		},
+	}
+
+	for _, tt := range tests {
+		lm := &mocks.Client{}
+		user_sr := &ldap.SearchRequest{
+			BaseDN:     "uid=user",
+			Scope:      0,
+			SizeLimit:  1,
+			Filter:     "(objectClass=inetOrgPerson)",
+			Attributes: []string{"displayname", "entryUUID", "mail", "uid", "sn", "givenname"},
+			Controls:   []ldap.Control(nil),
+		}
+		lm.On("Search", user_sr).Return(&ldap.SearchResult{Entries: []*ldap.Entry{userEntry}}, nil)
+		sr := &ldap.SearchRequest{
+			BaseDN:     "ou=groups,dc=test",
+			Scope:      2,
+			SizeLimit:  1,
+			Filter:     tt.filter,
+			Attributes: []string{"cn", "entryUUID", "ocEducationClassType", "ocEducationExternalId", "member"},
+			Controls:   []ldap.Control(nil),
+		}
+		if tt.expectedItemNotFound {
+			lm.On("Search", sr).Return(&ldap.SearchResult{Entries: []*ldap.Entry{}}, nil)
+		} else {
+			lm.On("Search", sr).Return(&ldap.SearchResult{Entries: []*ldap.Entry{classEntryWithMember}}, nil)
+		}
+
+		b, err := getMockedBackend(lm, eduConfig, &logger)
+		assert.Nil(t, err)
+
+		users, err := b.GetEducationClassMembers(context.Background(), tt.id)
+
+		if tt.expectedItemNotFound {
+			lm.AssertNumberOfCalls(t, "Search", 1)
+			assert.NotNil(t, err)
+			assert.Equal(t, "itemNotFound", err.Error())
+		} else {
+			lm.AssertNumberOfCalls(t, "Search", 2)
+			assert.Nil(t, err)
+			assert.Equal(t, len(users), 1)
+		}
+	}
+}
diff --git a/services/graph/pkg/identity/ldap_group_test.go b/services/graph/pkg/identity/ldap_group_test.go
new file mode 100644
index 00000000000..da79a9772a0
--- /dev/null
+++ b/services/graph/pkg/identity/ldap_group_test.go
@@ -0,0 +1,226 @@
+package identity
+
+import (
+	"context"
+	"errors"
+	"net/url"
+	"testing"
+
+	"github.com/go-ldap/ldap/v3"
+	"github.com/owncloud/ocis/v2/services/graph/mocks"
+	"github.com/test-go/testify/mock"
+)
+
+var groupEntry = ldap.NewEntry("cn=group",
+	map[string][]string{
+		"cn":        {"group"},
+		"entryuuid": {"abcd-defg"},
+		"member": {
+			"uid=user,ou=people,dc=test",
+			"uid=invalid,ou=people,dc=test",
+		},
+	})
+var invalidGroupEntry = ldap.NewEntry("cn=invalid",
+	map[string][]string{
+		"cn": {"invalid"},
+	})
+
+func TestGetGroup(t *testing.T) {
+	// Mock a Sizelimit Error
+	lm := &mocks.Client{}
+	lm.On("Search", mock.Anything).Return(nil, ldap.NewError(ldap.LDAPResultSizeLimitExceeded, errors.New("mock")))
+
+	queryParamExpand := url.Values{
+		"$expand": []string{"members"},
+	}
+	queryParamSelect := url.Values{
+		"$select": []string{"members"},
+	}
+	b, _ := getMockedBackend(lm, lconfig, &logger)
+	_, err := b.GetGroup(context.Background(), "group", nil)
+	if err == nil || err.Error() != "itemNotFound" {
+		t.Errorf("Expected 'itemNotFound' got '%s'", err.Error())
+	}
+	_, err = b.GetGroup(context.Background(), "group", queryParamExpand)
+	if err == nil || err.Error() != "itemNotFound" {
+		t.Errorf("Expected 'itemNotFound' got '%s'", err.Error())
+	}
+	_, err = b.GetGroup(context.Background(), "group", queryParamSelect)
+	if err == nil || err.Error() != "itemNotFound" {
+		t.Errorf("Expected 'itemNotFound' got '%s'", err.Error())
+	}
+
+	// Mock an empty Search Result
+	lm = &mocks.Client{}
+	lm.On("Search", mock.Anything).Return(&ldap.SearchResult{}, nil)
+	b, _ = getMockedBackend(lm, lconfig, &logger)
+	_, err = b.GetGroup(context.Background(), "group", nil)
+	if err == nil || err.Error() != "itemNotFound" {
+		t.Errorf("Expected 'itemNotFound' got '%s'", err.Error())
+	}
+	_, err = b.GetGroup(context.Background(), "group", queryParamExpand)
+	if err == nil || err.Error() != "itemNotFound" {
+		t.Errorf("Expected 'itemNotFound' got '%s'", err.Error())
+	}
+	_, err = b.GetGroup(context.Background(), "group", queryParamSelect)
+	if err == nil || err.Error() != "itemNotFound" {
+		t.Errorf("Expected 'itemNotFound' got '%s'", err.Error())
+	}
+
+	// Mock an invalid Search Result
+	lm = &mocks.Client{}
+	lm.On("Search", mock.Anything).Return(&ldap.SearchResult{
+		Entries: []*ldap.Entry{invalidGroupEntry},
+	}, nil)
+	b, _ = getMockedBackend(lm, lconfig, &logger)
+	g, err := b.GetGroup(context.Background(), "group", nil)
+	if err == nil || err.Error() != "itemNotFound" {
+		t.Errorf("Expected 'itemNotFound' got '%s'", err.Error())
+	}
+	g, err = b.GetGroup(context.Background(), "group", queryParamExpand)
+	if err == nil || err.Error() != "itemNotFound" {
+		t.Errorf("Expected 'itemNotFound' got '%s'", err.Error())
+	}
+	g, err = b.GetGroup(context.Background(), "group", queryParamSelect)
+	if err == nil || err.Error() != "itemNotFound" {
+		t.Errorf("Expected 'itemNotFound' got '%s'", err.Error())
+	}
+
+	// Mock a valid	Search Result
+	lm = &mocks.Client{}
+	sr1 := &ldap.SearchRequest{
+		BaseDN:     "ou=groups,dc=test",
+		Scope:      2,
+		SizeLimit:  1,
+		Filter:     "(&(objectClass=groupOfNames)(|(cn=group)(entryUUID=group)))",
+		Attributes: []string{"cn", "entryUUID", "member"},
+		Controls:   []ldap.Control(nil),
+	}
+	sr2 := &ldap.SearchRequest{
+		BaseDN:     "uid=user,ou=people,dc=test",
+		SizeLimit:  1,
+		Filter:     "(objectClass=inetOrgPerson)",
+		Attributes: []string{"displayname", "entryUUID", "mail", "uid", "sn", "givenname"},
+		Controls:   []ldap.Control(nil),
+	}
+	sr3 := &ldap.SearchRequest{
+		BaseDN:     "uid=invalid,ou=people,dc=test",
+		SizeLimit:  1,
+		Filter:     "(objectClass=inetOrgPerson)",
+		Attributes: []string{"displayname", "entryUUID", "mail", "uid", "sn", "givenname"},
+		Controls:   []ldap.Control(nil),
+	}
+
+	lm.On("Search", sr1).Return(&ldap.SearchResult{Entries: []*ldap.Entry{groupEntry}}, nil)
+	lm.On("Search", sr2).Return(&ldap.SearchResult{Entries: []*ldap.Entry{userEntry}}, nil)
+	lm.On("Search", sr3).Return(&ldap.SearchResult{Entries: []*ldap.Entry{invalidUserEntry}}, nil)
+	b, _ = getMockedBackend(lm, lconfig, &logger)
+	g, err = b.GetGroup(context.Background(), "group", nil)
+	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")
+	}
+	g, err = b.GetGroup(context.Background(), "group", queryParamExpand)
+	switch {
+	case err != nil:
+		t.Errorf("Expected GetGroup to succeed. Got %s", err.Error())
+	case g.GetId() != groupEntry.GetEqualFoldAttributeValue(b.groupAttributeMap.id):
+		t.Errorf("Expected GetGroup to return a valid group")
+	case len(g.Members) != 1:
+		t.Errorf("Expected GetGroup with expand to return one member")
+	case g.Members[0].GetId() != userEntry.GetEqualFoldAttributeValue(b.userAttributeMap.id):
+		t.Errorf("Expected GetGroup with expand to return correct member")
+	}
+	g, err = b.GetGroup(context.Background(), "group", queryParamSelect)
+	switch {
+	case err != nil:
+		t.Errorf("Expected GetGroup to succeed. Got %s", err.Error())
+	case g.GetId() != groupEntry.GetEqualFoldAttributeValue(b.groupAttributeMap.id):
+		t.Errorf("Expected GetGroup to return a valid group")
+	case len(g.Members) != 1:
+		t.Errorf("Expected GetGroup with expand to return one member")
+	case g.Members[0].GetId() != userEntry.GetEqualFoldAttributeValue(b.userAttributeMap.id):
+		t.Errorf("Expected GetGroup with expand to return correct member")
+	}
+}
+
+func TestGetGroups(t *testing.T) {
+	queryParamExpand := url.Values{
+		"$expand": []string{"members"},
+	}
+	queryParamSelect := url.Values{
+		"$select": []string{"members"},
+	}
+	lm := &mocks.Client{}
+	lm.On("Search", mock.Anything).Return(nil, ldap.NewError(ldap.LDAPResultOperationsError, errors.New("mock")))
+	b, _ := getMockedBackend(lm, lconfig, &logger)
+	_, err := b.GetGroups(context.Background(), url.Values{})
+	if err == nil || err.Error() != "itemNotFound" {
+		t.Errorf("Expected 'itemNotFound' got '%s'", err.Error())
+	}
+
+	lm = &mocks.Client{}
+	lm.On("Search", mock.Anything).Return(&ldap.SearchResult{}, nil)
+	b, _ = getMockedBackend(lm, 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")
+	}
+
+	lm = &mocks.Client{}
+	lm.On("Search", mock.Anything).Return(&ldap.SearchResult{
+		Entries: []*ldap.Entry{groupEntry},
+	}, nil)
+	b, _ = getMockedBackend(lm, lconfig, &logger)
+	g, err = b.GetGroups(context.Background(), url.Values{})
+	if err != nil {
+		t.Errorf("Expected GetGroup to succeed. Got %s", err.Error())
+	} else if *g[0].Id != groupEntry.GetEqualFoldAttributeValue(b.groupAttributeMap.id) {
+		t.Errorf("Expected GetGroup to return a valid group")
+	}
+
+	// Mock a valid	Search Result with expanded group members
+	lm = &mocks.Client{}
+	sr1 := &ldap.SearchRequest{
+		BaseDN:     "ou=groups,dc=test",
+		Scope:      2,
+		Filter:     "(&(objectClass=groupOfNames))",
+		Attributes: []string{"cn", "entryUUID", "member"},
+		Controls:   []ldap.Control(nil),
+	}
+	sr2 := &ldap.SearchRequest{
+		BaseDN:     "uid=user,ou=people,dc=test",
+		SizeLimit:  1,
+		Filter:     "(objectClass=inetOrgPerson)",
+		Attributes: []string{"displayname", "entryUUID", "mail", "uid", "sn", "givenname"},
+		Controls:   []ldap.Control(nil),
+	}
+	sr3 := &ldap.SearchRequest{
+		BaseDN:     "uid=invalid,ou=people,dc=test",
+		SizeLimit:  1,
+		Filter:     "(objectClass=inetOrgPerson)",
+		Attributes: []string{"displayname", "entryUUID", "mail", "uid", "sn", "givenname"},
+		Controls:   []ldap.Control(nil),
+	}
+
+	for _, param := range []url.Values{queryParamSelect, queryParamExpand} {
+		lm.On("Search", sr1).Return(&ldap.SearchResult{Entries: []*ldap.Entry{groupEntry}}, nil)
+		lm.On("Search", sr2).Return(&ldap.SearchResult{Entries: []*ldap.Entry{userEntry}}, nil)
+		lm.On("Search", sr3).Return(&ldap.SearchResult{Entries: []*ldap.Entry{invalidUserEntry}}, nil)
+		b, _ = getMockedBackend(lm, lconfig, &logger)
+		g, err = b.GetGroups(context.Background(), param)
+		switch {
+		case err != nil:
+			t.Errorf("Expected GetGroup to succeed. Got %s", err.Error())
+		case g[0].GetId() != groupEntry.GetEqualFoldAttributeValue(b.groupAttributeMap.id):
+			t.Errorf("Expected GetGroup to return a valid group")
+		case len(g[0].Members) != 1:
+			t.Errorf("Expected GetGroup to return group with one member")
+		case g[0].Members[0].GetId() != userEntry.GetEqualFoldAttributeValue(b.userAttributeMap.id):
+			t.Errorf("Expected GetGroup to return group with correct member")
+		}
+	}
+}
diff --git a/services/graph/pkg/identity/ldap_test.go b/services/graph/pkg/identity/ldap_test.go
index e36466d17e9..1f92e50213e 100644
--- a/services/graph/pkg/identity/ldap_test.go
+++ b/services/graph/pkg/identity/ldap_test.go
@@ -3,6 +3,7 @@ package identity
 import (
 	"context"
 	"errors"
+	"fmt"
 	"net/url"
 	"testing"
 
@@ -56,21 +57,6 @@ var invalidUserEntry = ldap.NewEntry("uid=user",
 		"mail":        {"user@example"},
 	})
 
-var groupEntry = ldap.NewEntry("cn=group",
-	map[string][]string{
-		"cn":        {"group"},
-		"entryuuid": {"abcd-defg"},
-		"member": {
-			"uid=user,ou=people,dc=test",
-			"uid=invalid,ou=people,dc=test",
-		},
-	})
-
-var invalidGroupEntry = ldap.NewEntry("cn=invalid",
-	map[string][]string{
-		"cn": {"invalid"},
-	})
-
 var logger = log.NewLogger(log.Level("debug"))
 
 func TestNewLDAPBackend(t *testing.T) {
@@ -106,6 +92,21 @@ func TestNewLDAPBackend(t *testing.T) {
 }
 
 func TestCreateUser(t *testing.T) {
+	displayName := "DisplayName"
+	mail := "user@example"
+	userName := "user"
+	surname := "surname"
+	givenName := "givenName"
+
+	ar := ldap.NewAddRequest(fmt.Sprintf("uid=user,%s", lconfig.UserBaseDN), nil)
+	ar.Attribute(lconfig.UserDisplayNameAttribute, []string{displayName})
+	ar.Attribute(lconfig.UserNameAttribute, []string{userName})
+	ar.Attribute(lconfig.UserEmailAttribute, []string{mail})
+	ar.Attribute("sn", []string{surname})
+	ar.Attribute("givenname", []string{givenName})
+	ar.Attribute("objectClass", []string{"inetOrgPerson", "organizationalPerson", "person", "top"})
+	ar.Attribute("cn", []string{userName})
+
 	l := &mocks.Client{}
 	l.On("Search", mock.Anything).
 		Return(
@@ -113,15 +114,9 @@ func TestCreateUser(t *testing.T) {
 				Entries: []*ldap.Entry{userEntry},
 			},
 			nil)
-	l.On("Add", mock.Anything).Return(nil)
+	l.On("Add", ar).Return(nil)
 	logger := log.NewLogger(log.Level("debug"))
 
-	displayName := "DisplayName"
-	mail := "user@example"
-	userName := "user"
-	surname := "surname"
-	givenName := "givenName"
-
 	user := libregraph.NewUser()
 	user.SetDisplayName(displayName)
 	user.SetMail(mail)
@@ -287,203 +282,3 @@ func TestGetUsers(t *testing.T) {
 		t.Errorf("Expected zero length user slice")
 	}
 }
-
-func TestGetGroup(t *testing.T) {
-	// Mock a Sizelimit Error
-	lm := &mocks.Client{}
-	lm.On("Search", mock.Anything).Return(nil, ldap.NewError(ldap.LDAPResultSizeLimitExceeded, errors.New("mock")))
-
-	queryParamExpand := url.Values{
-		"$expand": []string{"members"},
-	}
-	queryParamSelect := url.Values{
-		"$select": []string{"members"},
-	}
-	b, _ := getMockedBackend(lm, lconfig, &logger)
-	_, err := b.GetGroup(context.Background(), "group", nil)
-	if err == nil || err.Error() != "itemNotFound" {
-		t.Errorf("Expected 'itemNotFound' got '%s'", err.Error())
-	}
-	_, err = b.GetGroup(context.Background(), "group", queryParamExpand)
-	if err == nil || err.Error() != "itemNotFound" {
-		t.Errorf("Expected 'itemNotFound' got '%s'", err.Error())
-	}
-	_, err = b.GetGroup(context.Background(), "group", queryParamSelect)
-	if err == nil || err.Error() != "itemNotFound" {
-		t.Errorf("Expected 'itemNotFound' got '%s'", err.Error())
-	}
-
-	// Mock an empty Search Result
-	lm = &mocks.Client{}
-	lm.On("Search", mock.Anything).Return(&ldap.SearchResult{}, nil)
-	b, _ = getMockedBackend(lm, lconfig, &logger)
-	_, err = b.GetGroup(context.Background(), "group", nil)
-	if err == nil || err.Error() != "itemNotFound" {
-		t.Errorf("Expected 'itemNotFound' got '%s'", err.Error())
-	}
-	_, err = b.GetGroup(context.Background(), "group", queryParamExpand)
-	if err == nil || err.Error() != "itemNotFound" {
-		t.Errorf("Expected 'itemNotFound' got '%s'", err.Error())
-	}
-	_, err = b.GetGroup(context.Background(), "group", queryParamSelect)
-	if err == nil || err.Error() != "itemNotFound" {
-		t.Errorf("Expected 'itemNotFound' got '%s'", err.Error())
-	}
-
-	// Mock an invalid Search Result
-	lm = &mocks.Client{}
-	lm.On("Search", mock.Anything).Return(&ldap.SearchResult{
-		Entries: []*ldap.Entry{invalidGroupEntry},
-	}, nil)
-	b, _ = getMockedBackend(lm, lconfig, &logger)
-	g, err := b.GetGroup(context.Background(), "group", nil)
-	if err == nil || err.Error() != "itemNotFound" {
-		t.Errorf("Expected 'itemNotFound' got '%s'", err.Error())
-	}
-	g, err = b.GetGroup(context.Background(), "group", queryParamExpand)
-	if err == nil || err.Error() != "itemNotFound" {
-		t.Errorf("Expected 'itemNotFound' got '%s'", err.Error())
-	}
-	g, err = b.GetGroup(context.Background(), "group", queryParamSelect)
-	if err == nil || err.Error() != "itemNotFound" {
-		t.Errorf("Expected 'itemNotFound' got '%s'", err.Error())
-	}
-
-	// Mock a valid	Search Result
-	lm = &mocks.Client{}
-	sr1 := &ldap.SearchRequest{
-		BaseDN:     "ou=groups,dc=test",
-		Scope:      2,
-		SizeLimit:  1,
-		Filter:     "(&(objectClass=groupOfNames)(|(cn=group)(entryUUID=group)))",
-		Attributes: []string{"cn", "entryUUID", "member"},
-		Controls:   []ldap.Control(nil),
-	}
-	sr2 := &ldap.SearchRequest{
-		BaseDN:     "uid=user,ou=people,dc=test",
-		SizeLimit:  1,
-		Filter:     "(objectClass=inetOrgPerson)",
-		Attributes: []string{"displayname", "entryUUID", "mail", "uid", "sn", "givenname"},
-		Controls:   []ldap.Control(nil),
-	}
-	sr3 := &ldap.SearchRequest{
-		BaseDN:     "uid=invalid,ou=people,dc=test",
-		SizeLimit:  1,
-		Filter:     "(objectClass=inetOrgPerson)",
-		Attributes: []string{"displayname", "entryUUID", "mail", "uid", "sn", "givenname"},
-		Controls:   []ldap.Control(nil),
-	}
-
-	lm.On("Search", sr1).Return(&ldap.SearchResult{Entries: []*ldap.Entry{groupEntry}}, nil)
-	lm.On("Search", sr2).Return(&ldap.SearchResult{Entries: []*ldap.Entry{userEntry}}, nil)
-	lm.On("Search", sr3).Return(&ldap.SearchResult{Entries: []*ldap.Entry{invalidUserEntry}}, nil)
-	b, _ = getMockedBackend(lm, lconfig, &logger)
-	g, err = b.GetGroup(context.Background(), "group", nil)
-	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")
-	}
-	g, err = b.GetGroup(context.Background(), "group", queryParamExpand)
-	switch {
-	case err != nil:
-		t.Errorf("Expected GetGroup to succeed. Got %s", err.Error())
-	case g.GetId() != groupEntry.GetEqualFoldAttributeValue(b.groupAttributeMap.id):
-		t.Errorf("Expected GetGroup to return a valid group")
-	case len(g.Members) != 1:
-		t.Errorf("Expected GetGroup with expand to return one member")
-	case g.Members[0].GetId() != userEntry.GetEqualFoldAttributeValue(b.userAttributeMap.id):
-		t.Errorf("Expected GetGroup with expand to return correct member")
-	}
-	g, err = b.GetGroup(context.Background(), "group", queryParamSelect)
-	switch {
-	case err != nil:
-		t.Errorf("Expected GetGroup to succeed. Got %s", err.Error())
-	case g.GetId() != groupEntry.GetEqualFoldAttributeValue(b.groupAttributeMap.id):
-		t.Errorf("Expected GetGroup to return a valid group")
-	case len(g.Members) != 1:
-		t.Errorf("Expected GetGroup with expand to return one member")
-	case g.Members[0].GetId() != userEntry.GetEqualFoldAttributeValue(b.userAttributeMap.id):
-		t.Errorf("Expected GetGroup with expand to return correct member")
-	}
-}
-
-func TestGetGroups(t *testing.T) {
-	queryParamExpand := url.Values{
-		"$expand": []string{"members"},
-	}
-	queryParamSelect := url.Values{
-		"$select": []string{"members"},
-	}
-	lm := &mocks.Client{}
-	lm.On("Search", mock.Anything).Return(nil, ldap.NewError(ldap.LDAPResultOperationsError, errors.New("mock")))
-	b, _ := getMockedBackend(lm, lconfig, &logger)
-	_, err := b.GetGroups(context.Background(), url.Values{})
-	if err == nil || err.Error() != "itemNotFound" {
-		t.Errorf("Expected 'itemNotFound' got '%s'", err.Error())
-	}
-
-	lm = &mocks.Client{}
-	lm.On("Search", mock.Anything).Return(&ldap.SearchResult{}, nil)
-	b, _ = getMockedBackend(lm, 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")
-	}
-
-	lm = &mocks.Client{}
-	lm.On("Search", mock.Anything).Return(&ldap.SearchResult{
-		Entries: []*ldap.Entry{groupEntry},
-	}, nil)
-	b, _ = getMockedBackend(lm, lconfig, &logger)
-	g, err = b.GetGroups(context.Background(), url.Values{})
-	if err != nil {
-		t.Errorf("Expected GetGroup to succeed. Got %s", err.Error())
-	} else if *g[0].Id != groupEntry.GetEqualFoldAttributeValue(b.groupAttributeMap.id) {
-		t.Errorf("Expected GetGroup to return a valid group")
-	}
-
-	// Mock a valid	Search Result with expanded group members
-	lm = &mocks.Client{}
-	sr1 := &ldap.SearchRequest{
-		BaseDN:     "ou=groups,dc=test",
-		Scope:      2,
-		Filter:     "(&(objectClass=groupOfNames))",
-		Attributes: []string{"cn", "entryUUID", "member"},
-		Controls:   []ldap.Control(nil),
-	}
-	sr2 := &ldap.SearchRequest{
-		BaseDN:     "uid=user,ou=people,dc=test",
-		SizeLimit:  1,
-		Filter:     "(objectClass=inetOrgPerson)",
-		Attributes: []string{"displayname", "entryUUID", "mail", "uid", "sn", "givenname"},
-		Controls:   []ldap.Control(nil),
-	}
-	sr3 := &ldap.SearchRequest{
-		BaseDN:     "uid=invalid,ou=people,dc=test",
-		SizeLimit:  1,
-		Filter:     "(objectClass=inetOrgPerson)",
-		Attributes: []string{"displayname", "entryUUID", "mail", "uid", "sn", "givenname"},
-		Controls:   []ldap.Control(nil),
-	}
-
-	for _, param := range []url.Values{queryParamSelect, queryParamExpand} {
-		lm.On("Search", sr1).Return(&ldap.SearchResult{Entries: []*ldap.Entry{groupEntry}}, nil)
-		lm.On("Search", sr2).Return(&ldap.SearchResult{Entries: []*ldap.Entry{userEntry}}, nil)
-		lm.On("Search", sr3).Return(&ldap.SearchResult{Entries: []*ldap.Entry{invalidUserEntry}}, nil)
-		b, _ = getMockedBackend(lm, lconfig, &logger)
-		g, err = b.GetGroups(context.Background(), param)
-		switch {
-		case err != nil:
-			t.Errorf("Expected GetGroup to succeed. Got %s", err.Error())
-		case g[0].GetId() != groupEntry.GetEqualFoldAttributeValue(b.groupAttributeMap.id):
-			t.Errorf("Expected GetGroup to return a valid group")
-		case len(g[0].Members) != 1:
-			t.Errorf("Expected GetGroup to return group with one member")
-		case g[0].Members[0].GetId() != userEntry.GetEqualFoldAttributeValue(b.userAttributeMap.id):
-			t.Errorf("Expected GetGroup to return group with correct member")
-		}
-	}
-}