Repository: owncloud/ocis
Pull request #5271 (2022-12-21 14:22:04)

enable tags in capabilities
## Description
tags are technically ported but where not enabled in the capabilities.
This is fixed now.

## Related Issue
- Extends https://github.com/owncloud/ocis/pull/5227


Patch:
diff --git a/.golangci.yml b/.golangci.yml
index 11a419de011..3a6a29ef526 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -47,6 +47,12 @@ linters-settings:
 severity:
   default-severity: error
 
+skip-dirs:
+  - protogen/gen
+  - docs
+  - deployments
+  - changelog
+
 issues:
   exclude-use-default: true
   exclude-rules:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1c593a55eb5..39a51ff3977 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,7 @@ The following sections list the changes for unreleased.
 
 ## Summary
 
+* Bugfix - Fix Postprocessing events: [#5269](https://github.com/owncloud/ocis/pull/5269)
 * Enhancement - Add global env variable extractor: [#5164](https://github.com/owncloud/ocis/pull/5164)
 * Enhancement - Async Postprocessing: [#5207](https://github.com/owncloud/ocis/pull/5207)
 * Enhancement - Bump reva version: [#5243](https://github.com/owncloud/ocis/pull/5243)
diff --git a/changelog/unreleased/enhancement-tags.md b/changelog/unreleased/enhancement-tags.md
index 647b2340f88..9328c3479e3 100644
--- a/changelog/unreleased/enhancement-tags.md
+++ b/changelog/unreleased/enhancement-tags.md
@@ -5,4 +5,5 @@ Tags can be added (put request) and removed (delete request) from a resource,
 a list of available tags can also be requested by sending a get request to the graph endpoint.
 
 https://github.com/owncloud/ocis/pull/5227
+https://github.com/owncloud/ocis/pull/5271
 https://github.com/owncloud/ocis/issues/5184
diff --git a/changelog/unreleased/fix-postprocessing.md b/changelog/unreleased/fix-postprocessing.md
new file mode 100644
index 00000000000..86e04c8b775
--- /dev/null
+++ b/changelog/unreleased/fix-postprocessing.md
@@ -0,0 +1,5 @@
+Bugfix: Fix Postprocessing events
+
+Postprocessing service did not want to play with non-tls events. That is fixed now
+
+https://github.com/owncloud/ocis/pull/5269
diff --git a/docs/.gitignore b/docs/.gitignore
index 02053cc8227..58ffe8ed3f9 100644
--- a/docs/.gitignore
+++ b/docs/.gitignore
@@ -1,4 +1,4 @@
 hugo/
-grpc_apis/
+/**/grpc_apis/ocis/
 mutagen.yml.lock
-helpers/output/*
\ No newline at end of file
+helpers/output/*
diff --git a/docs/apis/_index.md b/docs/apis/_index.md
new file mode 100644
index 00000000000..f1792dc1b03
--- /dev/null
+++ b/docs/apis/_index.md
@@ -0,0 +1,3 @@
+---
+title: APIs
+---
diff --git a/docs/apis/grpc_apis/_index.md b/docs/apis/grpc_apis/_index.md
new file mode 100644
index 00000000000..3dd2acefd35
--- /dev/null
+++ b/docs/apis/grpc_apis/_index.md
@@ -0,0 +1,11 @@
+---
+title: gRPC
+geekdocRepo: https://github.com/owncloud/ocis
+geekdocEditPath: edit/master/docs/apis/grpc_apis/
+geekdocFilePath: _index.md
+geekdocCollapseSection: true
+---
+
+## **R**emote  &nbsp; **P**rocedure  &nbsp; **C**alls
+
+[gRPC](https://grpc.io) is a modern open source high performance Remote Procedure Call (RPC) framework that can run in any environment. It can efficiently connect services in and across data centers with pluggable support for load balancing, tracing, health checking and authentication. It is also applicable in last mile of distributed computing to connect devices, mobile applications and browsers to backend services.
diff --git a/docs/apis/http/_index.md b/docs/apis/http/_index.md
new file mode 100644
index 00000000000..c64ba1e4a07
--- /dev/null
+++ b/docs/apis/http/_index.md
@@ -0,0 +1,11 @@
+---
+title: Http
+geekdocRepo: https://github.com/owncloud/ocis
+geekdocEditPath: edit/master/docs/apis/http/
+geekdocFilePath: _index.md
+geekdocCollapseSection: true
+---
+
+The [Hypertext Transfer Protocol (HTTP)](https://www.rfc-editor.org/rfc/rfc2616) is a stateless application-level protocol for distributed, collaborative, hypertext information systems. HTTP is the foundation of data communication for the World Wide Web, where hypertext documents include hyperlinks to other resources that the user can easily access, for example by a mouse click or by tapping the screen in a web browser.
+
+Development of HTTP was initiated by Tim Berners-Lee at CERN in 1989 and summarized in a simple document describing the behavior of a client and a server using the first HTTP protocol version that was named 0.9. That first version of HTTP protocol soon evolved into a more elaborated version that was the first draft toward a far future version 1.0
diff --git a/docs/apis/http/graph/_index.md b/docs/apis/http/graph/_index.md
new file mode 100644
index 00000000000..1bf37998a1c
--- /dev/null
+++ b/docs/apis/http/graph/_index.md
@@ -0,0 +1,66 @@
+---
+title: "LibreGraph"
+date: 2018-05-02T00:00:00+00:00
+weight: 20
+geekdocRepo: https://github.com/owncloud/ocis
+geekdocEditPath: edit/master/docs/apis/http/graph
+geekdocFilePath: _index.md
+geekdocCollapseSection: true
+---
+
+The LibreGraph API is a REST Api which is inspired by the [Microsoft Graph API](https://developer.microsoft.com/en-us/graph). It tries to stay compliant with the Microsoft Graph API and aims to be the Next Generation Api in Infinite Scale where we want to support most of the features of the platform.
+The [API specification](https://github.com/owncloud/libre-graph-api) is available in the OpenApi 3 standard and there are generated client and server [SDKs](https://github.com/owncloud/libre-graph-api#clients) available. You can browse the API with the [Swagger UI](https://owncloud.dev/libre-graph-api/).
+
+## Calling the LibreGraph API
+
+```sh
+{HTTP method} https://ocis.url/graph/{version}/{resource}?{query-parameters}
+```
+
+The request component consists of:
+
+| Component          | Description                                                             |
+|--------------------|-------------------------------------------------------------------------|
+| {HTTP method}      | The HTTP method which is used in the request.                           |
+| {version}          | The version of the LibreGraph API used by the client.                   |
+| {resource}         | The LibreGraph Resource which the client is referencing in the request. |
+| {query-parameters} | Optional parameters for the request to customize the response.          |
+
+### HTTP methods
+
+| Method | Description                   |
+|--------|-------------------------------|
+| GET    | Read data from a resource.    |
+| POST   | Create a new resource.        |
+| PATCH  | Update an existing resource.  |
+| PUT    | Replace an existing resource. |
+| DELETE | Delete an existing resource.  |
+
+The methods `GET` and `DELETE` need no request body. The methods `POST`, `PATCH` and `PUT` require a request body, normally in JSON format to provide the needed values.
+
+### Version
+
+Infinite Scale currently provides the version `v1.0`.
+
+### Resource
+
+A resource could be an entity or a complex type and is usually defined by properties. Entities are always recognizable by an `Id` property. The URL contains the resource which you are interacting with e.g. `/me/drives` or `/groups/{group-id}`.
+
+Each resource could possibly require different permissions. Usually you need permissions on a higher level for creating or updating an existing resource than for reading.
+
+### Query parameters
+
+Query parameters can be OData system query options, or other strings that a method accepts to customize its response.
+
+You can use optional OData system query options to include more or fewer properties than the default response, filter the response for items that match a custom query, or provide additional parameters for a method.
+
+For example, adding the following filter parameter restricts the drives returned to only those with the driveType property of `project`.
+
+```shell
+GET https://ocis.url/graph/v1.0/drives?$filter=driveType eq 'project'
+```
+For more information about OData query options please check the [API specification](https://github.com/owncloud/libre-graph-api) and the provided examples.
+
+## Resources
+
+{{< toc-tree >}}
diff --git a/docs/services/graph/groups.md b/docs/apis/http/graph/groups.md
similarity index 100%
rename from docs/services/graph/groups.md
rename to docs/apis/http/graph/groups.md
diff --git a/docs/services/graph/spaces.md b/docs/apis/http/graph/spaces.md
similarity index 100%
rename from docs/services/graph/spaces.md
rename to docs/apis/http/graph/spaces.md
diff --git a/docs/services/graph/users.md b/docs/apis/http/graph/users.md
similarity index 100%
rename from docs/services/graph/users.md
rename to docs/apis/http/graph/users.md
diff --git a/docs/grpc_apis/_index.md b/docs/grpc_apis/_index.md
deleted file mode 100644
index f6588c8d7f9..00000000000
--- a/docs/grpc_apis/_index.md
+++ /dev/null
@@ -1,3 +0,0 @@
----
-title: GRPC apis
----
diff --git a/docs/helpers/README.md b/docs/helpers/README.md
index c69f16931e3..914ecd876f2 100644
--- a/docs/helpers/README.md
+++ b/docs/helpers/README.md
@@ -8,9 +8,9 @@
 
 Output:
 
-- 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: `global_configvars.adoc`.
+- 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`.
 
 Admin doc process:
 
@@ -24,7 +24,7 @@ It can happen that rouge envvars are found but do not need to be published as th
 
 IMPORTANT:
 
-- Once a rouge envvar has been identified, it is added to the `global_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.
+- 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.
 
 - 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.
 
diff --git a/docs/helpers/extended_vars.yaml b/docs/helpers/extended_vars.yaml
new file mode 100644
index 00000000000..9808b52601c
--- /dev/null
+++ b/docs/helpers/extended_vars.yaml
@@ -0,0 +1,81 @@
+variables:
+- rawname: CS3_GATEWAY
+  path: services/idp/pkg/backends/cs3/bootstrap/cs3.go:76
+  foundincode: true
+  name: CS3_GATEWAY
+  type: ""
+  default_value: ""
+  description: ""
+  do_ignore: true
+- rawname: CS3_MACHINE_AUTH_API_KEY
+  path: services/idp/pkg/backends/cs3/bootstrap/cs3.go:77
+  foundincode: true
+  name: CS3_MACHINE_AUTH_API_KEY
+  type: ""
+  default_value: ""
+  description: ""
+  do_ignore: true
+- rawname: MICRO_LOG_LEVEL
+  path: ocis-pkg/log/log.go:34
+  foundincode: true
+  name: MICRO_LOG_LEVEL
+  type: string
+  default_value: Error
+  description: Set the log level for the internal go micro framework. Only change
+    on supervision of ownCloud Support.
+  do_ignore: false
+- rawname: MICRO_LOG_LEVEL
+  path: ocis-pkg/log/log.go:30
+  foundincode: true
+  name: MICRO_LOG_LEVEL
+  type: ""
+  default_value: ""
+  description: ""
+  do_ignore: true
+- rawname: registryEnv
+  path: ocis-pkg/registry/registry.go:44
+  foundincode: true
+  name: MICRO_REGISTRY
+  type: string
+  default_value: ""
+  description: 'Go micro registry type to use. Supported types are: ''nats'', ''kubernetes'',
+    ''etcd'', ''consul'' and ''memory''. Will be selected automatically. Only change
+    on supervision of ownCloud Support.'
+  do_ignore: false
+- rawname: registryAddressEnv
+  path: ocis-pkg/registry/registry.go:42
+  foundincode: true
+  name: MICRO_REGISTRY_ADDRESS
+  type: string
+  default_value: ""
+  description: The bind address of the internal go micro framework. Only change on
+    supervision of ownCloud Support.
+  do_ignore: false
+- rawname: OCIS_BASE_DATA_PATH
+  path: ocis-pkg/config/defaults/paths.go:23
+  foundincode: true
+  name: OCIS_BASE_DATA_PATH
+  type: string
+  default_value: '''/var/lib/ocis'' or ''$HOME/.ocis/'''
+  description: The base directory location used by several services and for user data.
+    Predefined to '/var/lib/ocis' for container images (inside the container) or '$HOME/.ocis/'
+    for binary releases. Can be adapted for services individually.
+  do_ignore: false
+- rawname: OCIS_CONFIG_DIR
+  path: ocis-pkg/config/defaults/paths.go:56
+  foundincode: true
+  name: OCIS_CONFIG_DIR
+  type: string
+  default_value: '''/etc/ocis'' or ''$HOME/.ocis/config'''
+  description: The default directory location for config files. Predefined to '/etc/ocis'
+    for container images (inside the container) or '$HOME/.ocis/config' for binary
+    releases.
+  do_ignore: false
+- rawname: parts[0]
+  path: ocis-pkg/config/envdecode/envdecode.go:382
+  foundincode: true
+  name: parts[0]
+  type: ""
+  default_value: ""
+  description: false positive - code that extract envvars for config structs
+  do_ignore: true
diff --git a/docs/helpers/global_vars.yaml b/docs/helpers/global_vars.yaml
deleted file mode 100644
index 25f11a9157f..00000000000
--- a/docs/helpers/global_vars.yaml
+++ /dev/null
@@ -1,73 +0,0 @@
-variables:
-- rawname: CS3_GATEWAY
-  path: services/idp/pkg/backends/cs3/bootstrap/cs3.go:76
-  foundincode: true
-  name: "CS3_GATEWAY"
-  type: ""
-  default_value: ""
-  description: ""
-  do_ignore: true
-- rawname: CS3_MACHINE_AUTH_API_KEY
-  path: services/idp/pkg/backends/cs3/bootstrap/cs3.go:77
-  foundincode: true
-  name: "CS3_MACHINE_AUTH_API_KEY"
-  type: ""
-  default_value: ""
-  description: ""
-  do_ignore: true
-- rawname: MICRO_LOG_LEVEL
-  path: ocis-pkg/log/log.go:34
-  foundincode: true
-  name: "MICRO_LOG_LEVEL"
-  type: "string"
-  default_value: "Error"
-  description: "Set the log level for the internal go micro framework. Only change on supervision of ownCloud Support."
-  do_ignore: false
-- rawname: MICRO_LOG_LEVEL
-  path: ocis-pkg/log/log.go:30
-  foundincode: true
-  name: "MICRO_LOG_LEVEL"
-  type: ""
-  default_value: ""
-  description: ""
-  do_ignore: true
-- rawname: registryEnv
-  path: ocis-pkg/registry/registry.go:44
-  foundincode: true
-  name: "MICRO_REGISTRY"
-  type: "string"
-  default_value: ""
-  description: "Go micro registry type to use. Supported types are: 'nats', 'kubernetes', 'etcd', 'consul' and 'memory'. Will be selected automatically. Only change on supervision of ownCloud Support."
-  do_ignore: false
-- rawname: registryAddressEnv
-  path: ocis-pkg/registry/registry.go:42
-  foundincode: true
-  name: "MICRO_REGISTRY_ADDRESS"
-  type: "string"
-  default_value: ""
-  description: "The bind address of the internal go micro framework. Only change on supervision of ownCloud Support."
-  do_ignore: false
-- rawname: OCIS_BASE_DATA_PATH
-  path: ocis-pkg/config/defaults/paths.go:23
-  foundincode: true
-  name: "OCIS_BASE_DATA_PATH"
-  type: "string"
-  default_value: "'/var/lib/ocis' or '$HOME/.ocis/'"
-  description: "The base directory location used by several services and for user data. Predefined to '/var/lib/ocis' for container images (inside the container) or '$HOME/.ocis/' for binary releases. Can be adapted for services individually."
-  do_ignore: false
-- rawname: OCIS_CONFIG_DIR
-  path: ocis-pkg/config/defaults/paths.go:56
-  foundincode: true
-  name: "OCIS_CONFIG_DIR"
-  type: "string"
-  default_value: "'/etc/ocis' or '$HOME/.ocis/config'"
-  description: "The default directory location for config files. Predefined to '/etc/ocis' for container images (inside the container) or '$HOME/.ocis/config' for binary releases."
-  do_ignore: false
-- rawname: parts[0]
-  path: ocis-pkg/config/envdecode/envdecode.go:382
-  foundincode: true
-  name: parts[0]
-  type: ""
-  default_value: ""
-  description: false positive - code that extract envvars for config structs
-  do_ignore: true
diff --git a/docs/helpers/rogueEnv.go b/docs/helpers/rogueEnv.go
index ec177bd4cbc..1b6bcd98857 100644
--- a/docs/helpers/rogueEnv.go
+++ b/docs/helpers/rogueEnv.go
@@ -15,7 +15,7 @@ import (
 	"gopkg.in/yaml.v2"
 )
 
-const yamlSource = "global_vars.yaml"
+const yamlSource = "extended_vars.yaml"
 
 // ConfigVars is the main yaml source
 type ConfigVars struct {
@@ -70,7 +70,7 @@ func GetRogueEnvs() {
 		log.Fatal(err)
 	}
 	fmt.Println("Gathering variable definitions from source")
-	out, err := exec.Command("bash", "-c", "grep -RHn os.Getenv | grep -v rogueEnv.go |grep \\.go").Output()
+	out, err := exec.Command("sh", "-c", "grep -RHn os.Getenv | grep -v rogueEnv.go |grep \\.go").Output()
 	if err != nil {
 		log.Fatal(err)
 	}
@@ -164,7 +164,7 @@ func RenderGlobalVarsTemplate() {
 		log.Fatal(err)
 	}
 
-	targetFile, err := os.Create(filepath.Join(targetFolder, "global_configvars.adoc"))
+	targetFile, err := os.Create(filepath.Join(targetFolder, "extended_configvars.adoc"))
 	if err != nil {
 		log.Fatalf("Failed to create target file: %s", err)
 	}
diff --git a/go.mod b/go.mod
index d028cd71cc0..937e6792f03 100644
--- a/go.mod
+++ b/go.mod
@@ -35,7 +35,7 @@ require (
 	github.com/go-micro/plugins/v4/wrapper/trace/opencensus v1.1.0
 	github.com/go-ozzo/ozzo-validation/v4 v4.3.0
 	github.com/gofrs/uuid v4.3.1+incompatible
-	github.com/golang-jwt/jwt/v4 v4.4.2
+	github.com/golang-jwt/jwt/v4 v4.4.3
 	github.com/golang/protobuf v1.5.2
 	github.com/google/go-tika v0.2.0
 	github.com/gookit/config/v2 v2.1.8
diff --git a/go.sum b/go.sum
index 1799ea341a5..2f087d623d6 100644
--- a/go.sum
+++ b/go.sum
@@ -589,8 +589,9 @@ github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keL
 github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
 github.com/golang-jwt/jwt/v4 v4.1.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg=
 github.com/golang-jwt/jwt/v4 v4.3.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg=
-github.com/golang-jwt/jwt/v4 v4.4.2 h1:rcc4lwaZgFMCZ5jxF9ABolDcIHdBytAFgqFPbSJQAYs=
 github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
+github.com/golang-jwt/jwt/v4 v4.4.3 h1:Hxl6lhQFj4AnOX6MLrsCb/+7tCj7DxP7VA+2rDIq5AU=
+github.com/golang-jwt/jwt/v4 v4.4.3/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
 github.com/golang/geo v0.0.0-20210211234256-740aa86cb551 h1:gtexQ/VGyN+VVFRXSFiguSNcXmS6rkKT+X7FdIrTtfo=
 github.com/golang/geo v0.0.0-20210211234256-740aa86cb551/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
 github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
diff --git a/protogen/docs/GRPC.tmpl b/protogen/docs/GRPC.tmpl
index b7262c5d099..797cb442b99 100644
--- a/protogen/docs/GRPC.tmpl
+++ b/protogen/docs/GRPC.tmpl
@@ -1,7 +1,7 @@
 ---
 {{- $package := (index .Files 0).Package }}
 title: "{{ $package }}"
-url: /grpc_apis/{{ $package | replace "." "_" }}
+url: /apis/grpc_apis/{{ $package | replace "." "_" }}
 date: {{ now | date "2006-01-02T15:04:05Z07:00" }}
 weight: 50
 geekdocRepo: https://github.com/owncloud/ocis
@@ -28,7 +28,7 @@ geekdocRepo: https://github.com/owncloud/ocis
 {{- if and (hasPrefix "ocis." .LongType) (ge (len $filenameParts) 3) -}}
 	{{- $ltypeSpl := .LongType | splitList "." -}}
 	{{- $ltypePkg := slice $ltypeSpl 0 (sub (len $ltypeSpl) 1) | join "_" -}}
-	{{- $loca = printf "/grpc_apis/%s/#%s" $ltypePkg (.Type | lower) -}}
+	{{- $loca = printf "/apis/grpc_apis/%s/#%s" $ltypePkg (.Type | lower) -}}
 {{- end -}}
 | {{.Name}} | [{{.LongType}}]({{ $loca }}) | {{.Label}} | {{ .Description | replace "\n" "<br>" }}{{if .DefaultValue}} Default: {{.DefaultValue}}{{end }} |
 {{ end -}}
@@ -61,7 +61,7 @@ geekdocRepo: https://github.com/owncloud/ocis
 {{ range .Extensions -}}
 | {{.Name}} | {{.LongType}} | {{.ContainingLongType}} | {{.Number}} | {{ .Description | replace "\n" "<br>" }}{{if .DefaultValue}} Default: `{{.DefaultValue}}`{{end}} |
 {{ end -}}
-{{ end -}} 
+{{ end -}}
 {{- range .Services }}
 ### {{ .Name }}
 
diff --git a/protogen/proto/buf.gen.yaml b/protogen/proto/buf.gen.yaml
index ff14a89dd0a..c71c259a979 100644
--- a/protogen/proto/buf.gen.yaml
+++ b/protogen/proto/buf.gen.yaml
@@ -29,6 +29,6 @@ plugins:
 
   - name: doc
     path: ../../.bingo/protoc-gen-doc
-    out: ../../docs/grpc_apis
+    out: ../../docs/apis/grpc_apis
     opt:
       - ../docs/GRPC.tmpl,grpc.md,source_relative
diff --git a/services/frontend/pkg/revaconfig/config.go b/services/frontend/pkg/revaconfig/config.go
index e39e72460f4..283423e8d98 100644
--- a/services/frontend/pkg/revaconfig/config.go
+++ b/services/frontend/pkg/revaconfig/config.go
@@ -47,6 +47,7 @@ func FrontendConfigFromStruct(cfg *config.Config) (map[string]interface{}, error
 		"blacklisted_files": []string{},
 		"undelete":          true,
 		"versioning":        true,
+		"tags":              true,
 		"archivers":         archivers,
 		"app_providers":     appProviders,
 		"favorites":         cfg.EnableFavorites,
diff --git a/services/graph/Makefile b/services/graph/Makefile
index 692f9ede179..382f03f125d 100644
--- a/services/graph/Makefile
+++ b/services/graph/Makefile
@@ -31,6 +31,7 @@ ci-go-generate: $(MOCKERY) # CI runs ci-node-generate automatically before this
 	$(MOCKERY) --dir pkg/service/v0 --case underscore --name Permissions
 	$(MOCKERY) --dir pkg/service/v0 --case underscore --name RoleService
 	$(MOCKERY) --dir pkg/identity --output pkg/identity/mocks --case underscore --name Backend
+	$(MOCKERY) --dir pkg/identity --output pkg/identity/mocks --case underscore --name EducationBackend
 	$(MOCKERY) --srcpkg github.com/go-ldap/ldap/v3 --case underscore --filename ldapclient.go --name Client
 
 
diff --git a/services/graph/mocks/gateway_client.go b/services/graph/mocks/gateway_client.go
index ae1869dd55a..bc60230b667 100644
--- a/services/graph/mocks/gateway_client.go
+++ b/services/graph/mocks/gateway_client.go
@@ -288,6 +288,36 @@ func (_m *GatewayClient) ListStorageSpaces(ctx context.Context, in *providerv1be
 	return r0, r1
 }
 
+// SetArbitraryMetadata provides a mock function with given fields: ctx, request, opts
+func (_m *GatewayClient) SetArbitraryMetadata(ctx context.Context, request *providerv1beta1.SetArbitraryMetadataRequest, opts ...grpc.CallOption) (*providerv1beta1.SetArbitraryMetadataResponse, error) {
+	_va := make([]interface{}, len(opts))
+	for _i := range opts {
+		_va[_i] = opts[_i]
+	}
+	var _ca []interface{}
+	_ca = append(_ca, ctx, request)
+	_ca = append(_ca, _va...)
+	ret := _m.Called(_ca...)
+
+	var r0 *providerv1beta1.SetArbitraryMetadataResponse
+	if rf, ok := ret.Get(0).(func(context.Context, *providerv1beta1.SetArbitraryMetadataRequest, ...grpc.CallOption) *providerv1beta1.SetArbitraryMetadataResponse); ok {
+		r0 = rf(ctx, request, opts...)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(*providerv1beta1.SetArbitraryMetadataResponse)
+		}
+	}
+
+	var r1 error
+	if rf, ok := ret.Get(1).(func(context.Context, *providerv1beta1.SetArbitraryMetadataRequest, ...grpc.CallOption) error); ok {
+		r1 = rf(ctx, request, opts...)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
 // Stat provides a mock function with given fields: ctx, in, opts
 func (_m *GatewayClient) Stat(ctx context.Context, in *providerv1beta1.StatRequest, opts ...grpc.CallOption) (*providerv1beta1.StatResponse, error) {
 	_va := make([]interface{}, len(opts))
diff --git a/services/graph/pkg/config/config.go b/services/graph/pkg/config/config.go
index 012dd6163aa..79d18268a9a 100644
--- a/services/graph/pkg/config/config.go
+++ b/services/graph/pkg/config/config.go
@@ -62,6 +62,22 @@ type LDAP struct {
 	GroupObjectClass   string `yaml:"group_objectclass" env:"LDAP_GROUP_OBJECTCLASS;GRAPH_LDAP_GROUP_OBJECTCLASS" desc:"The object class to use for groups in the default group search filter ('groupOfNames'). "`
 	GroupNameAttribute string `yaml:"group_name_attribute" env:"LDAP_GROUP_SCHEMA_GROUPNAME;GRAPH_LDAP_GROUP_NAME_ATTRIBUTE" desc:"LDAP Attribute to use for the name of groups."`
 	GroupIDAttribute   string `yaml:"group_id_attribute" env:"LDAP_GROUP_SCHEMA_ID;GRAPH_LDAP_GROUP_ID_ATTRIBUTE" desc:"LDAP Attribute to use as the unique id for groups. This should be a stable globally unique ID like a UUID."`
+
+	EducationResourcesEnabled bool `yaml:"education_resources_enabled" env:"LDAP_EDUCATION_RESOURCES_ENABLED;GRAPH_LDAP_EDUCATION_RESOURCES_ENABLED" desc:"Enable LDAP support for managing education related resources"`
+	EducationConfig           LDAPEducationConfig
+}
+
+// LDAPEducationConfig represents the LDAP configuration for education related resources
+type LDAPEducationConfig struct {
+	SchoolBaseDN      string `yaml:"school_base_dn" env:"LDAP_SCHOOL_BASE_DN;GRAPH_LDAP_SCHOOL_BASE_DN" desc:"Search base DN for looking up LDAP schools."`
+	SchoolSearchScope string `yaml:"school_search_scope" env:"LDAP_SCHOOL_SCOPE;GRAPH_LDAP_SCHOOL_SEARCH_SCOPE" desc:"LDAP search scope to use when looking up schools. Supported scopes are 'base', 'one' and 'sub'."`
+
+	SchoolFilter      string `yaml:"school_filter" env:"LDAP_SCHOOL_FILTER;GRAPH_LDAP_SCHOOL_FILTER" desc:"LDAP filter to add to the default filters for school searches."`
+	SchoolObjectClass string `yaml:"school_objectclass" env:"LDAP_SCHOOL_OBJECTCLASS;GRAPH_LDAP_SCHOOL_OBJECTCLASS" desc:"The object class to use for schools in the default school search filter."`
+
+	SchoolNameAttribute   string `yaml:"school_name_attribute" env:"LDAP_SCHOOL_SCHEMA_SCHOOL_NAME;GRAPH_LDAP_SCHOOL_NAME_ATTRIBUTE" desc:"LDAP Attribute to use for the name of a school."`
+	SchoolNumberAttribute string `yaml:"school_number_attribute" env:"LDAP_SCHOOL_SCHEMA_SCHOOL_NUMBER;GRAPH_LDAP_SCHOOL_NUMBER_ATTRIBUTE" desc:"LDAP Attribute to use for the number of a school."`
+	SchoolIDAttribute     string `yaml:"school_id_attribute" env:"LDAP_SCHOOL_SCHEMA_ID;GRAPH_LDAP_SCHOOL_ID_ATTRIBUTE" desc:"LDAP Attribute to use as the unique id for schools. This should be a stable globally unique ID like a UUID."`
 }
 
 type Identity struct {
diff --git a/services/graph/pkg/config/defaults/defaultconfig.go b/services/graph/pkg/config/defaults/defaultconfig.go
index f238f21b212..84c0fc94936 100644
--- a/services/graph/pkg/config/defaults/defaultconfig.go
+++ b/services/graph/pkg/config/defaults/defaultconfig.go
@@ -55,13 +55,14 @@ func DefaultConfig() *config.Config {
 				UserNameAttribute:        "uid",
 				// FIXME: switch this to some more widely available attribute by default
 				//        ideally this needs to	be constant for the lifetime of a users
-				UserIDAttribute:    "owncloudUUID",
-				GroupBaseDN:        "ou=groups,o=libregraph-idm",
-				GroupSearchScope:   "sub",
-				GroupFilter:        "",
-				GroupObjectClass:   "groupOfNames",
-				GroupNameAttribute: "cn",
-				GroupIDAttribute:   "owncloudUUID",
+				UserIDAttribute:           "owncloudUUID",
+				GroupBaseDN:               "ou=groups,o=libregraph-idm",
+				GroupSearchScope:          "sub",
+				GroupFilter:               "",
+				GroupObjectClass:          "groupOfNames",
+				GroupNameAttribute:        "cn",
+				GroupIDAttribute:          "owncloudUUID",
+				EducationResourcesEnabled: false,
 			},
 		},
 		Events: config.Events{
diff --git a/services/graph/pkg/identity/backend.go b/services/graph/pkg/identity/backend.go
index db003922e2f..1156a1153e6 100644
--- a/services/graph/pkg/identity/backend.go
+++ b/services/graph/pkg/identity/backend.go
@@ -8,6 +8,7 @@ import (
 	libregraph "github.com/owncloud/libre-graph-api-go"
 )
 
+// Backend defines the Interface for an IdentityBackend implementation
 type Backend interface {
 	// CreateUser creates a given user in the identity backend.
 	CreateUser(ctx context.Context, user libregraph.User) (*libregraph.User, error)
@@ -31,6 +32,33 @@ type Backend interface {
 	RemoveMemberFromGroup(ctx context.Context, groupID string, memberID string) error
 }
 
+// EducationBackend defines the Interface for an EducationBackend implementation
+type EducationBackend interface {
+	// CreateEducationSchool creates the supplied school in the identity backend.
+	CreateEducationSchool(ctx context.Context, group libregraph.EducationSchool) (*libregraph.EducationSchool, error)
+	// DeleteSchool deletes a given school, identified by id
+	DeleteEducationSchool(ctx context.Context, id string) error
+	// GetEducationSchool reads a given school by id
+	GetEducationSchool(ctx context.Context, nameOrID string, queryParam url.Values) (*libregraph.EducationSchool, error)
+	// GetEducationSchools lists all schools
+	GetEducationSchools(ctx context.Context, queryParam url.Values) ([]*libregraph.EducationSchool, error)
+	// GetEducationSchoolMembers lists all members of a school
+	GetEducationSchoolMembers(ctx context.Context, id string) ([]*libregraph.EducationUser, error)
+	// AddMembersToEducationSchool adds new members (reference by a slice of IDs) to supplied school in the identity backend.
+	AddMembersToEducationSchool(ctx context.Context, schoolID string, memberID []string) error
+	// RemoveMemberFromEducationSchool removes a single member (by ID) from a school
+	RemoveMemberFromEducationSchool(ctx context.Context, schoolID string, memberID string) error
+
+	// CreateEducationUser creates a given education user in the identity backend.
+	CreateEducationUser(ctx context.Context, user libregraph.EducationUser) (*libregraph.EducationUser, error)
+	// DeleteEducationUser deletes a given educationuser, identified by username or id, from the backend
+	DeleteEducationUser(ctx context.Context, nameOrID string) error
+	// UpdateEducationUser applies changes to given education user, identified by username or id
+	UpdateEducationUser(ctx context.Context, nameOrID string, user libregraph.EducationUser) (*libregraph.EducationUser, error)
+	GetEducationUser(ctx context.Context, nameOrID string, queryParam url.Values) (*libregraph.EducationUser, error)
+	GetEducationUsers(ctx context.Context, queryParam url.Values) ([]*libregraph.EducationUser, error)
+}
+
 func CreateUserModelFromCS3(u *cs3.User) *libregraph.User {
 	if u.Id == nil {
 		u.Id = &cs3.UserId{}
diff --git a/services/graph/pkg/identity/err_school.go b/services/graph/pkg/identity/err_school.go
new file mode 100644
index 00000000000..103ae9ecd1a
--- /dev/null
+++ b/services/graph/pkg/identity/err_school.go
@@ -0,0 +1,71 @@
+package identity
+
+import (
+	"context"
+	"net/url"
+
+	libregraph "github.com/owncloud/libre-graph-api-go"
+)
+
+// ErrEducationBackend is a dummy EducationBackend, doing nothing
+type ErrEducationBackend struct{}
+
+// CreateEducationSchool creates the supplied school in the identity backend.
+func (i *ErrEducationBackend) CreateEducationSchool(ctx context.Context, school libregraph.EducationSchool) (*libregraph.EducationSchool, error) {
+	return nil, errNotImplemented
+}
+
+// DeleteEducationSchool deletes a given school, identified by id
+func (i *ErrEducationBackend) DeleteEducationSchool(ctx context.Context, id string) error {
+	return errNotImplemented
+}
+
+// GetEducationSchool implements the EducationBackend interface for the ErrEducationBackend backend.
+func (i *ErrEducationBackend) GetEducationSchool(ctx context.Context, nameOrID string, queryParam url.Values) (*libregraph.EducationSchool, error) {
+	return nil, errNotImplemented
+}
+
+// GetEducationSchools implements the EducationBackend interface for the ErrEducationBackend backend.
+func (i *ErrEducationBackend) GetEducationSchools(ctx context.Context, queryParam url.Values) ([]*libregraph.EducationSchool, error) {
+	return nil, errNotImplemented
+}
+
+// GetEducationSchoolMembers implements the EducationBackend interface for the ErrEducationBackend backend.
+func (i *ErrEducationBackend) GetEducationSchoolMembers(ctx context.Context, id string) ([]*libregraph.EducationUser, error) {
+	return nil, errNotImplemented
+}
+
+// AddMembersToEducationSchool adds new members (reference by a slice of IDs) to supplied school in the identity backend.
+func (i *ErrEducationBackend) AddMembersToEducationSchool(ctx context.Context, schoolID string, memberID []string) error {
+	return errNotImplemented
+}
+
+// RemoveMemberFromEducationSchool removes a single member (by ID) from a school
+func (i *ErrEducationBackend) RemoveMemberFromEducationSchool(ctx context.Context, schoolID string, memberID string) error {
+	return errNotImplemented
+}
+
+// CreateEducationUser creates a given education user in the identity backend.
+func (i *ErrEducationBackend) CreateEducationUser(ctx context.Context, user libregraph.EducationUser) (*libregraph.EducationUser, error) {
+	return nil, errNotImplemented
+}
+
+// DeleteEducationUser deletes a given educationuser, identified by username or id, from the backend
+func (i *ErrEducationBackend) DeleteEducationUser(ctx context.Context, nameOrID string) error {
+	return errNotImplemented
+}
+
+// UpdateEducationUser applies changes to given education user, identified by username or id
+func (i *ErrEducationBackend) UpdateEducationUser(ctx context.Context, nameOrID string, user libregraph.EducationUser) (*libregraph.EducationUser, error) {
+	return nil, errNotImplemented
+}
+
+// GetEducationUser implements the EducationBackend interface for the ErrEducationBackend backend.
+func (i *ErrEducationBackend) GetEducationUser(ctx context.Context, nameOrID string, queryParam url.Values) (*libregraph.EducationUser, error) {
+	return nil, errNotImplemented
+}
+
+// GetEducationUsers implements the EducationBackend interface for the ErrEducationBackend backend.
+func (i *ErrEducationBackend) GetEducationUsers(ctx context.Context, queryParam url.Values) ([]*libregraph.EducationUser, error) {
+	return nil, errNotImplemented
+}
diff --git a/services/graph/pkg/identity/ldap.go b/services/graph/pkg/identity/ldap.go
index ea879e6be6b..2256a46b287 100644
--- a/services/graph/pkg/identity/ldap.go
+++ b/services/graph/pkg/identity/ldap.go
@@ -40,6 +40,8 @@ type LDAP struct {
 	groupScope        int
 	groupAttributeMap groupAttributeMap
 
+	educationConfig educationConfig
+
 	logger *log.Logger
 	conn   ldap.Client
 }
@@ -90,6 +92,11 @@ func NewLDAPBackend(lc ldap.Client, config config.LDAP, logger *log.Logger) (*LD
 		return nil, fmt.Errorf("error configuring group scope: %w", err)
 	}
 
+	var educationConfig educationConfig
+	if educationConfig, err = newEducationConfig(config); err != nil {
+		return nil, fmt.Errorf("error setting up education resource config: %w", err)
+	}
+
 	return &LDAP{
 		useServerUUID:     config.UseServerUUID,
 		usePwModifyExOp:   config.UsePasswordModExOp,
@@ -103,6 +110,7 @@ func NewLDAPBackend(lc ldap.Client, config config.LDAP, logger *log.Logger) (*LD
 		groupObjectClass:  config.GroupObjectClass,
 		groupScope:        groupScope,
 		groupAttributeMap: gam,
+		educationConfig:   educationConfig,
 		logger:            logger,
 		conn:              lc,
 		writeEnabled:      config.WriteEnabled,
@@ -303,7 +311,14 @@ func (i *LDAP) getUserByDN(dn string) (*ldap.Entry, error) {
 		i.userAttributeMap.mail,
 		i.userAttributeMap.userName,
 	}
-	return i.getEntryByDN(dn, attrs)
+
+	filter := fmt.Sprintf("(objectClass=%s)", i.userObjectClass)
+
+	if i.userFilter != "" {
+		filter = fmt.Sprintf("(&%s(%s))", filter, i.userFilter)
+	}
+
+	return i.getEntryByDN(dn, attrs, filter)
 }
 
 func (i *LDAP) getGroupByDN(dn string) (*ldap.Entry, error) {
@@ -311,13 +326,22 @@ func (i *LDAP) getGroupByDN(dn string) (*ldap.Entry, error) {
 		i.groupAttributeMap.id,
 		i.groupAttributeMap.name,
 	}
-	return i.getEntryByDN(dn, attrs)
+	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) (*ldap.Entry, error) {
+func (i *LDAP) getEntryByDN(dn string, attrs []string, filter string) (*ldap.Entry, error) {
+	if filter == "" {
+		filter = "(objectclass=*)"
+	}
+
 	searchRequest := ldap.NewSearchRequest(
 		dn, ldap.ScopeBaseObject, ldap.NeverDerefAliases, 1, 0, false,
-		"(objectclass=*)",
+		filter,
 		attrs,
 		nil,
 	)
diff --git a/services/graph/pkg/identity/ldap_education_user.go b/services/graph/pkg/identity/ldap_education_user.go
new file mode 100644
index 00000000000..391584b25f7
--- /dev/null
+++ b/services/graph/pkg/identity/ldap_education_user.go
@@ -0,0 +1,33 @@
+package identity
+
+import (
+	"context"
+	"net/url"
+
+	libregraph "github.com/owncloud/libre-graph-api-go"
+)
+
+// CreateEducationUser creates a given education user in the identity backend.
+func (i *LDAP) CreateEducationUser(ctx context.Context, user libregraph.EducationUser) (*libregraph.EducationUser, error) {
+	return nil, errNotImplemented
+}
+
+// DeleteEducationUser deletes a given educationuser, identified by username or id, from the backend
+func (i *LDAP) DeleteEducationUser(ctx context.Context, nameOrID string) error {
+	return errNotImplemented
+}
+
+// UpdateEducationUser applies changes to given education user, identified by username or id
+func (i *LDAP) UpdateEducationUser(ctx context.Context, nameOrID string, user libregraph.EducationUser) (*libregraph.EducationUser, error) {
+	return nil, errNotImplemented
+}
+
+// GetEducationUser implements the EducationBackend interface for the LDAP backend.
+func (i *LDAP) GetEducationUser(ctx context.Context, nameOrID string, queryParam url.Values) (*libregraph.EducationUser, error) {
+	return nil, errNotImplemented
+}
+
+// GetEducationUsers implements the EducationBackend interface for the LDAP backend.
+func (i *LDAP) GetEducationUsers(ctx context.Context, queryParam url.Values) ([]*libregraph.EducationUser, error) {
+	return nil, errNotImplemented
+}
diff --git a/services/graph/pkg/identity/ldap_school.go b/services/graph/pkg/identity/ldap_school.go
new file mode 100644
index 00000000000..ec30b1ccc1a
--- /dev/null
+++ b/services/graph/pkg/identity/ldap_school.go
@@ -0,0 +1,295 @@
+package identity
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"net/url"
+
+	"github.com/go-ldap/ldap/v3"
+	"github.com/gofrs/uuid"
+	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/config"
+	"github.com/owncloud/ocis/v2/services/graph/pkg/service/v0/errorcode"
+)
+
+type educationConfig struct {
+	schoolBaseDN       string
+	schoolFilter       string
+	schoolObjectClass  string
+	schoolScope        int
+	schoolAttributeMap schoolAttributeMap
+}
+
+type schoolAttributeMap struct {
+	displayName  string
+	schoolNumber string
+	id           string
+}
+
+func defaultEducationConfig() educationConfig {
+	return educationConfig{
+		schoolObjectClass:  "ocEducationSchool",
+		schoolScope:        ldap.ScopeWholeSubtree,
+		schoolAttributeMap: newSchoolAttributeMap(),
+	}
+}
+
+func newEducationConfig(config config.LDAP) (educationConfig, error) {
+	if config.EducationResourcesEnabled {
+		var err error
+		eduCfg := defaultEducationConfig()
+		eduCfg.schoolBaseDN = config.EducationConfig.SchoolBaseDN
+		if config.EducationConfig.SchoolSearchScope != "" {
+			if eduCfg.schoolScope, err = stringToScope(config.EducationConfig.SchoolSearchScope); err != nil {
+				return educationConfig{}, fmt.Errorf("error configuring school search scope: %w", err)
+			}
+		}
+		if config.EducationConfig.SchoolFilter != "" {
+			eduCfg.schoolFilter = config.EducationConfig.SchoolFilter
+		}
+		if config.EducationConfig.SchoolObjectClass != "" {
+			eduCfg.schoolObjectClass = config.EducationConfig.SchoolObjectClass
+		}
+
+		// Attribute mapping config
+		if config.EducationConfig.SchoolNameAttribute != "" {
+			eduCfg.schoolAttributeMap.displayName = config.EducationConfig.SchoolNameAttribute
+		}
+		if config.EducationConfig.SchoolNumberAttribute != "" {
+			eduCfg.schoolAttributeMap.schoolNumber = config.EducationConfig.SchoolNumberAttribute
+		}
+		if config.EducationConfig.SchoolIDAttribute != "" {
+			eduCfg.schoolAttributeMap.id = config.EducationConfig.SchoolIDAttribute
+		}
+
+		return eduCfg, nil
+	}
+	return educationConfig{}, nil
+}
+
+func newSchoolAttributeMap() schoolAttributeMap {
+	return schoolAttributeMap{
+		displayName:  "ou",
+		schoolNumber: "ocEducationSchoolNumber",
+		id:           "owncloudUUID",
+	}
+}
+
+// CreateEducationSchool creates the supplied school in the identity backend.
+func (i *LDAP) CreateEducationSchool(ctx context.Context, school libregraph.EducationSchool) (*libregraph.EducationSchool, error) {
+	logger := i.logger.SubloggerWithRequestID(ctx)
+	logger.Debug().Str("backend", "ldap").Msg("CreateEducationSchool")
+	if !i.writeEnabled {
+		return nil, errReadOnly
+	}
+
+	dn := fmt.Sprintf("%s=%s,%s",
+		i.educationConfig.schoolAttributeMap.displayName,
+		oldap.EscapeDNAttributeValue(school.GetDisplayName()),
+		i.educationConfig.schoolBaseDN,
+	)
+	ar := ldap.NewAddRequest(dn, nil)
+	ar.Attribute(i.educationConfig.schoolAttributeMap.displayName, []string{school.GetDisplayName()})
+	ar.Attribute(i.educationConfig.schoolAttributeMap.schoolNumber, []string{school.GetSchoolNumber()})
+	if !i.useServerUUID {
+		ar.Attribute(i.educationConfig.schoolAttributeMap.id, []string{uuid.Must(uuid.NewV4()).String()})
+	}
+	objectClasses := []string{"organizationalUnit", i.educationConfig.schoolObjectClass, "top"}
+	ar.Attribute("objectClass", objectClasses)
+
+	if err := i.conn.Add(ar); err != nil {
+		var lerr *ldap.Error
+		logger.Debug().Err(err).Msg("error adding school")
+		if errors.As(err, &lerr) {
+			if lerr.ResultCode == ldap.LDAPResultEntryAlreadyExists {
+				err = errorcode.New(errorcode.NameAlreadyExists, lerr.Error())
+			}
+		}
+		return nil, err
+	}
+
+	// Read	back school from LDAP to get the generated UUID
+	e, err := i.getSchoolByDN(ar.DN)
+	if err != nil {
+		return nil, err
+	}
+	return i.createSchoolModelFromLDAP(e), nil
+}
+
+// DeleteEducationSchool deletes a given school, identified by id
+func (i *LDAP) DeleteEducationSchool(ctx context.Context, id string) error {
+	logger := i.logger.SubloggerWithRequestID(ctx)
+	logger.Debug().Str("backend", "ldap").Msg("DeleteEducationSchool")
+	if !i.writeEnabled {
+		return errReadOnly
+	}
+	e, err := i.getSchoolByID(id)
+	if err != nil {
+		return err
+	}
+
+	dr := ldap.DelRequest{DN: e.DN}
+	if err = i.conn.Del(&dr); err != nil {
+		return err
+	}
+	return nil
+}
+
+// GetEducationSchool implements the EducationBackend interface for the LDAP backend.
+func (i *LDAP) GetEducationSchool(ctx context.Context, nameOrID string, queryParam url.Values) (*libregraph.EducationSchool, error) {
+	logger := i.logger.SubloggerWithRequestID(ctx)
+	logger.Debug().Str("backend", "ldap").Msg("GetEducationSchool")
+	e, err := i.getSchoolByID(nameOrID)
+	if err != nil {
+		return nil, err
+	}
+
+	return i.createSchoolModelFromLDAP(e), nil
+}
+
+// GetEducationSchools implements the EducationBackend interface for the LDAP backend.
+func (i *LDAP) GetEducationSchools(ctx context.Context, queryParam url.Values) ([]*libregraph.EducationSchool, error) {
+	var filter string
+	filter = fmt.Sprintf("(objectClass=%s)", i.educationConfig.schoolObjectClass)
+
+	if i.educationConfig.schoolFilter != "" {
+		filter = fmt.Sprintf("(&%s%s)", i.educationConfig.schoolFilter, filter)
+	}
+
+	searchRequest := ldap.NewSearchRequest(
+		i.educationConfig.schoolBaseDN,
+		i.educationConfig.schoolScope,
+		ldap.NeverDerefAliases, 0, 0, false,
+		filter,
+		[]string{
+			i.educationConfig.schoolAttributeMap.displayName,
+			i.educationConfig.schoolAttributeMap.id,
+			i.educationConfig.schoolAttributeMap.schoolNumber,
+		},
+		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("GetEducationSchools")
+	res, err := i.conn.Search(searchRequest)
+	if err != nil {
+		return nil, errorcode.New(errorcode.ItemNotFound, err.Error())
+	}
+
+	schools := make([]*libregraph.EducationSchool, 0, len(res.Entries))
+	for _, e := range res.Entries {
+		school := i.createSchoolModelFromLDAP(e)
+		// Skip invalid LDAP entries
+		if school == nil {
+			continue
+		}
+		schools = append(schools, school)
+	}
+	return schools, nil
+}
+
+// GetEducationSchoolMembers implements the EducationBackend interface for the LDAP backend.
+func (i *LDAP) GetEducationSchoolMembers(ctx context.Context, id string) ([]*libregraph.EducationUser, error) {
+	return nil, errNotImplemented
+}
+
+// AddMembersToEducationSchool adds new members (reference by a slice of IDs) to supplied school in the identity backend.
+func (i *LDAP) AddMembersToEducationSchool(ctx context.Context, schoolID string, memberID []string) error {
+	return errNotImplemented
+}
+
+// RemoveMemberFromEducationSchool removes a single member (by ID) from a school
+func (i *LDAP) RemoveMemberFromEducationSchool(ctx context.Context, schoolID string, memberID string) error {
+	return errNotImplemented
+}
+
+func (i *LDAP) getSchoolByDN(dn string) (*ldap.Entry, error) {
+	attrs := []string{
+		i.educationConfig.schoolAttributeMap.displayName,
+		i.educationConfig.schoolAttributeMap.id,
+		i.educationConfig.schoolAttributeMap.schoolNumber,
+	}
+	filter := fmt.Sprintf("(objectClass=%s)", i.educationConfig.schoolObjectClass)
+
+	if i.educationConfig.schoolFilter != "" {
+		filter = fmt.Sprintf("(&%s(%s))", filter, i.educationConfig.schoolFilter)
+	}
+	return i.getEntryByDN(dn, attrs, filter)
+}
+
+func (i *LDAP) getSchoolByID(id string) (*ldap.Entry, error) {
+	id = ldap.EscapeFilter(id)
+	filter := fmt.Sprintf("(%s=%s)", i.educationConfig.schoolAttributeMap.id, id)
+	return i.getSchoolByFilter(filter)
+}
+
+func (i *LDAP) getSchoolByFilter(filter string) (*ldap.Entry, error) {
+	filter = fmt.Sprintf("(&%s(objectClass=%s)%s)",
+		i.educationConfig.schoolFilter,
+		i.educationConfig.schoolObjectClass,
+		filter,
+	)
+	searchRequest := ldap.NewSearchRequest(
+		i.educationConfig.schoolBaseDN,
+		i.educationConfig.schoolScope,
+		ldap.NeverDerefAliases, 1, 0, false,
+		filter,
+		[]string{
+			i.educationConfig.schoolAttributeMap.displayName,
+			i.educationConfig.schoolAttributeMap.id,
+			i.educationConfig.schoolAttributeMap.schoolNumber,
+		},
+		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("getSchoolByFilter")
+	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 school '%s'", filter)
+				i.logger.Debug().Str("backend", "ldap").Err(lerr).
+					Str("schoolfilter", filter).Msg("too many results searching for school")
+			}
+		}
+		return nil, errorcode.New(errorcode.ItemNotFound, errmsg)
+	}
+	if len(res.Entries) == 0 {
+		return nil, errNotFound
+	}
+
+	return res.Entries[0], nil
+}
+
+func (i *LDAP) createSchoolModelFromLDAP(e *ldap.Entry) *libregraph.EducationSchool {
+	if e == nil {
+		return nil
+	}
+
+	displayName := e.GetEqualFoldAttributeValue(i.educationConfig.schoolAttributeMap.displayName)
+	id := e.GetEqualFoldAttributeValue(i.educationConfig.schoolAttributeMap.id)
+	schoolNumber := e.GetEqualFoldAttributeValue(i.educationConfig.schoolAttributeMap.schoolNumber)
+
+	if id != "" && displayName != "" && schoolNumber != "" {
+		school := libregraph.NewEducationSchool()
+		school.SetDisplayName(displayName)
+		school.SetSchoolNumber(schoolNumber)
+		school.SetId(id)
+		return school
+	}
+	i.logger.Warn().Str("dn", e.DN).Str("id", id).Str("displayName", displayName).Str("schoolNumber", schoolNumber).Msg("Invalid School. Missing required attribute")
+	return nil
+}
diff --git a/services/graph/pkg/identity/mocks/education_backend.go b/services/graph/pkg/identity/mocks/education_backend.go
new file mode 100644
index 00000000000..edd08944702
--- /dev/null
+++ b/services/graph/pkg/identity/mocks/education_backend.go
@@ -0,0 +1,273 @@
+// Code generated by mockery v2.14.1. DO NOT EDIT.
+
+package mocks
+
+import (
+	context "context"
+
+	libregraph "github.com/owncloud/libre-graph-api-go"
+
+	mock "github.com/stretchr/testify/mock"
+
+	url "net/url"
+)
+
+// EducationBackend is an autogenerated mock type for the EducationBackend type
+type EducationBackend struct {
+	mock.Mock
+}
+
+// AddMembersToEducationSchool provides a mock function with given fields: ctx, schoolID, memberID
+func (_m *EducationBackend) AddMembersToEducationSchool(ctx context.Context, schoolID string, memberID []string) error {
+	ret := _m.Called(ctx, schoolID, memberID)
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(context.Context, string, []string) error); ok {
+		r0 = rf(ctx, schoolID, memberID)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// CreateEducationSchool provides a mock function with given fields: ctx, group
+func (_m *EducationBackend) CreateEducationSchool(ctx context.Context, group libregraph.EducationSchool) (*libregraph.EducationSchool, error) {
+	ret := _m.Called(ctx, group)
+
+	var r0 *libregraph.EducationSchool
+	if rf, ok := ret.Get(0).(func(context.Context, libregraph.EducationSchool) *libregraph.EducationSchool); ok {
+		r0 = rf(ctx, group)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(*libregraph.EducationSchool)
+		}
+	}
+
+	var r1 error
+	if rf, ok := ret.Get(1).(func(context.Context, libregraph.EducationSchool) error); ok {
+		r1 = rf(ctx, group)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// CreateEducationUser provides a mock function with given fields: ctx, user
+func (_m *EducationBackend) CreateEducationUser(ctx context.Context, user libregraph.EducationUser) (*libregraph.EducationUser, error) {
+	ret := _m.Called(ctx, user)
+
+	var r0 *libregraph.EducationUser
+	if rf, ok := ret.Get(0).(func(context.Context, libregraph.EducationUser) *libregraph.EducationUser); ok {
+		r0 = rf(ctx, user)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(*libregraph.EducationUser)
+		}
+	}
+
+	var r1 error
+	if rf, ok := ret.Get(1).(func(context.Context, libregraph.EducationUser) error); ok {
+		r1 = rf(ctx, user)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// DeleteEducationSchool provides a mock function with given fields: ctx, id
+func (_m *EducationBackend) DeleteEducationSchool(ctx context.Context, id string) error {
+	ret := _m.Called(ctx, id)
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(context.Context, string) error); ok {
+		r0 = rf(ctx, id)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// DeleteEducationUser provides a mock function with given fields: ctx, nameOrID
+func (_m *EducationBackend) DeleteEducationUser(ctx context.Context, nameOrID string) error {
+	ret := _m.Called(ctx, nameOrID)
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(context.Context, string) error); ok {
+		r0 = rf(ctx, nameOrID)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// GetEducationSchool provides a mock function with given fields: ctx, nameOrID, queryParam
+func (_m *EducationBackend) GetEducationSchool(ctx context.Context, nameOrID string, queryParam url.Values) (*libregraph.EducationSchool, error) {
+	ret := _m.Called(ctx, nameOrID, queryParam)
+
+	var r0 *libregraph.EducationSchool
+	if rf, ok := ret.Get(0).(func(context.Context, string, url.Values) *libregraph.EducationSchool); ok {
+		r0 = rf(ctx, nameOrID, queryParam)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(*libregraph.EducationSchool)
+		}
+	}
+
+	var r1 error
+	if rf, ok := ret.Get(1).(func(context.Context, string, url.Values) error); ok {
+		r1 = rf(ctx, nameOrID, queryParam)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// GetEducationSchoolMembers provides a mock function with given fields: ctx, id
+func (_m *EducationBackend) GetEducationSchoolMembers(ctx context.Context, id string) ([]*libregraph.EducationUser, error) {
+	ret := _m.Called(ctx, id)
+
+	var r0 []*libregraph.EducationUser
+	if rf, ok := ret.Get(0).(func(context.Context, string) []*libregraph.EducationUser); ok {
+		r0 = rf(ctx, id)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).([]*libregraph.EducationUser)
+		}
+	}
+
+	var r1 error
+	if rf, ok := ret.Get(1).(func(context.Context, string) error); ok {
+		r1 = rf(ctx, id)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// GetEducationSchools provides a mock function with given fields: ctx, queryParam
+func (_m *EducationBackend) GetEducationSchools(ctx context.Context, queryParam url.Values) ([]*libregraph.EducationSchool, error) {
+	ret := _m.Called(ctx, queryParam)
+
+	var r0 []*libregraph.EducationSchool
+	if rf, ok := ret.Get(0).(func(context.Context, url.Values) []*libregraph.EducationSchool); ok {
+		r0 = rf(ctx, queryParam)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).([]*libregraph.EducationSchool)
+		}
+	}
+
+	var r1 error
+	if rf, ok := ret.Get(1).(func(context.Context, url.Values) error); ok {
+		r1 = rf(ctx, queryParam)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// GetEducationUser provides a mock function with given fields: ctx, nameOrID, queryParam
+func (_m *EducationBackend) GetEducationUser(ctx context.Context, nameOrID string, queryParam url.Values) (*libregraph.EducationUser, error) {
+	ret := _m.Called(ctx, nameOrID, queryParam)
+
+	var r0 *libregraph.EducationUser
+	if rf, ok := ret.Get(0).(func(context.Context, string, url.Values) *libregraph.EducationUser); ok {
+		r0 = rf(ctx, nameOrID, queryParam)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(*libregraph.EducationUser)
+		}
+	}
+
+	var r1 error
+	if rf, ok := ret.Get(1).(func(context.Context, string, url.Values) error); ok {
+		r1 = rf(ctx, nameOrID, queryParam)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// GetEducationUsers provides a mock function with given fields: ctx, queryParam
+func (_m *EducationBackend) GetEducationUsers(ctx context.Context, queryParam url.Values) ([]*libregraph.EducationUser, error) {
+	ret := _m.Called(ctx, queryParam)
+
+	var r0 []*libregraph.EducationUser
+	if rf, ok := ret.Get(0).(func(context.Context, url.Values) []*libregraph.EducationUser); ok {
+		r0 = rf(ctx, queryParam)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).([]*libregraph.EducationUser)
+		}
+	}
+
+	var r1 error
+	if rf, ok := ret.Get(1).(func(context.Context, url.Values) error); ok {
+		r1 = rf(ctx, queryParam)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// RemoveMemberFromEducationSchool provides a mock function with given fields: ctx, schoolID, memberID
+func (_m *EducationBackend) RemoveMemberFromEducationSchool(ctx context.Context, schoolID string, memberID string) error {
+	ret := _m.Called(ctx, schoolID, memberID)
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(context.Context, string, string) error); ok {
+		r0 = rf(ctx, schoolID, memberID)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// UpdateEducationUser provides a mock function with given fields: ctx, nameOrID, user
+func (_m *EducationBackend) UpdateEducationUser(ctx context.Context, nameOrID string, user libregraph.EducationUser) (*libregraph.EducationUser, error) {
+	ret := _m.Called(ctx, nameOrID, user)
+
+	var r0 *libregraph.EducationUser
+	if rf, ok := ret.Get(0).(func(context.Context, string, libregraph.EducationUser) *libregraph.EducationUser); ok {
+		r0 = rf(ctx, nameOrID, user)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(*libregraph.EducationUser)
+		}
+	}
+
+	var r1 error
+	if rf, ok := ret.Get(1).(func(context.Context, string, libregraph.EducationUser) error); ok {
+		r1 = rf(ctx, nameOrID, user)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+type mockConstructorTestingTNewEducationBackend interface {
+	mock.TestingT
+	Cleanup(func())
+}
+
+// NewEducationBackend creates a new instance of EducationBackend. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
+func NewEducationBackend(t mockConstructorTestingTNewEducationBackend) *EducationBackend {
+	mock := &EducationBackend{}
+	mock.Mock.Test(t)
+
+	t.Cleanup(func() { mock.AssertExpectations(t) })
+
+	return mock
+}
diff --git a/services/graph/pkg/service/v0/drives.go b/services/graph/pkg/service/v0/drives.go
index 8bed0119e73..b03242c1fe9 100644
--- a/services/graph/pkg/service/v0/drives.go
+++ b/services/graph/pkg/service/v0/drives.go
@@ -33,6 +33,13 @@ import (
 	merrors "go-micro.dev/v4/errors"
 )
 
+const (
+	_spaceTypePersonal = "personal"
+	_spaceTypeProject  = "project"
+
+	_sortDescending = "desc"
+)
+
 var (
 	_invalidSpaceNameCharacters = []string{`/`, `\`, `.`, `:`, `?`, `*`, `"`, `>`, `<`, `|`}
 	_maxSpaceNameLength         = 255
@@ -253,8 +260,8 @@ func (g Graph) CreateDrive(w http.ResponseWriter, r *http.Request) {
 		driveType = *drive.DriveType
 	}
 	switch driveType {
-	case "", "project":
-		driveType = "project"
+	case "", _spaceTypeProject:
+		driveType = _spaceTypeProject
 	default:
 		logger.Debug().Str("type", driveType).Msg("could not create drive: drives of this type cannot be created via this api")
 		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "drives of this type cannot be created via this api")
@@ -275,7 +282,7 @@ func (g Graph) CreateDrive(w http.ResponseWriter, r *http.Request) {
 		csr.Opaque = utils.AppendPlainToOpaque(csr.Opaque, "spaceAlias", *drive.DriveAlias)
 	}
 
-	if driveType == "personal" {
+	if driveType == _spaceTypePersonal {
 		csr.Owner = us
 	}
 
@@ -911,7 +918,7 @@ func sortSpaces(req *godata.GoDataRequest, spaces []*libregraph.Drive) ([]*libre
 		return nil, errors.Errorf("we do not support <%s> as a order parameter", req.Query.OrderBy.OrderByItems[0].Field.Value)
 	}
 
-	if req.Query.OrderBy.OrderByItems[0].Order == "desc" {
+	if req.Query.OrderBy.OrderByItems[0].Order == _sortDescending {
 		sorter = sort.Reverse(sorter)
 	}
 	sort.Sort(sorter)
diff --git a/services/graph/pkg/service/v0/educationschools.go b/services/graph/pkg/service/v0/educationschools.go
new file mode 100644
index 00000000000..f9942f8c4ff
--- /dev/null
+++ b/services/graph/pkg/service/v0/educationschools.go
@@ -0,0 +1,411 @@
+package svc
+
+import (
+	"encoding/json"
+	"errors"
+	"fmt"
+	"net/http"
+	"net/url"
+	"sort"
+	"strings"
+
+	"github.com/CiscoM31/godata"
+	libregraph "github.com/owncloud/libre-graph-api-go"
+	"github.com/owncloud/ocis/v2/services/graph/pkg/service/v0/errorcode"
+
+	"github.com/go-chi/chi/v5"
+	"github.com/go-chi/render"
+)
+
+// GetEducationSchools implements the Service interface.
+func (g Graph) GetEducationSchools(w http.ResponseWriter, r *http.Request) {
+	logger := g.logger.SubloggerWithRequestID(r.Context())
+	logger.Info().Interface("query", r.URL.Query()).Msg("calling get schools")
+	sanitizedPath := strings.TrimPrefix(r.URL.Path, "/graph/v1.0/")
+	odataReq, err := godata.ParseRequest(r.Context(), sanitizedPath, r.URL.Query())
+	if err != nil {
+		logger.Debug().Err(err).Interface("query", r.URL.Query()).Msg("could not get schools: query error")
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
+		return
+	}
+
+	schools, err := g.identityEducationBackend.GetEducationSchools(r.Context(), r.URL.Query())
+	if err != nil {
+		logger.Debug().Err(err).Msg("could not get schools: backend error")
+		var errcode errorcode.Error
+		if errors.As(err, &errcode) {
+			errcode.Render(w, r)
+		} else {
+			errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
+		}
+		return
+	}
+
+	schools, err = sortEducationSchools(odataReq, schools)
+	if err != nil {
+		logger.Debug().Err(err).Interface("query", r.URL.Query()).Msg("cannot get schools: could not sort schools according to query")
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
+		return
+	}
+	render.Status(r, http.StatusOK)
+	render.JSON(w, r, &ListResponse{Value: schools})
+}
+
+// PostEducationSchool implements the Service interface.
+func (g Graph) PostEducationSchool(w http.ResponseWriter, r *http.Request) {
+	logger := g.logger.SubloggerWithRequestID(r.Context())
+	logger.Info().Msg("calling post school")
+	school := libregraph.NewEducationSchool()
+	err := json.NewDecoder(r.Body).Decode(school)
+	if err != nil {
+		logger.Debug().Err(err).Interface("body", r.Body).Msg("could not create school: invalid request body")
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("invalid request body: %s", err.Error()))
+		return
+	}
+
+	// Disallow user-supplied IDs. It's supposed to be readonly. We're either
+	// generating them in the backend ourselves or rely on the Backend's
+	// storage (e.g. LDAP) to provide a unique ID.
+	if _, ok := school.GetIdOk(); ok {
+		logger.Debug().Msg("could not create school: id is a read-only attribute")
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "school id is a read-only attribute")
+		return
+	}
+
+	if _, ok := school.GetDisplayNameOk(); !ok {
+		logger.Debug().Interface("school", school).Msg("could not create school: missing required attribute")
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "Missing Required Attribute")
+		return
+	}
+
+	if _, ok := school.GetSchoolNumberOk(); !ok {
+		logger.Debug().Interface("school", school).Msg("could not create school: missing required attribute")
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "Missing Required Attribute")
+		return
+	}
+
+	if school, err = g.identityEducationBackend.CreateEducationSchool(r.Context(), *school); err != nil {
+		logger.Debug().Err(err).Interface("school", school).Msg("could not create school: backend error")
+		errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
+		return
+	}
+
+	/* TODO requires reva changes
+	if school != nil && school.Id != nil {
+		e := events.SchoolCreated{SchoolID: *school.Id}
+		if currentUser, ok := ctxpkg.ContextGetUser(r.Context()); ok {
+			e.Executant = currentUser.GetId()
+		}
+		g.publishEvent(e)
+	}
+	*/
+
+	render.Status(r, http.StatusCreated)
+	render.JSON(w, r, school)
+}
+
+// PatchEducationSchool implements the Service interface.
+func (g Graph) PatchEducationSchool(w http.ResponseWriter, r *http.Request) {
+	logger := g.logger.SubloggerWithRequestID(r.Context())
+	logger.Info().Msg("calling patch school")
+	schoolID := chi.URLParam(r, "schoolID")
+	schoolID, err := url.PathUnescape(schoolID)
+	if err != nil {
+		logger.Debug().Str("id", schoolID).Msg("could not change school: unescaping school id failed")
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping school id failed")
+		return
+	}
+
+	if schoolID == "" {
+		logger.Debug().Msg("could not change school: missing school id")
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing school id")
+		return
+	}
+	changes := libregraph.NewEducationSchool()
+	err = json.NewDecoder(r.Body).Decode(changes)
+	if err != nil {
+		logger.Debug().Err(err).Interface("body", r.Body).Msg("could not change school: invalid request body")
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("invalid request body: %s", err.Error()))
+		return
+	}
+
+	/* TODO requires reva changes
+	e := events.SchoolFeatureChanged{SchoolID: schoolID}
+	if currentUser, ok := ctxpkg.ContextGetUser(r.Context()); ok {
+		e.Executant = currentUser.GetId()
+	}
+	g.publishEvent(e)
+	*/
+
+	render.Status(r, http.StatusNoContent)
+	render.NoContent(w, r)
+}
+
+// GetEducationSchool implements the Service interface.
+func (g Graph) GetEducationSchool(w http.ResponseWriter, r *http.Request) {
+	logger := g.logger.SubloggerWithRequestID(r.Context())
+	logger.Info().Msg("calling get school")
+	schoolID := chi.URLParam(r, "schoolID")
+	schoolID, err := url.PathUnescape(schoolID)
+	if err != nil {
+		logger.Debug().Str("id", schoolID).Msg("could not get school: unescaping school id failed")
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping school id failed")
+	}
+
+	if schoolID == "" {
+		logger.Debug().Msg("could not get school: missing school id")
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing school id")
+		return
+	}
+
+	logger.Debug().
+		Str("id", schoolID).
+		Interface("query", r.URL.Query()).
+		Msg("calling get school on backend")
+	school, err := g.identityEducationBackend.GetEducationSchool(r.Context(), schoolID, r.URL.Query())
+	if err != nil {
+		logger.Debug().Err(err).Msg("could not get school: backend error")
+		var errcode errorcode.Error
+		if errors.As(err, &errcode) {
+			errcode.Render(w, r)
+		} else {
+			errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
+		}
+	}
+
+	render.Status(r, http.StatusOK)
+	render.JSON(w, r, school)
+}
+
+// DeleteEducationSchool implements the Service interface.
+func (g Graph) DeleteEducationSchool(w http.ResponseWriter, r *http.Request) {
+	logger := g.logger.SubloggerWithRequestID(r.Context())
+	logger.Info().Msg("calling delete school")
+	schoolID := chi.URLParam(r, "schoolID")
+	schoolID, err := url.PathUnescape(schoolID)
+	if err != nil {
+		logger.Debug().Err(err).Str("id", schoolID).Msg("could not delete school: unescaping school id failed")
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping school id failed")
+		return
+	}
+
+	if schoolID == "" {
+		logger.Debug().Msg("could not delete school: missing school id")
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing school id")
+		return
+	}
+
+	logger.Debug().Str("id", schoolID).Msg("calling delete school on backend")
+	err = g.identityEducationBackend.DeleteEducationSchool(r.Context(), schoolID)
+
+	if err != nil {
+		logger.Debug().Err(err).Msg("could not delete school: backend error")
+		var errcode errorcode.Error
+		if errors.As(err, &errcode) {
+			errcode.Render(w, r)
+		} else {
+			errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
+		}
+		return
+	}
+
+	/* TODO requires reva changes
+	e := events.SchoolDeleted{SchoolID: schoolID}
+	if currentUser, ok := ctxpkg.ContextGetUser(r.Context()); ok {
+		e.Executant = currentUser.GetId()
+	}
+	g.publishEvent(e)
+	*/
+
+	render.Status(r, http.StatusNoContent)
+	render.NoContent(w, r)
+}
+
+// GetEducationSchoolMembers implements the Service interface.
+func (g Graph) GetEducationSchoolMembers(w http.ResponseWriter, r *http.Request) {
+	logger := g.logger.SubloggerWithRequestID(r.Context())
+	logger.Info().Msg("calling get school members")
+	schoolID := chi.URLParam(r, "schoolID")
+	schoolID, err := url.PathUnescape(schoolID)
+	if err != nil {
+		logger.Debug().Str("id", schoolID).Msg("could not get school members: unescaping school id failed")
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping school id failed")
+		return
+	}
+
+	if schoolID == "" {
+		logger.Debug().Msg("could not get school members: missing school id")
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing school id")
+		return
+	}
+
+	logger.Debug().Str("id", schoolID).Msg("calling get school members on backend")
+	members, err := g.identityEducationBackend.GetEducationSchoolMembers(r.Context(), schoolID)
+	if err != nil {
+		logger.Debug().Err(err).Msg("could not get school members: backend error")
+		var errcode errorcode.Error
+		if errors.As(err, &errcode) {
+			errcode.Render(w, r)
+		} else {
+			errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
+		}
+		return
+	}
+
+	render.Status(r, http.StatusOK)
+	render.JSON(w, r, members)
+}
+
+// PostEducationSchoolMember implements the Service interface.
+func (g Graph) PostEducationSchoolMember(w http.ResponseWriter, r *http.Request) {
+	logger := g.logger.SubloggerWithRequestID(r.Context())
+	logger.Info().Msg("Calling post school member")
+
+	schoolID := chi.URLParam(r, "schoolID")
+	schoolID, err := url.PathUnescape(schoolID)
+	if err != nil {
+		logger.Debug().
+			Err(err).
+			Str("id", schoolID).
+			Msg("could not add member to school: unescaping school id failed")
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping school id failed")
+		return
+	}
+
+	if schoolID == "" {
+		logger.Debug().Msg("could not add school member: missing school id")
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing school id")
+		return
+	}
+	memberRef := libregraph.NewMemberReference()
+	err = json.NewDecoder(r.Body).Decode(memberRef)
+	if err != nil {
+		logger.Debug().
+			Err(err).
+			Interface("body", r.Body).
+			Msg("could not add school member: invalid request body")
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("invalid request body: %s", err.Error()))
+		return
+	}
+	memberRefURL, ok := memberRef.GetOdataIdOk()
+	if !ok {
+		logger.Debug().Msg("could not add school member: @odata.id reference is missing")
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "@odata.id reference is missing")
+		return
+	}
+	memberType, id, err := g.parseMemberRef(*memberRefURL)
+	if err != nil {
+		logger.Debug().Err(err).Msg("could not add school member: error parsing @odata.id url")
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "Error parsing @odata.id url")
+		return
+	}
+	// The MS Graph spec allows "directoryObject", "user", "school" and "organizational Contact"
+	// we restrict this to users for now. Might add Schools as members later
+	if memberType != "users" {
+		logger.Debug().Str("type", memberType).Msg("could not add school member: Only users are allowed as school members")
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "Only users are allowed as school members")
+		return
+	}
+
+	logger.Debug().Str("memberType", memberType).Str("id", id).Msg("calling add member on backend")
+	err = g.identityEducationBackend.AddMembersToEducationSchool(r.Context(), schoolID, []string{id})
+
+	if err != nil {
+		logger.Debug().Err(err).Msg("could not add school member: backend error")
+		var errcode errorcode.Error
+		if errors.As(err, &errcode) {
+			errcode.Render(w, r)
+		} else {
+			errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
+		}
+		return
+	}
+
+	/* TODO requires reva changes
+	e := events.SchoolMemberAdded{SchoolID: schoolID, UserID: id}
+	if currentUser, ok := ctxpkg.ContextGetUser(r.Context()); ok {
+		e.Executant = currentUser.GetId()
+	}
+	g.publishEvent(e)
+	*/
+
+	render.Status(r, http.StatusNoContent)
+	render.NoContent(w, r)
+}
+
+// DeleteEducationSchoolMember implements the Service interface.
+func (g Graph) DeleteEducationSchoolMember(w http.ResponseWriter, r *http.Request) {
+	logger := g.logger.SubloggerWithRequestID(r.Context())
+	logger.Info().Msg("calling delete school member")
+
+	schoolID := chi.URLParam(r, "schoolID")
+	schoolID, err := url.PathUnescape(schoolID)
+	if err != nil {
+		logger.Debug().Err(err).Str("id", schoolID).Msg("could not delete school member: unescaping school id failed")
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping school id failed")
+		return
+	}
+
+	if schoolID == "" {
+		logger.Debug().Msg("could not delete school member: missing school id")
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing school id")
+		return
+	}
+
+	memberID := chi.URLParam(r, "memberID")
+	memberID, err = url.PathUnescape(memberID)
+	if err != nil {
+		logger.Debug().Err(err).Str("id", memberID).Msg("could not delete school member: unescaping member id failed")
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping member id failed")
+		return
+	}
+
+	if memberID == "" {
+		logger.Debug().Msg("could not delete school member: missing member id")
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing member id")
+		return
+	}
+	logger.Debug().Str("schoolID", schoolID).Str("memberID", memberID).Msg("calling delete member on backend")
+	err = g.identityEducationBackend.RemoveMemberFromEducationSchool(r.Context(), schoolID, memberID)
+
+	if err != nil {
+		logger.Debug().Err(err).Msg("could not delete school member: backend error")
+		var errcode errorcode.Error
+		if errors.As(err, &errcode) {
+			errcode.Render(w, r)
+		} else {
+			errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
+		}
+		return
+	}
+
+	/* TODO requires reva changes
+	e := events.SchoolMemberRemoved{SchoolID: schoolID, UserID: memberID}
+	if currentUser, ok := ctxpkg.ContextGetUser(r.Context()); ok {
+		e.Executant = currentUser.GetId()
+	}
+	g.publishEvent(e)
+	*/
+
+	render.Status(r, http.StatusNoContent)
+	render.NoContent(w, r)
+}
+
+func sortEducationSchools(req *godata.GoDataRequest, schools []*libregraph.EducationSchool) ([]*libregraph.EducationSchool, error) {
+	var sorter sort.Interface
+	if req.Query.OrderBy == nil || len(req.Query.OrderBy.OrderByItems) != 1 {
+		return schools, nil
+	}
+	switch req.Query.OrderBy.OrderByItems[0].Field.Value {
+	case displayNameAttr:
+		sorter = schoolsByDisplayName{schools}
+	default:
+		return nil, fmt.Errorf("we do not support <%s> as a order parameter", req.Query.OrderBy.OrderByItems[0].Field.Value)
+	}
+
+	if req.Query.OrderBy.OrderByItems[0].Order == "desc" {
+		sorter = sort.Reverse(sorter)
+	}
+	sort.Sort(sorter)
+	return schools, nil
+}
diff --git a/services/graph/pkg/service/v0/educationuser.go b/services/graph/pkg/service/v0/educationuser.go
new file mode 100644
index 00000000000..7757b0e0fd4
--- /dev/null
+++ b/services/graph/pkg/service/v0/educationuser.go
@@ -0,0 +1,481 @@
+package svc
+
+import (
+	"encoding/json"
+	"errors"
+	"fmt"
+	"net/http"
+	"net/url"
+	"sort"
+	"strings"
+
+	"github.com/CiscoM31/godata"
+	cs3rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
+	storageprovider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
+	revactx "github.com/cs3org/reva/v2/pkg/ctx"
+	"github.com/cs3org/reva/v2/pkg/events"
+	"github.com/cs3org/reva/v2/pkg/rgrpc/status"
+	"github.com/cs3org/reva/v2/pkg/utils"
+	"github.com/go-chi/chi/v5"
+	"github.com/go-chi/render"
+	libregraph "github.com/owncloud/libre-graph-api-go"
+	settings "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/settings/v0"
+	"github.com/owncloud/ocis/v2/services/graph/pkg/service/v0/errorcode"
+	settingssvc "github.com/owncloud/ocis/v2/services/settings/pkg/service/v0"
+	"golang.org/x/exp/slices"
+)
+
+// GetEducationUsers implements the Service interface.
+func (g Graph) GetEducationUsers(w http.ResponseWriter, r *http.Request) {
+	logger := g.logger.SubloggerWithRequestID(r.Context())
+	logger.Info().Interface("query", r.URL.Query()).Msg("calling get education users")
+	sanitizedPath := strings.TrimPrefix(r.URL.Path, "/graph/v1.0/")
+	odataReq, err := godata.ParseRequest(r.Context(), sanitizedPath, r.URL.Query())
+	if err != nil {
+		logger.Debug().Err(err).Interface("query", r.URL.Query()).Msg("could not get education users: query error")
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
+		return
+	}
+
+	logger.Debug().Interface("query", r.URL.Query()).Msg("calling get education users on backend")
+	users, err := g.identityEducationBackend.GetEducationUsers(r.Context(), r.URL.Query())
+	if err != nil {
+		logger.Debug().Err(err).Interface("query", r.URL.Query()).Msg("could not get education users from backend")
+		var errcode errorcode.Error
+		if errors.As(err, &errcode) {
+			errcode.Render(w, r)
+		} else {
+			errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
+		}
+		return
+	}
+
+	users, err = sortEducationUsers(odataReq, users)
+	if err != nil {
+		logger.Debug().Interface("query", odataReq).Msg("error while sorting education users according to query")
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
+		return
+	}
+	render.Status(r, http.StatusOK)
+	render.JSON(w, r, &ListResponse{Value: users})
+}
+
+// PostEducationUser implements the Service interface.
+func (g Graph) PostEducationUser(w http.ResponseWriter, r *http.Request) {
+	logger := g.logger.SubloggerWithRequestID(r.Context())
+	logger.Info().Interface("body", r.Body).Msg("calling create education user")
+	u := libregraph.NewEducationUser()
+	err := json.NewDecoder(r.Body).Decode(u)
+	if err != nil {
+		logger.Debug().Err(err).Interface("body", r.Body).Msg("could not create education user: invalid request body")
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("invalid request body: %v", err.Error()))
+		return
+	}
+
+	// Disallow user-supplied IDs. It's supposed to be readonly. We're either
+	// generating them in the backend ourselves or rely on the Backend's
+	// storage (e.g. LDAP) to provide a unique ID.
+	if _, ok := u.GetIdOk(); ok {
+		logger.Debug().Interface("user", u).Msg("could not create education user: id is a read-only attribute")
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "education user id is a read-only attribute")
+		return
+	}
+
+	if _, ok := u.GetDisplayNameOk(); !ok {
+		logger.Debug().Err(err).Interface("user", u).Msg("could not create education user: missing required Attribute: 'displayName'")
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing required Attribute: 'displayName'")
+		return
+	}
+
+	identities, ok := u.GetIdentitiesOk()
+	if !ok {
+		logger.Debug().Err(err).Interface("user", u).Msg("could not create education user: missing required Collection: 'identities'")
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing required Attribute: 'identities'")
+		return
+	}
+	if len(identities) < 1 {
+		logger.Debug().Err(err).Interface("user", u).Msg("could not create education user: missing entry in Collection: 'identities'")
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing required Collection: 'identities'")
+		return
+	}
+	for i, identity := range identities {
+		if _, ok := identity.GetIssuerOk(); !ok {
+			logger.Debug().Err(err).Interface("user", u).Msgf("could not create education user: missing Attribute in 'identities' Collection Entry %d: 'issuer'", i)
+			errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("missing Attribute in 'identities' Collection Entry %d: 'issuer'", i))
+			return
+		}
+		if _, ok := identity.GetIssuerAssignedIdOk(); !ok {
+			logger.Debug().Err(err).Interface("user", u).Msgf("could not create education user: missing Attribute in 'identities' Collection Entry %d: 'issuerAssignedId'", i)
+			errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("missing Attribute in 'identities' Collection Entry %d: 'issuerAssignedId'", i))
+			return
+		}
+	}
+
+	if accountName, ok := u.GetOnPremisesSamAccountNameOk(); ok {
+		if !isValidUsername(*accountName) {
+			logger.Debug().Str("username", *accountName).Msg("could not create education user: username must be at least the local part of an email")
+			errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("username %s must be at least the local part of an email", *u.OnPremisesSamAccountName))
+			return
+		}
+	} else {
+		logger.Debug().Interface("user", u).Msg("could not create education user: missing required Attribute: 'onPremisesSamAccountName'")
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing required Attribute: 'onPremisesSamAccountName'")
+		return
+	}
+
+	if mail, ok := u.GetMailOk(); ok {
+		if !isValidEmail(*mail) {
+			logger.Debug().Str("mail", *u.Mail).Msg("could not create education user: invalid email address")
+			errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("%v is not a valid email address", *u.Mail))
+			return
+		}
+	} else {
+		logger.Debug().Interface("user", u).Msg("could not create education user: missing required Attribute: 'mail'")
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing required Attribute: 'mail'")
+		return
+	}
+
+	if _, ok := u.GetPrimaryRoleOk(); !ok {
+		logger.Debug().Err(err).Interface("user", u).Msg("could not create education user: missing required Attribute: 'primaryRole'")
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing required Attribute: 'primaryRole'")
+		return
+	}
+
+	logger.Debug().Interface("user", u).Msg("calling create education user on backend")
+	if u, err = g.identityEducationBackend.CreateEducationUser(r.Context(), *u); err != nil {
+		logger.Debug().Err(err).Msg("could not create education user: backend error")
+		var ecErr errorcode.Error
+		if errors.As(err, &ecErr) {
+			ecErr.Render(w, r)
+		} else {
+			errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
+		}
+		return
+	}
+
+	// assign roles if possible
+	if g.roleService != nil {
+		// All users get the user role by default currently.
+		// to all new users for now, as create Account request does not have any role field
+		if _, err = g.roleService.AssignRoleToUser(r.Context(), &settings.AssignRoleToUserRequest{
+			AccountUuid: *u.Id,
+			RoleId:      settingssvc.BundleUUIDRoleUser,
+		}); err != nil {
+			// log as error, admin eventually needs to do something
+			logger.Error().Err(err).Str("id", *u.Id).Str("role", settingssvc.BundleUUIDRoleUser).Msg("could not create education user: role assignment failed")
+			errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, "role assignment failed")
+			return
+		}
+	}
+
+	e := events.UserCreated{UserID: *u.Id}
+	if currentUser, ok := revactx.ContextGetUser(r.Context()); ok {
+		e.Executant = currentUser.GetId()
+	}
+	g.publishEvent(e)
+
+	render.Status(r, http.StatusOK)
+	render.JSON(w, r, u)
+}
+
+// GetEducationUser implements the Service interface.
+func (g Graph) GetEducationUser(w http.ResponseWriter, r *http.Request) {
+	logger := g.logger.SubloggerWithRequestID(r.Context())
+	logger.Info().Msg("calling get education user")
+	userID := chi.URLParam(r, "userID")
+	userID, err := url.PathUnescape(userID)
+	if err != nil {
+		logger.Debug().Err(err).Str("id", userID).Msg("could not get education user: unescaping education user id failed")
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping education user id failed")
+		return
+	}
+
+	if userID == "" {
+		logger.Debug().Msg("could not get user: missing education user id")
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing education user id")
+		return
+	}
+
+	logger.Debug().Str("id", userID).Msg("calling get education user from backend")
+	user, err := g.identityEducationBackend.GetEducationUser(r.Context(), userID, r.URL.Query())
+
+	if err != nil {
+		logger.Debug().Err(err).Msg("could not get education user: error fetching education user from backend")
+		var errcode errorcode.Error
+		if errors.As(err, &errcode) {
+			errcode.Render(w, r)
+		} else {
+			errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
+		}
+		return
+	}
+	sel := strings.Split(r.URL.Query().Get("$select"), ",")
+	exp := strings.Split(r.URL.Query().Get("$expand"), ",")
+	if slices.Contains(sel, "drive") || slices.Contains(sel, "drives") || slices.Contains(exp, "drive") || slices.Contains(exp, "drives") {
+		wdu, err := g.getWebDavBaseURL()
+		if err != nil {
+			// log error, wrong configuration
+			logger.Error().
+				Err(err).
+				Str("webdav_base", g.config.Spaces.WebDavBase).
+				Str("webdav_path", g.config.Spaces.WebDavPath).
+				Msg("error parsing webdav URL")
+			render.Status(r, http.StatusInternalServerError)
+			return
+		}
+		logger.Debug().Str("id", user.GetId()).Msg("calling list storage spaces with education user id filter")
+		f := listStorageSpacesUserFilter(user.GetId())
+		// use the unrestricted flag to get all possible spaces
+		// users with the canListAllSpaces permission should see all spaces
+		opaque := utils.AppendPlainToOpaque(nil, "unrestricted", "T")
+		lspr, err := g.gatewayClient.ListStorageSpaces(r.Context(), &storageprovider.ListStorageSpacesRequest{
+			Opaque:  opaque,
+			Filters: []*storageprovider.ListStorageSpacesRequest_Filter{f},
+		})
+		if err != nil {
+			// transport error, needs to be fixed by admin
+			logger.Error().Err(err).Interface("query", r.URL.Query()).Msg("error getting storages: transport error")
+			render.Status(r, http.StatusInternalServerError)
+			render.JSON(w, r, user)
+			return
+		}
+		if lspr.GetStatus().GetCode() != cs3rpc.Code_CODE_OK {
+			logger.Debug().Str("grpc", lspr.GetStatus().GetMessage()).Msg("could not get drive for education user")
+			// in case of NOT_OK, we can just return the user object with empty drives
+			render.Status(r, status.HTTPStatusFromCode(http.StatusOK))
+			render.JSON(w, r, user)
+			return
+		}
+		drives := []libregraph.Drive{}
+		for _, sp := range lspr.GetStorageSpaces() {
+			d, err := g.cs3StorageSpaceToDrive(r.Context(), wdu, sp)
+			if err != nil {
+				logger.Debug().Err(err).Interface("id", sp.Id).Msg("error converting space to drive")
+				continue
+			}
+			quota, err := g.getDriveQuota(r.Context(), sp)
+			if err != nil {
+				logger.Debug().Err(err).Interface("id", sp.Id).Msg("error calling get quota on drive")
+			}
+			d.Quota = quota
+			if slices.Contains(sel, "drive") || slices.Contains(exp, "drive") {
+				if *d.DriveType == "personal" {
+					user.Drive = d
+				}
+			} else {
+				drives = append(drives, *d)
+				user.Drives = drives
+			}
+		}
+	}
+
+	render.Status(r, http.StatusOK)
+	render.JSON(w, r, user)
+}
+
+// DeleteEducationUser implements the Service interface.
+func (g Graph) DeleteEducationUser(w http.ResponseWriter, r *http.Request) {
+	logger := g.logger.SubloggerWithRequestID(r.Context())
+	logger.Info().Msg("calling delete education user")
+	userID := chi.URLParam(r, "userID")
+	userID, err := url.PathUnescape(userID)
+	if err != nil {
+		logger.Debug().Err(err).Str("id", userID).Msg("could not delete education user: unescaping education user id failed")
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping education user id failed")
+		return
+	}
+
+	if userID == "" {
+		logger.Debug().Msg("could not delete education user: missing education user id")
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing education user id")
+		return
+	}
+	logger.Debug().Str("id", userID).Msg("calling get education user on user backend")
+	user, err := g.identityEducationBackend.GetEducationUser(r.Context(), userID, r.URL.Query())
+	if err != nil {
+		logger.Debug().Err(err).Str("userID", userID).Msg("failed to get education user from backend")
+		var errcode errorcode.Error
+		if errors.As(err, &errcode) {
+			errcode.Render(w, r)
+		} else {
+			errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
+		}
+		return
+	}
+
+	e := events.UserDeleted{UserID: user.GetId()}
+	if currentUser, ok := revactx.ContextGetUser(r.Context()); ok {
+		if currentUser.GetId().GetOpaqueId() == user.GetId() {
+			logger.Debug().Msg("could not delete education user: self deletion forbidden")
+			errorcode.NotAllowed.Render(w, r, http.StatusForbidden, "self deletion forbidden")
+			return
+		}
+		e.Executant = currentUser.GetId()
+	}
+
+	if g.gatewayClient != nil {
+		logger.Debug().
+			Str("user", user.GetId()).
+			Msg("calling list spaces with user filter to fetch the personal space for deletion")
+		opaque := utils.AppendPlainToOpaque(nil, "unrestricted", "T")
+		f := listStorageSpacesUserFilter(user.GetId())
+		lspr, err := g.gatewayClient.ListStorageSpaces(r.Context(), &storageprovider.ListStorageSpacesRequest{
+			Opaque:  opaque,
+			Filters: []*storageprovider.ListStorageSpacesRequest_Filter{f},
+		})
+		if err != nil {
+			// transport error, log as error
+			logger.Error().Err(err).Msg("could not fetch spaces: transport error")
+			errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, "could not fetch spaces for deletion, aborting")
+			return
+		}
+		for _, sp := range lspr.GetStorageSpaces() {
+			if !(sp.SpaceType == "personal" && sp.Owner.Id.OpaqueId == user.GetId()) {
+				continue
+			}
+			// TODO: check if request contains a homespace and if, check if requesting user has the privilege to
+			// delete it and make sure it is not deleting its own homespace
+			// needs modification of the cs3api
+
+			// Deleting a space a two step process (1. disabling/trashing, 2. purging)
+			// Do the "disable/trash" step only if the space is not marked as trashed yet:
+			if _, ok := sp.Opaque.Map["trashed"]; !ok {
+				_, err := g.gatewayClient.DeleteStorageSpace(r.Context(), &storageprovider.DeleteStorageSpaceRequest{
+					Id: &storageprovider.StorageSpaceId{
+						OpaqueId: sp.Id.OpaqueId,
+					},
+				})
+				if err != nil {
+					logger.Error().Err(err).Msg("could not disable homespace: transport error")
+					errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, "could not disable homespace, aborting")
+					return
+				}
+			}
+			purgeFlag := utils.AppendPlainToOpaque(nil, "purge", "")
+			_, err := g.gatewayClient.DeleteStorageSpace(r.Context(), &storageprovider.DeleteStorageSpaceRequest{
+				Opaque: purgeFlag,
+				Id: &storageprovider.StorageSpaceId{
+					OpaqueId: sp.Id.OpaqueId,
+				},
+			})
+			if err != nil {
+				// transport error, log as error
+				logger.Error().Err(err).Msg("could not delete homespace: transport error")
+				errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, "could not delete homespace, aborting")
+				return
+			}
+			break
+		}
+	}
+
+	logger.Debug().Str("id", user.GetId()).Msg("calling delete education user on backend")
+	err = g.identityEducationBackend.DeleteEducationUser(r.Context(), user.GetId())
+
+	if err != nil {
+		logger.Debug().Err(err).Msg("could not delete education user: backend error")
+		var errcode errorcode.Error
+		if errors.As(err, &errcode) {
+			errcode.Render(w, r)
+		} else {
+			errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
+			return
+		}
+	}
+
+	g.publishEvent(e)
+
+	render.Status(r, http.StatusNoContent)
+	render.NoContent(w, r)
+}
+
+// PatchEducationUser implements the Service Interface. Updates the specified attributes of an
+// ExistingUser
+func (g Graph) PatchEducationUser(w http.ResponseWriter, r *http.Request) {
+	logger := g.logger.SubloggerWithRequestID(r.Context())
+	logger.Info().Msg("calling patch education user")
+	nameOrID := chi.URLParam(r, "userID")
+	nameOrID, err := url.PathUnescape(nameOrID)
+	if err != nil {
+		logger.Debug().Err(err).Str("id", nameOrID).Msg("could not update education user: unescaping education user id failed")
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping education user id failed")
+		return
+	}
+
+	if nameOrID == "" {
+		logger.Debug().Msg("could not update education user: missing education user id")
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing education user id")
+		return
+	}
+	changes := libregraph.NewEducationUser()
+	err = json.NewDecoder(r.Body).Decode(changes)
+	if err != nil {
+		logger.Debug().Err(err).Interface("body", r.Body).Msg("could not update education user: invalid request body")
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest,
+			fmt.Sprintf("invalid request body: %s", err.Error()))
+		return
+	}
+
+	var features []events.UserFeature
+	if mail, ok := changes.GetMailOk(); ok {
+		if !isValidEmail(*mail) {
+			logger.Debug().Str("mail", *mail).Msg("could not update education user: email is not a valid email address")
+			errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest,
+				fmt.Sprintf("'%s' is not a valid email address", *mail))
+			return
+		}
+		features = append(features, events.UserFeature{Name: "email", Value: *mail})
+	}
+
+	if name, ok := changes.GetDisplayNameOk(); ok {
+		features = append(features, events.UserFeature{Name: "displayname", Value: *name})
+	}
+
+	logger.Debug().Str("nameid", nameOrID).Interface("changes", *changes).Msg("calling update education user on backend")
+	u, err := g.identityEducationBackend.UpdateEducationUser(r.Context(), nameOrID, *changes)
+	if err != nil {
+		logger.Debug().Err(err).Str("id", nameOrID).Msg("could not update education user: backend error")
+		var errcode errorcode.Error
+		if errors.As(err, &errcode) {
+			errcode.Render(w, r)
+		} else {
+			errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
+		}
+		return
+	}
+
+	e := events.UserFeatureChanged{
+		UserID:   nameOrID,
+		Features: features,
+	}
+	if currentUser, ok := revactx.ContextGetUser(r.Context()); ok {
+		e.Executant = currentUser.GetId()
+	}
+	g.publishEvent(e)
+
+	render.Status(r, http.StatusOK) // TODO StatusNoContent when prefer=minimal is used
+	render.JSON(w, r, u)
+
+}
+
+func sortEducationUsers(req *godata.GoDataRequest, users []*libregraph.EducationUser) ([]*libregraph.EducationUser, error) {
+	var sorter sort.Interface
+	if req.Query.OrderBy == nil || len(req.Query.OrderBy.OrderByItems) != 1 {
+		return users, nil
+	}
+	switch req.Query.OrderBy.OrderByItems[0].Field.Value {
+	case displayNameAttr:
+		sorter = educationUsersByDisplayName{users}
+	case "mail":
+		sorter = educationUsersByMail{users}
+	case "onPremisesSamAccountName":
+		sorter = educationUsersByOnPremisesSamAccountName{users}
+	default:
+		return nil, fmt.Errorf("we do not support <%s> as a order parameter", req.Query.OrderBy.OrderByItems[0].Field.Value)
+	}
+
+	if req.Query.OrderBy.OrderByItems[0].Order == "desc" {
+		sorter = sort.Reverse(sorter)
+	}
+	sort.Sort(sorter)
+	return users, nil
+}
diff --git a/services/graph/pkg/service/v0/graph.go b/services/graph/pkg/service/v0/graph.go
index 3ee2af89864..db97c56f8c2 100644
--- a/services/graph/pkg/service/v0/graph.go
+++ b/services/graph/pkg/service/v0/graph.go
@@ -2,9 +2,11 @@ package svc
 
 import (
 	"context"
+	"errors"
 	"net/http"
 	"net/url"
 	"path"
+	"strings"
 
 	gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
 	provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
@@ -90,16 +92,17 @@ type RoleService interface {
 
 // Graph defines implements the business logic for Service.
 type Graph struct {
-	config               *config.Config
-	mux                  *chi.Mux
-	logger               *log.Logger
-	identityBackend      identity.Backend
-	gatewayClient        GatewayClient
-	roleService          RoleService
-	permissionsService   Permissions
-	spacePropertiesCache *ttlcache.Cache
-	eventsPublisher      events.Publisher
-	searchService        searchsvc.SearchProviderService
+	config                   *config.Config
+	mux                      *chi.Mux
+	logger                   *log.Logger
+	identityBackend          identity.Backend
+	identityEducationBackend identity.EducationBackend
+	gatewayClient            GatewayClient
+	roleService              RoleService
+	permissionsService       Permissions
+	spacePropertiesCache     *ttlcache.Cache
+	eventsPublisher          events.Publisher
+	searchService            searchsvc.SearchProviderService
 }
 
 // ServeHTTP implements the Service interface.
@@ -107,7 +110,7 @@ func (g Graph) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	g.mux.ServeHTTP(w, r)
 }
 
-// GetClient returns a gateway client to talk to reva
+// GetGatewayClient returns a gateway client to talk to reva
 func (g Graph) GetGatewayClient() GatewayClient {
 	return g.gatewayClient
 }
@@ -131,11 +134,29 @@ func (g Graph) getWebDavBaseURL() (*url.URL, error) {
 	return webDavBaseURL, nil
 }
 
+// ListResponse is used for proper marshalling of Graph list responses
 type ListResponse struct {
 	Value interface{} `json:"value,omitempty"`
 }
 
 const (
-	ReadmeSpecialFolderName     = "readme"
+	// ReadmeSpecialFolderName for the drive specialFolder property
+	ReadmeSpecialFolderName = "readme"
+	// SpaceImageSpecialFolderName for the drive specialFolder property
 	SpaceImageSpecialFolderName = "image"
 )
+
+// TODO might be different for /education/users vs /users
+func (g Graph) parseMemberRef(ref string) (string, string, error) {
+	memberURL, err := url.ParseRequestURI(ref)
+	if err != nil {
+		return "", "", err
+	}
+	segments := strings.Split(memberURL.Path, "/")
+	if len(segments) < 2 {
+		return "", "", errors.New("invalid member reference")
+	}
+	id := segments[len(segments)-1]
+	memberType := segments[len(segments)-2]
+	return memberType, id, nil
+}
diff --git a/services/graph/pkg/service/v0/groups.go b/services/graph/pkg/service/v0/groups.go
index 3dd8abbe860..760105f1de3 100644
--- a/services/graph/pkg/service/v0/groups.go
+++ b/services/graph/pkg/service/v0/groups.go
@@ -20,6 +20,7 @@ import (
 )
 
 const memberRefsLimit = 20
+const memberTypeUsers = "users"
 
 // GetGroups implements the Service interface.
 func (g Graph) GetGroups(w http.ResponseWriter, r *http.Request) {
@@ -145,7 +146,7 @@ func (g Graph) PatchGroup(w http.ResponseWriter, r *http.Request) {
 			logger.Debug().Str("membertype", memberType).Str("memberid", id).Msg("add group member")
 			// The MS Graph spec allows "directoryObject", "user", "group" and "organizational Contact"
 			// we restrict this to users for now. Might add Groups as members later
-			if memberType != "users" {
+			if memberType != memberTypeUsers {
 				logger.Debug().
 					Str("type", memberType).
 					Msg("could not change group: could not add member, only user type is allowed")
@@ -245,6 +246,7 @@ func (g Graph) DeleteGroup(w http.ResponseWriter, r *http.Request) {
 	render.NoContent(w, r)
 }
 
+// GetGroupMembers implements the Service interface.
 func (g Graph) GetGroupMembers(w http.ResponseWriter, r *http.Request) {
 	logger := g.logger.SubloggerWithRequestID(r.Context())
 	logger.Info().Msg("calling get group members")
@@ -324,7 +326,7 @@ func (g Graph) PostGroupMember(w http.ResponseWriter, r *http.Request) {
 	}
 	// The MS Graph spec allows "directoryObject", "user", "group" and "organizational Contact"
 	// we restrict this to users for now. Might add Groups as members later
-	if memberType != "users" {
+	if memberType != memberTypeUsers {
 		logger.Debug().Str("type", memberType).Msg("could not add group member: Only users are allowed as group members")
 		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "Only users are allowed as group members")
 		return
@@ -401,33 +403,19 @@ func (g Graph) DeleteGroupMember(w http.ResponseWriter, r *http.Request) {
 	render.NoContent(w, r)
 }
 
-func (g Graph) parseMemberRef(ref string) (string, string, error) {
-	memberURL, err := url.ParseRequestURI(ref)
-	if err != nil {
-		return "", "", err
-	}
-	segments := strings.Split(memberURL.Path, "/")
-	if len(segments) < 2 {
-		return "", "", errors.New("invalid member reference")
-	}
-	id := segments[len(segments)-1]
-	memberType := segments[len(segments)-2]
-	return memberType, id, nil
-}
-
 func sortGroups(req *godata.GoDataRequest, groups []*libregraph.Group) ([]*libregraph.Group, error) {
 	var sorter sort.Interface
 	if req.Query.OrderBy == nil || len(req.Query.OrderBy.OrderByItems) != 1 {
 		return groups, nil
 	}
 	switch req.Query.OrderBy.OrderByItems[0].Field.Value {
-	case "displayName":
+	case displayNameAttr:
 		sorter = groupsByDisplayName{groups}
 	default:
 		return nil, fmt.Errorf("we do not support <%s> as a order parameter", req.Query.OrderBy.OrderByItems[0].Field.Value)
 	}
 
-	if req.Query.OrderBy.OrderByItems[0].Order == "desc" {
+	if req.Query.OrderBy.OrderByItems[0].Order == _sortDescending {
 		sorter = sort.Reverse(sorter)
 	}
 	sort.Sort(sorter)
diff --git a/services/graph/pkg/service/v0/instrument.go b/services/graph/pkg/service/v0/instrument.go
index 42d57307500..81fc6dbbf3d 100644
--- a/services/graph/pkg/service/v0/instrument.go
+++ b/services/graph/pkg/service/v0/instrument.go
@@ -99,6 +99,71 @@ func (i instrument) DeleteGroupMember(w http.ResponseWriter, r *http.Request) {
 	i.next.DeleteGroupMember(w, r)
 }
 
+// GetEducationSchools implements the Service interface.
+func (i instrument) GetEducationSchools(w http.ResponseWriter, r *http.Request) {
+	i.next.GetEducationSchools(w, r)
+}
+
+// GetEducationSchool implements the Service interface.
+func (i instrument) GetEducationSchool(w http.ResponseWriter, r *http.Request) {
+	i.next.GetEducationSchool(w, r)
+}
+
+// PostEducationSchool implements the Service interface.
+func (i instrument) PostEducationSchool(w http.ResponseWriter, r *http.Request) {
+	i.next.PostEducationSchool(w, r)
+}
+
+// PatchEducationSchool implements the Service interface.
+func (i instrument) PatchEducationSchool(w http.ResponseWriter, r *http.Request) {
+	i.next.PatchEducationSchool(w, r)
+}
+
+// DeleteEducationSchool implements the Service interface.
+func (i instrument) DeleteEducationSchool(w http.ResponseWriter, r *http.Request) {
+	i.next.DeleteEducationSchool(w, r)
+}
+
+// GetEducationSchoolMembers implements the Service interface.
+func (i instrument) GetEducationSchoolMembers(w http.ResponseWriter, r *http.Request) {
+	i.next.GetEducationSchoolMembers(w, r)
+}
+
+// PostEducationSchoolMember implements the Service interface.
+func (i instrument) PostEducationSchoolMember(w http.ResponseWriter, r *http.Request) {
+	i.next.PostEducationSchoolMember(w, r)
+}
+
+// DeleteEducationSchoolMember implements the Service interface.
+func (i instrument) DeleteEducationSchoolMember(w http.ResponseWriter, r *http.Request) {
+	i.next.DeleteEducationSchoolMember(w, r)
+}
+
+// GetEducationUsers implements the Service interface.
+func (i instrument) GetEducationUsers(w http.ResponseWriter, r *http.Request) {
+	i.next.GetEducationUsers(w, r)
+}
+
+// GetEducationUser implements the Service interface.
+func (i instrument) GetEducationUser(w http.ResponseWriter, r *http.Request) {
+	i.next.GetEducationUser(w, r)
+}
+
+// PostEducationUser implements the Service interface.
+func (i instrument) PostEducationUser(w http.ResponseWriter, r *http.Request) {
+	i.next.PostEducationUser(w, r)
+}
+
+// DeleteEducationUser implements the Service interface.
+func (i instrument) DeleteEducationUser(w http.ResponseWriter, r *http.Request) {
+	i.next.DeleteEducationUser(w, r)
+}
+
+// PatchEducationUser implements the Service interface.
+func (i instrument) PatchEducationUser(w http.ResponseWriter, r *http.Request) {
+	i.next.PatchEducationUser(w, r)
+}
+
 // GetDrives implements the Service interface.
 func (i instrument) GetDrives(w http.ResponseWriter, r *http.Request) {
 	i.next.GetDrives(w, r)
diff --git a/services/graph/pkg/service/v0/logging.go b/services/graph/pkg/service/v0/logging.go
index 316bbd55b85..69361da2cea 100644
--- a/services/graph/pkg/service/v0/logging.go
+++ b/services/graph/pkg/service/v0/logging.go
@@ -99,6 +99,71 @@ func (l logging) DeleteGroupMember(w http.ResponseWriter, r *http.Request) {
 	l.next.DeleteGroupMember(w, r)
 }
 
+// GetEducationSchools implements the Service interface.
+func (l logging) GetEducationSchools(w http.ResponseWriter, r *http.Request) {
+	l.next.GetEducationSchools(w, r)
+}
+
+// GetEducationSchool implements the Service interface.
+func (l logging) GetEducationSchool(w http.ResponseWriter, r *http.Request) {
+	l.next.GetEducationSchool(w, r)
+}
+
+// PostEducationSchool implements the Service interface.
+func (l logging) PostEducationSchool(w http.ResponseWriter, r *http.Request) {
+	l.next.PostEducationSchool(w, r)
+}
+
+// PatchEducationSchool implements the Service interface.
+func (l logging) PatchEducationSchool(w http.ResponseWriter, r *http.Request) {
+	l.next.PatchEducationSchool(w, r)
+}
+
+// DeleteEducationSchool implements the Service interface.
+func (l logging) DeleteEducationSchool(w http.ResponseWriter, r *http.Request) {
+	l.next.DeleteEducationSchool(w, r)
+}
+
+// GetEducationSchoolMembers implements the Service interface.
+func (l logging) GetEducationSchoolMembers(w http.ResponseWriter, r *http.Request) {
+	l.next.GetEducationSchoolMembers(w, r)
+}
+
+// PostEducationSchoolMember implements the Service interface.
+func (l logging) PostEducationSchoolMember(w http.ResponseWriter, r *http.Request) {
+	l.next.PostEducationSchoolMember(w, r)
+}
+
+// DeleteEducationSchoolMember implements the Service interface.
+func (l logging) DeleteEducationSchoolMember(w http.ResponseWriter, r *http.Request) {
+	l.next.DeleteEducationSchoolMember(w, r)
+}
+
+// GetEducationUsers implements the Service interface.
+func (l logging) GetEducationUsers(w http.ResponseWriter, r *http.Request) {
+	l.next.GetEducationUsers(w, r)
+}
+
+// GetEducationUser implements the Service interface.
+func (l logging) GetEducationUser(w http.ResponseWriter, r *http.Request) {
+	l.next.GetEducationUser(w, r)
+}
+
+// PostEducationUser implements the Service interface.
+func (l logging) PostEducationUser(w http.ResponseWriter, r *http.Request) {
+	l.next.PostEducationUser(w, r)
+}
+
+// DeleteEducationUser implements the Service interface.
+func (l logging) DeleteEducationUser(w http.ResponseWriter, r *http.Request) {
+	l.next.DeleteEducationUser(w, r)
+}
+
+// PatchEducationUser implements the Service interface.
+func (l logging) PatchEducationUser(w http.ResponseWriter, r *http.Request) {
+	l.next.PatchEducationUser(w, r)
+}
+
 // GetDrives implements the Service interface.
 func (l logging) GetDrives(w http.ResponseWriter, r *http.Request) {
 	l.next.GetDrives(w, r)
diff --git a/services/graph/pkg/service/v0/option.go b/services/graph/pkg/service/v0/option.go
index 4ac405b7893..b159b806115 100644
--- a/services/graph/pkg/service/v0/option.go
+++ b/services/graph/pkg/service/v0/option.go
@@ -17,17 +17,18 @@ type Option func(o *Options)
 
 // Options defines the available options for this package.
 type Options struct {
-	Logger                 log.Logger
-	Config                 *config.Config
-	Middleware             []func(http.Handler) http.Handler
-	RequireAdminMiddleware func(http.Handler) http.Handler
-	GatewayClient          GatewayClient
-	IdentityBackend        identity.Backend
-	RoleService            RoleService
-	PermissionService      Permissions
-	RoleManager            *roles.Manager
-	EventsPublisher        events.Publisher
-	SearchService          searchsvc.SearchProviderService
+	Logger                   log.Logger
+	Config                   *config.Config
+	Middleware               []func(http.Handler) http.Handler
+	RequireAdminMiddleware   func(http.Handler) http.Handler
+	GatewayClient            GatewayClient
+	IdentityBackend          identity.Backend
+	IdentityEducationBackend identity.EducationBackend
+	RoleService              RoleService
+	PermissionService        Permissions
+	RoleManager              *roles.Manager
+	EventsPublisher          events.Publisher
+	SearchService            searchsvc.SearchProviderService
 }
 
 // newOptions initializes the available default options.
@@ -83,6 +84,13 @@ func WithIdentityBackend(val identity.Backend) Option {
 	}
 }
 
+// WithIdentityEducationBackend provides a function to set the IdentityEducationBackend option.
+func WithIdentityEducationBackend(val identity.EducationBackend) Option {
+	return func(o *Options) {
+		o.IdentityEducationBackend = val
+	}
+}
+
 // WithRoleService provides a function to set the RoleService option.
 func WithRoleService(val RoleService) Option {
 	return func(o *Options) {
@@ -90,7 +98,7 @@ func WithRoleService(val RoleService) Option {
 	}
 }
 
-// WithRoleService provides a function to set the RoleService option.
+// WithSearchService provides a function to set the SearchService option.
 func WithSearchService(val searchsvc.SearchProviderService) Option {
 	return func(o *Options) {
 		o.SearchService = val
diff --git a/services/graph/pkg/service/v0/ordering.go b/services/graph/pkg/service/v0/ordering.go
index 117356a4ff8..f3464619fc2 100644
--- a/services/graph/pkg/service/v0/ordering.go
+++ b/services/graph/pkg/service/v0/ordering.go
@@ -101,3 +101,59 @@ type groupsByDisplayName struct {
 func (g groupsByDisplayName) Less(i, j int) bool {
 	return strings.ToLower(g.groupSlice[i].GetDisplayName()) < strings.ToLower(g.groupSlice[j].GetDisplayName())
 }
+
+type schoolSlice []*libregraph.EducationSchool
+
+// Len is the number of elements in the collection.
+func (d schoolSlice) Len() int { return len(d) }
+
+// Swap swaps the elements with indexes i and j.
+func (d schoolSlice) Swap(i, j int) { d[i], d[j] = d[j], d[i] }
+
+type schoolsByDisplayName struct {
+	schoolSlice
+}
+
+// Less reports whether the element with index i
+// must sort before the element with index j.
+func (g schoolsByDisplayName) Less(i, j int) bool {
+	return strings.ToLower(g.schoolSlice[i].GetDisplayName()) < strings.ToLower(g.schoolSlice[j].GetDisplayName())
+}
+
+type educationUserSlice []*libregraph.EducationUser
+
+// Len is the number of elements in the collection.
+func (d educationUserSlice) Len() int { return len(d) }
+
+// Swap swaps the elements with indexes i and j.
+func (d educationUserSlice) Swap(i, j int) { d[i], d[j] = d[j], d[i] }
+
+type educationUsersByDisplayName struct {
+	educationUserSlice
+}
+
+type educationUsersByMail struct {
+	educationUserSlice
+}
+
+type educationUsersByOnPremisesSamAccountName struct {
+	educationUserSlice
+}
+
+// Less reports whether the element with index i
+// must sort before the element with index j.
+func (u educationUsersByDisplayName) Less(i, j int) bool {
+	return strings.ToLower(u.educationUserSlice[i].GetDisplayName()) < strings.ToLower(u.educationUserSlice[j].GetDisplayName())
+}
+
+// Less reports whether the element with index i
+// must sort before the element with index j.
+func (u educationUsersByMail) Less(i, j int) bool {
+	return strings.ToLower(u.educationUserSlice[i].GetMail()) < strings.ToLower(u.educationUserSlice[j].GetMail())
+}
+
+// Less reports whether the element with index i
+// must sort before the element with index j.
+func (u educationUsersByOnPremisesSamAccountName) Less(i, j int) bool {
+	return strings.ToLower(u.educationUserSlice[i].GetOnPremisesSamAccountName()) < strings.ToLower(u.educationUserSlice[j].GetOnPremisesSamAccountName())
+}
diff --git a/services/graph/pkg/service/v0/service.go b/services/graph/pkg/service/v0/service.go
index 3bd3a4ce5bf..08c68ab908c 100644
--- a/services/graph/pkg/service/v0/service.go
+++ b/services/graph/pkg/service/v0/service.go
@@ -23,7 +23,8 @@ import (
 
 const (
 	// HeaderPurge defines the header name for the purge header.
-	HeaderPurge = "Purge"
+	HeaderPurge     = "Purge"
+	displayNameAttr = "displayName"
 )
 
 // Service defines the service handlers.
@@ -46,6 +47,21 @@ type Service interface {
 	PostGroupMember(http.ResponseWriter, *http.Request)
 	DeleteGroupMember(http.ResponseWriter, *http.Request)
 
+	GetEducationSchools(http.ResponseWriter, *http.Request)
+	GetEducationSchool(http.ResponseWriter, *http.Request)
+	PostEducationSchool(http.ResponseWriter, *http.Request)
+	PatchEducationSchool(http.ResponseWriter, *http.Request)
+	DeleteEducationSchool(http.ResponseWriter, *http.Request)
+	GetEducationSchoolMembers(http.ResponseWriter, *http.Request)
+	PostEducationSchoolMember(http.ResponseWriter, *http.Request)
+	DeleteEducationSchoolMember(http.ResponseWriter, *http.Request)
+
+	GetEducationUsers(http.ResponseWriter, *http.Request)
+	GetEducationUser(http.ResponseWriter, *http.Request)
+	PostEducationUser(http.ResponseWriter, *http.Request)
+	DeleteEducationUser(http.ResponseWriter, *http.Request)
+	PatchEducationUser(http.ResponseWriter, *http.Request)
+
 	GetDrives(w http.ResponseWriter, r *http.Request)
 	GetSingleDrive(w http.ResponseWriter, r *http.Request)
 	GetAllDrives(w http.ResponseWriter, r *http.Request)
@@ -67,13 +83,14 @@ func NewService(opts ...Option) Service {
 	m.Use(options.Middleware...)
 
 	svc := Graph{
-		config:               options.Config,
-		mux:                  m,
-		logger:               &options.Logger,
-		spacePropertiesCache: ttlcache.NewCache(),
-		eventsPublisher:      options.EventsPublisher,
-		gatewayClient:        options.GatewayClient,
-		searchService:        options.SearchService,
+		config:                   options.Config,
+		mux:                      m,
+		logger:                   &options.Logger,
+		spacePropertiesCache:     ttlcache.NewCache(),
+		eventsPublisher:          options.EventsPublisher,
+		gatewayClient:            options.GatewayClient,
+		searchService:            options.SearchService,
+		identityEducationBackend: options.IdentityEducationBackend,
 	}
 
 	if options.IdentityBackend == nil {
@@ -129,10 +146,20 @@ func NewService(opts ...Option) Service {
 					TLSConfig:    tlsConf,
 				},
 			)
-			if svc.identityBackend, err = identity.NewLDAPBackend(conn, options.Config.Identity.LDAP, &options.Logger); err != nil {
+			lb, err := identity.NewLDAPBackend(conn, options.Config.Identity.LDAP, &options.Logger)
+			if err != nil {
 				options.Logger.Error().Msgf("Error initializing LDAP Backend: '%s'", err)
 				return nil
 			}
+			svc.identityBackend = lb
+			if options.IdentityEducationBackend == nil {
+				if options.Config.Identity.LDAP.EducationResourcesEnabled {
+					svc.identityEducationBackend = lb
+				} else {
+					errEduBackend := &identity.ErrEducationBackend{}
+					svc.identityEducationBackend = errEduBackend
+				}
+			}
 		default:
 			options.Logger.Error().Msgf("Unknown Identity Backend: '%s'", options.Config.Identity.Backend)
 			return nil
@@ -215,6 +242,45 @@ func NewService(opts ...Option) Service {
 					r.Delete("/", svc.DeleteDrive)
 				})
 			})
+			r.With(requireAdmin).Route("/education", func(r chi.Router) {
+				r.Route("/schools", func(r chi.Router) {
+					r.Get("/", svc.GetEducationSchools)
+					r.Post("/", svc.PostEducationSchool)
+					r.Route("/{schoolID}", func(r chi.Router) {
+						r.Get("/", svc.GetEducationSchool)
+						r.Delete("/", svc.DeleteEducationSchool)
+						r.Patch("/", svc.PatchEducationSchool)
+						r.Route("/members", func(r chi.Router) {
+							r.Get("/", svc.GetEducationSchoolMembers)
+							r.Post("/$ref", svc.PostEducationSchoolMember)
+							r.Delete("/{memberID}/$ref", svc.DeleteEducationSchoolMember)
+						})
+					})
+				})
+				r.Route("/users", func(r chi.Router) {
+					r.Get("/", svc.GetEducationUsers)
+					r.Post("/", svc.PostEducationUser)
+					r.Route("/{userID}", func(r chi.Router) {
+						r.Get("/", svc.GetEducationUser)
+						r.Delete("/", svc.DeleteEducationUser)
+						r.Patch("/", svc.PatchEducationUser)
+					})
+				})
+				r.Route("/classes", func(r chi.Router) {
+					r.Get("/", svc.GetGroups)
+					r.Post("/", svc.PostGroup)
+					r.Route("/{groupID}", func(r chi.Router) {
+						r.Get("/", svc.GetGroup)
+						r.Delete("/", svc.DeleteGroup)
+						r.Patch("/", svc.PatchGroup)
+						r.Route("/members", func(r chi.Router) {
+							r.Get("/", svc.GetGroupMembers)
+							r.Post("/$ref", svc.PostGroupMember)
+							r.Delete("/{memberID}/$ref", svc.DeleteGroupMember)
+						})
+					})
+				})
+			})
 		})
 	})
 
diff --git a/services/graph/pkg/service/v0/tracing.go b/services/graph/pkg/service/v0/tracing.go
index 357e23dfe84..306cf74456e 100644
--- a/services/graph/pkg/service/v0/tracing.go
+++ b/services/graph/pkg/service/v0/tracing.go
@@ -95,6 +95,71 @@ func (t tracing) DeleteGroupMember(w http.ResponseWriter, r *http.Request) {
 	t.next.DeleteGroupMember(w, r)
 }
 
+// GetEducationSchools implements the Service interface.
+func (t tracing) GetEducationSchools(w http.ResponseWriter, r *http.Request) {
+	t.next.GetEducationSchools(w, r)
+}
+
+// GetEducationSchool implements the Service interface.
+func (t tracing) GetEducationSchool(w http.ResponseWriter, r *http.Request) {
+	t.next.GetEducationSchool(w, r)
+}
+
+// PostEducationSchool implements the Service interface.
+func (t tracing) PostEducationSchool(w http.ResponseWriter, r *http.Request) {
+	t.next.PostEducationSchool(w, r)
+}
+
+// PatchEducationSchool implements the Service interface.
+func (t tracing) PatchEducationSchool(w http.ResponseWriter, r *http.Request) {
+	t.next.PatchEducationSchool(w, r)
+}
+
+// DeleteEducationSchool implements the Service interface.
+func (t tracing) DeleteEducationSchool(w http.ResponseWriter, r *http.Request) {
+	t.next.DeleteEducationSchool(w, r)
+}
+
+// GetEducationSchoolMembers implements the Service interface.
+func (t tracing) GetEducationSchoolMembers(w http.ResponseWriter, r *http.Request) {
+	t.next.GetEducationSchoolMembers(w, r)
+}
+
+// PostEducationSchoolMember implements the Service interface.
+func (t tracing) PostEducationSchoolMember(w http.ResponseWriter, r *http.Request) {
+	t.next.PostEducationSchoolMember(w, r)
+}
+
+// DeleteEducationSchoolMember implements the Service interface.
+func (t tracing) DeleteEducationSchoolMember(w http.ResponseWriter, r *http.Request) {
+	t.next.DeleteEducationSchoolMember(w, r)
+}
+
+// GetEducationUsers implements the Service interface.
+func (t tracing) GetEducationUsers(w http.ResponseWriter, r *http.Request) {
+	t.next.GetEducationUsers(w, r)
+}
+
+// GetEducationUser implements the Service interface.
+func (t tracing) GetEducationUser(w http.ResponseWriter, r *http.Request) {
+	t.next.GetEducationUser(w, r)
+}
+
+// PostEducationUser implements the Service interface.
+func (t tracing) PostEducationUser(w http.ResponseWriter, r *http.Request) {
+	t.next.PostEducationUser(w, r)
+}
+
+// DeleteEducationUser implements the Service interface.
+func (t tracing) DeleteEducationUser(w http.ResponseWriter, r *http.Request) {
+	t.next.DeleteEducationUser(w, r)
+}
+
+// PatchEducationUser implements the Service interface.
+func (t tracing) PatchEducationUser(w http.ResponseWriter, r *http.Request) {
+	t.next.PatchEducationUser(w, r)
+}
+
 // GetDrives implements the Service interface.
 func (t tracing) GetDrives(w http.ResponseWriter, r *http.Request) {
 	t.next.GetDrives(w, r)
diff --git a/services/graph/pkg/service/v0/users.go b/services/graph/pkg/service/v0/users.go
index dc9253ffaaf..56238e719f1 100644
--- a/services/graph/pkg/service/v0/users.go
+++ b/services/graph/pkg/service/v0/users.go
@@ -97,6 +97,7 @@ func (g Graph) GetUsers(w http.ResponseWriter, r *http.Request) {
 	render.JSON(w, r, &ListResponse{Value: users})
 }
 
+// PostUser implements the Service interface.
 func (g Graph) PostUser(w http.ResponseWriter, r *http.Request) {
 	logger := g.logger.SubloggerWithRequestID(r.Context())
 	logger.Info().Interface("body", r.Body).Msg("calling create user")
@@ -278,6 +279,7 @@ func (g Graph) GetUser(w http.ResponseWriter, r *http.Request) {
 	render.JSON(w, r, user)
 }
 
+// DeleteUser implements the Service interface.
 func (g Graph) DeleteUser(w http.ResponseWriter, r *http.Request) {
 	logger := g.logger.SubloggerWithRequestID(r.Context())
 	logger.Info().Msg("calling delete user")
@@ -489,7 +491,7 @@ func sortUsers(req *godata.GoDataRequest, users []*libregraph.User) ([]*libregra
 		return users, nil
 	}
 	switch req.Query.OrderBy.OrderByItems[0].Field.Value {
-	case "displayName":
+	case displayNameAttr:
 		sorter = usersByDisplayName{users}
 	case "mail":
 		sorter = usersByMail{users}
@@ -499,7 +501,7 @@ func sortUsers(req *godata.GoDataRequest, users []*libregraph.User) ([]*libregra
 		return nil, fmt.Errorf("we do not support <%s> as a order parameter", req.Query.OrderBy.OrderByItems[0].Field.Value)
 	}
 
-	if req.Query.OrderBy.OrderByItems[0].Order == "desc" {
+	if req.Query.OrderBy.OrderByItems[0].Order == _sortDescending {
 		sorter = sort.Reverse(sorter)
 	}
 	sort.Sort(sorter)
diff --git a/services/idp/pkg/command/server.go b/services/idp/pkg/command/server.go
index a17d0c42d43..b8d299f3f0b 100644
--- a/services/idp/pkg/command/server.go
+++ b/services/idp/pkg/command/server.go
@@ -139,7 +139,7 @@ func ensureEncryptionSecretExists(path string) error {
 
 	f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0600)
 	if err != nil {
-		return nil
+		return err
 	}
 	defer f.Close()
 
@@ -175,7 +175,7 @@ func ensureSigningPrivateKeyExists(paths []string) error {
 
 		f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0600)
 		if err != nil {
-			return nil
+			return err
 		}
 		defer f.Close()
 
diff --git a/services/ocs/pkg/service/v0/groups.go b/services/ocs/pkg/service/v0/groups.go
index be933718efb..0f136422610 100644
--- a/services/ocs/pkg/service/v0/groups.go
+++ b/services/ocs/pkg/service/v0/groups.go
@@ -2,35 +2,32 @@ package svc
 
 import (
 	"net/http"
-	"net/url"
 
-	"github.com/go-chi/chi/v5"
 	"github.com/owncloud/ocis/v2/services/ocs/pkg/service/v0/data"
 	"github.com/owncloud/ocis/v2/services/ocs/pkg/service/v0/response"
 )
 
+const (
+	_backendCS3 = "cs3"
+)
+
 // ListUserGroups lists a users groups
 func (o Ocs) ListUserGroups(w http.ResponseWriter, r *http.Request) {
-	userid := chi.URLParam(r, "userid")
-	userid, _ = url.PathUnescape(userid)
 	switch o.config.AccountBackend {
-	case "cs3":
+	case _backendCS3:
 		// TODO
 		o.mustRender(w, r, response.DataRender(&data.Groups{}))
-		return
 	default:
 		o.logger.Fatal().Msgf("Invalid accounts backend type '%s'", o.config.AccountBackend)
 	}
-	return
 }
 
 // AddToGroup adds a user to a group
 func (o Ocs) AddToGroup(w http.ResponseWriter, r *http.Request) {
 	switch o.config.AccountBackend {
-	case "cs3":
+	case _backendCS3:
 		// TODO
 		o.cs3WriteNotSupported(w, r)
-		return
 	default:
 		o.logger.Fatal().Msgf("Invalid accounts backend type '%s'", o.config.AccountBackend)
 	}
@@ -39,10 +36,9 @@ func (o Ocs) AddToGroup(w http.ResponseWriter, r *http.Request) {
 // RemoveFromGroup removes a user from a group
 func (o Ocs) RemoveFromGroup(w http.ResponseWriter, r *http.Request) {
 	switch o.config.AccountBackend {
-	case "cs3":
+	case _backendCS3:
 		// TODO
 		o.cs3WriteNotSupported(w, r)
-		return
 	default:
 		o.logger.Fatal().Msgf("Invalid accounts backend type '%s'", o.config.AccountBackend)
 	}
@@ -51,23 +47,20 @@ func (o Ocs) RemoveFromGroup(w http.ResponseWriter, r *http.Request) {
 // ListGroups lists all groups
 func (o Ocs) ListGroups(w http.ResponseWriter, r *http.Request) {
 	switch o.config.AccountBackend {
-	case "cs3":
+	case _backendCS3:
 		// TODO
 		o.mustRender(w, r, response.DataRender(&data.Groups{}))
-		return
 	default:
 		o.logger.Fatal().Msgf("Invalid accounts backend type '%s'", o.config.AccountBackend)
 	}
-	return
 }
 
 // AddGroup adds a group
 // oC10 implementation: https://github.com/owncloud/core/blob/762780a23c9eadda4fb5fa8db99eba66a5100b6e/apps/provisioning_api/lib/Groups.php#L126-L154
 func (o Ocs) AddGroup(w http.ResponseWriter, r *http.Request) {
 	switch o.config.AccountBackend {
-	case "cs3":
+	case _backendCS3:
 		o.cs3WriteNotSupported(w, r)
-		return
 	default:
 		o.logger.Fatal().Msgf("Invalid accounts backend type '%s'", o.config.AccountBackend)
 	}
@@ -76,9 +69,8 @@ func (o Ocs) AddGroup(w http.ResponseWriter, r *http.Request) {
 // DeleteGroup deletes a group
 func (o Ocs) DeleteGroup(w http.ResponseWriter, r *http.Request) {
 	switch o.config.AccountBackend {
-	case "cs3":
+	case _backendCS3:
 		o.cs3WriteNotSupported(w, r)
-		return
 	default:
 		o.logger.Fatal().Msgf("Invalid accounts backend type '%s'", o.config.AccountBackend)
 	}
@@ -87,12 +79,10 @@ func (o Ocs) DeleteGroup(w http.ResponseWriter, r *http.Request) {
 // GetGroupMembers lists all members of a group
 func (o Ocs) GetGroupMembers(w http.ResponseWriter, r *http.Request) {
 	switch o.config.AccountBackend {
-	case "cs3":
+	case _backendCS3:
 		// TODO
 		o.mustRender(w, r, response.DataRender(&data.Users{}))
-		return
 	default:
 		o.logger.Fatal().Msgf("Invalid accounts backend type '%s'", o.config.AccountBackend)
 	}
-	return
 }
diff --git a/services/ocs/pkg/service/v0/users.go b/services/ocs/pkg/service/v0/users.go
index 84f7ab52f2c..df788d91034 100644
--- a/services/ocs/pkg/service/v0/users.go
+++ b/services/ocs/pkg/service/v0/users.go
@@ -6,7 +6,6 @@ import (
 	"encoding/hex"
 	"net/http"
 	"net/url"
-	"strings"
 
 	storemsg "github.com/owncloud/ocis/v2/protogen/gen/ocis/messages/store/v0"
 	storesvc "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/store/v0"
@@ -37,7 +36,6 @@ func (o Ocs) GetSelf(w http.ResponseWriter, r *http.Request) {
 		GIDNumber:         u.GidNumber,
 	}
 	o.mustRender(w, r, response.DataRender(d))
-	return
 }
 
 // GetUser returns the user with the given userid
@@ -237,11 +235,6 @@ func (o Ocs) ListUsers(w http.ResponseWriter, r *http.Request) {
 	}
 }
 
-// escapeValue escapes all special characters in the value
-func escapeValue(value string) string {
-	return strings.ReplaceAll(value, "'", "''")
-}
-
 func (o Ocs) fetchAccountFromCS3Backend(ctx context.Context, name string) (*cs3.User, error) {
 	backend := o.getCS3Backend()
 	u, _, err := backend.GetUserByClaims(ctx, "username", name, false)
@@ -254,5 +247,4 @@ func (o Ocs) fetchAccountFromCS3Backend(ctx context.Context, name string) (*cs3.
 func (o Ocs) cs3WriteNotSupported(w http.ResponseWriter, r *http.Request) {
 	o.logger.Warn().Msg("the CS3 backend does not support adding or updating users")
 	o.NotImplementedStub(w, r)
-	return
 }
diff --git a/services/postprocessing/pkg/command/server.go b/services/postprocessing/pkg/command/server.go
index be2010d15e0..94feca20138 100644
--- a/services/postprocessing/pkg/command/server.go
+++ b/services/postprocessing/pkg/command/server.go
@@ -36,7 +36,7 @@ func Server(cfg *config.Config) *cli.Command {
 			evtsCfg := cfg.Postprocessing.Events
 			var tlsConf *tls.Config
 
-			if !evtsCfg.TLSInsecure {
+			if evtsCfg.EnableTLS {
 				var rootCAPool *x509.CertPool
 				if evtsCfg.TLSRootCACertificate != "" {
 					rootCrtFile, err := os.Open(evtsCfg.TLSRootCACertificate)
diff --git a/services/postprocessing/pkg/config/config.go b/services/postprocessing/pkg/config/config.go
index 479b8db8fbb..3e04b0bd2e5 100644
--- a/services/postprocessing/pkg/config/config.go
+++ b/services/postprocessing/pkg/config/config.go
@@ -32,6 +32,7 @@ type Events struct {
 	Endpoint string `yaml:"endpoint" env:"POSTPROCESSING_EVENTS_ENDPOINT" desc:"Endpoint of the event system."`
 	Cluster  string `yaml:"cluster" env:"POSTPROCESSING_EVENTS_CLUSTER" desc:"Cluster ID of the event system."`
 
-	TLSInsecure          bool   `yaml:"tls_insecure" env:"OCIS_INSECURE;SEARCH_EVENTS_TLS_INSECURE" desc:"Whether to verify the server TLS certificates."`
-	TLSRootCACertificate string `yaml:"tls_root_ca_certificate" env:"SEARCH_EVENTS_TLS_ROOT_CA_CERTIFICATE" desc:"The root CA certificate used to validate the server's TLS certificate. If provided SEARCH_EVENTS_TLS_INSECURE will be seen as false."`
+	TLSInsecure          bool   `yaml:"tls_insecure" env:"OCIS_INSECURE;POSTPROCESSING_EVENTS_TLS_INSECURE" desc:"Whether to verify the server TLS certificates."`
+	TLSRootCACertificate string `yaml:"tls_root_ca_certificate" env:"POSTPROCESSING_EVENTS_TLS_ROOT_CA_CERTIFICATE" desc:"The root CA certificate used to validate the server's TLS certificate. If provided POSTPROCESSING_EVENTS_TLS_INSECURE will be seen as false."`
+	EnableTLS            bool   `yaml:"enable_tls" env:"OCIS_EVENTS_ENABLE_TLS;POSTPROCESSING_EVENTS_ENABLE_TLS" desc:"Enable TLS for the connection to the events broker. The events broker is the ocis service which receives and delivers events between the services.."`
 }
diff --git a/services/settings/pkg/store/filesystem/assignments.go b/services/settings/pkg/store/filesystem/assignments.go
index 350a04cb1d9..7e997c9d863 100644
--- a/services/settings/pkg/store/filesystem/assignments.go
+++ b/services/settings/pkg/store/filesystem/assignments.go
@@ -15,7 +15,7 @@ func (s Store) ListRoleAssignments(accountUUID string) ([]*settingsmsg.UserRoleA
 	assignmentsFolder := s.buildFolderPathForRoleAssignments(false)
 	assignmentFiles, err := os.ReadDir(assignmentsFolder)
 	if err != nil {
-		return records, nil
+		return nil, nil
 	}
 
 	for _, assignmentFile := range assignmentFiles {
diff --git a/services/settings/pkg/store/filesystem/bundles.go b/services/settings/pkg/store/filesystem/bundles.go
index 17bce7bc9fc..513bff747f7 100644
--- a/services/settings/pkg/store/filesystem/bundles.go
+++ b/services/settings/pkg/store/filesystem/bundles.go
@@ -23,7 +23,7 @@ func (s Store) ListBundles(bundleType settingsmsg.Bundle_Type, bundleIDs []strin
 	bundlesFolder := s.buildFolderPathForBundles(false)
 	bundleFiles, err := os.ReadDir(bundlesFolder)
 	if err != nil {
-		return []*settingsmsg.Bundle{}, nil
+		return nil, err
 	}
 
 	records := make([]*settingsmsg.Bundle, 0, len(bundleFiles))
@@ -133,7 +133,7 @@ func (s Store) AddSettingToBundle(bundleID string, setting *settingsmsg.Setting)
 func (s Store) RemoveSettingFromBundle(bundleID string, settingID string) error {
 	bundle, err := s.ReadBundle(bundleID)
 	if err != nil {
-		return nil
+		return err
 	}
 	if ok := removeSetting(bundle, settingID); ok {
 		if _, err := s.WriteBundle(bundle); err != nil {
diff --git a/services/settings/pkg/store/filesystem/values.go b/services/settings/pkg/store/filesystem/values.go
index b0a26ebff85..230a0505593 100644
--- a/services/settings/pkg/store/filesystem/values.go
+++ b/services/settings/pkg/store/filesystem/values.go
@@ -18,7 +18,7 @@ func (s Store) ListValues(bundleID, accountUUID string) ([]*settingsmsg.Value, e
 	valuesFolder := s.buildFolderPathForValues(false)
 	valueFiles, err := os.ReadDir(valuesFolder)
 	if err != nil {
-		return []*settingsmsg.Value{}, nil
+		return nil, err
 	}
 
 	records := make([]*settingsmsg.Value, 0, len(valueFiles))
@@ -71,7 +71,7 @@ func (s Store) ReadValueByUniqueIdentifiers(accountUUID, settingID string) (*set
 			s.Logger.Debug().Msgf("reading contents from file: %v", filepath.Join(valuesFolder, files[i].Name()))
 			if err := s.parseRecordFromFile(&r, filepath.Join(valuesFolder, files[i].Name())); err != nil {
 				s.Logger.Debug().Msgf("match found: %v", filepath.Join(valuesFolder, files[i].Name()))
-				return &settingsmsg.Value{}, nil
+				return nil, err
 			}
 
 			// if value saved without accountUUID, then it's a global value
diff --git a/services/sharing/pkg/config/parser/parse.go b/services/sharing/pkg/config/parser/parse.go
index 32cc5624c78..c22fb4c0a32 100644
--- a/services/sharing/pkg/config/parser/parse.go
+++ b/services/sharing/pkg/config/parser/parse.go
@@ -11,6 +11,10 @@ import (
 	"github.com/owncloud/ocis/v2/ocis-pkg/config/envdecode"
 )
 
+const (
+	_backendCS3 = "cs3"
+)
+
 // ParseConfig loads configuration from known paths.
 func ParseConfig(cfg *config.Config) error {
 	_, err := ociscfg.BindSourcesToStructs(cfg.Service.Name, cfg)
@@ -38,19 +42,19 @@ func Validate(cfg *config.Config) error {
 		return shared.MissingJWTTokenError(cfg.Service.Name)
 	}
 
-	if cfg.PublicSharingDriver == "cs3" && cfg.PublicSharingDrivers.CS3.SystemUserAPIKey == "" {
+	if cfg.PublicSharingDriver == _backendCS3 && cfg.PublicSharingDrivers.CS3.SystemUserAPIKey == "" {
 		return shared.MissingSystemUserApiKeyError(cfg.Service.Name)
 	}
 
-	if cfg.PublicSharingDriver == "cs3" && cfg.PublicSharingDrivers.CS3.SystemUserID == "" {
+	if cfg.PublicSharingDriver == _backendCS3 && cfg.PublicSharingDrivers.CS3.SystemUserID == "" {
 		return shared.MissingSystemUserID(cfg.Service.Name)
 	}
 
-	if cfg.UserSharingDriver == "cs3" && cfg.UserSharingDrivers.CS3.SystemUserAPIKey == "" {
+	if cfg.UserSharingDriver == _backendCS3 && cfg.UserSharingDrivers.CS3.SystemUserAPIKey == "" {
 		return shared.MissingSystemUserApiKeyError(cfg.Service.Name)
 	}
 
-	if cfg.UserSharingDriver == "cs3" && cfg.UserSharingDrivers.CS3.SystemUserID == "" {
+	if cfg.UserSharingDriver == _backendCS3 && cfg.UserSharingDrivers.CS3.SystemUserID == "" {
 		return shared.MissingSystemUserID(cfg.Service.Name)
 	}
 
diff --git a/services/thumbnails/pkg/service/grpc/v0/service.go b/services/thumbnails/pkg/service/grpc/v0/service.go
index e6cc0cac598..4420258ad8a 100644
--- a/services/thumbnails/pkg/service/grpc/v0/service.go
+++ b/services/thumbnails/pkg/service/grpc/v0/service.go
@@ -82,13 +82,11 @@ func (g Thumbnail) GetThumbnail(ctx context.Context, req *thumbnailssvc.GetThumb
 	}
 	generator, err := thumbnail.GeneratorForType(tType)
 	if err != nil {
-		g.logger.Debug().Str("thumbnail_type", tType).Msg("unsupported thumbnail type")
-		return nil
+		return merrors.BadRequest(g.serviceID, "unsupported thumbnail type")
 	}
 	encoder, err := thumbnail.EncoderForType(tType)
 	if err != nil {
-		g.logger.Debug().Str("thumbnail_type", tType).Msg("unsupported thumbnail type")
-		return nil
+		return merrors.BadRequest(g.serviceID, "unsupported thumbnail type")
 	}
 
 	var key string
diff --git a/services/webdav/pkg/propfind/propfind.go b/services/webdav/pkg/propfind/propfind.go
index 3bc8e4f9c6b..5a488eec283 100644
--- a/services/webdav/pkg/propfind/propfind.go
+++ b/services/webdav/pkg/propfind/propfind.go
@@ -10,10 +10,6 @@ import (
 	"github.com/owncloud/ocis/v2/services/webdav/pkg/prop"
 )
 
-const (
-	_spaceTypeProject = "project"
-)
-
 type countingReader struct {
 	n int
 	r io.Reader


Test patch:
diff --git a/services/graph/pkg/identity/ldap_school_test.go b/services/graph/pkg/identity/ldap_school_test.go
new file mode 100644
index 00000000000..c6a4e651714
--- /dev/null
+++ b/services/graph/pkg/identity/ldap_school_test.go
@@ -0,0 +1,169 @@
+package identity
+
+import (
+	"context"
+	"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/owncloud/ocis/v2/services/graph/pkg/config"
+	"github.com/test-go/testify/assert"
+	"github.com/test-go/testify/mock"
+)
+
+var eduConfig = config.LDAP{
+	UserBaseDN:               "ou=people,dc=test",
+	UserObjectClass:          "inetOrgPerson",
+	UserSearchScope:          "sub",
+	UserFilter:               "",
+	UserDisplayNameAttribute: "displayname",
+	UserIDAttribute:          "entryUUID",
+	UserEmailAttribute:       "mail",
+	UserNameAttribute:        "uid",
+
+	GroupBaseDN:        "ou=groups,dc=test",
+	GroupObjectClass:   "groupOfNames",
+	GroupSearchScope:   "sub",
+	GroupFilter:        "",
+	GroupNameAttribute: "cn",
+	GroupIDAttribute:   "entryUUID",
+
+	WriteEnabled:              true,
+	EducationResourcesEnabled: true,
+}
+
+var schoolEntry = ldap.NewEntry("ou=Test School",
+	map[string][]string{
+		"ou":                      {"Test School"},
+		"ocEducationSchoolNumber": {"0123"},
+		"owncloudUUID":            {"abcd-defg"},
+	})
+var schoolEntry1 = ldap.NewEntry("ou=Test School1",
+	map[string][]string{
+		"ou":                      {"Test School1"},
+		"ocEducationSchoolNumber": {"0042"},
+		"owncloudUUID":            {"hijk-defg"},
+	})
+
+func TestCreateEducationSchool(t *testing.T) {
+	lm := &mocks.Client{}
+	lm.On("Add", mock.Anything).
+		Return(nil)
+
+	lm.On("Search", mock.Anything).
+		Return(
+			&ldap.SearchResult{
+				Entries: []*ldap.Entry{schoolEntry},
+			},
+			nil)
+
+	b, err := getMockedBackend(lm, eduConfig, &logger)
+	assert.Nil(t, err)
+	assert.NotEqual(t, "", b.educationConfig.schoolObjectClass)
+	school := libregraph.NewEducationSchool()
+	school.SetDisplayName("Test School")
+	school.SetSchoolNumber("0123")
+	school.SetId("abcd-defg")
+	res_school, err := b.CreateEducationSchool(context.Background(), *school)
+	lm.AssertNumberOfCalls(t, "Add", 1)
+	lm.AssertNumberOfCalls(t, "Search", 1)
+	assert.Nil(t, err)
+	assert.NotNil(t, res_school)
+	assert.Equal(t, res_school.GetDisplayName(), school.GetDisplayName())
+	assert.Equal(t, res_school.GetId(), school.GetId())
+	assert.Equal(t, res_school.GetSchoolNumber(), school.GetSchoolNumber())
+}
+
+func TestDeleteEducationSchool(t *testing.T) {
+	lm := &mocks.Client{}
+	sr1 := &ldap.SearchRequest{
+		BaseDN:     "",
+		Scope:      2,
+		SizeLimit:  1,
+		Filter:     "(&(objectClass=ocEducationSchool)(owncloudUUID=abcd-defg))",
+		Attributes: []string{"ou", "owncloudUUID", "ocEducationSchoolNumber"},
+		Controls:   []ldap.Control(nil),
+	}
+	sr2 := &ldap.SearchRequest{
+		BaseDN:     "",
+		Scope:      2,
+		SizeLimit:  1,
+		Filter:     "(&(objectClass=ocEducationSchool)(owncloudUUID=xxxx-xxxx))",
+		Attributes: []string{"ou", "owncloudUUID", "ocEducationSchoolNumber"},
+		Controls:   []ldap.Control(nil),
+	}
+	lm.On("Search", sr1).Return(&ldap.SearchResult{Entries: []*ldap.Entry{schoolEntry}}, nil)
+	lm.On("Search", sr2).Return(&ldap.SearchResult{Entries: []*ldap.Entry{}}, nil)
+	dr1 := &ldap.DelRequest{
+		DN: "ou=Test School",
+	}
+	lm.On("Del", dr1).Return(nil)
+	b, err := getMockedBackend(lm, eduConfig, &logger)
+	assert.Nil(t, err)
+
+	err = b.DeleteEducationSchool(context.Background(), "abcd-defg")
+	lm.AssertNumberOfCalls(t, "Search", 1)
+	lm.AssertNumberOfCalls(t, "Del", 1)
+	assert.Nil(t, err)
+
+	err = b.DeleteEducationSchool(context.Background(), "xxxx-xxxx")
+	lm.AssertNumberOfCalls(t, "Search", 2)
+	lm.AssertNumberOfCalls(t, "Del", 1)
+	assert.NotNil(t, err)
+	assert.Equal(t, "itemNotFound", err.Error())
+}
+
+func TestGetEducationSchool(t *testing.T) {
+	lm := &mocks.Client{}
+	sr1 := &ldap.SearchRequest{
+		BaseDN:     "",
+		Scope:      2,
+		SizeLimit:  1,
+		Filter:     "(&(objectClass=ocEducationSchool)(owncloudUUID=abcd-defg))",
+		Attributes: []string{"ou", "owncloudUUID", "ocEducationSchoolNumber"},
+		Controls:   []ldap.Control(nil),
+	}
+	sr2 := &ldap.SearchRequest{
+		BaseDN:     "",
+		Scope:      2,
+		SizeLimit:  1,
+		Filter:     "(&(objectClass=ocEducationSchool)(owncloudUUID=xxxx-xxxx))",
+		Attributes: []string{"ou", "owncloudUUID", "ocEducationSchoolNumber"},
+		Controls:   []ldap.Control(nil),
+	}
+	lm.On("Search", sr1).Return(&ldap.SearchResult{Entries: []*ldap.Entry{schoolEntry}}, nil)
+	lm.On("Search", sr2).Return(&ldap.SearchResult{Entries: []*ldap.Entry{}}, nil)
+	b, err := getMockedBackend(lm, eduConfig, &logger)
+	assert.Nil(t, err)
+	school, err := b.GetEducationSchool(context.Background(), "abcd-defg", nil)
+	lm.AssertNumberOfCalls(t, "Search", 1)
+	assert.Nil(t, err)
+	assert.Equal(t, "Test School", school.GetDisplayName())
+	assert.Equal(t, "abcd-defg", school.GetId())
+	assert.Equal(t, "0123", school.GetSchoolNumber())
+
+	school, err = b.GetEducationSchool(context.Background(), "xxxx-xxxx", nil)
+	lm.AssertNumberOfCalls(t, "Search", 2)
+	assert.NotNil(t, err)
+	assert.Equal(t, "itemNotFound", err.Error())
+}
+
+func TestGetEducationSchools(t *testing.T) {
+	lm := &mocks.Client{}
+	sr1 := &ldap.SearchRequest{
+		BaseDN:     "",
+		Scope:      2,
+		SizeLimit:  0,
+		Filter:     "(objectClass=ocEducationSchool)",
+		Attributes: []string{"ou", "owncloudUUID", "ocEducationSchoolNumber"},
+		Controls:   []ldap.Control(nil),
+	}
+	lm.On("Search", sr1).Return(&ldap.SearchResult{Entries: []*ldap.Entry{schoolEntry, schoolEntry1}}, nil)
+	//	lm.On("Search", sr2).Return(&ldap.SearchResult{Entries: []*ldap.Entry{}}, nil)
+	b, err := getMockedBackend(lm, eduConfig, &logger)
+	assert.Nil(t, err)
+	_, err = b.GetEducationSchools(context.Background(), nil)
+	lm.AssertNumberOfCalls(t, "Search", 1)
+	assert.Nil(t, err)
+}
diff --git a/services/graph/pkg/identity/ldap_test.go b/services/graph/pkg/identity/ldap_test.go
index b442a906542..8f8260aec5b 100644
--- a/services/graph/pkg/identity/ldap_test.go
+++ b/services/graph/pkg/identity/ldap_test.go
@@ -14,7 +14,7 @@ import (
 )
 
 func getMockedBackend(l ldap.Client, lc config.LDAP, logger *log.Logger) (*LDAP, error) {
-	return NewLDAPBackend(l, lconfig, logger)
+	return NewLDAPBackend(l, lc, logger)
 }
 
 var lconfig = config.LDAP{
@@ -33,6 +33,8 @@ var lconfig = config.LDAP{
 	GroupFilter:        "",
 	GroupNameAttribute: "cn",
 	GroupIDAttribute:   "entryUUID",
+
+	WriteEnabled: true,
 }
 
 var userEntry = ldap.NewEntry("uid=user",
@@ -318,14 +320,14 @@ func TestGetGroup(t *testing.T) {
 	sr2 := &ldap.SearchRequest{
 		BaseDN:     "uid=user,ou=people,dc=test",
 		SizeLimit:  1,
-		Filter:     "(objectclass=*)",
+		Filter:     "(objectClass=inetOrgPerson)",
 		Attributes: []string{"displayname", "entryUUID", "mail", "uid"},
 		Controls:   []ldap.Control(nil),
 	}
 	sr3 := &ldap.SearchRequest{
 		BaseDN:     "uid=invalid,ou=people,dc=test",
 		SizeLimit:  1,
-		Filter:     "(objectclass=*)",
+		Filter:     "(objectClass=inetOrgPerson)",
 		Attributes: []string{"displayname", "entryUUID", "mail", "uid"},
 		Controls:   []ldap.Control(nil),
 	}
@@ -413,14 +415,14 @@ func TestGetGroups(t *testing.T) {
 	sr2 := &ldap.SearchRequest{
 		BaseDN:     "uid=user,ou=people,dc=test",
 		SizeLimit:  1,
-		Filter:     "(objectclass=*)",
+		Filter:     "(objectClass=inetOrgPerson)",
 		Attributes: []string{"displayname", "entryUUID", "mail", "uid"},
 		Controls:   []ldap.Control(nil),
 	}
 	sr3 := &ldap.SearchRequest{
 		BaseDN:     "uid=invalid,ou=people,dc=test",
 		SizeLimit:  1,
-		Filter:     "(objectclass=*)",
+		Filter:     "(objectClass=inetOrgPerson)",
 		Attributes: []string{"displayname", "entryUUID", "mail", "uid"},
 		Controls:   []ldap.Control(nil),
 	}
diff --git a/services/graph/pkg/service/v0/drives_test.go b/services/graph/pkg/service/v0/drives_test.go
index 2c13866e30a..7cd084036e8 100644
--- a/services/graph/pkg/service/v0/drives_test.go
+++ b/services/graph/pkg/service/v0/drives_test.go
@@ -56,7 +56,7 @@ var sortTests = []sortTest{
 			Query: &godata.GoDataQuery{
 				OrderBy: &godata.GoDataOrderByQuery{
 					OrderByItems: []*godata.OrderByItem{
-						{Field: &godata.Token{Value: "name"}, Order: "desc"},
+						{Field: &godata.Token{Value: "name"}, Order: _sortDescending},
 					},
 				},
 			},
@@ -94,7 +94,7 @@ var sortTests = []sortTest{
 			Query: &godata.GoDataQuery{
 				OrderBy: &godata.GoDataOrderByQuery{
 					OrderByItems: []*godata.OrderByItem{
-						{Field: &godata.Token{Value: "lastModifiedDateTime"}, Order: "desc"},
+						{Field: &godata.Token{Value: "lastModifiedDateTime"}, Order: _sortDescending},
 					},
 				},
 			},
diff --git a/services/graph/pkg/service/v0/educationschools_test.go b/services/graph/pkg/service/v0/educationschools_test.go
new file mode 100644
index 00000000000..b47bfa69980
--- /dev/null
+++ b/services/graph/pkg/service/v0/educationschools_test.go
@@ -0,0 +1,462 @@
+package svc_test
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"errors"
+	"io"
+	"net/http"
+	"net/http/httptest"
+
+	userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
+	ctxpkg "github.com/cs3org/reva/v2/pkg/ctx"
+	"github.com/go-chi/chi/v5"
+	. "github.com/onsi/ginkgo/v2"
+	. "github.com/onsi/gomega"
+	"github.com/test-go/testify/mock"
+
+	libregraph "github.com/owncloud/libre-graph-api-go"
+	ogrpc "github.com/owncloud/ocis/v2/ocis-pkg/service/grpc"
+	"github.com/owncloud/ocis/v2/ocis-pkg/shared"
+	"github.com/owncloud/ocis/v2/services/graph/mocks"
+	"github.com/owncloud/ocis/v2/services/graph/pkg/config"
+	"github.com/owncloud/ocis/v2/services/graph/pkg/config/defaults"
+	identitymocks "github.com/owncloud/ocis/v2/services/graph/pkg/identity/mocks"
+	service "github.com/owncloud/ocis/v2/services/graph/pkg/service/v0"
+	"github.com/owncloud/ocis/v2/services/graph/pkg/service/v0/errorcode"
+)
+
+type schoolList struct {
+	Value []*libregraph.EducationSchool
+}
+
+var _ = Describe("Schools", func() {
+	var (
+		svc                      service.Service
+		ctx                      context.Context
+		cfg                      *config.Config
+		gatewayClient            *mocks.GatewayClient
+		identityEducationBackend *identitymocks.EducationBackend
+
+		rr *httptest.ResponseRecorder
+
+		newSchool   *libregraph.EducationSchool
+		currentUser = &userv1beta1.User{
+			Id: &userv1beta1.UserId{
+				OpaqueId: "user",
+			},
+		}
+	)
+
+	BeforeEach(func() {
+
+		identityEducationBackend = &identitymocks.EducationBackend{}
+		gatewayClient = &mocks.GatewayClient{}
+		newSchool = libregraph.NewEducationSchool()
+		newSchool.SetId("school1")
+
+		rr = httptest.NewRecorder()
+		ctx = context.Background()
+
+		cfg = defaults.FullDefaultConfig()
+		cfg.Identity.LDAP.CACert = "" // skip the startup checks, we don't use LDAP at all in this tests
+		cfg.TokenManager.JWTSecret = "loremipsum"
+		cfg.Commons = &shared.Commons{}
+		cfg.GRPCClientTLS = &shared.GRPCClientTLS{}
+
+		_ = ogrpc.Configure(ogrpc.GetClientOptions(cfg.GRPCClientTLS)...)
+		svc = service.NewService(
+			service.Config(cfg),
+			service.WithGatewayClient(gatewayClient),
+			service.WithIdentityEducationBackend(identityEducationBackend),
+		)
+	})
+
+	Describe("GetEducationSchools", func() {
+		It("handles invalid ODATA parameters", func() {
+			r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/schools?§foo=bar", nil)
+			svc.GetEducationSchools(rr, r)
+
+			Expect(rr.Code).To(Equal(http.StatusBadRequest))
+		})
+
+		It("handles invalid sorting queries", func() {
+			identityEducationBackend.On("GetEducationSchools", ctx, mock.Anything).Return([]*libregraph.EducationSchool{newSchool}, nil)
+
+			r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/schools?$orderby=invalid", nil)
+			svc.GetEducationSchools(rr, r)
+
+			Expect(rr.Code).To(Equal(http.StatusBadRequest))
+			data, err := io.ReadAll(rr.Body)
+			Expect(err).ToNot(HaveOccurred())
+
+			odataerr := libregraph.OdataError{}
+			err = json.Unmarshal(data, &odataerr)
+			Expect(err).ToNot(HaveOccurred())
+			Expect(odataerr.Error.Code).To(Equal("invalidRequest"))
+		})
+
+		It("handles unknown backend errors", func() {
+			identityEducationBackend.On("GetEducationSchools", ctx, mock.Anything).Return(nil, errors.New("failed"))
+
+			r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/schools", nil)
+			svc.GetEducationSchools(rr, r)
+			Expect(rr.Code).To(Equal(http.StatusInternalServerError))
+			data, err := io.ReadAll(rr.Body)
+			Expect(err).ToNot(HaveOccurred())
+
+			odataerr := libregraph.OdataError{}
+			err = json.Unmarshal(data, &odataerr)
+			Expect(err).ToNot(HaveOccurred())
+			Expect(odataerr.Error.Code).To(Equal("generalException"))
+		})
+
+		It("handles backend errors", func() {
+			identityEducationBackend.On("GetEducationSchools", ctx, mock.Anything).Return(nil, errorcode.New(errorcode.AccessDenied, "access denied"))
+
+			r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/schools", nil)
+			svc.GetEducationSchools(rr, r)
+
+			Expect(rr.Code).To(Equal(http.StatusInternalServerError))
+			data, err := io.ReadAll(rr.Body)
+			Expect(err).ToNot(HaveOccurred())
+
+			odataerr := libregraph.OdataError{}
+			err = json.Unmarshal(data, &odataerr)
+			Expect(err).ToNot(HaveOccurred())
+			Expect(odataerr.Error.Code).To(Equal("accessDenied"))
+		})
+
+		It("renders an empty list of schools", func() {
+			identityEducationBackend.On("GetEducationSchools", ctx, mock.Anything).Return([]*libregraph.EducationSchool{}, nil)
+
+			r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/schools", nil)
+			svc.GetEducationSchools(rr, r)
+
+			Expect(rr.Code).To(Equal(http.StatusOK))
+			data, err := io.ReadAll(rr.Body)
+			Expect(err).ToNot(HaveOccurred())
+
+			res := service.ListResponse{}
+			err = json.Unmarshal(data, &res)
+			Expect(err).ToNot(HaveOccurred())
+			Expect(res.Value).To(Equal([]interface{}{}))
+		})
+
+		It("renders a list of schools", func() {
+			identityEducationBackend.On("GetEducationSchools", ctx, mock.Anything).Return([]*libregraph.EducationSchool{newSchool}, nil)
+
+			r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/schools", nil)
+			svc.GetEducationSchools(rr, r)
+
+			Expect(rr.Code).To(Equal(http.StatusOK))
+			data, err := io.ReadAll(rr.Body)
+			Expect(err).ToNot(HaveOccurred())
+
+			res := schoolList{}
+			err = json.Unmarshal(data, &res)
+			Expect(err).ToNot(HaveOccurred())
+
+			Expect(len(res.Value)).To(Equal(1))
+			Expect(res.Value[0].GetId()).To(Equal("school1"))
+		})
+	})
+
+	Describe("GetEducationSchool", func() {
+		It("handles missing or empty school id", func() {
+			r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/schools", nil)
+			svc.GetEducationSchool(rr, r)
+
+			Expect(rr.Code).To(Equal(http.StatusBadRequest))
+
+			r = httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/schools", nil)
+			rctx := chi.NewRouteContext()
+			rctx.URLParams.Add("schoolID", "")
+			r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, nil), chi.RouteCtxKey, rctx))
+			svc.GetEducationSchool(rr, r)
+
+			Expect(rr.Code).To(Equal(http.StatusBadRequest))
+		})
+
+		Context("with an existing school", func() {
+			BeforeEach(func() {
+				identityEducationBackend.On("GetEducationSchool", mock.Anything, mock.Anything, mock.Anything).Return(newSchool, nil)
+			})
+
+			It("gets the school", func() {
+				r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/schools/"+*newSchool.Id, nil)
+				rctx := chi.NewRouteContext()
+				rctx.URLParams.Add("schoolID", *newSchool.Id)
+				r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, nil), chi.RouteCtxKey, rctx))
+
+				svc.GetEducationSchool(rr, r)
+
+				Expect(rr.Code).To(Equal(http.StatusOK))
+			})
+		})
+	})
+
+	Describe("PostEducationSchool", func() {
+		It("handles invalid body", func() {
+			r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/schools/", bytes.NewBufferString("{invalid"))
+
+			svc.PostEducationSchool(rr, r)
+			Expect(rr.Code).To(Equal(http.StatusBadRequest))
+		})
+
+		It("handles missing display name", func() {
+			newSchool = libregraph.NewEducationSchool()
+			newSchool.SetSchoolNumber("0034")
+			newSchoolJson, err := json.Marshal(newSchool)
+			Expect(err).ToNot(HaveOccurred())
+
+			r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/schools/", bytes.NewBuffer(newSchoolJson))
+
+			svc.PostEducationSchool(rr, r)
+			Expect(rr.Code).To(Equal(http.StatusBadRequest))
+		})
+
+		It("handles missing school number", func() {
+			newSchool = libregraph.NewEducationSchool()
+			newSchool.SetDisplayName("New School")
+			newSchoolJson, err := json.Marshal(newSchool)
+			Expect(err).ToNot(HaveOccurred())
+
+			r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/schools/", bytes.NewBuffer(newSchoolJson))
+
+			svc.PostEducationSchool(rr, r)
+			Expect(rr.Code).To(Equal(http.StatusBadRequest))
+		})
+
+		It("disallows school create ids", func() {
+			newSchool = libregraph.NewEducationSchool()
+			newSchool.SetId("disallowed")
+			newSchool.SetDisplayName("New School")
+			newSchool.SetSchoolNumber("0034")
+			newSchoolJson, err := json.Marshal(newSchool)
+			Expect(err).ToNot(HaveOccurred())
+
+			r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/schools/", bytes.NewBuffer(newSchoolJson))
+
+			svc.PostEducationSchool(rr, r)
+			Expect(rr.Code).To(Equal(http.StatusBadRequest))
+		})
+
+		It("handles backend errors", func() {
+			newSchool = libregraph.NewEducationSchool()
+			newSchool.SetDisplayName("New School")
+			newSchool.SetSchoolNumber("0034")
+			newSchoolJson, err := json.Marshal(newSchool)
+			Expect(err).ToNot(HaveOccurred())
+
+			identityEducationBackend.On("CreateEducationSchool", mock.Anything, mock.Anything).Return(nil, errorcode.New(errorcode.AccessDenied, "access denied"))
+
+			r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/schools/", bytes.NewBuffer(newSchoolJson))
+
+			svc.PostEducationSchool(rr, r)
+
+			Expect(rr.Code).To(Equal(http.StatusInternalServerError))
+		})
+
+		It("creates the school", func() {
+			newSchool = libregraph.NewEducationSchool()
+			newSchool.SetDisplayName("New School")
+			newSchool.SetSchoolNumber("0034")
+			newSchoolJson, err := json.Marshal(newSchool)
+			Expect(err).ToNot(HaveOccurred())
+
+			identityEducationBackend.On("CreateEducationSchool", mock.Anything, mock.Anything).Return(newSchool, nil)
+
+			r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/schools/", bytes.NewBuffer(newSchoolJson))
+
+			svc.PostEducationSchool(rr, r)
+
+			Expect(rr.Code).To(Equal(http.StatusCreated))
+		})
+	})
+	Describe("PatchEducationSchool", func() {
+		It("handles invalid body", func() {
+			r := httptest.NewRequest(http.MethodPatch, "/graph/v1.0/education/schools/", bytes.NewBufferString("{invalid"))
+			rctx := chi.NewRouteContext()
+			rctx.URLParams.Add("schoolID", *newSchool.Id)
+			r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
+			svc.PatchEducationSchool(rr, r)
+			Expect(rr.Code).To(Equal(http.StatusBadRequest))
+		})
+
+		It("handles missing or empty school id", func() {
+			r := httptest.NewRequest(http.MethodPatch, "/graph/v1.0/education/schools", nil)
+			svc.PatchEducationSchool(rr, r)
+
+			Expect(rr.Code).To(Equal(http.StatusBadRequest))
+
+			r = httptest.NewRequest(http.MethodPatch, "/graph/v1.0/education/schools", nil)
+			rctx := chi.NewRouteContext()
+			rctx.URLParams.Add("schoolID", "")
+			r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
+			svc.PatchEducationSchool(rr, r)
+
+			Expect(rr.Code).To(Equal(http.StatusBadRequest))
+		})
+
+		It("handles malformed school id", func() {
+			r := httptest.NewRequest(http.MethodPatch, "/graph/v1.0/education/schools", nil)
+			rctx := chi.NewRouteContext()
+			rctx.URLParams.Add("schoolID", "school%id")
+			r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
+			svc.PatchEducationSchool(rr, r)
+
+			Expect(rr.Code).To(Equal(http.StatusBadRequest))
+		})
+
+		It("updates the school", func() {
+			newSchool = libregraph.NewEducationSchool()
+			newSchool.SetDisplayName("New School Name")
+			newSchoolJson, err := json.Marshal(newSchool)
+			Expect(err).ToNot(HaveOccurred())
+
+			r := httptest.NewRequest(http.MethodPatch, "/graph/v1.0/education/schools/schoolid", bytes.NewBuffer(newSchoolJson))
+			rctx := chi.NewRouteContext()
+			rctx.URLParams.Add("schoolID", "school-id")
+			r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
+
+			svc.PatchEducationSchool(rr, r)
+
+			Expect(rr.Code).To(Equal(http.StatusNoContent))
+		})
+	})
+
+	Describe("DeleteEducationSchool", func() {
+		Context("with an existing school", func() {
+			BeforeEach(func() {
+				identityEducationBackend.On("GetEducationSchool", mock.Anything, mock.Anything, mock.Anything).Return(newSchool, nil)
+			})
+		})
+
+		It("deletes the school", func() {
+			identityEducationBackend.On("DeleteEducationSchool", mock.Anything, mock.Anything, mock.Anything).Return(nil)
+			r := httptest.NewRequest(http.MethodPatch, "/graph/v1.0/education/schools", nil)
+			rctx := chi.NewRouteContext()
+			rctx.URLParams.Add("schoolID", *newSchool.Id)
+			r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
+			svc.DeleteEducationSchool(rr, r)
+
+			Expect(rr.Code).To(Equal(http.StatusNoContent))
+			identityEducationBackend.AssertNumberOfCalls(GinkgoT(), "DeleteEducationSchool", 1)
+		})
+	})
+
+	Describe("GetEducationSchoolMembers", func() {
+		It("gets the list of members", func() {
+			user := libregraph.NewEducationUser()
+			user.SetId("user")
+			identityEducationBackend.On("GetEducationSchoolMembers", mock.Anything, mock.Anything, mock.Anything).Return([]*libregraph.EducationUser{user}, nil)
+
+			r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/schools/{schoolID}/members", nil)
+			rctx := chi.NewRouteContext()
+			rctx.URLParams.Add("schoolID", *newSchool.Id)
+			r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
+			svc.GetEducationSchoolMembers(rr, r)
+			Expect(rr.Code).To(Equal(http.StatusOK))
+
+			data, err := io.ReadAll(rr.Body)
+			Expect(err).ToNot(HaveOccurred())
+
+			var members []*libregraph.User
+			err = json.Unmarshal(data, &members)
+			Expect(err).ToNot(HaveOccurred())
+
+			Expect(len(members)).To(Equal(1))
+			Expect(members[0].GetId()).To(Equal("user"))
+		})
+	})
+
+	Describe("PostEducationSchoolMembers", func() {
+		It("fails on invalid body", func() {
+			r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/schools/{schoolID}/members", bytes.NewBufferString("{invalid"))
+			rctx := chi.NewRouteContext()
+			rctx.URLParams.Add("schoolID", *newSchool.Id)
+			r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
+			svc.PostEducationSchoolMember(rr, r)
+			Expect(rr.Code).To(Equal(http.StatusBadRequest))
+		})
+
+		It("fails on missing member refs", func() {
+			member := libregraph.NewMemberReference()
+			data, err := json.Marshal(member)
+			Expect(err).ToNot(HaveOccurred())
+
+			r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/schools/{schoolID}/members", bytes.NewBuffer(data))
+			rctx := chi.NewRouteContext()
+			rctx.URLParams.Add("schoolID", *newSchool.Id)
+			r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
+			svc.PostEducationSchoolMember(rr, r)
+			Expect(rr.Code).To(Equal(http.StatusBadRequest))
+		})
+
+		It("fails on invalid member refs", func() {
+			member := libregraph.NewMemberReference()
+			member.SetOdataId("/invalidtype/user")
+			data, err := json.Marshal(member)
+			Expect(err).ToNot(HaveOccurred())
+
+			r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/schools/{schoolID}/members", bytes.NewBuffer(data))
+			rctx := chi.NewRouteContext()
+			rctx.URLParams.Add("schoolID", *newSchool.Id)
+			r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
+			svc.PostEducationSchoolMember(rr, r)
+			Expect(rr.Code).To(Equal(http.StatusBadRequest))
+		})
+
+		It("adds a new member", func() {
+			member := libregraph.NewMemberReference()
+			member.SetOdataId("/users/user")
+			data, err := json.Marshal(member)
+			Expect(err).ToNot(HaveOccurred())
+			identityEducationBackend.On("AddMembersToEducationSchool", mock.Anything, mock.Anything, mock.Anything).Return(nil)
+
+			r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/schools/{schoolID}/members", bytes.NewBuffer(data))
+			rctx := chi.NewRouteContext()
+			rctx.URLParams.Add("schoolID", *newSchool.Id)
+			r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
+			svc.PostEducationSchoolMember(rr, r)
+			Expect(rr.Code).To(Equal(http.StatusNoContent))
+
+			identityEducationBackend.AssertNumberOfCalls(GinkgoT(), "AddMembersToEducationSchool", 1)
+		})
+	})
+
+	Describe("DeleteEducationSchoolMembers", func() {
+		It("handles missing or empty member id", func() {
+			r := httptest.NewRequest(http.MethodDelete, "/graph/v1.0/education/schools/{schoolID}/members/{memberID}/$ref", nil)
+			rctx := chi.NewRouteContext()
+			rctx.URLParams.Add("schoolID", *newSchool.Id)
+			r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
+			svc.DeleteEducationSchoolMember(rr, r)
+			Expect(rr.Code).To(Equal(http.StatusBadRequest))
+		})
+		It("handles missing or empty member id", func() {
+			r := httptest.NewRequest(http.MethodDelete, "/graph/v1.0/education/schools/{schoolID}/members/{memberID}/$ref", nil)
+			rctx := chi.NewRouteContext()
+			rctx.URLParams.Add("memberID", "/users/user")
+			r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
+			svc.DeleteEducationSchoolMember(rr, r)
+			Expect(rr.Code).To(Equal(http.StatusBadRequest))
+		})
+
+		It("deletes members", func() {
+			identityEducationBackend.On("RemoveMemberFromEducationSchool", mock.Anything, mock.Anything, mock.Anything).Return(nil)
+
+			r := httptest.NewRequest(http.MethodDelete, "/graph/v1.0/education/schools/{schoolID}/members/{memberID}/$ref", nil)
+			rctx := chi.NewRouteContext()
+			rctx.URLParams.Add("schoolID", *newSchool.Id)
+			rctx.URLParams.Add("memberID", "/users/user1")
+			r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
+			svc.DeleteEducationSchoolMember(rr, r)
+			Expect(rr.Code).To(Equal(http.StatusNoContent))
+
+			identityEducationBackend.AssertNumberOfCalls(GinkgoT(), "RemoveMemberFromEducationSchool", 1)
+		})
+	})
+})
diff --git a/services/graph/pkg/service/v0/educationuser_test.go b/services/graph/pkg/service/v0/educationuser_test.go
new file mode 100644
index 00000000000..98ee06809bd
--- /dev/null
+++ b/services/graph/pkg/service/v0/educationuser_test.go
@@ -0,0 +1,508 @@
+package svc_test
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"io"
+	"net/http"
+	"net/http/httptest"
+
+	userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
+	provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
+	typesv1beta1 "github.com/cs3org/go-cs3apis/cs3/types/v1beta1"
+	revactx "github.com/cs3org/reva/v2/pkg/ctx"
+	"github.com/cs3org/reva/v2/pkg/rgrpc/status"
+	"github.com/go-chi/chi/v5"
+	. "github.com/onsi/ginkgo/v2"
+	. "github.com/onsi/gomega"
+	"github.com/stretchr/testify/mock"
+
+	libregraph "github.com/owncloud/libre-graph-api-go"
+	ogrpc "github.com/owncloud/ocis/v2/ocis-pkg/service/grpc"
+	"github.com/owncloud/ocis/v2/ocis-pkg/shared"
+	settingssvc "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/settings/v0"
+	"github.com/owncloud/ocis/v2/services/graph/mocks"
+	"github.com/owncloud/ocis/v2/services/graph/pkg/config"
+	"github.com/owncloud/ocis/v2/services/graph/pkg/config/defaults"
+	identitymocks "github.com/owncloud/ocis/v2/services/graph/pkg/identity/mocks"
+	service "github.com/owncloud/ocis/v2/services/graph/pkg/service/v0"
+)
+
+type educationUserList struct {
+	Value []*libregraph.EducationUser
+}
+
+var _ = Describe("EducationUsers", func() {
+	var (
+		svc                      service.Service
+		ctx                      context.Context
+		cfg                      *config.Config
+		gatewayClient            *mocks.GatewayClient
+		eventsPublisher          mocks.Publisher
+		roleService              *mocks.RoleService
+		identityEducationBackend *identitymocks.EducationBackend
+
+		rr *httptest.ResponseRecorder
+
+		currentUser = &userv1beta1.User{
+			Id: &userv1beta1.UserId{
+				OpaqueId: "user",
+			},
+		}
+	)
+
+	BeforeEach(func() {
+		eventsPublisher.On("Publish", mock.Anything, mock.Anything, mock.Anything).Return(nil)
+
+		identityEducationBackend = &identitymocks.EducationBackend{}
+		roleService = &mocks.RoleService{}
+		gatewayClient = &mocks.GatewayClient{}
+
+		rr = httptest.NewRecorder()
+		ctx = context.Background()
+
+		cfg = defaults.FullDefaultConfig()
+		cfg.Identity.LDAP.CACert = "" // skip the startup checks, we don't use LDAP at all in this tests
+		cfg.TokenManager.JWTSecret = "loremipsum"
+		cfg.Commons = &shared.Commons{}
+		cfg.GRPCClientTLS = &shared.GRPCClientTLS{}
+
+		_ = ogrpc.Configure(ogrpc.GetClientOptions(cfg.GRPCClientTLS)...)
+		svc = service.NewService(
+			service.Config(cfg),
+			service.WithGatewayClient(gatewayClient),
+			service.EventsPublisher(&eventsPublisher),
+			service.WithIdentityEducationBackend(identityEducationBackend),
+			//service.WithRoleService(roleService),
+		)
+	})
+
+	Describe("GetEducationUsers", func() {
+		It("handles invalid requests", func() {
+			r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/users?$invalid=true", nil)
+			svc.GetEducationUsers(rr, r)
+
+			Expect(rr.Code).To(Equal(http.StatusBadRequest))
+		})
+
+		It("lists the users", func() {
+			user := &libregraph.EducationUser{}
+			user.SetId("user1")
+			users := []*libregraph.EducationUser{user}
+
+			identityEducationBackend.On("GetEducationUsers", mock.Anything, mock.Anything, mock.Anything).Return(users, nil)
+
+			r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/users", nil)
+			svc.GetEducationUsers(rr, r)
+
+			Expect(rr.Code).To(Equal(http.StatusOK))
+			data, err := io.ReadAll(rr.Body)
+			Expect(err).ToNot(HaveOccurred())
+
+			res := educationUserList{}
+			err = json.Unmarshal(data, &res)
+			Expect(err).ToNot(HaveOccurred())
+
+			Expect(len(res.Value)).To(Equal(1))
+			Expect(res.Value[0].GetId()).To(Equal("user1"))
+		})
+
+		It("sorts", func() {
+			user := &libregraph.EducationUser{}
+			user.SetId("user1")
+			user.SetMail("z@example.com")
+			user.SetDisplayName("9")
+			user.SetOnPremisesSamAccountName("9")
+			user2 := &libregraph.EducationUser{}
+			user2.SetId("user2")
+			user2.SetMail("a@example.com")
+			user2.SetDisplayName("1")
+			user2.SetOnPremisesSamAccountName("1")
+			users := []*libregraph.EducationUser{user, user2}
+
+			identityEducationBackend.On("GetEducationUsers", mock.Anything, mock.Anything, mock.Anything).Return(users, nil)
+
+			getUsers := func(path string) []*libregraph.EducationUser {
+				r := httptest.NewRequest(http.MethodGet, path, nil)
+				rec := httptest.NewRecorder()
+				svc.GetEducationUsers(rec, r)
+
+				Expect(rec.Code).To(Equal(http.StatusOK))
+				data, err := io.ReadAll(rec.Body)
+				Expect(err).ToNot(HaveOccurred())
+
+				res := educationUserList{}
+				err = json.Unmarshal(data, &res)
+				Expect(err).ToNot(HaveOccurred())
+				return res.Value
+			}
+
+			unsorted := getUsers("/graph/v1.0/education/users")
+			Expect(len(unsorted)).To(Equal(2))
+			Expect(unsorted[0].GetId()).To(Equal("user1"))
+			Expect(unsorted[1].GetId()).To(Equal("user2"))
+
+			byMail := getUsers("/graph/v1.0/education/users?$orderby=mail")
+			Expect(len(byMail)).To(Equal(2))
+			Expect(byMail[0].GetId()).To(Equal("user2"))
+			Expect(byMail[1].GetId()).To(Equal("user1"))
+			byMail = getUsers("/graph/v1.0/education/users?$orderby=mail%20asc")
+			Expect(len(byMail)).To(Equal(2))
+			Expect(byMail[0].GetId()).To(Equal("user2"))
+			Expect(byMail[1].GetId()).To(Equal("user1"))
+			byMail = getUsers("/graph/v1.0/education/users?$orderby=mail%20desc")
+			Expect(len(byMail)).To(Equal(2))
+			Expect(byMail[0].GetId()).To(Equal("user1"))
+			Expect(byMail[1].GetId()).To(Equal("user2"))
+
+			byDisplayName := getUsers("/graph/v1.0/education/users?$orderby=displayName")
+			Expect(len(byDisplayName)).To(Equal(2))
+			Expect(byDisplayName[0].GetId()).To(Equal("user2"))
+			Expect(byDisplayName[1].GetId()).To(Equal("user1"))
+			byDisplayName = getUsers("/graph/v1.0/education/users?$orderby=displayName%20asc")
+			Expect(len(byDisplayName)).To(Equal(2))
+			Expect(byDisplayName[0].GetId()).To(Equal("user2"))
+			Expect(byDisplayName[1].GetId()).To(Equal("user1"))
+			byDisplayName = getUsers("/graph/v1.0/education/users?$orderby=displayName%20desc")
+			Expect(len(byDisplayName)).To(Equal(2))
+			Expect(byDisplayName[0].GetId()).To(Equal("user1"))
+			Expect(byDisplayName[1].GetId()).To(Equal("user2"))
+
+			byOnPremisesSamAccountName := getUsers("/graph/v1.0/education/users?$orderby=onPremisesSamAccountName")
+			Expect(len(byOnPremisesSamAccountName)).To(Equal(2))
+			Expect(byOnPremisesSamAccountName[0].GetId()).To(Equal("user2"))
+			Expect(byOnPremisesSamAccountName[1].GetId()).To(Equal("user1"))
+			byOnPremisesSamAccountName = getUsers("/graph/v1.0/education/users?$orderby=onPremisesSamAccountName%20asc")
+			Expect(len(byOnPremisesSamAccountName)).To(Equal(2))
+			Expect(byOnPremisesSamAccountName[0].GetId()).To(Equal("user2"))
+			Expect(byOnPremisesSamAccountName[1].GetId()).To(Equal("user1"))
+			byOnPremisesSamAccountName = getUsers("/graph/v1.0/education/users?$orderby=onPremisesSamAccountName%20desc")
+			Expect(len(byOnPremisesSamAccountName)).To(Equal(2))
+			Expect(byOnPremisesSamAccountName[0].GetId()).To(Equal("user1"))
+			Expect(byOnPremisesSamAccountName[1].GetId()).To(Equal("user2"))
+
+			// Handles invalid sort field
+			r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/users?$orderby=invalid", nil)
+			svc.GetEducationUsers(rr, r)
+
+			Expect(rr.Code).To(Equal(http.StatusBadRequest))
+		})
+	})
+
+	Describe("GetEducationUser", func() {
+		It("handles missing userids", func() {
+			r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/users", nil)
+			svc.GetEducationUser(rr, r)
+
+			Expect(rr.Code).To(Equal(http.StatusBadRequest))
+		})
+
+		It("gets the user", func() {
+			user := &libregraph.EducationUser{}
+			user.SetId("user1")
+
+			identityEducationBackend.On("GetEducationUser", mock.Anything, mock.Anything, mock.Anything).Return(user, nil)
+			r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/users", nil)
+			rctx := chi.NewRouteContext()
+			rctx.URLParams.Add("userID", *user.Id)
+			r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
+			svc.GetEducationUser(rr, r)
+
+			Expect(rr.Code).To(Equal(http.StatusOK))
+			data, err := io.ReadAll(rr.Body)
+			Expect(err).ToNot(HaveOccurred())
+			responseUser := &libregraph.EducationUser{}
+			err = json.Unmarshal(data, &responseUser)
+			Expect(err).ToNot(HaveOccurred())
+			Expect(responseUser.GetId()).To(Equal("user1"))
+			Expect(len(responseUser.GetDrives())).To(Equal(0))
+		})
+
+		It("includes the personal space if requested", func() {
+			user := &libregraph.EducationUser{}
+			user.SetId("user1")
+
+			identityEducationBackend.On("GetEducationUser", mock.Anything, mock.Anything, mock.Anything).Return(user, nil)
+			gatewayClient.On("GetQuota", mock.Anything, mock.Anything, mock.Anything).Return(&provider.GetQuotaResponse{
+				Status:     status.NewOK(ctx),
+				TotalBytes: 10,
+			}, nil)
+			gatewayClient.On("ListStorageSpaces", mock.Anything, mock.Anything, mock.Anything).Return(&provider.ListStorageSpacesResponse{
+				Status: status.NewOK(ctx),
+				StorageSpaces: []*provider.StorageSpace{
+					{
+						Id:        &provider.StorageSpaceId{OpaqueId: "drive1"},
+						Root:      &provider.ResourceId{SpaceId: "space", OpaqueId: "space"},
+						SpaceType: "project",
+					},
+					{
+						Id:        &provider.StorageSpaceId{OpaqueId: "personal"},
+						Root:      &provider.ResourceId{SpaceId: "personal", OpaqueId: "personal"},
+						SpaceType: "personal",
+					},
+				},
+			}, nil)
+
+			r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/users?$expand=drive", nil)
+			rctx := chi.NewRouteContext()
+			rctx.URLParams.Add("userID", *user.Id)
+			r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
+			svc.GetEducationUser(rr, r)
+
+			Expect(rr.Code).To(Equal(http.StatusOK))
+			data, err := io.ReadAll(rr.Body)
+			Expect(err).ToNot(HaveOccurred())
+			responseUser := &libregraph.EducationUser{}
+			err = json.Unmarshal(data, &responseUser)
+			Expect(err).ToNot(HaveOccurred())
+			Expect(responseUser.GetId()).To(Equal("user1"))
+			Expect(*responseUser.GetDrive().Id).To(Equal("personal"))
+		})
+
+		It("includes the drives if requested", func() {
+			user := &libregraph.EducationUser{}
+			user.SetId("user1")
+
+			identityEducationBackend.On("GetEducationUser", mock.Anything, mock.Anything, mock.Anything).Return(user, nil)
+			gatewayClient.On("GetQuota", mock.Anything, mock.Anything, mock.Anything).Return(&provider.GetQuotaResponse{
+				Status:     status.NewOK(ctx),
+				TotalBytes: 10,
+			}, nil)
+			gatewayClient.On("ListStorageSpaces", mock.Anything, mock.Anything, mock.Anything).Return(&provider.ListStorageSpacesResponse{
+				Status: status.NewOK(ctx),
+				StorageSpaces: []*provider.StorageSpace{
+					{
+						Id:   &provider.StorageSpaceId{OpaqueId: "drive1"},
+						Root: &provider.ResourceId{SpaceId: "space", OpaqueId: "space"},
+					},
+				},
+			}, nil)
+
+			r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/users?$expand=drives", nil)
+			rctx := chi.NewRouteContext()
+			rctx.URLParams.Add("userID", *user.Id)
+			r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
+			svc.GetEducationUser(rr, r)
+
+			Expect(rr.Code).To(Equal(http.StatusOK))
+			data, err := io.ReadAll(rr.Body)
+			Expect(err).ToNot(HaveOccurred())
+			responseUser := &libregraph.EducationUser{}
+			err = json.Unmarshal(data, &responseUser)
+			Expect(err).ToNot(HaveOccurred())
+			Expect(responseUser.GetId()).To(Equal("user1"))
+			Expect(len(responseUser.GetDrives())).To(Equal(1))
+		})
+	})
+
+	Describe("PostEducationUser", func() {
+		var (
+			user *libregraph.EducationUser
+
+			assertHandleBadAttributes = func(user *libregraph.EducationUser) {
+				userJson, err := json.Marshal(user)
+				Expect(err).ToNot(HaveOccurred())
+
+				r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/users", bytes.NewBuffer(userJson))
+				svc.PostEducationUser(rr, r)
+
+				Expect(rr.Code).To(Equal(http.StatusBadRequest))
+			}
+		)
+
+		BeforeEach(func() {
+			identity := libregraph.ObjectIdentity{}
+			identity.SetIssuer("issu.er")
+			identity.SetIssuerAssignedId("our-user.1")
+
+			user = &libregraph.EducationUser{}
+			user.SetDisplayName("Display Name")
+			user.SetOnPremisesSamAccountName("user")
+			user.SetMail("user@example.com")
+			user.SetAccountEnabled(true)
+			user.SetIdentities([]libregraph.ObjectIdentity{identity})
+			user.SetPrimaryRole("student")
+		})
+
+		It("handles invalid bodies", func() {
+			r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/users?$invalid=true", nil)
+			svc.PostEducationUser(rr, r)
+
+			Expect(rr.Code).To(Equal(http.StatusBadRequest))
+		})
+
+		It("handles missing display names", func() {
+			user.DisplayName = nil
+			assertHandleBadAttributes(user)
+
+		})
+
+		It("handles missing OnPremisesSamAccountName", func() {
+			user.OnPremisesSamAccountName = nil
+			assertHandleBadAttributes(user)
+
+			user.SetOnPremisesSamAccountName("")
+			assertHandleBadAttributes(user)
+		})
+
+		It("handles bad Mails", func() {
+			user.Mail = nil
+			assertHandleBadAttributes(user)
+
+			user.SetMail("not-a-mail-address")
+			assertHandleBadAttributes(user)
+		})
+
+		It("handles set Ids - they are read-only", func() {
+			user.SetId("/users/user")
+			assertHandleBadAttributes(user)
+		})
+
+		It("creates a user", func() {
+			roleService.On("AssignRoleToUser", mock.Anything, mock.Anything).Return(&settingssvc.AssignRoleToUserResponse{}, nil)
+			identityEducationBackend.On("CreateEducationUser", mock.Anything, mock.Anything).Return(func(ctx context.Context, user libregraph.EducationUser) *libregraph.EducationUser {
+				user.SetId("/users/user")
+				return &user
+			}, nil)
+			userJson, err := json.Marshal(user)
+			Expect(err).ToNot(HaveOccurred())
+
+			r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/users", bytes.NewBuffer(userJson))
+			r = r.WithContext(revactx.ContextSetUser(ctx, currentUser))
+			svc.PostEducationUser(rr, r)
+
+			Expect(rr.Code).To(Equal(http.StatusOK))
+		})
+	})
+
+	Describe("DeleteEducationUser", func() {
+		It("handles missing userids", func() {
+			r := httptest.NewRequest(http.MethodDelete, "/graph/v1.0/education/users/{userid}", nil)
+			svc.DeleteEducationUser(rr, r)
+
+			Expect(rr.Code).To(Equal(http.StatusBadRequest))
+		})
+
+		It("prevents a user from deleting themselves", func() {
+			lu := libregraph.EducationUser{}
+			lu.SetId(currentUser.Id.OpaqueId)
+			identityEducationBackend.On("GetEducationUser", mock.Anything, mock.Anything, mock.Anything).Return(&lu, nil)
+
+			r := httptest.NewRequest(http.MethodDelete, "/graph/v1.0/education/users/{userid}", nil)
+			rctx := chi.NewRouteContext()
+			rctx.URLParams.Add("userID", currentUser.Id.OpaqueId)
+			r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
+			svc.DeleteEducationUser(rr, r)
+
+			Expect(rr.Code).To(Equal(http.StatusForbidden))
+		})
+
+		It("deletes a user from deleting themselves", func() {
+			otheruser := &userv1beta1.User{
+				Id: &userv1beta1.UserId{
+					OpaqueId: "otheruser",
+				},
+			}
+
+			lu := libregraph.EducationUser{}
+			lu.SetId(otheruser.Id.OpaqueId)
+			identityEducationBackend.On("GetEducationUser", mock.Anything, mock.Anything, mock.Anything).Return(&lu, nil)
+			identityEducationBackend.On("DeleteEducationUser", mock.Anything, mock.Anything).Return(nil)
+			gatewayClient.On("DeleteStorageSpace", mock.Anything, mock.Anything).Return(&provider.DeleteStorageSpaceResponse{
+				Status: status.NewOK(ctx),
+			}, nil)
+			gatewayClient.On("ListStorageSpaces", mock.Anything, mock.Anything, mock.Anything).Return(&provider.ListStorageSpacesResponse{
+				Status: status.NewOK(ctx),
+				StorageSpaces: []*provider.StorageSpace{
+					{
+						Opaque:    &typesv1beta1.Opaque{},
+						Id:        &provider.StorageSpaceId{OpaqueId: "drive1"},
+						Root:      &provider.ResourceId{SpaceId: "space", OpaqueId: "space"},
+						SpaceType: "personal",
+						Owner:     otheruser,
+					},
+				},
+			}, nil)
+
+			r := httptest.NewRequest(http.MethodDelete, "/graph/v1.0/education/users/{userid}", nil)
+			rctx := chi.NewRouteContext()
+			rctx.URLParams.Add("userID", lu.GetId())
+			r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
+			svc.DeleteEducationUser(rr, r)
+
+			Expect(rr.Code).To(Equal(http.StatusNoContent))
+			gatewayClient.AssertNumberOfCalls(GinkgoT(), "DeleteStorageSpace", 2) // 2 calls for the home space. first trash, then purge
+		})
+	})
+
+	Describe("PatchEducationUser", func() {
+		var (
+			user *libregraph.EducationUser
+		)
+
+		BeforeEach(func() {
+			user = &libregraph.EducationUser{}
+			user.SetDisplayName("Display Name")
+			user.SetOnPremisesSamAccountName("user")
+			user.SetMail("user@example.com")
+			user.SetId("/users/user")
+
+			identityEducationBackend.On("GetEducationUser", mock.Anything, mock.Anything, mock.Anything).Return(&user, nil)
+		})
+
+		It("handles missing userids", func() {
+			r := httptest.NewRequest(http.MethodPatch, "/graph/v1.0/education/users/{userid}", nil)
+			svc.PatchEducationUser(rr, r)
+
+			Expect(rr.Code).To(Equal(http.StatusBadRequest))
+		})
+
+		It("handles invalid bodies", func() {
+			r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/users?$invalid=true", nil)
+			rctx := chi.NewRouteContext()
+			rctx.URLParams.Add("userID", user.GetId())
+			r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
+			svc.PatchEducationUser(rr, r)
+
+			Expect(rr.Code).To(Equal(http.StatusBadRequest))
+		})
+
+		It("handles invalid email", func() {
+			user.SetMail("invalid")
+			data, err := json.Marshal(user)
+			Expect(err).ToNot(HaveOccurred())
+
+			r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/users?$invalid=true", bytes.NewBuffer(data))
+			rctx := chi.NewRouteContext()
+			rctx.URLParams.Add("userID", user.GetId())
+			r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
+			svc.PatchEducationUser(rr, r)
+
+			Expect(rr.Code).To(Equal(http.StatusBadRequest))
+		})
+
+		It("updates attributes", func() {
+			identityEducationBackend.On("UpdateEducationUser", mock.Anything, user.GetId(), mock.Anything).Return(user, nil)
+
+			user.SetDisplayName("New Display Name")
+			data, err := json.Marshal(user)
+			Expect(err).ToNot(HaveOccurred())
+
+			r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/users?$invalid=true", bytes.NewBuffer(data))
+			rctx := chi.NewRouteContext()
+			rctx.URLParams.Add("userID", user.GetId())
+			r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
+			svc.PatchEducationUser(rr, r)
+
+			Expect(rr.Code).To(Equal(http.StatusOK))
+			data, err = io.ReadAll(rr.Body)
+			Expect(err).ToNot(HaveOccurred())
+
+			updatedUser := libregraph.EducationUser{}
+			err = json.Unmarshal(data, &updatedUser)
+			Expect(err).ToNot(HaveOccurred())
+			Expect(updatedUser.GetDisplayName()).To(Equal("New Display Name"))
+		})
+	})
+})