Repository: owncloud/ocis
Pull request #5318 (2023-01-03 15:46:31)

graph: add appRoleAssignments and minimal application resource
- [x] bump libregraph go dependency
- [x] add appRoleAssignment stub
- [x] add application stub
- [x] add positive test cases
- [ ] add failure test cases
- [x] wire up settings service
- [x] make applicationid configurable

For now, I decided to intdroduce an application that is configured with `GRAPH_APPLICATION_ID` and `GRAPH_APPLICATION_DISPLAYNAME`. They are necessary to represent an application that owns the roles. In our case that is oCIS.

First question: is it only oCIS Web? I currently default to `"oCIS Web"`. 

Second question: the application id should be randomized on init. AFAICT it resembles the oc10 instance id. Although I'm not sure if it is the web client or the server side, see first question. The more I type the more I think it is the server side.

Third question: if we make the id configurable then we require clients to know the application id in advance. Should we implement listing applications at `/applications`? That way clients could discover the applications that are available. The WebUI could discover not only ocis web, but also onlyoffice and other applications. I strongly encourage everyone to check out the [MS Graph Application Properties](https://learn.microsoft.com/en-us/graph/api/resources/application?view=graph-rest-1.0#properties). It has things like a logo stream, endpoints for redirects of web applications, links to terms of service, support urls, ... If we entertain a publicly hosted oCIS web instance that could discover a graph endpoint using webfinger, similar to the openid connect discovery, being able to discover the application id, a displayname and a logo is desireable. Also ... I don't want to have to configure the wab ui to know the application id in advance.  An in case the user management web ui (soon to become admin web ui) encounters multiple applications it will have to be able to deal with more than one application and multiple sets of appRoleIDs.

In light of the above, I propose to use just `"oCIS"` as the default application name, roll a uuid for the applcation id on `ocis init` and implement the ListApplications endpoint.

@kulmann @michaelstingl @felix-schwarz I just noticed how ms graph allows applications to register as a handler for certain filetypes: 

<html>
<body>
<!--StartFragment-->

Property | Type | Description
-- | -- | --
addIns | [addIn](https://learn.microsoft.com/en-us/graph/api/resources/addin?view=graph-rest-1.0) collection | Defines custom behavior that a consuming service can use to call an app in specific contexts. For example, applications that can render file streams [may set the addIns property](https://learn.microsoft.com/en-us/onedrive/developer/file-handlers) for its "FileHandler" functionality. This will let services like Office 365 call the application in the context of a document the user is working on.

<!--EndFragment-->
</body>
</html>




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

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

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

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


ownclouders: :boom: Acceptance test [<strong>Core-API-Tests-ocis-storage-6</strong>](https://drone.owncloud.com/owncloud/ocis/17947/54/1) failed. Further test are cancelled...
<!-- id: pr-5318 -->

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

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

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


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

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

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


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

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

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


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

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

[![59.9%](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/CoverageChart/50-16px.png '59.9%')](https://sonarcloud.io/component_measures?id=owncloud_ocis&pullRequest=5318&metric=new_coverage&view=list) [59.9% Coverage](https://sonarcloud.io/component_measures?id=owncloud_ocis&pullRequest=5318&metric=new_coverage&view=list)  
[![0.0%](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/Duplications/3-16px.png '0.0%')](https://sonarcloud.io/component_measures?id=owncloud_ocis&pullRequest=5318&metric=new_duplicated_lines_density&view=list) [0.0% Duplication](https://sonarcloud.io/component_measures?id=owncloud_ocis&pullRequest=5318&metric=new_duplicated_lines_density&view=list)


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

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

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


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

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

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


butonic: @micbar @kulmann the [permissions in msgraph](https://learn.microsoft.com/en-us/graph/permissions-overview?tabs=http) have a technical layout: `{resource}.{operation}.{constraint}`
<html>
<body>
<!--StartFragment-->

Value | Description | Examples
-- | -- | --
`{resource}` | Refers to a Microsoft Graph resource to which the permission allows access. For example, the `user` resource. | `User`, `Application`, or `Group`
`{operation}` | Refers to the Microsoft Graph API operations that are allowed on the data that's exposed by the resource. For example, `Read` for read operations only, or `ReadWrite` for read, create, update, and delete operations. | `Read`, `ReadBasic`, `ReadWrite`, `Create`, `Manage`, or `Migrate`
`{constraint}` | Determines the potential extent of access an app will have within the directory. This value may not be explicitly declared. When undeclared, the default constraint is limited to data that's owned by the signed-in user. | `All`, `AppFolder`, `OwnedBy`, `Selected`, `Shared`, `Hidden`

<!--EndFragment-->
</body>
</html>

I recommend skimming the [MS Graph permissions reference](https://learn.microsoft.com/en-us/graph/permissions-reference) to get a feeling of how granular they are.

Regarding [appRoleAssignments](https://learn.microsoft.com/en-us/graph/api/resources/approleassignment?view=graph-rest-1.0) I found this explanation that clarifies how the **value** property of an appRole is used:
> When the [app role](https://learn.microsoft.com/en-us/graph/api/resources/approle?view=graph-rest-1.0) which has been assigned to a principal has a non-empty **value** property, this will be included in the **roles** claim of tokens where the subject is the assigned principal (e.g. SAML responses, ID tokens, access tokens identifying a signed-in user, or an access token identifying a service principal). Applications and APIs use these claims as part of their authorization logic.

AFAICT there is no direct permission lookup mechanism. Instead, the naming pattern `{resource}.{operation}.{constraint}` determines the granularity by
1. sharding all permissions by `resource`
2. either granting only a subset of `operation`s, eg. `Read` or `ReadWrite`, specific permissions like `ReadBasic`, `Create`, `Migrate` or `Manage`. I assume that often one set of operations includes anoter, eg. `Read` includes `ReadBasic` and `ReadWrite` includes `Read` and hence `ReadBasic`.
3. the constraint 

4. 
butonic: Trying to map our current roles to MS Graph permissions:

First a set of relevant permissions:

Permission | Display String | Description | Admin Consent Required | Microsoft Account supported
-- | -- | -- | -- | --
*User.ReadWrite* | Read and write access to user profile | Allows the app to read the signed-in user's full profile. It also allows the app to update the signed-in user's profile information on their behalf. | No | Yes
*User.ReadBasic.All* | Read all users' basic profiles | Allows the app to read a basic set of profile properties of other users in your organization on behalf of the signed-in user. This includes display name, first and last name, email address, open extensions and photo. Also allows the app to read the full profile of the signed-in user. | No | No
*Files.ReadWrite.All* | Have full access to all files user can access | Allows the app to read, create, update, and delete all files the signed-in user can access. | No | Yes
*GroupMember.ReadWrite.All* | Read and write group memberships | Allows the app to list groups, read basic properties, read and update the membership of the groups the signed-in user has access to. Group properties and owners cannot be updated and groups cannot be deleted. | Yes | No
*Group.ReadWrite.All* | Read and write all groups | Allows the app to create groups and read all group properties and memberships on behalf of the signed-in user. Also allows the app to read and write calendar, conversations, files, and other group content for all groups the signed-in user can access. Additionally allows group owners to manage their groups and allows group members to update group content. | Yes | No
*User.ReadWrite.All* | Read and write all users' full profiles | Allows the app to read and write the full set of profile properties, reports, and managers of other users in your organization, on behalf of the signed-in user. Also allows the app to create and delete users as well as reset user passwords on behalf of the signed-in user. | Yes | No
*UnifiedGroupMember.Read.AsGuest* | Read unified (Microsoft 365) group memberships as a guest user | Allows the app to read basic unified group properties, memberships, and owners of the group the signed-in guest is a member of. | Yes | No
*Drive.Manage.All* | Read, Write all drive (aka spaces) properties in the organization, on behalf of the signed-in user. Also allows the app to create and delete drives on behalf of the signed-in user. | Yes | No

Then our current user roles would be expressed as

oCIS role | delegated MS Graph permissions | Comment
-- | -- | --
User | `User.ReadWrite User.ReadBasic.All Files.ReadWrite.All GroupMember.ReadWrite.All` | This is the basic set of permissions to allow users access to files in personal as well as project drives / spaces
Admin | `User.ReadWrite User.ReadBasic.All Files.ReadWrite.All Group.ReadWrite.All User.ReadWrite.All Drives.Manage.All` | Admins can in addition manage users, groups and drives/spaces
Space Admin | `User.ReadWrite User.ReadBasic.All Files.ReadWrite.All GroupMember.ReadWrite.All Drive.Manage.All` | space admins can aeccess their own files and manage all drives / spaces
Guest | `User.ReadWrite Files.ReadWrite.All UnifiedGroupMember.Read.AsGuest` | guests cannot list the basic user properties (so he cannot list users for internal sharing)?, they can read and write all files they have access to, and they can list which groups they are a member of



Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 413de620928..76881d415e0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -17,8 +17,10 @@ The following sections list the changes for unreleased.
 * Enhancement - Drive group permissions: [#5312](https://github.com/owncloud/ocis/pull/5312)
 * Enhancement - Make the group members addition limit configurable: [#5357](https://github.com/owncloud/ocis/pull/5357)
 * Enhancement - Graph Drives IdentitySet displayName: [#5347](https://github.com/owncloud/ocis/pull/5347)
+* Enhancement - Display surname and givenName attributes: [#5388](https://github.com/owncloud/ocis/pull/5388)
 * Enhancement - Extended search: [#5221](https://github.com/owncloud/ocis/pull/5221)
 * Enhancement - Resource tags: [#5227](https://github.com/owncloud/ocis/pull/5227)
+* Enhancement - Expiration Notifications: [#5330](https://github.com/owncloud/ocis/pull/5330)
 
 # Changelog for [2.0.0] (2022-11-30)
 
diff --git a/changelog/unreleased/enhancement-name-attributes.md b/changelog/unreleased/enhancement-name-attributes.md
new file mode 100644
index 00000000000..6558b927eec
--- /dev/null
+++ b/changelog/unreleased/enhancement-name-attributes.md
@@ -0,0 +1,6 @@
+Enhancement: display surname and givenName attributes
+
+When querying the graph API, the surname and givenName attributes are now displayed for users.
+
+https://github.com/owncloud/ocis/pull/5388
+https://github.com/owncloud/ocis/issues/5386
diff --git a/changelog/unreleased/expiration-notifications.md b/changelog/unreleased/expiration-notifications.md
new file mode 100644
index 00000000000..06b16c0808d
--- /dev/null
+++ b/changelog/unreleased/expiration-notifications.md
@@ -0,0 +1,5 @@
+Enhancement: Expiration Notifications
+
+Send emails to the user informing that a share or a space membership expires.
+
+https://github.com/owncloud/ocis/pull/5330
diff --git a/go.mod b/go.mod
index 429b843e4b7..2964562fce2 100644
--- a/go.mod
+++ b/go.mod
@@ -11,7 +11,7 @@ require (
 	github.com/blevesearch/bleve/v2 v2.3.5
 	github.com/coreos/go-oidc/v3 v3.4.0
 	github.com/cs3org/go-cs3apis v0.0.0-20221012090518-ef2996678965
-	github.com/cs3org/reva/v2 v2.12.1-0.20221230073811-e52401790323
+	github.com/cs3org/reva/v2 v2.12.1-0.20230112100946-f8c784168ef7
 	github.com/disintegration/imaging v1.6.2
 	github.com/ggwhite/go-masker v1.0.9
 	github.com/go-chi/chi/v5 v5.0.7
@@ -55,7 +55,7 @@ require (
 	github.com/onsi/ginkgo/v2 v2.7.0
 	github.com/onsi/gomega v1.24.1
 	github.com/orcaman/concurrent-map v1.0.0
-	github.com/owncloud/libre-graph-api-go v1.0.1
+	github.com/owncloud/libre-graph-api-go v1.0.2-0.20230105141655-9384face4d5d
 	github.com/pkg/errors v0.9.1
 	github.com/prometheus/client_golang v1.14.0
 	github.com/rs/zerolog v1.28.0
diff --git a/go.sum b/go.sum
index 4a009167dcb..351c458f36f 100644
--- a/go.sum
+++ b/go.sum
@@ -344,8 +344,8 @@ github.com/crewjam/saml v0.4.9 h1:X2jDv4dv3IvfT9t+RhADavzNFAcq3fVxzTCIH3G605U=
 github.com/crewjam/saml v0.4.9/go.mod h1:9Zh6dWPtB3MSzTRt8fIFH60Z351QQ+s7hCU3J/tTlA4=
 github.com/cs3org/go-cs3apis v0.0.0-20221012090518-ef2996678965 h1:y4n2j68LLnvac+zw/al8MfPgO5aQiIwLmHM/JzYN8AM=
 github.com/cs3org/go-cs3apis v0.0.0-20221012090518-ef2996678965/go.mod h1:UXha4TguuB52H14EMoSsCqDj7k8a/t7g4gVP+bgY5LY=
-github.com/cs3org/reva/v2 v2.12.1-0.20221230073811-e52401790323 h1:zkyBR7O5NjyEdI71AhGyHMq3+EL2rlJmR3QnR2oS+OE=
-github.com/cs3org/reva/v2 v2.12.1-0.20221230073811-e52401790323/go.mod h1:GpocVB1w6yxeSr1VBsO9jztmt1SyNC4lCwudLwDzxHQ=
+github.com/cs3org/reva/v2 v2.12.1-0.20230112100946-f8c784168ef7 h1:xULPUJdydKJbZ8z0MD4zkOqnz2oE+SXKw+3GzSYr+yY=
+github.com/cs3org/reva/v2 v2.12.1-0.20230112100946-f8c784168ef7/go.mod h1:GpocVB1w6yxeSr1VBsO9jztmt1SyNC4lCwudLwDzxHQ=
 github.com/cubewise-code/go-mime v0.0.0-20200519001935-8c5762b177d8 h1:Z9lwXumT5ACSmJ7WGnFl+OMLLjpz5uR2fyz7dC255FI=
 github.com/cubewise-code/go-mime v0.0.0-20200519001935-8c5762b177d8/go.mod h1:4abs/jPXcmJzYoYGF91JF9Uq9s/KL5n1jvFDix8KcqY=
 github.com/cyberdelia/templates v0.0.0-20141128023046-ca7fffd4298c/go.mod h1:GyV+0YP4qX0UQ7r2MoYZ+AvYDp12OF5yg4q8rGnyNh4=
@@ -873,6 +873,8 @@ github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa02
 github.com/klauspost/cpuid/v2 v2.0.4/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
 github.com/klauspost/cpuid/v2 v2.1.0 h1:eyi1Ad2aNJMW95zcSbmGg7Cg6cq3ADwLpMAP96d8rF0=
 github.com/klauspost/cpuid/v2 v2.1.0/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
+github.com/kobergj/reva/v2 v2.0.0-20230112095039-1aba27929ce4 h1:jvN+oN9vHYXhrYv76OJ6bhXx7ldDpErblPZSwu0UzmA=
+github.com/kobergj/reva/v2 v2.0.0-20230112095039-1aba27929ce4/go.mod h1:GpocVB1w6yxeSr1VBsO9jztmt1SyNC4lCwudLwDzxHQ=
 github.com/kolo/xmlrpc v0.0.0-20200310150728-e0350524596b/go.mod h1:o03bZfuBwAXHetKXuInt4S7omeXUu62/A845kiycsSQ=
 github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
 github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
@@ -1058,8 +1060,8 @@ github.com/oracle/oci-go-sdk v24.3.0+incompatible/go.mod h1:VQb79nF8Z2cwLkLS35uk
 github.com/orcaman/concurrent-map v1.0.0 h1:I/2A2XPCb4IuQWcQhBhSwGfiuybl/J0ev9HDbW65HOY=
 github.com/orcaman/concurrent-map v1.0.0/go.mod h1:Lu3tH6HLW3feq74c2GC+jIMS/K2CFcDWnWD9XkenwhI=
 github.com/ovh/go-ovh v1.1.0/go.mod h1:AxitLZ5HBRPyUd+Zl60Ajaag+rNTdVXWIkzfrVuTXWA=
-github.com/owncloud/libre-graph-api-go v1.0.1 h1:wj3aQQr/yDPoc97ddg7DCadvMx6ui6N7re/oRV9+yNs=
-github.com/owncloud/libre-graph-api-go v1.0.1/go.mod h1:579sFrPP7aP24LZXGPopLfvE+hAka/2DYHk0+Ij+w+U=
+github.com/owncloud/libre-graph-api-go v1.0.2-0.20230105141655-9384face4d5d h1:aqVf2yJEdSgFQd3k5fnwtYxjTwC/UREAKTZIzeupwHg=
+github.com/owncloud/libre-graph-api-go v1.0.2-0.20230105141655-9384face4d5d/go.mod h1:iKdVH6nYpI8RBeK9sjeLfzrPByST6r9d+NG2IJHoJmU=
 github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c h1:rp5dCmg/yLR3mgFuSOe4oEnDDmGLROTvMragMUXpTQw=
 github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c/go.mod h1:X07ZCGwUbLaax7L0S3Tw4hpejzu63ZrrQiUe6W0hcy0=
 github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
diff --git a/ocis/pkg/init/init.go b/ocis/pkg/init/init.go
index 3663172a0ec..560af63144d 100644
--- a/ocis/pkg/init/init.go
+++ b/ocis/pkg/init/init.go
@@ -50,11 +50,15 @@ type LdapBasedService struct {
 type Events struct {
 	TLSInsecure bool `yaml:"tls_insecure"`
 }
+type GraphApplication struct {
+	ID string `yaml:"id"`
+}
 
 type GraphService struct {
-	Events   Events
-	Spaces   InsecureService
-	Identity LdapBasedService
+	Application GraphApplication
+	Events      Events
+	Spaces      InsecureService
+	Identity    LdapBasedService
 }
 
 type ServiceUserPasswordsSettings struct {
@@ -219,6 +223,7 @@ func CreateConfig(insecure, forceOverwrite bool, configPath, adminPassword strin
 
 	systemUserID := uuid.Must(uuid.NewV4()).String()
 	adminUserID := uuid.Must(uuid.NewV4()).String()
+	graphApplicationID := uuid.Must(uuid.NewV4()).String()
 	storageUsersMountID := uuid.Must(uuid.NewV4()).String()
 
 	idmServicePassword, err := generators.GenerateRandomPassword(passwordLength)
@@ -306,6 +311,9 @@ func CreateConfig(insecure, forceOverwrite bool, configPath, adminPassword strin
 			},
 		},
 		Graph: GraphService{
+			Application: GraphApplication{
+				ID: graphApplicationID,
+			},
 			Identity: LdapBasedService{
 				Ldap: LdapSettings{
 					BindPassword: idmServicePassword,
diff --git a/services/graph/pkg/config/application.go b/services/graph/pkg/config/application.go
new file mode 100644
index 00000000000..c33af28652c
--- /dev/null
+++ b/services/graph/pkg/config/application.go
@@ -0,0 +1,7 @@
+package config
+
+// Application defines the available graph application configuration.
+type Application struct {
+	ID          string `yaml:"id" env:"GRAPH_APPLICATION_ID" desc:"The ocis application id shown in the graph. All app roles are tied to this."`
+	DisplayName string `yaml:"displayname" env:"GRAPH_APPLICATION_DISPLAYNAME" desc:"The oCIS application name"`
+}
diff --git a/services/graph/pkg/config/config.go b/services/graph/pkg/config/config.go
index b3618c72e96..5b2f00e5189 100644
--- a/services/graph/pkg/config/config.go
+++ b/services/graph/pkg/config/config.go
@@ -25,9 +25,10 @@ type Config struct {
 	TokenManager  *TokenManager         `yaml:"token_manager"`
 	GRPCClientTLS *shared.GRPCClientTLS `yaml:"grpc_client_tls"`
 
-	Spaces   Spaces   `yaml:"spaces"`
-	Identity Identity `yaml:"identity"`
-	Events   Events   `yaml:"events"`
+	Application Application `yaml:"application"`
+	Spaces      Spaces      `yaml:"spaces"`
+	Identity    Identity    `yaml:"identity"`
+	Events      Events      `yaml:"events"`
 
 	Context context.Context `yaml:"-"`
 }
diff --git a/services/graph/pkg/config/defaults/defaultconfig.go b/services/graph/pkg/config/defaults/defaultconfig.go
index 1d06dad3fdb..023efe04939 100644
--- a/services/graph/pkg/config/defaults/defaultconfig.go
+++ b/services/graph/pkg/config/defaults/defaultconfig.go
@@ -31,6 +31,9 @@ func DefaultConfig() *config.Config {
 		Service: config.Service{
 			Name: "graph",
 		},
+		Application: config.Application{
+			DisplayName: "ownCloud Infinite Scale",
+		},
 		API: config.API{
 			GroupMembersPatchLimit: 20,
 		},
diff --git a/services/graph/pkg/config/parser/parse.go b/services/graph/pkg/config/parser/parse.go
index d0fa4780b82..ffd91247ea8 100644
--- a/services/graph/pkg/config/parser/parse.go
+++ b/services/graph/pkg/config/parser/parse.go
@@ -2,8 +2,10 @@ package parser
 
 import (
 	"errors"
+	"fmt"
 
 	ociscfg "github.com/owncloud/ocis/v2/ocis-pkg/config"
+	defaults2 "github.com/owncloud/ocis/v2/ocis-pkg/config/defaults"
 	"github.com/owncloud/ocis/v2/ocis-pkg/shared"
 	"github.com/owncloud/ocis/v2/services/graph/pkg/config"
 	"github.com/owncloud/ocis/v2/services/graph/pkg/config/defaults"
@@ -42,5 +44,13 @@ func Validate(cfg *config.Config) error {
 		return shared.MissingLDAPBindPassword(cfg.Service.Name)
 	}
 
+	if cfg.Application.ID == "" {
+		return fmt.Errorf("The application ID has not been configured for %s. "+
+			"Make sure your %s config contains the proper values "+
+			"(e.g. by running ocis init or setting it manually in "+
+			"the config/corresponding environment variable).",
+			"graph", defaults2.BaseConfigPath())
+	}
+
 	return nil
 }
diff --git a/services/graph/pkg/identity/backend.go b/services/graph/pkg/identity/backend.go
index b521e32d45d..5b8d5f5f444 100644
--- a/services/graph/pkg/identity/backend.go
+++ b/services/graph/pkg/identity/backend.go
@@ -60,6 +60,17 @@ type EducationBackend interface {
 	// RemoveUserFromEducationSchool removes a single member (by ID) from a school
 	RemoveUserFromEducationSchool(ctx context.Context, schoolID string, memberID string) error
 
+	// GetEducationClasses lists all classes
+	GetEducationClasses(ctx context.Context, queryParam url.Values) ([]*libregraph.EducationClass, error)
+	// GetEducationClasses reads a given class by id
+	GetEducationClass(ctx context.Context, namedOrID string, queryParam url.Values) (*libregraph.EducationClass, error)
+	// CreateEducationClass creates the supplied education class in the identity backend.
+	CreateEducationClass(ctx context.Context, class libregraph.EducationClass) (*libregraph.EducationClass, error)
+	// DeleteEducationClass deletes the supplied education class in the identity backend.
+	DeleteEducationClass(ctx context.Context, nameOrID string) error
+	// GetEducationClassMembers returns the EducationUser members for an EducationClass
+	GetEducationClassMembers(ctx context.Context, nameOrID string) ([]*libregraph.EducationUser, 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
diff --git a/services/graph/pkg/identity/err_school.go b/services/graph/pkg/identity/err_education.go
similarity index 75%
rename from services/graph/pkg/identity/err_school.go
rename to services/graph/pkg/identity/err_education.go
index 6460fa46dfa..6e81f7c9c58 100644
--- a/services/graph/pkg/identity/err_school.go
+++ b/services/graph/pkg/identity/err_education.go
@@ -50,6 +50,31 @@ func (i *ErrEducationBackend) RemoveUserFromEducationSchool(ctx context.Context,
 	return errNotImplemented
 }
 
+// GetEducationClasses implements the EducationBackend interface
+func (i *ErrEducationBackend) GetEducationClasses(ctx context.Context, queryParam url.Values) ([]*libregraph.EducationClass, error) {
+	return nil, errNotImplemented
+}
+
+// GetEducationClass implements the EducationBackend interface
+func (i *ErrEducationBackend) GetEducationClass(ctx context.Context, namedOrID string, queryParam url.Values) (*libregraph.EducationClass, error) {
+	return nil, errNotImplemented
+}
+
+// CreateEducationClass implements the EducationBackend interface
+func (i *ErrEducationBackend) CreateEducationClass(ctx context.Context, class libregraph.EducationClass) (*libregraph.EducationClass, error) {
+	return nil, errNotImplemented
+}
+
+// DeleteEducationClass implements the EducationBackend interface
+func (i *ErrEducationBackend) DeleteEducationClass(ctx context.Context, nameOrID string) error {
+	return errNotImplemented
+}
+
+// GetEducationClassMembers implements the EducationBackend interface
+func (i *ErrEducationBackend) GetEducationClassMembers(ctx context.Context, nameOrID string) ([]*libregraph.EducationUser, error) {
+	return nil, 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
diff --git a/services/graph/pkg/identity/ldap.go b/services/graph/pkg/identity/ldap.go
index 9f96812ad74..735f4ee6a4d 100644
--- a/services/graph/pkg/identity/ldap.go
+++ b/services/graph/pkg/identity/ldap.go
@@ -18,6 +18,11 @@ import (
 	"golang.org/x/exp/slices"
 )
 
+const (
+	givenNameAttribute = "givenname"
+	surNameAttribute   = "sn"
+)
+
 type LDAP struct {
 	useServerUUID   bool
 	writeEnabled    bool
@@ -46,6 +51,8 @@ type userAttributeMap struct {
 	id          string
 	mail        string
 	userName    string
+	givenName   string
+	surname     string
 }
 
 type groupAttributeMap struct {
@@ -67,6 +74,8 @@ func NewLDAPBackend(lc ldap.Client, config config.LDAP, logger *log.Logger) (*LD
 		id:          config.UserIDAttribute,
 		mail:        config.UserEmailAttribute,
 		userName:    config.UserNameAttribute,
+		givenName:   givenNameAttribute,
+		surname:     surNameAttribute,
 	}
 
 	if config.GroupNameAttribute == "" || config.GroupIDAttribute == "" {
@@ -266,6 +275,8 @@ func (i *LDAP) getUserByDN(dn string) (*ldap.Entry, error) {
 		i.userAttributeMap.id,
 		i.userAttributeMap.mail,
 		i.userAttributeMap.userName,
+		i.userAttributeMap.surname,
+		i.userAttributeMap.givenName,
 	}
 
 	filter := fmt.Sprintf("(objectClass=%s)", i.userObjectClass)
@@ -373,6 +384,8 @@ func (i *LDAP) getLDAPUserByFilter(filter string) (*ldap.Entry, error) {
 		i.userAttributeMap.id,
 		i.userAttributeMap.mail,
 		i.userAttributeMap.userName,
+		i.userAttributeMap.surname,
+		i.userAttributeMap.givenName,
 	}
 	return i.searchLDAPEntryByFilter(i.userBaseDN, attrs, filter)
 }
@@ -430,6 +443,8 @@ func (i *LDAP) GetUsers(ctx context.Context, queryParam url.Values) ([]*libregra
 			i.userAttributeMap.id,
 			i.userAttributeMap.mail,
 			i.userAttributeMap.userName,
+			i.userAttributeMap.surname,
+			i.userAttributeMap.givenName,
 		},
 		nil,
 	)
@@ -932,6 +947,8 @@ func (i *LDAP) createUserModelFromLDAP(e *ldap.Entry) *libregraph.User {
 
 	opsan := e.GetEqualFoldAttributeValue(i.userAttributeMap.userName)
 	id := e.GetEqualFoldAttributeValue(i.userAttributeMap.id)
+	givenName := e.GetEqualFoldAttributeValue(i.userAttributeMap.givenName)
+	surname := e.GetEqualFoldAttributeValue(i.userAttributeMap.surname)
 
 	if id != "" && opsan != "" {
 		return &libregraph.User{
@@ -939,6 +956,8 @@ func (i *LDAP) createUserModelFromLDAP(e *ldap.Entry) *libregraph.User {
 			Mail:                     pointerOrNil(e.GetEqualFoldAttributeValue(i.userAttributeMap.mail)),
 			OnPremisesSamAccountName: &opsan,
 			Id:                       &id,
+			GivenName:                &givenName,
+			Surname:                  &surname,
 		}
 	}
 	i.logger.Warn().Str("dn", e.DN).Msg("Invalid User. Missing username or id attribute")
@@ -991,11 +1010,11 @@ func (i *LDAP) userToLDAPAttrValues(user libregraph.User) (map[string][]string,
 	} else {
 		sn = *user.OnPremisesSamAccountName
 	}
-	attrs["sn"] = []string{sn}
+	attrs[i.userAttributeMap.surname] = []string{sn}
 
 	// When we get a givenName, we set the attribute.
 	if givenName := user.GetGivenName(); givenName != "" {
-		attrs["givenname"] = []string{givenName}
+		attrs[i.userAttributeMap.givenName] = []string{givenName}
 	}
 
 	if !i.usePwModifyExOp && user.PasswordProfile != nil && user.PasswordProfile.Password != nil {
diff --git a/services/graph/pkg/identity/ldap_education_class.go b/services/graph/pkg/identity/ldap_education_class.go
new file mode 100644
index 00000000000..4fc3082bae2
--- /dev/null
+++ b/services/graph/pkg/identity/ldap_education_class.go
@@ -0,0 +1,33 @@
+package identity
+
+import (
+	"context"
+	"net/url"
+
+	libregraph "github.com/owncloud/libre-graph-api-go"
+)
+
+// GetEducationClasses implements the EducationBackend interface for the LDAP backend.
+func (i *LDAP) GetEducationClasses(ctx context.Context, queryParam url.Values) ([]*libregraph.EducationClass, error) {
+	return nil, errNotImplemented
+}
+
+// CreateEducationClass implements the EducationBackend interface for the LDAP backend.
+func (i *LDAP) CreateEducationClass(ctx context.Context, class libregraph.EducationClass) (*libregraph.EducationClass, error) {
+	return nil, errNotImplemented
+}
+
+// GetEducationClass implements the EducationBackend interface for the LDAP backend.
+func (i *LDAP) GetEducationClass(ctx context.Context, namedOrID string, queryParam url.Values) (*libregraph.EducationClass, error) {
+	return nil, errNotImplemented
+}
+
+// DeleteEducationClass implements the EducationBackend interface for the LDAP backend.
+func (i *LDAP) DeleteEducationClass(ctx context.Context, nameOrID string) error {
+	return errNotImplemented
+}
+
+// GetEducationClassMembers implements the EducationBackend interface for the LDAP backend.
+func (i *LDAP) GetEducationClassMembers(ctx context.Context, nameOrID string) ([]*libregraph.EducationUser, error) {
+	return nil, errNotImplemented
+}
diff --git a/services/graph/pkg/identity/mocks/education_backend.go b/services/graph/pkg/identity/mocks/education_backend.go
index 772299ba9ef..2c2b4868b72 100644
--- a/services/graph/pkg/identity/mocks/education_backend.go
+++ b/services/graph/pkg/identity/mocks/education_backend.go
@@ -31,6 +31,29 @@ func (_m *EducationBackend) AddUsersToEducationSchool(ctx context.Context, schoo
 	return r0
 }
 
+// CreateEducationClass provides a mock function with given fields: ctx, class
+func (_m *EducationBackend) CreateEducationClass(ctx context.Context, class libregraph.EducationClass) (*libregraph.EducationClass, error) {
+	ret := _m.Called(ctx, class)
+
+	var r0 *libregraph.EducationClass
+	if rf, ok := ret.Get(0).(func(context.Context, libregraph.EducationClass) *libregraph.EducationClass); ok {
+		r0 = rf(ctx, class)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(*libregraph.EducationClass)
+		}
+	}
+
+	var r1 error
+	if rf, ok := ret.Get(1).(func(context.Context, libregraph.EducationClass) error); ok {
+		r1 = rf(ctx, class)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
 // 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)
@@ -77,6 +100,20 @@ func (_m *EducationBackend) CreateEducationUser(ctx context.Context, user libreg
 	return r0, r1
 }
 
+// DeleteEducationClass provides a mock function with given fields: ctx, nameOrID
+func (_m *EducationBackend) DeleteEducationClass(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
+}
+
 // 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)
@@ -105,6 +142,75 @@ func (_m *EducationBackend) DeleteEducationUser(ctx context.Context, nameOrID st
 	return r0
 }
 
+// GetEducationClass provides a mock function with given fields: ctx, namedOrID, queryParam
+func (_m *EducationBackend) GetEducationClass(ctx context.Context, namedOrID string, queryParam url.Values) (*libregraph.EducationClass, error) {
+	ret := _m.Called(ctx, namedOrID, queryParam)
+
+	var r0 *libregraph.EducationClass
+	if rf, ok := ret.Get(0).(func(context.Context, string, url.Values) *libregraph.EducationClass); ok {
+		r0 = rf(ctx, namedOrID, queryParam)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(*libregraph.EducationClass)
+		}
+	}
+
+	var r1 error
+	if rf, ok := ret.Get(1).(func(context.Context, string, url.Values) error); ok {
+		r1 = rf(ctx, namedOrID, queryParam)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// GetEducationClassMembers provides a mock function with given fields: ctx, nameOrID
+func (_m *EducationBackend) GetEducationClassMembers(ctx context.Context, nameOrID string) ([]*libregraph.EducationUser, error) {
+	ret := _m.Called(ctx, nameOrID)
+
+	var r0 []*libregraph.EducationUser
+	if rf, ok := ret.Get(0).(func(context.Context, string) []*libregraph.EducationUser); ok {
+		r0 = rf(ctx, nameOrID)
+	} 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, nameOrID)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// GetEducationClasses provides a mock function with given fields: ctx, queryParam
+func (_m *EducationBackend) GetEducationClasses(ctx context.Context, queryParam url.Values) ([]*libregraph.EducationClass, error) {
+	ret := _m.Called(ctx, queryParam)
+
+	var r0 []*libregraph.EducationClass
+	if rf, ok := ret.Get(0).(func(context.Context, url.Values) []*libregraph.EducationClass); ok {
+		r0 = rf(ctx, queryParam)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).([]*libregraph.EducationClass)
+		}
+	}
+
+	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
+}
+
 // 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)
diff --git a/services/graph/pkg/service/v0/application.go b/services/graph/pkg/service/v0/application.go
new file mode 100644
index 00000000000..c7c7c7a51c3
--- /dev/null
+++ b/services/graph/pkg/service/v0/application.go
@@ -0,0 +1,77 @@
+package svc
+
+import (
+	"fmt"
+	"net/http"
+
+	"github.com/go-chi/chi/v5"
+	"github.com/go-chi/render"
+	libregraph "github.com/owncloud/libre-graph-api-go"
+	settingssvc "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/settings/v0"
+	"github.com/owncloud/ocis/v2/services/graph/pkg/service/v0/errorcode"
+)
+
+// ListApplications implements the Service interface.
+func (g Graph) ListApplications(w http.ResponseWriter, r *http.Request) {
+	logger := g.logger.SubloggerWithRequestID(r.Context())
+	logger.Info().Interface("query", r.URL.Query()).Msg("calling list applications")
+
+	lbr, err := g.roleService.ListRoles(r.Context(), &settingssvc.ListBundlesRequest{})
+	if err != nil {
+		logger.Error().Err(err).Msg("could not list roles: transport error")
+		errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
+		return
+	}
+
+	roles := make([]libregraph.AppRole, 0, len(lbr.Bundles))
+	for _, bundle := range lbr.GetBundles() {
+		role := libregraph.NewAppRole(bundle.GetId())
+		role.SetDisplayName(bundle.GetDisplayName())
+		roles = append(roles, *role)
+	}
+
+	application := libregraph.NewApplication(g.config.Application.ID)
+	application.SetDisplayName(g.config.Application.DisplayName)
+	application.SetAppRoles(roles)
+
+	applications := []*libregraph.Application{
+		application,
+	}
+
+	render.Status(r, http.StatusOK)
+	render.JSON(w, r, &ListResponse{Value: applications})
+}
+
+// GetApplication implements the Service interface.
+func (g Graph) GetApplication(w http.ResponseWriter, r *http.Request) {
+	logger := g.logger.SubloggerWithRequestID(r.Context())
+	logger.Info().Interface("query", r.URL.Query()).Msg("calling get application")
+
+	applicationID := chi.URLParam(r, "applicationID")
+
+	if applicationID != g.config.Application.ID {
+		errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, fmt.Sprintf("requested id %s does not match expected application id %v", applicationID, g.config.Application.ID))
+		return
+	}
+
+	lbr, err := g.roleService.ListRoles(r.Context(), &settingssvc.ListBundlesRequest{})
+	if err != nil {
+		logger.Error().Err(err).Msg("could not list roles: transport error")
+		errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
+		return
+	}
+
+	roles := make([]libregraph.AppRole, 0, len(lbr.Bundles))
+	for _, bundle := range lbr.GetBundles() {
+		role := libregraph.NewAppRole(bundle.GetId())
+		role.SetDisplayName(bundle.GetDisplayName())
+		roles = append(roles, *role)
+	}
+
+	application := libregraph.NewApplication(applicationID)
+	application.SetDisplayName(g.config.Application.DisplayName)
+	application.SetAppRoles(roles)
+
+	render.Status(r, http.StatusOK)
+	render.JSON(w, r, application)
+}
diff --git a/services/graph/pkg/service/v0/approleassignments.go b/services/graph/pkg/service/v0/approleassignments.go
new file mode 100644
index 00000000000..849193cd61b
--- /dev/null
+++ b/services/graph/pkg/service/v0/approleassignments.go
@@ -0,0 +1,129 @@
+package svc
+
+import (
+	"encoding/json"
+	"fmt"
+	"net/http"
+
+	"github.com/go-chi/chi/v5"
+	"github.com/go-chi/render"
+	libregraph "github.com/owncloud/libre-graph-api-go"
+	settingsmsg "github.com/owncloud/ocis/v2/protogen/gen/ocis/messages/settings/v0"
+	settingssvc "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/settings/v0"
+	"github.com/owncloud/ocis/v2/services/graph/pkg/service/v0/errorcode"
+)
+
+const principalTypeUser = "User"
+
+// ListAppRoleAssignments implements the Service interface.
+func (g Graph) ListAppRoleAssignments(w http.ResponseWriter, r *http.Request) {
+	logger := g.logger.SubloggerWithRequestID(r.Context())
+	logger.Info().Interface("query", r.URL.Query()).Msg("calling list appRoleAssignments")
+
+	userID := chi.URLParam(r, "userID")
+
+	lrar, err := g.roleService.ListRoleAssignments(r.Context(), &settingssvc.ListRoleAssignmentsRequest{
+		AccountUuid: userID,
+	})
+	if err != nil {
+		// TODO check the error type and return proper error code
+		errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
+		return
+	}
+
+	values := make([]libregraph.AppRoleAssignment, 0, len(lrar.GetAssignments()))
+	for _, assignment := range lrar.GetAssignments() {
+		values = append(values, g.assignmentToAppRoleAssignment(assignment))
+	}
+
+	render.Status(r, http.StatusOK)
+	render.JSON(w, r, &ListResponse{Value: values})
+}
+
+// CreateAppRoleAssignment implements the Service interface.
+func (g Graph) CreateAppRoleAssignment(w http.ResponseWriter, r *http.Request) {
+	logger := g.logger.SubloggerWithRequestID(r.Context())
+	logger.Info().Interface("query", r.URL.Query()).Msg("calling create appRoleAssignment")
+
+	appRoleAssignment := libregraph.NewAppRoleAssignmentWithDefaults()
+	err := json.NewDecoder(r.Body).Decode(appRoleAssignment)
+	if err != nil {
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("invalid request body: %v", err.Error()))
+		return
+	}
+
+	userID := chi.URLParam(r, "userID")
+
+	if appRoleAssignment.GetPrincipalId() != userID {
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("user id %s does not match principal id %v", userID, appRoleAssignment.GetPrincipalId()))
+		return
+	}
+	if appRoleAssignment.GetResourceId() != g.config.Application.ID {
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("resource id %s does not match expected application id %v", userID, g.config.Application.ID))
+		return
+	}
+
+	artur, err := g.roleService.AssignRoleToUser(r.Context(), &settingssvc.AssignRoleToUserRequest{
+		AccountUuid: userID,
+		RoleId:      appRoleAssignment.AppRoleId,
+	})
+	if err != nil {
+		errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
+		return
+	}
+
+	render.Status(r, http.StatusCreated)
+	render.JSON(w, r, g.assignmentToAppRoleAssignment(artur.GetAssignment()))
+}
+
+// DeleteAppRoleAssignment implements the Service interface.
+func (g Graph) DeleteAppRoleAssignment(w http.ResponseWriter, r *http.Request) {
+	logger := g.logger.SubloggerWithRequestID(r.Context())
+	logger.Info().Interface("body", r.Body).Msg("calling delete appRoleAssignment")
+
+	userID := chi.URLParam(r, "userID")
+
+	// check assignment belongs to the user
+	lrar, err := g.roleService.ListRoleAssignments(r.Context(), &settingssvc.ListRoleAssignmentsRequest{
+		AccountUuid: userID,
+	})
+	if err != nil {
+		errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
+		return
+	}
+
+	appRoleAssignmentID := chi.URLParam(r, "appRoleAssignmentID")
+
+	assignmentFound := false
+	for _, roleAssignment := range lrar.GetAssignments() {
+		if roleAssignment.Id == appRoleAssignmentID {
+			assignmentFound = true
+		}
+	}
+	if !assignmentFound {
+		errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, fmt.Sprintf("appRoleAssignment %v not found for user %v", appRoleAssignmentID, userID))
+		return
+	}
+
+	_, err = g.roleService.RemoveRoleFromUser(r.Context(), &settingssvc.RemoveRoleFromUserRequest{
+		Id: appRoleAssignmentID,
+	})
+	if err != nil {
+		errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
+		return
+	}
+
+	render.NoContent(w, r)
+}
+
+func (g Graph) assignmentToAppRoleAssignment(assignment *settingsmsg.UserRoleAssignment) libregraph.AppRoleAssignment {
+	appRoleAssignment := libregraph.NewAppRoleAssignmentWithDefaults()
+	appRoleAssignment.SetId(assignment.Id)
+	appRoleAssignment.SetAppRoleId(assignment.RoleId)
+	appRoleAssignment.SetPrincipalType(principalTypeUser) // currently always assigned to the user
+	appRoleAssignment.SetResourceId(g.config.Application.ID)
+	appRoleAssignment.SetResourceDisplayName(g.config.Application.DisplayName)
+	appRoleAssignment.SetPrincipalId(assignment.AccountUuid)
+	// appRoleAssignment.SetPrincipalDisplayName() // TODO fetch and cache
+	return *appRoleAssignment
+}
diff --git a/services/graph/pkg/service/v0/educationclasses.go b/services/graph/pkg/service/v0/educationclasses.go
new file mode 100644
index 00000000000..d50a1768eec
--- /dev/null
+++ b/services/graph/pkg/service/v0/educationclasses.go
@@ -0,0 +1,433 @@
+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"
+)
+
+// GetEducationClasses implements the Service interface.
+func (g Graph) GetEducationClasses(w http.ResponseWriter, r *http.Request) {
+	logger := g.logger.SubloggerWithRequestID(r.Context())
+	logger.Info().Interface("query", r.URL.Query()).Msg("calling GetEducationClasses")
+	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 educationClasses: query error")
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
+		return
+	}
+
+	classes, err := g.identityEducationBackend.GetEducationClasses(r.Context(), r.URL.Query())
+	if err != nil {
+		logger.Debug().Err(err).Msg("could not get classes: 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
+	}
+
+	classes, err = sortClasses(odataReq, classes)
+	if err != nil {
+		logger.Debug().Err(err).Interface("query", r.URL.Query()).Msg("cannot get classes: could not sort classes according to query")
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
+		return
+	}
+	render.Status(r, http.StatusOK)
+	render.JSON(w, r, &ListResponse{Value: classes})
+}
+
+// PostEducationClass implements the Service interface.
+func (g Graph) PostEducationClass(w http.ResponseWriter, r *http.Request) {
+	logger := g.logger.SubloggerWithRequestID(r.Context())
+	logger.Info().Msg("calling post EducationClass")
+	class := libregraph.NewEducationClassWithDefaults()
+	err := json.NewDecoder(r.Body).Decode(class)
+	if err != nil {
+		logger.Debug().Err(err).Interface("body", r.Body).Msg("could not create education class: invalid request body")
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("invalid request body: %s", err.Error()))
+		return
+	}
+
+	if _, ok := class.GetDisplayNameOk(); !ok {
+		logger.Debug().Err(err).Interface("class", class).Msg("could not create class: missing required attribute")
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "Missing Required Attribute")
+		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 := class.GetIdOk(); ok {
+		logger.Debug().Msg("could not create class: id is a read-only attribute")
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "class id is a read-only attribute")
+		return
+	}
+
+	if class, err = g.identityEducationBackend.CreateEducationClass(r.Context(), *class); err != nil {
+		logger.Debug().Interface("class", class).Msg("could not create class: backend error")
+		errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
+		return
+	}
+
+	/* TODO requires reva changes
+	if class != nil && class.Id != nil {
+		currentUser := revactx.ContextMustGetUser(r.Context())
+		g.publishEvent(events.EducationClassCreated{Executant: currentUser.Id, EducationClassID: *class.Id})
+	}
+	*/
+	render.Status(r, http.StatusCreated)
+	render.JSON(w, r, class)
+}
+
+// PatchEducationClass implements the Service interface.
+func (g Graph) PatchEducationClass(w http.ResponseWriter, r *http.Request) {
+	logger := g.logger.SubloggerWithRequestID(r.Context())
+	logger.Info().Msg("calling patch education class")
+	classID := chi.URLParam(r, "classID")
+	classID, err := url.PathUnescape(classID)
+	if err != nil {
+		logger.Debug().Str("id", classID).Msg("could not change class: unescaping class id failed")
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping class id failed")
+		return
+	}
+
+	if classID == "" {
+		logger.Debug().Msg("could not change class: missing class id")
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing class id")
+		return
+	}
+	changes := libregraph.NewEducationClassWithDefaults()
+	err = json.NewDecoder(r.Body).Decode(changes)
+	if err != nil {
+		logger.Debug().Err(err).Interface("body", r.Body).Msg("could not change class: invalid request body")
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("invalid request body: %s", err.Error()))
+		return
+	}
+
+	if memberRefs, ok := changes.GetMembersodataBindOk(); ok {
+		// The spec defines a limit of 20 members maxium per Request
+		if len(memberRefs) > g.config.API.GroupMembersPatchLimit {
+			logger.Debug().
+				Int("number", len(memberRefs)).
+				Int("limit", g.config.API.GroupMembersPatchLimit).
+				Msg("could not add group members, exceeded members limit")
+			errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest,
+				fmt.Sprintf("Request is limited to %d members", g.config.API.GroupMembersPatchLimit))
+			return
+		}
+		memberIDs := make([]string, 0, len(memberRefs))
+		for _, memberRef := range memberRefs {
+			memberType, id, err := g.parseMemberRef(memberRef)
+			if err != nil {
+				logger.Debug().
+					Str("memberref", memberRef).
+					Msg("could not change class: Error parsing member@odata.bind values")
+				errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "Error parsing member@odata.bind values")
+				return
+			}
+			logger.Debug().Str("membertype", memberType).Str("memberid", id).Msg("add class member")
+			// The MS Graph spec allows "directoryObject", "user", "class" and "organizational Contact"
+			// we restrict this to users for now. Might add Classes as members later
+			if memberType != memberTypeUsers {
+				logger.Debug().
+					Str("type", memberType).
+					Msg("could not change class: could not add member, only user type is allowed")
+				errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "Only user are allowed as class members")
+				return
+			}
+			memberIDs = append(memberIDs, id)
+		}
+		err = g.identityBackend.AddMembersToGroup(r.Context(), classID, memberIDs)
+	}
+
+	if err != nil {
+		logger.Debug().Err(err).Msg("could not change class: backend could not add members")
+		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.StatusNoContent) // TODO StatusNoContent when prefer=minimal is used, otherwise OK and the resource in the body
+	render.NoContent(w, r)
+}
+
+// GetEducationClass implements the Service interface.
+func (g Graph) GetEducationClass(w http.ResponseWriter, r *http.Request) {
+	logger := g.logger.SubloggerWithRequestID(r.Context())
+	logger.Info().Msg("calling get education class")
+	classID := chi.URLParam(r, "classID")
+	classID, err := url.PathUnescape(classID)
+	if err != nil {
+		logger.Debug().Str("id", classID).Msg("could not get class: unescaping class id failed")
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping class id failed")
+	}
+
+	if classID == "" {
+		logger.Debug().Msg("could not get class: missing class id")
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing class id")
+		return
+	}
+
+	logger.Debug().
+		Str("id", classID).
+		Interface("query", r.URL.Query()).
+		Msg("calling get class on backend")
+	class, err := g.identityEducationBackend.GetEducationClass(r.Context(), classID, r.URL.Query())
+	if err != nil {
+		logger.Debug().Err(err).Msg("could not get class: 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, class)
+}
+
+// DeleteEducationClass implements the Service interface.
+func (g Graph) DeleteEducationClass(w http.ResponseWriter, r *http.Request) {
+	logger := g.logger.SubloggerWithRequestID(r.Context())
+	logger.Info().Msg("calling delete class")
+	classID := chi.URLParam(r, "classID")
+	classID, err := url.PathUnescape(classID)
+	if err != nil {
+		logger.Debug().Err(err).Str("id", classID).Msg("could not delete class: unescaping class id failed")
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping class id failed")
+		return
+	}
+
+	if classID == "" {
+		logger.Debug().Msg("could not delete class: missing class id")
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing class id")
+		return
+	}
+
+	logger.Debug().Str("id", classID).Msg("calling delete class on backend")
+	err = g.identityEducationBackend.DeleteEducationClass(r.Context(), classID)
+
+	if err != nil {
+		logger.Debug().Err(err).Msg("could not delete class: 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
+	currentUser := revactx.ContextMustGetUser(r.Context())
+	g.publishEvent(events.ClassDeleted{Executant: currentUser.Id, ClassID: classID})
+	*/
+
+	render.Status(r, http.StatusNoContent)
+	render.NoContent(w, r)
+}
+
+// GetEducationClassMembers implements the Service interface.
+func (g Graph) GetEducationClassMembers(w http.ResponseWriter, r *http.Request) {
+	logger := g.logger.SubloggerWithRequestID(r.Context())
+	logger.Info().Msg("calling get class members")
+	classID := chi.URLParam(r, "classID")
+	classID, err := url.PathUnescape(classID)
+	if err != nil {
+		logger.Debug().Str("id", classID).Msg("could not get class members: unescaping class id failed")
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping class id failed")
+		return
+	}
+
+	if classID == "" {
+		logger.Debug().Msg("could not get class members: missing class id")
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing class id")
+		return
+	}
+
+	logger.Debug().Str("id", classID).Msg("calling get class members on backend")
+	members, err := g.identityEducationBackend.GetEducationClassMembers(r.Context(), classID)
+	if err != nil {
+		logger.Debug().Err(err).Msg("could not get class 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)
+}
+
+// PostEducationClassMember implements the Service interface.
+func (g Graph) PostEducationClassMember(w http.ResponseWriter, r *http.Request) {
+	logger := g.logger.SubloggerWithRequestID(r.Context())
+	logger.Info().Msg("Calling post class member")
+
+	classID := chi.URLParam(r, "classID")
+	classID, err := url.PathUnescape(classID)
+	if err != nil {
+		logger.Debug().
+			Err(err).
+			Str("id", classID).
+			Msg("could not add member to class: unescaping class id failed")
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping class id failed")
+		return
+	}
+
+	if classID == "" {
+		logger.Debug().Msg("could not add class member: missing class id")
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing class 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 class 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 class 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 class 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", "class" and "organizational Contact"
+	// we restrict this to users for now. Might add EducationClass as members later
+	if memberType != memberTypeUsers {
+		logger.Debug().Str("type", memberType).Msg("could not add class member: Only users are allowed as class members")
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "Only users are allowed as class members")
+		return
+	}
+
+	logger.Debug().Str("memberType", memberType).Str("id", id).Msg("calling add member on backend")
+	err = g.identityBackend.AddMembersToGroup(r.Context(), classID, []string{id})
+
+	if err != nil {
+		logger.Debug().Err(err).Msg("could not add class 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
+	currentUser := revactx.ContextMustGetUser(r.Context())
+	g.publishEvent(events.EducationClassMemberAdded{Executant: currentUser.Id, EducationClassID: classID, UserID: id})
+	*/
+	render.Status(r, http.StatusNoContent)
+	render.NoContent(w, r)
+}
+
+// DeleteEducationClassMember implements the Service interface.
+func (g Graph) DeleteEducationClassMember(w http.ResponseWriter, r *http.Request) {
+	logger := g.logger.SubloggerWithRequestID(r.Context())
+	logger.Info().Msg("calling delete class member")
+
+	classID := chi.URLParam(r, "classID")
+	classID, err := url.PathUnescape(classID)
+	if err != nil {
+		logger.Debug().Err(err).Str("id", classID).Msg("could not delete class member: unescaping class id failed")
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping class id failed")
+		return
+	}
+
+	if classID == "" {
+		logger.Debug().Msg("could not delete class member: missing class id")
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing class 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 class 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 class member: missing member id")
+		errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing member id")
+		return
+	}
+	logger.Debug().Str("classID", classID).Str("memberID", memberID).Msg("calling delete member on backend")
+	err = g.identityBackend.RemoveMemberFromGroup(r.Context(), classID, memberID)
+
+	if err != nil {
+		logger.Debug().Err(err).Msg("could not delete class 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
+	currentUser := revactx.ContextMustGetUser(r.Context())
+	g.publishEvent(events.EducationClassMemberRemoved{Executant: currentUser.Id, EducationClassID: classID, UserID: memberID})
+	*/
+	render.Status(r, http.StatusNoContent)
+	render.NoContent(w, r)
+}
+
+func sortClasses(req *godata.GoDataRequest, classes []*libregraph.EducationClass) ([]*libregraph.EducationClass, error) {
+	if req.Query.OrderBy == nil || len(req.Query.OrderBy.OrderByItems) != 1 {
+		return classes, nil
+	}
+	var less func(i, j int) bool
+
+	switch req.Query.OrderBy.OrderByItems[0].Field.Value {
+	case displayNameAttr:
+		less = func(i, j int) bool {
+			return strings.ToLower(classes[i].GetDisplayName()) < strings.ToLower(classes[j].GetDisplayName())
+		}
+	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 == _sortDescending {
+		sort.Slice(classes, reverse(less))
+	} else {
+		sort.Slice(classes, less)
+	}
+
+	return classes, nil
+}
diff --git a/services/graph/pkg/service/v0/groups.go b/services/graph/pkg/service/v0/groups.go
index 283676b9503..90ee6c18931 100644
--- a/services/graph/pkg/service/v0/groups.go
+++ b/services/graph/pkg/service/v0/groups.go
@@ -127,7 +127,7 @@ func (g Graph) PatchGroup(w http.ResponseWriter, r *http.Request) {
 			logger.Debug().
 				Int("number", len(memberRefs)).
 				Int("limit", g.config.API.GroupMembersPatchLimit).
-				Msg("could not create group, exceeded members limit")
+				Msg("could not add group members, exceeded members limit")
 			errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest,
 				fmt.Sprintf("Request is limited to %d members", g.config.API.GroupMembersPatchLimit))
 			return
diff --git a/services/graph/pkg/service/v0/instrument.go b/services/graph/pkg/service/v0/instrument.go
index eafb980fef9..27491b1d7d9 100644
--- a/services/graph/pkg/service/v0/instrument.go
+++ b/services/graph/pkg/service/v0/instrument.go
@@ -24,6 +24,16 @@ func (i instrument) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	i.next.ServeHTTP(w, r)
 }
 
+// ListApplications implements the Service interface.
+func (i instrument) ListApplications(w http.ResponseWriter, r *http.Request) {
+	i.next.ListApplications(w, r)
+}
+
+// GetApplication implements the Service interface.
+func (i instrument) GetApplication(w http.ResponseWriter, r *http.Request) {
+	i.next.GetApplication(w, r)
+}
+
 // GetMe implements the Service interface.
 func (i instrument) GetMe(w http.ResponseWriter, r *http.Request) {
 	i.next.GetMe(w, r)
@@ -59,6 +69,21 @@ func (i instrument) ChangeOwnPassword(w http.ResponseWriter, r *http.Request) {
 	i.next.ChangeOwnPassword(w, r)
 }
 
+// ListAppRoleAssignments implements the Service interface.
+func (i instrument) ListAppRoleAssignments(w http.ResponseWriter, r *http.Request) {
+	i.next.ListAppRoleAssignments(w, r)
+}
+
+// CreateAppRoleAssignment implements the Service interface.
+func (i instrument) CreateAppRoleAssignment(w http.ResponseWriter, r *http.Request) {
+	i.next.CreateAppRoleAssignment(w, r)
+}
+
+// DeleteAppRoleAssignment implements the Service interface.
+func (i instrument) DeleteAppRoleAssignment(w http.ResponseWriter, r *http.Request) {
+	i.next.DeleteAppRoleAssignment(w, r)
+}
+
 // GetGroups implements the Service interface.
 func (i instrument) GetGroups(w http.ResponseWriter, r *http.Request) {
 	i.next.GetGroups(w, r)
@@ -139,6 +164,46 @@ func (i instrument) DeleteEducationSchoolUser(w http.ResponseWriter, r *http.Req
 	i.next.DeleteEducationSchoolUser(w, r)
 }
 
+// GetEducationClasses implements the Service interface.
+func (i instrument) GetEducationClasses(w http.ResponseWriter, r *http.Request) {
+	i.next.GetEducationClasses(w, r)
+}
+
+// GetEducationClass implements the Service interface.
+func (i instrument) GetEducationClass(w http.ResponseWriter, r *http.Request) {
+	i.next.GetEducationClass(w, r)
+}
+
+// PostEducationClass implements the Service interface.
+func (i instrument) PostEducationClass(w http.ResponseWriter, r *http.Request) {
+	i.next.PostEducationClass(w, r)
+}
+
+// PatchEducationClass implements the Service interface.
+func (i instrument) PatchEducationClass(w http.ResponseWriter, r *http.Request) {
+	i.next.PatchEducationClass(w, r)
+}
+
+// DeleteEducationClass implements the Service interface.
+func (i instrument) DeleteEducationClass(w http.ResponseWriter, r *http.Request) {
+	i.next.DeleteEducationClass(w, r)
+}
+
+// GetEducationClassMembers implements the Service interface.
+func (i instrument) GetEducationClassMembers(w http.ResponseWriter, r *http.Request) {
+	i.next.GetEducationClassMembers(w, r)
+}
+
+// PostEducationClassMember implements the Service interface.
+func (i instrument) PostEducationClassMember(w http.ResponseWriter, r *http.Request) {
+	i.next.PostEducationClassMember(w, r)
+}
+
+// DeleteEducationClassMember implements the Service interface.
+func (i instrument) DeleteEducationClassMember(w http.ResponseWriter, r *http.Request) {
+	i.next.DeleteEducationClassMember(w, r)
+}
+
 // GetEducationUsers implements the Service interface.
 func (i instrument) GetEducationUsers(w http.ResponseWriter, r *http.Request) {
 	i.next.GetEducationUsers(w, r)
diff --git a/services/graph/pkg/service/v0/logging.go b/services/graph/pkg/service/v0/logging.go
index 1e3369b6ce1..178071dd1e0 100644
--- a/services/graph/pkg/service/v0/logging.go
+++ b/services/graph/pkg/service/v0/logging.go
@@ -24,6 +24,16 @@ func (l logging) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	l.next.ServeHTTP(w, r)
 }
 
+// ListApplications implements the Service interface.
+func (l logging) ListApplications(w http.ResponseWriter, r *http.Request) {
+	l.next.ListApplications(w, r)
+}
+
+// GetApplication implements the Service interface.
+func (l logging) GetApplication(w http.ResponseWriter, r *http.Request) {
+	l.next.GetApplication(w, r)
+}
+
 // GetMe implements the Service interface.
 func (l logging) GetMe(w http.ResponseWriter, r *http.Request) {
 	l.next.GetMe(w, r)
@@ -59,6 +69,21 @@ func (l logging) ChangeOwnPassword(w http.ResponseWriter, r *http.Request) {
 	l.next.ChangeOwnPassword(w, r)
 }
 
+// ListAppRoleAssignments implements the Service interface.
+func (l logging) ListAppRoleAssignments(w http.ResponseWriter, r *http.Request) {
+	l.next.ListAppRoleAssignments(w, r)
+}
+
+// CreateAppRoleAssignment implements the Service interface.
+func (l logging) CreateAppRoleAssignment(w http.ResponseWriter, r *http.Request) {
+	l.next.CreateAppRoleAssignment(w, r)
+}
+
+// DeleteAppRoleAssignment implements the Service interface.
+func (l logging) DeleteAppRoleAssignment(w http.ResponseWriter, r *http.Request) {
+	l.next.DeleteAppRoleAssignment(w, r)
+}
+
 // GetGroups implements the Service interface.
 func (l logging) GetGroups(w http.ResponseWriter, r *http.Request) {
 	l.next.GetGroups(w, r)
@@ -139,6 +164,46 @@ func (l logging) DeleteEducationSchoolUser(w http.ResponseWriter, r *http.Reques
 	l.next.DeleteEducationSchoolUser(w, r)
 }
 
+// GetEducationClasses implements the Service interface.
+func (l logging) GetEducationClasses(w http.ResponseWriter, r *http.Request) {
+	l.next.GetEducationClasses(w, r)
+}
+
+// GetEducationClass implements the Service interface.
+func (l logging) GetEducationClass(w http.ResponseWriter, r *http.Request) {
+	l.next.GetEducationClass(w, r)
+}
+
+// PostEducationClass implements the Service interface.
+func (l logging) PostEducationClass(w http.ResponseWriter, r *http.Request) {
+	l.next.PostEducationClass(w, r)
+}
+
+// PatchEducationClass implements the Service interface.
+func (l logging) PatchEducationClass(w http.ResponseWriter, r *http.Request) {
+	l.next.PatchEducationClass(w, r)
+}
+
+// DeleteEducationClass implements the Service interface.
+func (l logging) DeleteEducationClass(w http.ResponseWriter, r *http.Request) {
+	l.next.DeleteEducationClass(w, r)
+}
+
+// GetEducationClassMembers implements the Service interface.
+func (l logging) GetEducationClassMembers(w http.ResponseWriter, r *http.Request) {
+	l.next.GetEducationClassMembers(w, r)
+}
+
+// PostEducationClassMember implements the Service interface.
+func (l logging) PostEducationClassMember(w http.ResponseWriter, r *http.Request) {
+	l.next.PostEducationClassMember(w, r)
+}
+
+// DeleteEducationClassMember implements the Service interface.
+func (l logging) DeleteEducationClassMember(w http.ResponseWriter, r *http.Request) {
+	l.next.DeleteEducationClassMember(w, r)
+}
+
 // GetEducationUsers implements the Service interface.
 func (l logging) GetEducationUsers(w http.ResponseWriter, r *http.Request) {
 	l.next.GetEducationUsers(w, r)
diff --git a/services/graph/pkg/service/v0/service.go b/services/graph/pkg/service/v0/service.go
index 98ce2d1a27a..8ce2d93a243 100644
--- a/services/graph/pkg/service/v0/service.go
+++ b/services/graph/pkg/service/v0/service.go
@@ -32,6 +32,10 @@ const (
 // Service defines the service handlers.
 type Service interface {
 	ServeHTTP(http.ResponseWriter, *http.Request)
+
+	ListApplications(w http.ResponseWriter, r *http.Request)
+	GetApplication(http.ResponseWriter, *http.Request)
+
 	GetMe(http.ResponseWriter, *http.Request)
 	GetUsers(http.ResponseWriter, *http.Request)
 	GetUser(http.ResponseWriter, *http.Request)
@@ -40,6 +44,10 @@ type Service interface {
 	PatchUser(http.ResponseWriter, *http.Request)
 	ChangeOwnPassword(http.ResponseWriter, *http.Request)
 
+	ListAppRoleAssignments(http.ResponseWriter, *http.Request)
+	CreateAppRoleAssignment(http.ResponseWriter, *http.Request)
+	DeleteAppRoleAssignment(http.ResponseWriter, *http.Request)
+
 	GetGroups(http.ResponseWriter, *http.Request)
 	GetGroup(http.ResponseWriter, *http.Request)
 	PostGroup(http.ResponseWriter, *http.Request)
@@ -58,11 +66,20 @@ type Service interface {
 	PostEducationSchoolUser(http.ResponseWriter, *http.Request)
 	DeleteEducationSchoolUser(http.ResponseWriter, *http.Request)
 
+	GetEducationClasses(http.ResponseWriter, *http.Request)
+	GetEducationClass(http.ResponseWriter, *http.Request)
+	PostEducationClass(http.ResponseWriter, *http.Request)
+	PatchEducationClass(http.ResponseWriter, *http.Request)
+	DeleteEducationClass(w http.ResponseWriter, r *http.Request)
+	GetEducationClassMembers(w http.ResponseWriter, r *http.Request)
+	PostEducationClassMember(w http.ResponseWriter, r *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)
+	DeleteEducationClassMember(w http.ResponseWriter, r *http.Request)
 
 	GetDrives(w http.ResponseWriter, r *http.Request)
 	GetSingleDrive(w http.ResponseWriter, r *http.Request)
@@ -118,80 +135,8 @@ func NewService(opts ...Option) (Graph, error) {
 		identityEducationBackend: options.IdentityEducationBackend,
 	}
 
-	if options.IdentityBackend == nil {
-		switch options.Config.Identity.Backend {
-		case "cs3":
-			svc.identityBackend = &identity.CS3{
-				Config: options.Config.Reva,
-				Logger: &options.Logger,
-			}
-		case "ldap":
-			var err error
-
-			var tlsConf *tls.Config
-			if options.Config.Identity.LDAP.Insecure {
-				// When insecure is set to true then we don't need a certificate.
-				options.Config.Identity.LDAP.CACert = ""
-				tlsConf = &tls.Config{
-					MinVersion: tls.VersionTLS12,
-					//nolint:gosec // We need the ability to run with "insecure" (dev/testing)
-					InsecureSkipVerify: options.Config.Identity.LDAP.Insecure,
-				}
-			}
-
-			if options.Config.Identity.LDAP.CACert != "" {
-				if err := ocisldap.WaitForCA(options.Logger,
-					options.Config.Identity.LDAP.Insecure,
-					options.Config.Identity.LDAP.CACert); err != nil {
-					options.Logger.Fatal().Err(err).Msg("The configured LDAP CA cert does not exist")
-				}
-				if tlsConf == nil {
-					tlsConf = &tls.Config{
-						MinVersion: tls.VersionTLS12,
-					}
-				}
-				certs := x509.NewCertPool()
-				pemData, err := os.ReadFile(options.Config.Identity.LDAP.CACert)
-				if err != nil {
-					options.Logger.Error().Err(err).Msgf("Error initializing LDAP Backend")
-					return svc, err
-				}
-				if !certs.AppendCertsFromPEM(pemData) {
-					options.Logger.Error().Msgf("Error initializing LDAP Backend. Adding CA cert failed")
-					return svc, err
-				}
-				tlsConf.RootCAs = certs
-			}
-
-			conn := ldap.NewLDAPWithReconnect(&options.Logger,
-				ldap.Config{
-					URI:          options.Config.Identity.LDAP.URI,
-					BindDN:       options.Config.Identity.LDAP.BindDN,
-					BindPassword: options.Config.Identity.LDAP.BindPassword,
-					TLSConfig:    tlsConf,
-				},
-			)
-			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 svc, err
-			}
-			svc.identityBackend = lb
-			if options.IdentityEducationBackend == nil {
-				if options.Config.Identity.LDAP.EducationResourcesEnabled {
-					svc.identityEducationBackend = lb
-				} else {
-					errEduBackend := &identity.ErrEducationBackend{}
-					svc.identityEducationBackend = errEduBackend
-				}
-			}
-		default:
-			err := fmt.Errorf("Unknown Identity Backend: '%s'", options.Config.Identity.Backend)
-			options.Logger.Err(err)
-			return svc, err
-		}
-	} else {
-		svc.identityBackend = options.IdentityBackend
+	if err := setIdentityBackends(options, &svc); err != nil {
+		return svc, err
 	}
 
 	if options.PermissionService == nil {
@@ -200,6 +145,12 @@ func NewService(opts ...Option) (Graph, error) {
 		svc.permissionsService = options.PermissionService
 	}
 
+	if options.RoleService == nil {
+		svc.roleService = settingssvc.NewRoleService("com.owncloud.api.settings", grpc.DefaultClient())
+	} else {
+		svc.roleService = options.RoleService
+	}
+
 	roleManager := options.RoleManager
 	if roleManager == nil {
 		storeOptions := store.OcisStoreOptions{
@@ -230,6 +181,10 @@ func NewService(opts ...Option) (Graph, error) {
 				r.Put("/tags", svc.AssignTags)
 				r.Delete("/tags", svc.UnassignTags)
 			})
+			r.Route("/applications", func(r chi.Router) {
+				r.Get("/", svc.ListApplications)
+				r.Get("/{applicationID}", svc.GetApplication)
+			})
 			r.Route("/me", func(r chi.Router) {
 				r.Get("/", svc.GetMe)
 				r.Get("/drives", svc.GetDrives)
@@ -243,6 +198,11 @@ func NewService(opts ...Option) (Graph, error) {
 					r.Get("/", svc.GetUser)
 					r.With(requireAdmin).Delete("/", svc.DeleteUser)
 					r.With(requireAdmin).Patch("/", svc.PatchUser)
+					r.With(requireAdmin).Route("/appRoleAssignments", func(r chi.Router) {
+						r.Get("/", svc.ListAppRoleAssignments)
+						r.Post("/", svc.CreateAppRoleAssignment)
+						r.Delete("/{appRoleAssignmentID}", svc.DeleteAppRoleAssignment)
+					})
 				})
 			})
 			r.Route("/groups", func(r chi.Router) {
@@ -293,16 +253,16 @@ func NewService(opts ...Option) (Graph, error) {
 					})
 				})
 				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.Get("/", svc.GetEducationClasses)
+					r.Post("/", svc.PostEducationClass)
+					r.Route("/{classID}", func(r chi.Router) {
+						r.Get("/", svc.GetEducationClass)
+						r.Delete("/", svc.DeleteEducationClass)
+						r.Patch("/", svc.PatchEducationClass)
 						r.Route("/members", func(r chi.Router) {
-							r.Get("/", svc.GetGroupMembers)
-							r.Post("/$ref", svc.PostGroupMember)
-							r.Delete("/{memberID}/$ref", svc.DeleteGroupMember)
+							r.Get("/", svc.GetEducationClassMembers)
+							r.Post("/$ref", svc.PostEducationClassMember)
+							r.Delete("/{memberID}/$ref", svc.DeleteEducationClassMember)
 						})
 					})
 				})
@@ -313,6 +273,88 @@ func NewService(opts ...Option) (Graph, error) {
 	return svc, nil
 }
 
+func setIdentityBackends(options Options, svc *Graph) error {
+	if options.IdentityBackend == nil {
+		switch options.Config.Identity.Backend {
+		case "cs3":
+			svc.identityBackend = &identity.CS3{
+				Config: options.Config.Reva,
+				Logger: &options.Logger,
+			}
+		case "ldap":
+			var err error
+
+			var tlsConf *tls.Config
+			if options.Config.Identity.LDAP.Insecure {
+
+				// When insecure is set to true then we don't need a certificate.
+				options.Config.Identity.LDAP.CACert = ""
+				tlsConf = &tls.Config{
+					MinVersion: tls.VersionTLS12,
+
+					//nolint:gosec // We need the ability to run with "insecure" (dev/testing)
+					InsecureSkipVerify: options.Config.Identity.LDAP.Insecure,
+				}
+			}
+
+			if options.Config.Identity.LDAP.CACert != "" {
+				if err := ocisldap.WaitForCA(options.Logger,
+					options.Config.Identity.LDAP.Insecure,
+					options.Config.Identity.LDAP.CACert); err != nil {
+					options.Logger.Fatal().Err(err).Msg("The configured LDAP CA cert does not exist")
+				}
+				if tlsConf == nil {
+					tlsConf = &tls.Config{
+						MinVersion: tls.VersionTLS12,
+					}
+				}
+				certs := x509.NewCertPool()
+				pemData, err := os.ReadFile(options.Config.Identity.LDAP.CACert)
+				if err != nil {
+					options.Logger.Error().Err(err).Msg("Error initializing LDAP Backend")
+					return err
+				}
+				if !certs.AppendCertsFromPEM(pemData) {
+					options.Logger.Error().Msg("Error initializing LDAP Backend. Adding CA cert failed")
+					return err
+				}
+				tlsConf.RootCAs = certs
+			}
+
+			conn := ldap.NewLDAPWithReconnect(&options.Logger,
+				ldap.Config{
+					URI:          options.Config.Identity.LDAP.URI,
+					BindDN:       options.Config.Identity.LDAP.BindDN,
+					BindPassword: options.Config.Identity.LDAP.BindPassword,
+					TLSConfig:    tlsConf,
+				},
+			)
+			lb, err := identity.NewLDAPBackend(conn, options.Config.Identity.LDAP, &options.Logger)
+			if err != nil {
+				options.Logger.Error().Err(err).Msg("Error initializing LDAP Backend")
+				return err
+			}
+			svc.identityBackend = lb
+			if options.IdentityEducationBackend == nil {
+				if options.Config.Identity.LDAP.EducationResourcesEnabled {
+					svc.identityEducationBackend = lb
+				} else {
+					errEduBackend := &identity.ErrEducationBackend{}
+					svc.identityEducationBackend = errEduBackend
+				}
+			}
+		default:
+			err := fmt.Errorf("unknown identity backend: '%s'", options.Config.Identity.Backend)
+			options.Logger.Err(err)
+			return err
+		}
+	} else {
+		svc.identityBackend = options.IdentityBackend
+	}
+
+	return nil
+}
+
 // parseHeaderPurge parses the 'Purge' header.
 // '1', 't', 'T', 'TRUE', 'true', 'True' are parsed as true
 // all other values are false.
diff --git a/services/graph/pkg/service/v0/tracing.go b/services/graph/pkg/service/v0/tracing.go
index 160289f7416..54746b94390 100644
--- a/services/graph/pkg/service/v0/tracing.go
+++ b/services/graph/pkg/service/v0/tracing.go
@@ -20,6 +20,16 @@ func (t tracing) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	t.next.ServeHTTP(w, r)
 }
 
+// ListApplications implements the Service interface.
+func (t tracing) ListApplications(w http.ResponseWriter, r *http.Request) {
+	t.next.ListApplications(w, r)
+}
+
+// GetApplication implements the Service interface.
+func (t tracing) GetApplication(w http.ResponseWriter, r *http.Request) {
+	t.next.GetApplication(w, r)
+}
+
 // GetMe implements the Service interface.
 func (t tracing) GetMe(w http.ResponseWriter, r *http.Request) {
 	t.next.GetMe(w, r)
@@ -55,6 +65,21 @@ func (t tracing) ChangeOwnPassword(w http.ResponseWriter, r *http.Request) {
 	t.next.ChangeOwnPassword(w, r)
 }
 
+// ListAppRoleAssignments implements the Service interface.
+func (t tracing) ListAppRoleAssignments(w http.ResponseWriter, r *http.Request) {
+	t.next.ListAppRoleAssignments(w, r)
+}
+
+// CreateAppRoleAssignment implements the Service interface.
+func (t tracing) CreateAppRoleAssignment(w http.ResponseWriter, r *http.Request) {
+	t.next.CreateAppRoleAssignment(w, r)
+}
+
+// DeleteAppRoleAssignment implements the Service interface.
+func (t tracing) DeleteAppRoleAssignment(w http.ResponseWriter, r *http.Request) {
+	t.next.DeleteAppRoleAssignment(w, r)
+}
+
 // GetGroups implements the Service interface.
 func (t tracing) GetGroups(w http.ResponseWriter, r *http.Request) {
 	t.next.GetGroups(w, r)
@@ -135,6 +160,46 @@ func (t tracing) DeleteEducationSchoolUser(w http.ResponseWriter, r *http.Reques
 	t.next.DeleteEducationSchoolUser(w, r)
 }
 
+// GetEducationClasses implements the Service interface.
+func (t tracing) GetEducationClasses(w http.ResponseWriter, r *http.Request) {
+	t.next.GetEducationClasses(w, r)
+}
+
+// GetEducationClass implements the Service interface.
+func (t tracing) GetEducationClass(w http.ResponseWriter, r *http.Request) {
+	t.next.GetEducationClass(w, r)
+}
+
+// PostEducationClass implements the Service interface.
+func (t tracing) PostEducationClass(w http.ResponseWriter, r *http.Request) {
+	t.next.PostEducationClass(w, r)
+}
+
+// PatchEducationClass implements the Service interface.
+func (t tracing) PatchEducationClass(w http.ResponseWriter, r *http.Request) {
+	t.next.PatchEducationClass(w, r)
+}
+
+// DeleteEducationClass implements the Service interface.
+func (t tracing) DeleteEducationClass(w http.ResponseWriter, r *http.Request) {
+	t.next.DeleteEducationClass(w, r)
+}
+
+// GetEducationClassMembers implements the Service interface.
+func (t tracing) GetEducationClassMembers(w http.ResponseWriter, r *http.Request) {
+	t.next.GetEducationClassMembers(w, r)
+}
+
+// PostEducationClassMember implements the Service interface.
+func (t tracing) PostEducationClassMember(w http.ResponseWriter, r *http.Request) {
+	t.next.PostEducationClassMember(w, r)
+}
+
+// DeleteEducationClassMember implements the Service interface.
+func (t tracing) DeleteEducationClassMember(w http.ResponseWriter, r *http.Request) {
+	t.next.DeleteEducationClassMember(w, r)
+}
+
 // GetEducationUsers implements the Service interface.
 func (t tracing) GetEducationUsers(w http.ResponseWriter, r *http.Request) {
 	t.next.GetEducationUsers(w, r)
diff --git a/services/graph/pkg/service/v0/users.go b/services/graph/pkg/service/v0/users.go
index 31c390c87e5..b37180b12d5 100644
--- a/services/graph/pkg/service/v0/users.go
+++ b/services/graph/pkg/service/v0/users.go
@@ -58,6 +58,30 @@ func (g Graph) GetMe(w http.ResponseWriter, r *http.Request) {
 			return
 		}
 	}
+
+	// expand appRoleAssignments if requested
+	if slices.Contains(exp, "appRoleAssignments") {
+		lrar, err := g.roleService.ListRoleAssignments(r.Context(), &settings.ListRoleAssignmentsRequest{
+			AccountUuid: me.GetId(),
+		})
+		if err != nil {
+			logger.Debug().Err(err).Str("userid", me.GetId()).Msg("could not get appRoleAssignments for self")
+			var errcode errorcode.Error
+			if errors.As(err, &errcode) {
+				errcode.Render(w, r)
+			} else {
+				errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
+			}
+			return
+		}
+
+		values := make([]libregraph.AppRoleAssignment, 0, len(lrar.GetAssignments()))
+		for _, assignment := range lrar.GetAssignments() {
+			values = append(values, g.assignmentToAppRoleAssignment(assignment))
+		}
+		me.AppRoleAssignments = values
+	}
+
 	render.Status(r, http.StatusOK)
 	render.JSON(w, r, me)
 }
@@ -87,6 +111,26 @@ func (g Graph) GetUsers(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
+	// expand appRoleAssignments if requested
+	exp := strings.Split(r.URL.Query().Get("$expand"), ",")
+	if slices.Contains(exp, "appRoleAssignments") {
+		for _, u := range users {
+			lrar, err := g.roleService.ListRoleAssignments(r.Context(), &settings.ListRoleAssignmentsRequest{
+				AccountUuid: u.GetId(),
+			})
+			if err != nil {
+				logger.Debug().Err(err).Str("userid", u.GetId()).Msg("could not get appRoleAssignments when listing user")
+				continue
+			}
+
+			values := make([]libregraph.AppRoleAssignment, 0, len(lrar.GetAssignments()))
+			for _, assignment := range lrar.GetAssignments() {
+				values = append(values, g.assignmentToAppRoleAssignment(assignment))
+			}
+			u.AppRoleAssignments = values
+		}
+	}
+
 	users, err = sortUsers(odataReq, users)
 	if err != nil {
 		logger.Debug().Interface("query", odataReq).Msg("error while sorting users according to query")
@@ -274,6 +318,29 @@ func (g Graph) GetUser(w http.ResponseWriter, r *http.Request) {
 			}
 		}
 	}
+	// expand appRoleAssignments if requested
+	if slices.Contains(exp, "appRoleAssignments") {
+
+		lrar, err := g.roleService.ListRoleAssignments(r.Context(), &settings.ListRoleAssignmentsRequest{
+			AccountUuid: user.GetId(),
+		})
+		if err != nil {
+			logger.Debug().Err(err).Str("userid", user.GetId()).Msg("could not get appRoleAssignments for user")
+			var errcode errorcode.Error
+			if errors.As(err, &errcode) {
+				errcode.Render(w, r)
+			} else {
+				errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
+			}
+			return
+		}
+
+		values := make([]libregraph.AppRoleAssignment, 0, len(lrar.GetAssignments()))
+		for _, assignment := range lrar.GetAssignments() {
+			values = append(values, g.assignmentToAppRoleAssignment(assignment))
+		}
+		user.AppRoleAssignments = values
+	}
 
 	render.Status(r, http.StatusOK)
 	render.JSON(w, r, user)
diff --git a/services/notifications/pkg/command/server.go b/services/notifications/pkg/command/server.go
index 93998f7c0b2..d5eff832243 100644
--- a/services/notifications/pkg/command/server.go
+++ b/services/notifications/pkg/command/server.go
@@ -35,8 +35,10 @@ func Server(cfg *config.Config) *cli.Command {
 			// evs defines a list of events to subscribe to
 			evs := []events.Unmarshaller{
 				events.ShareCreated{},
+				events.ShareExpired{},
 				events.SpaceShared{},
 				events.SpaceUnshared{},
+				events.SpaceMembershipExpired{},
 			}
 
 			evtsCfg := cfg.Notifications.Events
diff --git a/services/notifications/pkg/email/templates/shares/shareExpired.email.body.tmpl b/services/notifications/pkg/email/templates/shares/shareExpired.email.body.tmpl
new file mode 100644
index 00000000000..322f28e520d
--- /dev/null
+++ b/services/notifications/pkg/email/templates/shares/shareExpired.email.body.tmpl
@@ -0,0 +1,18 @@
+Hello {{ .ShareGrantee }},
+
+Your share to {{ .ShareFolder }} has expired at {{ .ExpiredAt }}
+
+Even though this share has been revoked you still might have access through other shares and/or space memberships
+
+----------------------------------------------------------
+
+Hallo {{ .ShareGrantee }},
+
+Deine Freigabe zu {{ .ShareFolder }} ist am {{ .ExpiredAt }} abgelaufen.
+
+Obwohl diese Freigabe nicht mehr zur Verfügung steht, könntest du immer noch Zugriff über andere Freigaben und/oder Space Mitgliedschaften haben.
+
+
+---
+ownCloud - Store. Share. Work.
+https://owncloud.com
diff --git a/services/notifications/pkg/email/templates/shares/shareExpired.email.subject.tmpl b/services/notifications/pkg/email/templates/shares/shareExpired.email.subject.tmpl
new file mode 100644
index 00000000000..e9593dcd5af
--- /dev/null
+++ b/services/notifications/pkg/email/templates/shares/shareExpired.email.subject.tmpl
@@ -0,0 +1,1 @@
+Share to '{{ .ShareFolder }}' expired at {{ .ExpiredAt }}
\ No newline at end of file
diff --git a/services/notifications/pkg/email/templates/spaces/membershipExpired.email.body.tmpl b/services/notifications/pkg/email/templates/spaces/membershipExpired.email.body.tmpl
new file mode 100644
index 00000000000..95ba3897bab
--- /dev/null
+++ b/services/notifications/pkg/email/templates/spaces/membershipExpired.email.body.tmpl
@@ -0,0 +1,18 @@
+Hello {{ .SpaceGrantee }},
+
+Your membership of space {{ .SpaceName }} has expired at {{ .ExpiredAt }}
+
+Even though this membership has expired you still might have access through other shares and/or space memberships
+
+----------------------------------------------------------
+
+Hallo {{ .SpaceGrantee }},
+
+Deine Mitgliedschaft zu dem Space {{ .SpaceName }} ist am {{ .ExpiredAt }} abgelaufen.
+
+Obwohl diese Mitgliedschaft nicht mehr zur Verfügung steht, könntest du immer noch Zugriff über andere Freigaben und/oder Space Mitgliedschaften haben.
+
+
+---
+ownCloud - Store. Share. Work.
+https://owncloud.com
diff --git a/services/notifications/pkg/email/templates/spaces/membershipExpired.email.subject.tmpl b/services/notifications/pkg/email/templates/spaces/membershipExpired.email.subject.tmpl
new file mode 100644
index 00000000000..93658183883
--- /dev/null
+++ b/services/notifications/pkg/email/templates/spaces/membershipExpired.email.subject.tmpl
@@ -0,0 +1,1 @@
+Membership of '{{ .SpaceName }}' expired at {{ .ExpiredAt }}
\ No newline at end of file
diff --git a/services/notifications/pkg/email/templates/spaces/unsharedSpace.email.subject.tmpl b/services/notifications/pkg/email/templates/spaces/unsharedSpace.email.subject.tmpl
index b4a526abde6..c6628dfb3dd 100644
--- a/services/notifications/pkg/email/templates/spaces/unsharedSpace.email.subject.tmpl
+++ b/services/notifications/pkg/email/templates/spaces/unsharedSpace.email.subject.tmpl
@@ -1,1 +1,1 @@
-{{ .SpaceSharer }} removed you from {{ .SpaceName }}
+{{ .SpaceSharer }} removed you from {{ .SpaceName }}
\ No newline at end of file
diff --git a/services/notifications/pkg/service/service.go b/services/notifications/pkg/service/service.go
index b9a10734ca4..676f3dfa069 100644
--- a/services/notifications/pkg/service/service.go
+++ b/services/notifications/pkg/service/service.go
@@ -2,6 +2,7 @@ package service
 
 import (
 	"context"
+	"errors"
 	"fmt"
 	"net/url"
 	"os"
@@ -10,19 +11,18 @@ import (
 	"syscall"
 
 	gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
-	groupv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/group/v1beta1"
-	userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
-	rpcv1beta1 "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
-	providerv1beta1 "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
-	revactx "github.com/cs3org/reva/v2/pkg/ctx"
+	group "github.com/cs3org/go-cs3apis/cs3/identity/group/v1beta1"
+	user "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
+	rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
+	provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
 	"github.com/cs3org/reva/v2/pkg/events"
 	"github.com/owncloud/ocis/v2/ocis-pkg/log"
 	"github.com/owncloud/ocis/v2/services/notifications/pkg/channels"
 	"github.com/owncloud/ocis/v2/services/notifications/pkg/email"
-	"google.golang.org/grpc/metadata"
 	"google.golang.org/protobuf/types/known/fieldmaskpb"
 )
 
+// Service should be named `Runner`
 type Service interface {
 	Run() error
 }
@@ -70,8 +70,12 @@ func (s eventsNotifier) Run() error {
 					s.handleSpaceShared(e)
 				case events.SpaceUnshared:
 					s.handleSpaceUnshared(e)
+				case events.SpaceMembershipExpired:
+					s.handleSpaceMembershipExpired(e)
 				case events.ShareCreated:
 					s.handleShareCreated(e)
+				case events.ShareExpired:
+					s.handleShareExpired(e)
 				}
 			}()
 		case <-s.signals:
@@ -82,138 +86,80 @@ func (s eventsNotifier) Run() error {
 	}
 }
 
-func (s eventsNotifier) handleShareCreated(e events.ShareCreated) {
-	logger := s.logger.With().
-		Str("event", "ShareCreated").
-		Str("itemid", e.ItemID.OpaqueId).
-		Logger()
-
-	impersonateRes, err := s.impersonate(e.Sharer)
+func (s eventsNotifier) render(bodyTemplate string, subjTemplate string, values map[string]string) (string, string, error) {
+	msg, err := email.RenderEmailTemplate(bodyTemplate, values, s.emailTemplatePath)
 	if err != nil {
-		logger.Error().
-			Err(err).
-			Msg("Could not impersonate sharer")
-		return
+		return "", "", err
 	}
-	ownerCtx := metadata.AppendToOutgoingContext(context.Background(), revactx.TokenHeader, impersonateRes.Token)
 
-	resourceInfo, err := s.getResourceInfo(ownerCtx, e.ItemID, &fieldmaskpb.FieldMask{Paths: []string{"name"}})
+	sub, err := email.RenderEmailTemplate(subjTemplate, values, s.emailTemplatePath)
 	if err != nil {
-		logger.Error().
-			Err(err).
-			Msg("could not stat resource")
-		return
+		return "", "", err
 	}
 
-	shareLink, err := urlJoinPath(s.ocisURL, "files/shares/with-me")
-	if err != nil {
-		logger.Error().
-			Err(err).
-			Msg("could not create link to the share")
-		return
+	return msg, sub, nil
+}
+
+func (s eventsNotifier) send(ctx context.Context, u *user.UserId, g *group.GroupId, msg, subj, sender string) error {
+	if u != nil {
+		return s.channel.SendMessage(ctx, []string{u.GetOpaqueId()}, msg, subj, sender)
+
+	}
+
+	if g != nil {
+		return s.channel.SendMessageToGroup(ctx, g, msg, subj, sender)
 	}
 
-	shareGrantee := ""
+	return nil
+}
+
+func (s eventsNotifier) getGranteeName(ctx context.Context, u *user.UserId, g *group.GroupId) (string, error) {
 	switch {
-	// Note: We're using the 'ownerCtx' (authenticated as the share owner) here for requesting
-	// the Grantees of the shares. Ideally the notfication service would use some kind of service
-	// user for this.
-	case e.GranteeUserID != nil:
-		granteeUserResponse, err := s.gwClient.GetUser(ownerCtx, &userv1beta1.GetUserRequest{
-			UserId: e.GranteeUserID,
-		})
-		if err != nil || granteeUserResponse.Status.Code != rpcv1beta1.Code_CODE_OK {
-			s.logger.Error().
-				Err(err).
-				Str("event", "ShareCreated").
-				Msg("Could not get user response from gatway client")
-			return
-		}
-		shareGrantee = granteeUserResponse.GetUser().DisplayName
-	case e.GranteeGroupID != nil:
-		granteeGroupResponse, err := s.gwClient.GetGroup(ownerCtx, &groupv1beta1.GetGroupRequest{
-			GroupId: e.GranteeGroupID,
-		})
-		if err != nil || granteeGroupResponse.Status.Code != rpcv1beta1.Code_CODE_OK {
-			s.logger.Error().
-				Err(err).
-				Str("event", "ShareCreated").
-				Msg("Could not get group response from gatway client")
-			return
+	case u != nil:
+		r, err := s.gwClient.GetUser(ctx, &user.GetUserRequest{UserId: u})
+		if err != nil {
+			return "", err
 		}
-		shareGrantee = granteeGroupResponse.GetGroup().DisplayName
-	default:
-		s.logger.Error().
-			Str("event", "ShareCreated").
-			Msg("Event 'ShareCreated' has no grantee")
-		return
-	}
 
-	sharerDisplayName := impersonateRes.GetUser().DisplayName
-	msg, err := email.RenderEmailTemplate("shares/shareCreated.email.body.tmpl", map[string]string{
-		"ShareGrantee": shareGrantee,
-		"ShareSharer":  sharerDisplayName,
-		"ShareFolder":  resourceInfo.Name,
-		"ShareLink":    shareLink,
-	}, s.emailTemplatePath)
+		if r.Status.Code != rpc.Code_CODE_OK {
+			return "", fmt.Errorf("unexpected status code from gateway client: %d", r.GetStatus().GetCode())
+		}
 
-	if err != nil {
-		s.logger.Error().
-			Err(err).
-			Str("event", "ShareCreated").
-			Msg("Could not render E-Mail body template for shares")
-	}
+		return r.GetUser().GetDisplayName(), nil
+	case g != nil:
+		r, err := s.gwClient.GetGroup(ctx, &group.GetGroupRequest{GroupId: g})
+		if err != nil {
+			return "", err
+		}
 
-	emailSubject, err := email.RenderEmailTemplate("shares/shareCreated.email.subject.tmpl", map[string]string{
-		"ShareSharer": sharerDisplayName,
-		"ShareFolder": resourceInfo.Name,
-	}, s.emailTemplatePath)
+		if r.GetStatus().GetCode() != rpc.Code_CODE_OK {
+			return "", fmt.Errorf("unexpected status code from gateway client: %d", r.GetStatus().GetCode())
+		}
 
-	if err != nil {
-		s.logger.Error().
-			Err(err).
-			Str("event", "SpaceCreated").
-			Msg("Could not render E-Mail subject template for shares")
+		return r.GetGroup().GetDisplayName(), nil
+	default:
+		return "", errors.New("Need at least one non-nil grantee")
 	}
 
-	if e.GranteeUserID != nil {
-		err = s.channel.SendMessage(ownerCtx, []string{e.GranteeUserID.OpaqueId}, msg, emailSubject, sharerDisplayName)
-	} else if e.GranteeGroupID != nil {
-		err = s.channel.SendMessageToGroup(ownerCtx, e.GranteeGroupID, msg, emailSubject, sharerDisplayName)
-	}
-	if err != nil {
-		s.logger.Error().
-			Err(err).
-			Str("event", "ShareCreated").
-			Msg("failed to send a message")
-	}
 }
 
-func (s eventsNotifier) impersonate(userID *userv1beta1.UserId) (*gateway.AuthenticateResponse, error) {
-	getUserResponse, err := s.gwClient.GetUser(context.Background(), &userv1beta1.GetUserRequest{
-		UserId: userID,
+func (s eventsNotifier) getResourceInfo(ctx context.Context, resourceID *provider.ResourceId, fieldmask *fieldmaskpb.FieldMask) (*provider.ResourceInfo, error) {
+	// TODO: maybe cache this stat to reduce storage iops
+	md, err := s.gwClient.Stat(ctx, &provider.StatRequest{
+		Ref: &provider.Reference{
+			ResourceId: resourceID,
+		},
+		FieldMask: fieldmask,
 	})
-	if err != nil {
-		return nil, err
-	}
-	if getUserResponse.Status.Code != rpcv1beta1.Code_CODE_OK {
-		return nil, fmt.Errorf("error getting user: %s", getUserResponse.Status.Message)
-	}
 
-	// Get auth context
-	ownerCtx := revactx.ContextSetUser(context.Background(), getUserResponse.User)
-	authRes, err := s.gwClient.Authenticate(ownerCtx, &gateway.AuthenticateRequest{
-		Type:         "machine",
-		ClientId:     "userid:" + userID.OpaqueId,
-		ClientSecret: s.machineAuthAPIKey,
-	})
 	if err != nil {
 		return nil, err
 	}
-	if authRes.GetStatus().GetCode() != rpcv1beta1.Code_CODE_OK {
-		return nil, fmt.Errorf("error impersonating user: %s", authRes.Status.Message)
+
+	if md.Status.Code != rpc.Code_CODE_OK {
+		return nil, fmt.Errorf("could not resource info: %s", md.Status.Message)
 	}
-	return authRes, nil
+	return md.GetInfo(), nil
 }
 
 // TODO: this function is a backport for go1.19 url.JoinPath, upon go bump, replace this
@@ -225,22 +171,3 @@ func urlJoinPath(base string, elements ...string) (string, error) {
 	u.Path = path.Join(append([]string{u.Path}, elements...)...)
 	return u.String(), nil
 }
-
-func (s eventsNotifier) getResourceInfo(ctx context.Context, resourceID *providerv1beta1.ResourceId, fieldmask *fieldmaskpb.FieldMask) (*providerv1beta1.ResourceInfo, error) {
-	// TODO: maybe cache this stat to reduce storage iops
-	md, err := s.gwClient.Stat(ctx, &providerv1beta1.StatRequest{
-		Ref: &providerv1beta1.Reference{
-			ResourceId: resourceID,
-		},
-		FieldMask: fieldmask,
-	})
-
-	if err != nil {
-		return nil, err
-	}
-
-	if md.Status.Code != rpcv1beta1.Code_CODE_OK {
-		return nil, fmt.Errorf("could not resource info: %s", md.Status.Message)
-	}
-	return md.GetInfo(), nil
-}
diff --git a/services/notifications/pkg/service/shares.go b/services/notifications/pkg/service/shares.go
new file mode 100644
index 00000000000..67588494366
--- /dev/null
+++ b/services/notifications/pkg/service/shares.go
@@ -0,0 +1,101 @@
+package service
+
+import (
+	"github.com/cs3org/reva/v2/pkg/events"
+	"github.com/cs3org/reva/v2/pkg/utils"
+	"google.golang.org/protobuf/types/known/fieldmaskpb"
+)
+
+func (s eventsNotifier) handleShareCreated(e events.ShareCreated) {
+	logger := s.logger.With().
+		Str("event", "ShareCreated").
+		Str("itemid", e.ItemID.OpaqueId).
+		Logger()
+
+	ownerCtx, owner, err := utils.Impersonate(e.Sharer, s.gwClient, s.machineAuthAPIKey)
+	if err != nil {
+		logger.Error().Err(err).Msg("Could not impersonate sharer")
+		return
+	}
+
+	resourceInfo, err := s.getResourceInfo(ownerCtx, e.ItemID, &fieldmaskpb.FieldMask{Paths: []string{"name"}})
+	if err != nil {
+		logger.Error().
+			Err(err).
+			Msg("could not stat resource")
+		return
+	}
+
+	shareLink, err := urlJoinPath(s.ocisURL, "files/shares/with-me")
+	if err != nil {
+		logger.Error().
+			Err(err).
+			Msg("could not create link to the share")
+		return
+	}
+
+	shareGrantee, err := s.getGranteeName(ownerCtx, e.GranteeUserID, e.GranteeGroupID)
+	if err != nil {
+		s.logger.Error().Err(err).Str("event", "ShareCreated").Msg("Could not get grantee name")
+		return
+	}
+
+	sharerDisplayName := owner.GetDisplayName()
+	msg, subj, err := s.render("shares/shareCreated.email.body.tmpl", "shares/shareCreated.email.subject.tmpl", map[string]string{
+		"ShareGrantee": shareGrantee,
+		"ShareSharer":  sharerDisplayName,
+		"ShareFolder":  resourceInfo.Name,
+		"ShareLink":    shareLink,
+	})
+
+	if err != nil {
+		s.logger.Error().Err(err).Str("event", "ShareCreated").Msg("Could not render E-Mail body template for shares")
+	}
+
+	if err := s.send(ownerCtx, e.GranteeUserID, e.GranteeGroupID, msg, subj, sharerDisplayName); err != nil {
+		s.logger.Error().Err(err).Str("event", "ShareCreated").Msg("failed to send a message")
+	}
+
+}
+
+func (s eventsNotifier) handleShareExpired(e events.ShareExpired) {
+	logger := s.logger.With().
+		Str("event", "ShareExpired").
+		Str("itemid", e.ItemID.GetOpaqueId()).
+		Logger()
+
+	ctx, owner, err := utils.Impersonate(e.ShareOwner, s.gwClient, s.machineAuthAPIKey)
+	if err != nil {
+		logger.Error().Err(err).Msg("Could not impersonate sharer")
+		return
+	}
+
+	resourceInfo, err := s.getResourceInfo(ctx, e.ItemID, &fieldmaskpb.FieldMask{Paths: []string{"name"}})
+	if err != nil {
+		logger.Error().
+			Err(err).
+			Msg("could not stat resource")
+		return
+	}
+
+	shareGrantee, err := s.getGranteeName(ctx, e.GranteeUserID, e.GranteeGroupID)
+	if err != nil {
+		s.logger.Error().Err(err).Str("event", "ShareCreated").Msg("Could not get grantee name")
+		return
+	}
+
+	msg, subj, err := s.render("shares/shareExpired.email.body.tmpl", "shares/shareExpired.email.subject.tmpl", map[string]string{
+		"ShareGrantee": shareGrantee,
+		"ShareFolder":  resourceInfo.GetName(),
+		"ExpiredAt":    e.ExpiredAt.Format("2006-01-02 15:04:05"),
+	})
+
+	if err != nil {
+		s.logger.Error().Err(err).Str("event", "ShareCreated").Msg("Could not render E-Mail body template for shares")
+	}
+
+	if err := s.send(ctx, e.GranteeUserID, e.GranteeGroupID, msg, subj, owner.GetDisplayName()); err != nil {
+		s.logger.Error().Err(err).Str("event", "ShareCreated").Msg("failed to send a message")
+	}
+
+}
diff --git a/services/notifications/pkg/service/spaces.go b/services/notifications/pkg/service/spaces.go
index 9fd01342b62..44affdfcc46 100644
--- a/services/notifications/pkg/service/spaces.go
+++ b/services/notifications/pkg/service/spaces.go
@@ -1,16 +1,9 @@
 package service
 
 import (
-	"context"
-
-	groupv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/group/v1beta1"
-	userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
-	rpcv1beta1 "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
-	revactx "github.com/cs3org/reva/v2/pkg/ctx"
 	"github.com/cs3org/reva/v2/pkg/events"
 	"github.com/cs3org/reva/v2/pkg/storagespace"
-	"github.com/owncloud/ocis/v2/services/notifications/pkg/email"
-	"google.golang.org/grpc/metadata"
+	"github.com/cs3org/reva/v2/pkg/utils"
 )
 
 func (s eventsNotifier) handleSpaceShared(e events.SpaceShared) {
@@ -19,14 +12,13 @@ func (s eventsNotifier) handleSpaceShared(e events.SpaceShared) {
 		Str("itemid", e.ID.OpaqueId).
 		Logger()
 
-	impersonateRes, err := s.impersonate(e.Executant)
+	ownerCtx, owner, err := utils.Impersonate(e.Executant, s.gwClient, s.machineAuthAPIKey)
 	if err != nil {
 		logger.Error().
 			Err(err).
 			Msg("could not handle space shared event")
 		return
 	}
-	ownerCtx := metadata.AppendToOutgoingContext(context.Background(), revactx.TokenHeader, impersonateRes.Token)
 
 	resourceID, err := storagespace.ParseID(e.ID.OpaqueId)
 	if err != nil {
@@ -52,73 +44,29 @@ func (s eventsNotifier) handleSpaceShared(e events.SpaceShared) {
 		return
 	}
 
-	spaceGrantee := ""
-	switch {
 	// Note: We're using the 'ownerCtx' (authenticated as the share owner) here for requesting
 	// the Grantees of the shares. Ideally the notfication service would use some kind of service
 	// user for this.
-	case e.GranteeUserID != nil:
-		granteeUserResponse, err := s.gwClient.GetUser(ownerCtx, &userv1beta1.GetUserRequest{
-			UserId: e.GranteeUserID,
-		})
-		if err != nil || granteeUserResponse.Status.Code != rpcv1beta1.Code_CODE_OK {
-			logger.Error().
-				Err(err).
-				Msg("Could not get user response from gatway client")
-			return
-		}
-		spaceGrantee = granteeUserResponse.GetUser().GetDisplayName()
-	case e.GranteeGroupID != nil:
-		granteeGroupResponse, err := s.gwClient.GetGroup(ownerCtx, &groupv1beta1.GetGroupRequest{
-			GroupId: e.GranteeGroupID,
-		})
-		if err != nil || granteeGroupResponse.Status.Code != rpcv1beta1.Code_CODE_OK {
-			logger.Error().
-				Err(err).
-				Msg("Could not get group response from gatway client")
-			return
-		}
-		spaceGrantee = granteeGroupResponse.GetGroup().GetDisplayName()
-	default:
-		logger.Error().
-			Msg("Event 'SpaceShared' has no grantee")
+	spaceGrantee, err := s.getGranteeName(ownerCtx, e.GranteeUserID, e.GranteeGroupID)
+	if err != nil {
+		logger.Error().Err(err).Msg("Could not get grantee name")
 		return
 	}
 
-	sharerDisplayName := impersonateRes.GetUser().GetDisplayName()
-	msg, err := email.RenderEmailTemplate("spaces/sharedSpace.email.body.tmpl", map[string]string{
+	sharerDisplayName := owner.GetDisplayName()
+	msg, subj, err := s.render("spaces/sharedSpace.email.body.tmpl", "spaces/sharedSpace.email.subject.tmpl", map[string]string{
 		"SpaceGrantee": spaceGrantee,
 		"SpaceSharer":  sharerDisplayName,
-		"SpaceName":    resourceInfo.GetSpace().Name,
+		"SpaceName":    resourceInfo.GetSpace().GetName(),
 		"ShareLink":    shareLink,
-	}, s.emailTemplatePath)
-
-	if err != nil {
-		logger.Error().
-			Err(err).
-			Msg("Could not render E-Mail body template for spaces")
-	}
-
-	emailSubject, err := email.RenderEmailTemplate("spaces/sharedSpace.email.subject.tmpl", map[string]string{
-		"SpaceSharer": sharerDisplayName,
-		"SpaceName":   resourceInfo.GetSpace().Name,
-	}, s.emailTemplatePath)
-
+	})
 	if err != nil {
-		logger.Error().
-			Err(err).
-			Msg("Could not render E-Mail subject template for spaces")
+		logger.Error().Err(err).Msg("Could not render E-Mail template for spaces")
+		return
 	}
 
-	if e.GranteeUserID != nil {
-		err = s.channel.SendMessage(ownerCtx, []string{e.GranteeUserID.OpaqueId}, msg, emailSubject, sharerDisplayName)
-	} else if e.GranteeGroupID != nil {
-		err = s.channel.SendMessageToGroup(ownerCtx, e.GranteeGroupID, msg, emailSubject, sharerDisplayName)
-	}
-	if err != nil {
-		logger.Error().
-			Err(err).
-			Msg("failed to send a message")
+	if err := s.send(ownerCtx, e.GranteeUserID, e.GranteeGroupID, msg, subj, sharerDisplayName); err != nil {
+		logger.Error().Err(err).Msg("failed to send a message")
 	}
 }
 
@@ -128,14 +76,11 @@ func (s eventsNotifier) handleSpaceUnshared(e events.SpaceUnshared) {
 		Str("itemid", e.ID.OpaqueId).
 		Logger()
 
-	impersonateRes, err := s.impersonate(e.Executant)
+	ownerCtx, owner, err := utils.Impersonate(e.Executant, s.gwClient, s.machineAuthAPIKey)
 	if err != nil {
-		logger.Error().
-			Err(err).
-			Msg("could not handle space unshared event")
+		logger.Error().Err(err).Msg("could not handle space unshared event")
 		return
 	}
-	ownerCtx := metadata.AppendToOutgoingContext(context.Background(), revactx.TokenHeader, impersonateRes.Token)
 
 	resourceID, err := storagespace.ParseID(e.ID.OpaqueId)
 	if err != nil {
@@ -161,72 +106,63 @@ func (s eventsNotifier) handleSpaceUnshared(e events.SpaceUnshared) {
 		return
 	}
 
-	spaceGrantee := ""
-	switch {
 	// Note: We're using the 'ownerCtx' (authenticated as the share owner) here for requesting
 	// the Grantees of the shares. Ideally the notfication service would use some kind of service
 	// user for this.
-	case e.GranteeUserID != nil:
-		granteeUserResponse, err := s.gwClient.GetUser(ownerCtx, &userv1beta1.GetUserRequest{
-			UserId: e.GranteeUserID,
-		})
-		if err != nil || granteeUserResponse.Status.Code != rpcv1beta1.Code_CODE_OK {
-			logger.Error().
-				Err(err).
-				Msg("Could not get user response from gatway client")
-			return
-		}
-		spaceGrantee = granteeUserResponse.GetUser().GetDisplayName()
-	case e.GranteeGroupID != nil:
-		granteeGroupResponse, err := s.gwClient.GetGroup(ownerCtx, &groupv1beta1.GetGroupRequest{
-			GroupId: e.GranteeGroupID,
-		})
-		if err != nil || granteeGroupResponse.Status.Code != rpcv1beta1.Code_CODE_OK {
-			logger.Error().
-				Err(err).
-				Msg("Could not get group response from gatway client")
-			return
-		}
-		spaceGrantee = granteeGroupResponse.GetGroup().GetDisplayName()
-	default:
-		logger.Error().
-			Msg("Event 'SpaceShared' has no grantee")
+	spaceGrantee, err := s.getGranteeName(ownerCtx, e.GranteeUserID, e.GranteeGroupID)
+	if err != nil {
+		logger.Error().Err(err).Msg("Could not get grantee name")
 		return
 	}
 
-	sharerDisplayName := impersonateRes.GetUser().GetDisplayName()
-	msg, err := email.RenderEmailTemplate("spaces/unsharedSpace.email.body.tmpl", map[string]string{
+	sharerDisplayName := owner.GetDisplayName()
+	msg, subj, err := s.render("spaces/unsharedSpace.email.body.tmpl", "spaces/unsharedSpace.email.subject.tmpl", map[string]string{
 		"SpaceGrantee": spaceGrantee,
 		"SpaceSharer":  sharerDisplayName,
 		"SpaceName":    resourceInfo.GetSpace().Name,
 		"ShareLink":    shareLink,
-	}, s.emailTemplatePath)
+	})
 
 	if err != nil {
-		logger.Error().
-			Err(err).
-			Msg("Could not render E-Mail body template for spaces")
+		logger.Error().Err(err).Msg("Could not render E-Mail template for spaces")
+		return
 	}
 
-	emailSubject, err := email.RenderEmailTemplate("spaces/unsharedSpace.email.subject.tmpl", map[string]string{
-		"SpaceSharer": sharerDisplayName,
-		"SpaceName":   resourceInfo.GetSpace().Name,
-	}, s.emailTemplatePath)
+	if err := s.send(ownerCtx, e.GranteeUserID, e.GranteeGroupID, msg, subj, sharerDisplayName); err != nil {
+		logger.Error().Err(err).Msg("failed to send a message")
+	}
+}
 
+func (s eventsNotifier) handleSpaceMembershipExpired(e events.SpaceMembershipExpired) {
+	logger := s.logger.With().
+		Str("event", "SpaceMembershipExpired").
+		Str("itemid", e.SpaceID.GetOpaqueId()).
+		Logger()
+
+	ctx, owner, err := utils.Impersonate(e.SpaceOwner, s.gwClient, s.machineAuthAPIKey)
 	if err != nil {
-		logger.Error().
-			Err(err).
-			Msg("Could not render E-Mail subject template for spaces")
+		logger.Error().Err(err).Msg("Could not impersonate sharer")
+		return
 	}
 
-	if e.GranteeUserID != nil {
-		err = s.channel.SendMessage(ownerCtx, []string{e.GranteeUserID.OpaqueId}, msg, emailSubject, sharerDisplayName)
-	} else if e.GranteeGroupID != nil {
-		err = s.channel.SendMessageToGroup(ownerCtx, e.GranteeGroupID, msg, emailSubject, sharerDisplayName)
+	shareGrantee, err := s.getGranteeName(ctx, e.GranteeUserID, e.GranteeGroupID)
+	if err != nil {
+		s.logger.Error().Err(err).Str("event", "ShareCreated").Msg("Could not get grantee name")
+		return
 	}
+
+	msg, subj, err := s.render("spaces/membershipExpired.email.body.tmpl", "spaces/membershipExpired.email.subject.tmpl", map[string]string{
+		"SpaceGrantee": shareGrantee,
+		"SpaceName":    e.SpaceName,
+		"ExpiredAt":    e.ExpiredAt.Format("2006-01-02 15:04:05"),
+	})
+
 	if err != nil {
-		logger.Error().
-			Err(err).
-			Msg("failed to send a message")
+		s.logger.Error().Err(err).Str("event", "ShareCreated").Msg("Could not render E-Mail body template for shares")
 	}
+
+	if err := s.send(ctx, e.GranteeUserID, e.GranteeGroupID, msg, subj, owner.GetDisplayName()); err != nil {
+		s.logger.Error().Err(err).Str("event", "ShareCreated").Msg("failed to send a message")
+	}
+
 }
diff --git a/services/settings/pkg/service/v0/service.go b/services/settings/pkg/service/v0/service.go
index 0b9955381cd..e5db4683d70 100644
--- a/services/settings/pkg/service/v0/service.go
+++ b/services/settings/pkg/service/v0/service.go
@@ -79,7 +79,7 @@ func (g Service) CheckPermission(ctx context.Context, req *permissions.CheckPerm
 
 	permission, err := g.manager.ReadPermissionByName(req.Permission, roleIDs)
 	if err != nil {
-		if !errors.Is(err, settings.ErrPermissionNotFound) {
+		if !errors.Is(err, settings.ErrNotFound) {
 			return &permissions.CheckPermissionResponse{
 				Status: status.NewInternal(ctx, err.Error()),
 			}, nil
diff --git a/services/settings/pkg/settings/settings.go b/services/settings/pkg/settings/settings.go
index 737585834fc..6fefd82718e 100644
--- a/services/settings/pkg/settings/settings.go
+++ b/services/settings/pkg/settings/settings.go
@@ -12,7 +12,12 @@ var (
 	Registry = map[string]RegisterFunc{}
 
 	// ErrPermissionNotFound defines a new error for when a permission was not found
+	//
+	// Deprecated use the more generic ErrNotFound
 	ErrPermissionNotFound = errors.New("permission not found")
+
+	// ErrNotFound is the error to use when a resource was not found.
+	ErrNotFound = errors.New("not found")
 )
 
 // RegisterFunc stores store constructors
diff --git a/services/settings/pkg/store/errortypes/errortypes.go b/services/settings/pkg/store/errortypes/errortypes.go
index f6c37a644ac..0981f1c5cc0 100644
--- a/services/settings/pkg/store/errortypes/errortypes.go
+++ b/services/settings/pkg/store/errortypes/errortypes.go
@@ -1,6 +1,8 @@
 package errortypes
 
 // BundleNotFound is the error to use when a bundle is not found.
+//
+// Deprecated: use the genreric services/settings/pkg/settings.NotFound error
 type BundleNotFound string
 
 func (e BundleNotFound) Error() string { return "error: bundle not found: " + string(e) }
diff --git a/services/settings/pkg/store/filesystem/bundles.go b/services/settings/pkg/store/filesystem/bundles.go
index 513bff747f7..f0e5749c84a 100644
--- a/services/settings/pkg/store/filesystem/bundles.go
+++ b/services/settings/pkg/store/filesystem/bundles.go
@@ -2,6 +2,7 @@
 package store
 
 import (
+	"errors"
 	"fmt"
 	"os"
 	"path/filepath"
@@ -9,7 +10,7 @@ import (
 
 	"github.com/gofrs/uuid"
 	settingsmsg "github.com/owncloud/ocis/v2/protogen/gen/ocis/messages/settings/v0"
-	"github.com/owncloud/ocis/v2/services/settings/pkg/store/errortypes"
+	"github.com/owncloud/ocis/v2/services/settings/pkg/settings"
 )
 
 var m = &sync.RWMutex{}
@@ -111,7 +112,7 @@ func (s Store) WriteBundle(record *settingsmsg.Bundle) (*settingsmsg.Bundle, err
 func (s Store) AddSettingToBundle(bundleID string, setting *settingsmsg.Setting) (*settingsmsg.Setting, error) {
 	bundle, err := s.ReadBundle(bundleID)
 	if err != nil {
-		if _, notFound := err.(errortypes.BundleNotFound); !notFound {
+		if !errors.Is(err, settings.ErrNotFound) {
 			return nil, err
 		}
 		bundle = new(settingsmsg.Bundle)
diff --git a/services/settings/pkg/store/filesystem/io.go b/services/settings/pkg/store/filesystem/io.go
index 5a5cccdaa49..a1b0f832d3a 100644
--- a/services/settings/pkg/store/filesystem/io.go
+++ b/services/settings/pkg/store/filesystem/io.go
@@ -1,10 +1,11 @@
 package store
 
 import (
+	"fmt"
 	"io"
 	"os"
 
-	"github.com/owncloud/ocis/v2/services/settings/pkg/store/errortypes"
+	"github.com/owncloud/ocis/v2/services/settings/pkg/settings"
 	"google.golang.org/protobuf/encoding/protojson"
 	"google.golang.org/protobuf/proto"
 )
@@ -13,7 +14,7 @@ import (
 func (s Store) parseRecordFromFile(record proto.Message, filePath string) error {
 	_, err := os.Stat(filePath)
 	if err != nil {
-		return errortypes.BundleNotFound(err.Error())
+		return fmt.Errorf("%q: %w", filePath, settings.ErrNotFound)
 	}
 
 	file, err := os.Open(filePath)
@@ -28,7 +29,7 @@ func (s Store) parseRecordFromFile(record proto.Message, filePath string) error
 	}
 
 	if len(b) == 0 {
-		return errortypes.BundleNotFound(filePath)
+		return fmt.Errorf("%q: %w", filePath, settings.ErrNotFound)
 	}
 
 	if err := protojson.Unmarshal(b, record); err != nil {
diff --git a/services/settings/pkg/store/filesystem/permissions.go b/services/settings/pkg/store/filesystem/permissions.go
index a01ba654680..1c03e643871 100644
--- a/services/settings/pkg/store/filesystem/permissions.go
+++ b/services/settings/pkg/store/filesystem/permissions.go
@@ -55,7 +55,7 @@ func (s Store) ReadPermissionByName(name string, roleIDs []string) (*settingsmsg
 			}
 		}
 	}
-	return nil, settings.ErrPermissionNotFound
+	return nil, settings.ErrNotFound
 }
 
 // extractPermissionsByResource collects all permissions from the provided role that match the requested resource
diff --git a/services/settings/pkg/store/metadata/assignments.go b/services/settings/pkg/store/metadata/assignments.go
index 36f2adc44c4..003b248cf33 100644
--- a/services/settings/pkg/store/metadata/assignments.go
+++ b/services/settings/pkg/store/metadata/assignments.go
@@ -6,8 +6,10 @@ import (
 	"encoding/json"
 	"fmt"
 
+	"github.com/cs3org/reva/v2/pkg/errtypes"
 	"github.com/gofrs/uuid"
 	settingsmsg "github.com/owncloud/ocis/v2/protogen/gen/ocis/messages/settings/v0"
+	"github.com/owncloud/ocis/v2/services/settings/pkg/settings"
 )
 
 // ListRoleAssignments loads and returns all role assignments matching the given assignment identifier.
@@ -15,14 +17,24 @@ func (s *Store) ListRoleAssignments(accountUUID string) ([]*settingsmsg.UserRole
 	s.Init()
 	ctx := context.TODO()
 	assIDs, err := s.mdc.ReadDir(ctx, accountPath(accountUUID))
-	if err != nil {
+	switch err.(type) {
+	case nil:
+		// continue
+	case errtypes.NotFound:
+		return make([]*settingsmsg.UserRoleAssignment, 0), nil
+	default:
 		return nil, err
 	}
 
 	ass := make([]*settingsmsg.UserRoleAssignment, 0, len(assIDs))
 	for _, assID := range assIDs {
 		b, err := s.mdc.SimpleDownload(ctx, assignmentPath(accountUUID, assID))
-		if err != nil {
+		switch err.(type) {
+		case nil:
+			// continue
+		case errtypes.NotFound:
+			continue
+		default:
 			return nil, err
 		}
 
@@ -42,10 +54,17 @@ func (s *Store) WriteRoleAssignment(accountUUID, roleID string) (*settingsmsg.Us
 	s.Init()
 	ctx := context.TODO()
 	// as per https://github.com/owncloud/product/issues/103 "Each user can have exactly one role"
-	_ = s.mdc.Delete(ctx, accountPath(accountUUID))
-	// TODO: How to differentiate between 'not found' and other errors?
+	err := s.mdc.Delete(ctx, accountPath(accountUUID))
+	switch err.(type) {
+	case nil:
+		// continue
+	case errtypes.NotFound:
+		// already gone, continue
+	default:
+		return nil, err
+	}
 
-	err := s.mdc.MakeDirIfNotExist(ctx, accountPath(accountUUID))
+	err = s.mdc.MakeDirIfNotExist(ctx, accountPath(accountUUID))
 	if err != nil {
 		return nil, err
 	}
@@ -67,7 +86,12 @@ func (s *Store) RemoveRoleAssignment(assignmentID string) error {
 	s.Init()
 	ctx := context.TODO()
 	accounts, err := s.mdc.ReadDir(ctx, accountsFolderLocation)
-	if err != nil {
+	switch err.(type) {
+	case nil:
+		// continue
+	case errtypes.NotFound:
+		return fmt.Errorf("assignmentID '%s' %w", assignmentID, settings.ErrNotFound)
+	default:
 		return err
 	}
 
@@ -81,11 +105,13 @@ func (s *Store) RemoveRoleAssignment(assignmentID string) error {
 
 		for _, assID := range assIDs {
 			if assID == assignmentID {
-				return s.mdc.Delete(ctx, assignmentPath(accID, assID))
+				// as per https://github.com/owncloud/product/issues/103 "Each user can have exactly one role"
+				// we also have to delete the cached dir listing
+				return s.mdc.Delete(ctx, accountPath(accID))
 			}
 		}
 	}
-	return fmt.Errorf("assignmentID '%s' not found", assignmentID)
+	return fmt.Errorf("assignmentID '%s' %w", assignmentID, settings.ErrNotFound)
 }
 
 func accountPath(accountUUID string) string {
diff --git a/services/settings/pkg/store/metadata/bundles.go b/services/settings/pkg/store/metadata/bundles.go
index 5e25ac53877..9025c97893c 100644
--- a/services/settings/pkg/store/metadata/bundles.go
+++ b/services/settings/pkg/store/metadata/bundles.go
@@ -7,8 +7,10 @@ import (
 	"errors"
 	"fmt"
 
+	"github.com/cs3org/reva/v2/pkg/errtypes"
 	"github.com/gofrs/uuid"
 	settingsmsg "github.com/owncloud/ocis/v2/protogen/gen/ocis/messages/settings/v0"
+	"github.com/owncloud/ocis/v2/services/settings/pkg/settings"
 )
 
 // ListBundles returns all bundles in the dataPath folder that match the given type.
@@ -18,7 +20,12 @@ func (s *Store) ListBundles(bundleType settingsmsg.Bundle_Type, bundleIDs []stri
 
 	if len(bundleIDs) == 0 {
 		bIDs, err := s.mdc.ReadDir(ctx, bundleFolderLocation)
-		if err != nil {
+		switch err.(type) {
+		case nil:
+			// continue
+		case errtypes.NotFound:
+			return make([]*settingsmsg.Bundle, 0), nil
+		default:
 			return nil, err
 		}
 
@@ -27,7 +34,12 @@ func (s *Store) ListBundles(bundleType settingsmsg.Bundle_Type, bundleIDs []stri
 	var bundles []*settingsmsg.Bundle
 	for _, id := range bundleIDs {
 		b, err := s.mdc.SimpleDownload(ctx, bundlePath(id))
-		if err != nil {
+		switch err.(type) {
+		case nil:
+			// continue
+		case errtypes.NotFound:
+			continue
+		default:
 			return nil, err
 		}
 
@@ -50,7 +62,12 @@ func (s *Store) ReadBundle(bundleID string) (*settingsmsg.Bundle, error) {
 	s.Init()
 	ctx := context.TODO()
 	b, err := s.mdc.SimpleDownload(ctx, bundlePath(bundleID))
-	if err != nil {
+	switch err.(type) {
+	case nil:
+		// continue
+	case errtypes.NotFound:
+		return nil, fmt.Errorf("bundleID '%s' %w", bundleID, settings.ErrNotFound)
+	default:
 		return nil, err
 	}
 
@@ -64,7 +81,12 @@ func (s *Store) ReadSetting(settingID string) (*settingsmsg.Setting, error) {
 	ctx := context.TODO()
 
 	ids, err := s.mdc.ReadDir(ctx, bundleFolderLocation)
-	if err != nil {
+	switch err.(type) {
+	case nil:
+		// continue
+	case errtypes.NotFound:
+		return nil, fmt.Errorf("settingID '%s' %w", settingID, settings.ErrNotFound)
+	default:
 		return nil, err
 	}
 
@@ -72,6 +94,9 @@ func (s *Store) ReadSetting(settingID string) (*settingsmsg.Setting, error) {
 	for _, id := range ids {
 		b, err := s.ReadBundle(id)
 		if err != nil {
+			if errors.Is(err, settings.ErrNotFound) {
+				continue
+			}
 			return nil, err
 		}
 
@@ -82,7 +107,7 @@ func (s *Store) ReadSetting(settingID string) (*settingsmsg.Setting, error) {
 		}
 
 	}
-	return nil, fmt.Errorf("setting '%s' not found", settingID)
+	return nil, fmt.Errorf("settingID '%s' %w", settingID, settings.ErrNotFound)
 }
 
 // WriteBundle sends the givens record to the metadataclient. returns `record` for legacy reasons
@@ -102,7 +127,9 @@ func (s *Store) AddSettingToBundle(bundleID string, setting *settingsmsg.Setting
 	s.Init()
 	b, err := s.ReadBundle(bundleID)
 	if err != nil {
-		// TODO: How to differentiate 'not found'?
+		if !errors.Is(err, settings.ErrNotFound) {
+			return nil, err
+		}
 		b = new(settingsmsg.Bundle)
 		b.Id = bundleID
 		b.Type = settingsmsg.Bundle_TYPE_DEFAULT
diff --git a/services/settings/pkg/store/metadata/permissions.go b/services/settings/pkg/store/metadata/permissions.go
index 9756db8d62f..f13f0bc0992 100644
--- a/services/settings/pkg/store/metadata/permissions.go
+++ b/services/settings/pkg/store/metadata/permissions.go
@@ -55,7 +55,7 @@ func (s *Store) ReadPermissionByName(name string, roleIDs []string) (*settingsms
 			}
 		}
 	}
-	return nil, settings.ErrPermissionNotFound
+	return nil, settings.ErrNotFound
 }
 
 // extractPermissionsByResource collects all permissions from the provided role that match the requested resource
diff --git a/services/settings/pkg/store/metadata/values.go b/services/settings/pkg/store/metadata/values.go
index 5ab790f5076..a9df99dacea 100644
--- a/services/settings/pkg/store/metadata/values.go
+++ b/services/settings/pkg/store/metadata/values.go
@@ -7,8 +7,10 @@ import (
 	"errors"
 	"fmt"
 
+	"github.com/cs3org/reva/v2/pkg/errtypes"
 	"github.com/gofrs/uuid"
 	settingsmsg "github.com/owncloud/ocis/v2/protogen/gen/ocis/messages/settings/v0"
+	"github.com/owncloud/ocis/v2/services/settings/pkg/settings"
 )
 
 // ListValues reads all values that match the given bundleId and accountUUID.
@@ -20,7 +22,12 @@ func (s *Store) ListValues(bundleID, accountUUID string) ([]*settingsmsg.Value,
 	ctx := context.TODO()
 
 	vIDs, err := s.mdc.ReadDir(ctx, valuesFolderLocation)
-	if err != nil {
+	switch err.(type) {
+	case nil:
+		// continue
+	case errtypes.NotFound:
+		return make([]*settingsmsg.Value, 0), nil
+	default:
 		return nil, err
 	}
 
@@ -28,7 +35,12 @@ func (s *Store) ListValues(bundleID, accountUUID string) ([]*settingsmsg.Value,
 	var values []*settingsmsg.Value
 	for _, vid := range vIDs {
 		b, err := s.mdc.SimpleDownload(ctx, valuePath(vid))
-		if err != nil {
+		switch err.(type) {
+		case nil:
+			// continue
+		case errtypes.NotFound:
+			continue
+		default:
 			return nil, err
 		}
 
@@ -61,7 +73,12 @@ func (s *Store) ReadValue(valueID string) (*settingsmsg.Value, error) {
 	ctx := context.TODO()
 
 	b, err := s.mdc.SimpleDownload(ctx, valuePath(valueID))
-	if err != nil {
+	switch err.(type) {
+	case nil:
+		// continue
+	case errtypes.NotFound:
+		return nil, fmt.Errorf("valueID '%s' %w", valueID, settings.ErrNotFound)
+	default:
 		return nil, err
 	}
 	val := &settingsmsg.Value{}


Test patch:
diff --git a/services/graph/pkg/identity/ldap_school.go b/services/graph/pkg/identity/ldap_education_school.go
similarity index 100%
rename from services/graph/pkg/identity/ldap_school.go
rename to services/graph/pkg/identity/ldap_education_school.go
diff --git a/services/graph/pkg/identity/ldap_school_test.go b/services/graph/pkg/identity/ldap_education_school_test.go
similarity index 100%
rename from services/graph/pkg/identity/ldap_school_test.go
rename to services/graph/pkg/identity/ldap_education_school_test.go
diff --git a/services/graph/pkg/identity/ldap_test.go b/services/graph/pkg/identity/ldap_test.go
index 8f8260aec5b..e36466d17e9 100644
--- a/services/graph/pkg/identity/ldap_test.go
+++ b/services/graph/pkg/identity/ldap_test.go
@@ -7,9 +7,11 @@ import (
 	"testing"
 
 	"github.com/go-ldap/ldap/v3"
+	libregraph "github.com/owncloud/libre-graph-api-go"
 	"github.com/owncloud/ocis/v2/ocis-pkg/log"
 	"github.com/owncloud/ocis/v2/services/graph/mocks"
 	"github.com/owncloud/ocis/v2/services/graph/pkg/config"
+	"github.com/stretchr/testify/assert"
 	"github.com/test-go/testify/mock"
 )
 
@@ -43,13 +45,17 @@ var userEntry = ldap.NewEntry("uid=user",
 		"displayname": {"DisplayName"},
 		"mail":        {"user@example"},
 		"entryuuid":   {"abcd-defg"},
+		"sn":          {"surname"},
+		"givenname":   {"givenName"},
 	})
+
 var invalidUserEntry = ldap.NewEntry("uid=user",
 	map[string][]string{
 		"uid":         {"invalid"},
 		"displayname": {"DisplayName"},
 		"mail":        {"user@example"},
 	})
+
 var groupEntry = ldap.NewEntry("cn=group",
 	map[string][]string{
 		"cn":        {"group"},
@@ -59,6 +65,7 @@ var groupEntry = ldap.NewEntry("cn=group",
 			"uid=invalid,ou=people,dc=test",
 		},
 	})
+
 var invalidGroupEntry = ldap.NewEntry("cn=invalid",
 	map[string][]string{
 		"cn": {"invalid"},
@@ -67,7 +74,6 @@ var invalidGroupEntry = ldap.NewEntry("cn=invalid",
 var logger = log.NewLogger(log.Level("debug"))
 
 func TestNewLDAPBackend(t *testing.T) {
-
 	l := &mocks.Client{}
 
 	tc := lconfig
@@ -97,7 +103,43 @@ func TestNewLDAPBackend(t *testing.T) {
 	if _, err := NewLDAPBackend(l, lconfig, &logger); err != nil {
 		t.Errorf("Should fail with invalid group search scope")
 	}
+}
+
+func TestCreateUser(t *testing.T) {
+	l := &mocks.Client{}
+	l.On("Search", mock.Anything).
+		Return(
+			&ldap.SearchResult{
+				Entries: []*ldap.Entry{userEntry},
+			},
+			nil)
+	l.On("Add", mock.Anything).Return(nil)
+	logger := log.NewLogger(log.Level("debug"))
 
+	displayName := "DisplayName"
+	mail := "user@example"
+	userName := "user"
+	surname := "surname"
+	givenName := "givenName"
+
+	user := libregraph.NewUser()
+	user.SetDisplayName(displayName)
+	user.SetMail(mail)
+	user.SetOnPremisesSamAccountName(userName)
+	user.SetSurname(surname)
+	user.SetGivenName(givenName)
+
+	c := lconfig
+	c.UseServerUUID = true
+	b, _ := NewLDAPBackend(l, c, &logger)
+
+	newUser, err := b.CreateUser(context.Background(), *user)
+	assert.Nil(t, err)
+	assert.Equal(t, displayName, newUser.GetDisplayName())
+	assert.Equal(t, mail, newUser.GetMail())
+	assert.Equal(t, userName, newUser.GetOnPremisesSamAccountName())
+	assert.Equal(t, givenName, newUser.GetGivenName())
+	assert.Equal(t, surname, newUser.GetSurname())
 }
 
 func TestCreateUserModelFromLDAP(t *testing.T) {
@@ -321,14 +363,14 @@ func TestGetGroup(t *testing.T) {
 		BaseDN:     "uid=user,ou=people,dc=test",
 		SizeLimit:  1,
 		Filter:     "(objectClass=inetOrgPerson)",
-		Attributes: []string{"displayname", "entryUUID", "mail", "uid"},
+		Attributes: []string{"displayname", "entryUUID", "mail", "uid", "sn", "givenname"},
 		Controls:   []ldap.Control(nil),
 	}
 	sr3 := &ldap.SearchRequest{
 		BaseDN:     "uid=invalid,ou=people,dc=test",
 		SizeLimit:  1,
 		Filter:     "(objectClass=inetOrgPerson)",
-		Attributes: []string{"displayname", "entryUUID", "mail", "uid"},
+		Attributes: []string{"displayname", "entryUUID", "mail", "uid", "sn", "givenname"},
 		Controls:   []ldap.Control(nil),
 	}
 
@@ -416,14 +458,14 @@ func TestGetGroups(t *testing.T) {
 		BaseDN:     "uid=user,ou=people,dc=test",
 		SizeLimit:  1,
 		Filter:     "(objectClass=inetOrgPerson)",
-		Attributes: []string{"displayname", "entryUUID", "mail", "uid"},
+		Attributes: []string{"displayname", "entryUUID", "mail", "uid", "sn", "givenname"},
 		Controls:   []ldap.Control(nil),
 	}
 	sr3 := &ldap.SearchRequest{
 		BaseDN:     "uid=invalid,ou=people,dc=test",
 		SizeLimit:  1,
 		Filter:     "(objectClass=inetOrgPerson)",
-		Attributes: []string{"displayname", "entryUUID", "mail", "uid"},
+		Attributes: []string{"displayname", "entryUUID", "mail", "uid", "sn", "givenname"},
 		Controls:   []ldap.Control(nil),
 	}
 
diff --git a/services/graph/pkg/service/v0/application_test.go b/services/graph/pkg/service/v0/application_test.go
new file mode 100644
index 00000000000..028ab59408c
--- /dev/null
+++ b/services/graph/pkg/service/v0/application_test.go
@@ -0,0 +1,135 @@
+package svc_test
+
+import (
+	"context"
+	"encoding/json"
+	"io"
+	"net/http"
+	"net/http/httptest"
+
+	"github.com/go-chi/chi/v5"
+	. "github.com/onsi/ginkgo/v2"
+	. "github.com/onsi/gomega"
+	libregraph "github.com/owncloud/libre-graph-api-go"
+	"github.com/stretchr/testify/mock"
+
+	ogrpc "github.com/owncloud/ocis/v2/ocis-pkg/service/grpc"
+	"github.com/owncloud/ocis/v2/ocis-pkg/shared"
+	settingsmsg "github.com/owncloud/ocis/v2/protogen/gen/ocis/messages/settings/v0"
+	settings "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 applicationList struct {
+	Value []*libregraph.Application
+}
+
+var _ = Describe("Applications", func() {
+	var (
+		svc             service.Service
+		ctx             context.Context
+		cfg             *config.Config
+		gatewayClient   *mocks.GatewayClient
+		eventsPublisher mocks.Publisher
+		roleService     *mocks.RoleService
+		identityBackend *identitymocks.Backend
+
+		rr *httptest.ResponseRecorder
+	)
+
+	BeforeEach(func() {
+		eventsPublisher.On("Publish", mock.Anything, mock.Anything, mock.Anything).Return(nil)
+
+		identityBackend = &identitymocks.Backend{}
+		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{}
+		cfg.Application.ID = "some-application-ID"
+
+		_ = ogrpc.Configure(ogrpc.GetClientOptions(cfg.GRPCClientTLS)...)
+		svc, _ = service.NewService(
+			service.Config(cfg),
+			service.WithGatewayClient(gatewayClient),
+			service.EventsPublisher(&eventsPublisher),
+			service.WithIdentityBackend(identityBackend),
+			service.WithRoleService(roleService),
+		)
+	})
+
+	Describe("ListApplications", func() {
+		It("lists the configured application with appRoles", func() {
+			roleService.On("ListRoles", mock.Anything, mock.Anything, mock.Anything).Return(&settings.ListBundlesResponse{
+				Bundles: []*settingsmsg.Bundle{
+					{
+						Id:          "some-appRole-ID",
+						Type:        settingsmsg.Bundle_TYPE_ROLE,
+						DisplayName: "A human readable name for a role",
+					},
+				},
+			}, nil)
+
+			r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/applications", nil)
+			svc.ListApplications(rr, r)
+
+			Expect(rr.Code).To(Equal(http.StatusOK))
+
+			data, err := io.ReadAll(rr.Body)
+			Expect(err).ToNot(HaveOccurred())
+
+			responseList := applicationList{}
+			err = json.Unmarshal(data, &responseList)
+			Expect(err).ToNot(HaveOccurred())
+			Expect(len(responseList.Value)).To(Equal(1))
+			Expect(responseList.Value[0].Id).To(Equal(cfg.Application.ID))
+			Expect(len(responseList.Value[0].GetAppRoles())).To(Equal(1))
+			Expect(responseList.Value[0].GetAppRoles()[0].GetId()).To(Equal("some-appRole-ID"))
+			Expect(responseList.Value[0].GetAppRoles()[0].GetDisplayName()).To(Equal("A human readable name for a role"))
+		})
+	})
+
+	Describe("GetApplication", func() {
+		It("gets the application with appRoles", func() {
+			roleService.On("ListRoles", mock.Anything, mock.Anything, mock.Anything).Return(&settings.ListBundlesResponse{
+				Bundles: []*settingsmsg.Bundle{
+					{
+						Id:          "some-appRole-ID",
+						Type:        settingsmsg.Bundle_TYPE_ROLE,
+						DisplayName: "A human readable name for a role",
+					},
+				},
+			}, nil)
+
+			r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/applications/some-application-ID", nil)
+			rctx := chi.NewRouteContext()
+			rctx.URLParams.Add("applicationID", cfg.Application.ID)
+			r = r.WithContext(context.WithValue(ctx, chi.RouteCtxKey, rctx))
+			svc.GetApplication(rr, r)
+
+			Expect(rr.Code).To(Equal(http.StatusOK))
+
+			data, err := io.ReadAll(rr.Body)
+			Expect(err).ToNot(HaveOccurred())
+
+			application := libregraph.Application{}
+			err = json.Unmarshal(data, &application)
+			Expect(err).ToNot(HaveOccurred())
+			Expect(application.Id).To(Equal(cfg.Application.ID))
+			Expect(len(application.GetAppRoles())).To(Equal(1))
+			Expect(application.GetAppRoles()[0].GetId()).To(Equal("some-appRole-ID"))
+			Expect(application.GetAppRoles()[0].GetDisplayName()).To(Equal("A human readable name for a role"))
+		})
+	})
+
+})
diff --git a/services/graph/pkg/service/v0/approleassignments_test.go b/services/graph/pkg/service/v0/approleassignments_test.go
new file mode 100644
index 00000000000..807bbc1ceda
--- /dev/null
+++ b/services/graph/pkg/service/v0/approleassignments_test.go
@@ -0,0 +1,198 @@
+package svc_test
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"io"
+	"net/http"
+	"net/http/httptest"
+
+	userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
+	revactx "github.com/cs3org/reva/v2/pkg/ctx"
+	"github.com/go-chi/chi/v5"
+	"github.com/golang/protobuf/ptypes/empty"
+	. "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"
+	settingsmsg "github.com/owncloud/ocis/v2/protogen/gen/ocis/messages/settings/v0"
+	settings "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 assignmentList struct {
+	Value []*libregraph.AppRoleAssignment
+}
+
+var _ = Describe("AppRoleAssignments", func() {
+	var (
+		svc             service.Service
+		ctx             context.Context
+		cfg             *config.Config
+		gatewayClient   *mocks.GatewayClient
+		eventsPublisher mocks.Publisher
+		roleService     *mocks.RoleService
+		identityBackend *identitymocks.Backend
+
+		rr *httptest.ResponseRecorder
+
+		currentUser = &userv1beta1.User{
+			Id: &userv1beta1.UserId{
+				OpaqueId: "user",
+			},
+		}
+	)
+
+	BeforeEach(func() {
+		eventsPublisher.On("Publish", mock.Anything, mock.Anything, mock.Anything).Return(nil)
+
+		identityBackend = &identitymocks.Backend{}
+		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{}
+		cfg.Application.ID = "some-application-ID"
+
+		_ = ogrpc.Configure(ogrpc.GetClientOptions(cfg.GRPCClientTLS)...)
+		svc, _ = service.NewService(
+			service.Config(cfg),
+			service.WithGatewayClient(gatewayClient),
+			service.EventsPublisher(&eventsPublisher),
+			service.WithIdentityBackend(identityBackend),
+			service.WithRoleService(roleService),
+		)
+	})
+
+	Describe("ListAppRoleAssignments", func() {
+		It("lists the appRoleAssignments", func() {
+			user := &libregraph.User{
+				Id: libregraph.PtrString("user1"),
+			}
+			assignments := []*settingsmsg.UserRoleAssignment{
+				{
+					Id:          "some-appRoleAssignment-ID",
+					AccountUuid: user.GetId(),
+					RoleId:      "some-appRole-ID",
+				},
+			}
+			roleService.On("ListRoleAssignments", mock.Anything, mock.Anything, mock.Anything).Return(&settings.ListRoleAssignmentsResponse{Assignments: assignments}, nil)
+
+			r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/users/user1/appRoleAssignments", nil)
+			rctx := chi.NewRouteContext()
+			rctx.URLParams.Add("userID", user.GetId())
+			r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
+			svc.ListAppRoleAssignments(rr, r)
+
+			Expect(rr.Code).To(Equal(http.StatusOK))
+
+			data, err := io.ReadAll(rr.Body)
+			Expect(err).ToNot(HaveOccurred())
+
+			responseList := assignmentList{}
+			err = json.Unmarshal(data, &responseList)
+			Expect(err).ToNot(HaveOccurred())
+			Expect(len(responseList.Value)).To(Equal(1))
+			Expect(responseList.Value[0].GetId()).ToNot(BeEmpty())
+			Expect(responseList.Value[0].GetAppRoleId()).To(Equal("some-appRole-ID"))
+			Expect(responseList.Value[0].GetPrincipalId()).To(Equal(user.GetId()))
+			Expect(responseList.Value[0].GetResourceId()).To(Equal(cfg.Application.ID))
+
+		})
+
+	})
+
+	Describe("CreateAppRoleAssignment", func() {
+		It("creates an appRoleAssignment", func() {
+			user := &libregraph.User{
+				Id: libregraph.PtrString("user1"),
+			}
+			userRoleAssignment := &settingsmsg.UserRoleAssignment{
+				Id:          "some-appRoleAssignment-ID",
+				AccountUuid: user.GetId(),
+				RoleId:      "some-appRole-ID",
+			}
+			roleService.On("AssignRoleToUser", mock.Anything, mock.Anything, mock.Anything).Return(&settings.AssignRoleToUserResponse{Assignment: userRoleAssignment}, nil)
+
+			ara := libregraph.NewAppRoleAssignmentWithDefaults()
+			ara.SetAppRoleId("some-appRole-ID")
+			ara.SetPrincipalId(user.GetId())
+			ara.SetResourceId(cfg.Application.ID)
+
+			araJson, err := json.Marshal(ara)
+			Expect(err).ToNot(HaveOccurred())
+
+			r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/users/user1/appRoleAssignments", bytes.NewBuffer(araJson))
+			rctx := chi.NewRouteContext()
+			rctx.URLParams.Add("userID", user.GetId())
+			r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
+			svc.CreateAppRoleAssignment(rr, r)
+
+			Expect(rr.Code).To(Equal(http.StatusCreated))
+
+			data, err := io.ReadAll(rr.Body)
+			Expect(err).ToNot(HaveOccurred())
+
+			assignment := libregraph.AppRoleAssignment{}
+			err = json.Unmarshal(data, &assignment)
+			Expect(err).ToNot(HaveOccurred())
+			Expect(assignment.GetId()).ToNot(BeEmpty())
+			Expect(assignment.GetAppRoleId()).To(Equal("some-appRole-ID"))
+			Expect(assignment.GetPrincipalId()).To(Equal("user1"))
+			Expect(assignment.GetResourceId()).To(Equal(cfg.Application.ID))
+		})
+
+	})
+
+	Describe("DeleteAppRoleAssignment", func() {
+		It("deletes an appRoleAssignment", func() {
+			user := &libregraph.User{
+				Id: libregraph.PtrString("user1"),
+			}
+
+			assignments := []*settingsmsg.UserRoleAssignment{
+				{
+					Id:          "some-appRoleAssignment-ID",
+					AccountUuid: user.GetId(),
+					RoleId:      "some-appRole-ID",
+				},
+			}
+			roleService.On("ListRoleAssignments", mock.Anything, mock.Anything, mock.Anything).Return(&settings.ListRoleAssignmentsResponse{Assignments: assignments}, nil)
+
+			roleService.On("RemoveRoleFromUser", mock.Anything, mock.Anything, mock.Anything).Return(&empty.Empty{}, nil)
+
+			ara := libregraph.NewAppRoleAssignmentWithDefaults()
+			ara.SetAppRoleId("some-appRole-ID")
+			ara.SetPrincipalId(user.GetId())
+			ara.SetResourceId(cfg.Application.ID)
+
+			araJson, err := json.Marshal(ara)
+			Expect(err).ToNot(HaveOccurred())
+
+			r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/users/user1/appRoleAssignments/some-appRoleAssignment-ID", bytes.NewBuffer(araJson))
+			rctx := chi.NewRouteContext()
+			rctx.URLParams.Add("userID", user.GetId())
+			rctx.URLParams.Add("appRoleAssignmentID", "some-appRoleAssignment-ID")
+			r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
+			svc.DeleteAppRoleAssignment(rr, r)
+
+			Expect(rr.Code).To(Equal(http.StatusNoContent))
+
+		})
+
+	})
+})
diff --git a/services/graph/pkg/service/v0/educationclasses_test.go b/services/graph/pkg/service/v0/educationclasses_test.go
new file mode 100644
index 00000000000..850e1b8976b
--- /dev/null
+++ b/services/graph/pkg/service/v0/educationclasses_test.go
@@ -0,0 +1,530 @@
+package svc_test
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"errors"
+	"io"
+	"io/ioutil"
+	"net/http"
+	"net/http/httptest"
+
+	userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
+	revactx "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"
+	"github.com/owncloud/ocis/v2/ocis-pkg/log"
+	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 classList struct {
+	Value []*libregraph.EducationClass
+}
+
+var _ = Describe("EducationClass", func() {
+	var (
+		svc                      service.Service
+		ctx                      context.Context
+		cfg                      *config.Config
+		gatewayClient            *mocks.GatewayClient
+		eventsPublisher          mocks.Publisher
+		identityBackend          *identitymocks.Backend
+		identityEducationBackend *identitymocks.EducationBackend
+
+		rr *httptest.ResponseRecorder
+
+		newClass    *libregraph.EducationClass
+		currentUser = &userv1beta1.User{
+			Id: &userv1beta1.UserId{
+				OpaqueId: "user",
+			},
+		}
+	)
+
+	BeforeEach(func() {
+		eventsPublisher.On("Publish", mock.Anything, mock.Anything, mock.Anything).Return(nil)
+
+		identityEducationBackend = &identitymocks.EducationBackend{}
+		identityBackend = &identitymocks.Backend{}
+		gatewayClient = &mocks.GatewayClient{}
+		newClass = libregraph.NewEducationClass("math", "course")
+		newClass.SetMembersodataBind([]string{"/users/user1"})
+		newClass.SetId("math")
+
+		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.WithIdentityBackend(identityBackend),
+			service.WithIdentityEducationBackend(identityEducationBackend),
+			service.Logger(log.NewLogger(log.Level("debug"))),
+		)
+	})
+
+	Describe("GetEducationClasses", func() {
+		It("handles invalid ODATA parameters", func() {
+			r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/classes?§foo=bar", nil)
+			svc.GetEducationClasses(rr, r)
+
+			Expect(rr.Code).To(Equal(http.StatusBadRequest))
+		})
+
+		It("handles invalid sorting queries", func() {
+			identityEducationBackend.On("GetEducationClasses", ctx, mock.Anything).Return([]*libregraph.EducationClass{newClass}, nil)
+
+			r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/classes?$orderby=invalid", nil)
+			svc.GetEducationClasses(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("GetEducationClasses", ctx, mock.Anything).Return(nil, errors.New("failed"))
+
+			r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/classes", nil)
+			svc.GetEducationClasses(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("GetEducationClasses", ctx, mock.Anything).Return(nil, errorcode.New(errorcode.AccessDenied, "access denied"))
+
+			r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/classes", nil)
+			svc.GetEducationClasses(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 classes", func() {
+			identityEducationBackend.On("GetEducationClasses", ctx, mock.Anything).Return([]*libregraph.EducationClass{}, nil)
+
+			r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/classes", nil)
+			svc.GetEducationClasses(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 classes", func() {
+			identityEducationBackend.On("GetEducationClasses", ctx, mock.Anything).Return([]*libregraph.EducationClass{newClass}, nil)
+
+			r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/classes", nil)
+			svc.GetEducationClasses(rr, r)
+
+			Expect(rr.Code).To(Equal(http.StatusOK))
+			data, err := io.ReadAll(rr.Body)
+			Expect(err).ToNot(HaveOccurred())
+
+			res := groupList{}
+			err = json.Unmarshal(data, &res)
+			Expect(err).ToNot(HaveOccurred())
+
+			Expect(len(res.Value)).To(Equal(1))
+			Expect(res.Value[0].GetId()).To(Equal("math"))
+		})
+	})
+
+	Describe("GetEducationClass", func() {
+		It("handles missing or empty class id", func() {
+			r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/classes", nil)
+			svc.GetEducationClass(rr, r)
+
+			Expect(rr.Code).To(Equal(http.StatusBadRequest))
+
+			r = httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/classes", nil)
+			rctx := chi.NewRouteContext()
+			rctx.URLParams.Add("classID", "")
+			r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, nil), chi.RouteCtxKey, rctx))
+			svc.GetEducationClass(rr, r)
+
+			Expect(rr.Code).To(Equal(http.StatusBadRequest))
+		})
+		Context("with an existing class", func() {
+			BeforeEach(func() {
+				identityEducationBackend.On("GetEducationClass", mock.Anything, mock.Anything, mock.Anything).Return(newClass, nil)
+			})
+
+			It("gets the class", func() {
+				r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/classes/"+*newClass.Id, nil)
+				rctx := chi.NewRouteContext()
+				rctx.URLParams.Add("classID", *newClass.Id)
+				r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, nil), chi.RouteCtxKey, rctx))
+
+				svc.GetEducationClass(rr, r)
+
+				Expect(rr.Code).To(Equal(http.StatusOK))
+			})
+		})
+	})
+
+	Describe("PostEducationClass", func() {
+		It("handles invalid body", func() {
+			r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/classes/", bytes.NewBufferString("{invalid"))
+
+			svc.PostEducationClass(rr, r)
+			Expect(rr.Code).To(Equal(http.StatusBadRequest))
+		})
+
+		It("handles missing display name", func() {
+			newClass = libregraph.NewEducationClassWithDefaults()
+			newClass.SetId("disallowed")
+			newClass.SetMembersodataBind([]string{"/non-users/user"})
+			newClassJson, err := json.Marshal(newClass)
+			Expect(err).ToNot(HaveOccurred())
+
+			r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/classes/", bytes.NewBuffer(newClassJson))
+
+			svc.PostEducationClass(rr, r)
+			Expect(rr.Code).To(Equal(http.StatusBadRequest))
+		})
+
+		It("disallows group create ids", func() {
+			newClass = libregraph.NewEducationClassWithDefaults()
+			newClass.SetId("disallowed")
+			newClass.SetDisplayName("New Class")
+			newClass.SetMembersodataBind([]string{"/non-users/user"})
+			newClassJson, err := json.Marshal(newClass)
+			Expect(err).ToNot(HaveOccurred())
+
+			r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/classes/", bytes.NewBuffer(newClassJson))
+
+			svc.PostEducationClass(rr, r)
+			Expect(rr.Code).To(Equal(http.StatusBadRequest))
+		})
+
+		It("creates the Class", func() {
+			newClass = libregraph.NewEducationClassWithDefaults()
+			newClass.SetDisplayName("New Class")
+			newClassJson, err := json.Marshal(newClass)
+			Expect(err).ToNot(HaveOccurred())
+
+			identityEducationBackend.On("CreateEducationClass", mock.Anything, mock.Anything).Return(newClass, nil)
+
+			r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/class/", bytes.NewBuffer(newClassJson))
+
+			svc.PostEducationClass(rr, r)
+
+			Expect(rr.Code).To(Equal(http.StatusCreated))
+		})
+	})
+
+	Describe("PatchEducationClass", func() {
+		It("handles invalid body", func() {
+			r := httptest.NewRequest(http.MethodPatch, "/graph/v1.0/education/classes/", bytes.NewBufferString("{invalid"))
+			rctx := chi.NewRouteContext()
+			rctx.URLParams.Add("classID", *newClass.Id)
+			r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
+			svc.PatchEducationClass(rr, r)
+			Expect(rr.Code).To(Equal(http.StatusBadRequest))
+		})
+
+		It("handles missing or empty group id", func() {
+			r := httptest.NewRequest(http.MethodPatch, "/graph/v1.0/education/classes", nil)
+			svc.PatchEducationClass(rr, r)
+
+			Expect(rr.Code).To(Equal(http.StatusBadRequest))
+
+			r = httptest.NewRequest(http.MethodPatch, "/graph/v1.0/education/classes", nil)
+			rctx := chi.NewRouteContext()
+			rctx.URLParams.Add("classID", "")
+			r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
+			svc.PatchEducationClass(rr, r)
+
+			Expect(rr.Code).To(Equal(http.StatusBadRequest))
+		})
+
+		Context("with an existing group", func() {
+			BeforeEach(func() {
+				identityEducationBackend.On("GetEducationClass", mock.Anything, mock.Anything, mock.Anything).Return(newClass, nil)
+			})
+
+			It("fails when the number of users is exceeded - spec says 20 max", func() {
+				updatedClass := libregraph.NewEducationClassWithDefaults()
+				updatedClass.SetDisplayName("class updated")
+				updatedClass.SetMembersodataBind([]string{"1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16", "17", "18",
+					"19", "20", "21"})
+				updatedClassJson, err := json.Marshal(updatedClass)
+				Expect(err).ToNot(HaveOccurred())
+
+				r := httptest.NewRequest(http.MethodPatch, "/graph/v1.0/education/classes", bytes.NewBuffer(updatedClassJson))
+				rctx := chi.NewRouteContext()
+				rctx.URLParams.Add("classID", *newClass.Id)
+				r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
+				svc.PatchEducationClass(rr, r)
+
+				resp, err := ioutil.ReadAll(rr.Body)
+				Expect(err).ToNot(HaveOccurred())
+
+				Expect(string(resp)).To(ContainSubstring("Request is limited to 20"))
+				Expect(rr.Code).To(Equal(http.StatusBadRequest))
+			})
+
+			It("succeeds when the number of users is over 20 but the limit is raised to 21", func() {
+				updatedClass := libregraph.NewEducationClassWithDefaults()
+				updatedClass.SetDisplayName("group1 updated")
+				updatedClass.SetMembersodataBind([]string{
+					"1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16", "17", "18",
+					"19", "20", "21",
+				})
+				updatedClassJson, err := json.Marshal(updatedClass)
+				Expect(err).ToNot(HaveOccurred())
+
+				cfg.API.GroupMembersPatchLimit = 21
+				svc, _ = service.NewService(
+					service.Config(cfg),
+					service.WithGatewayClient(gatewayClient),
+					service.EventsPublisher(&eventsPublisher),
+					service.WithIdentityBackend(identityBackend),
+				)
+
+				r := httptest.NewRequest(http.MethodPatch, "/graph/v1.0/education/classes", bytes.NewBuffer(updatedClassJson))
+				rctx := chi.NewRouteContext()
+				rctx.URLParams.Add("classID", *newClass.Id)
+				r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
+				svc.PatchEducationClass(rr, r)
+
+				resp, err := ioutil.ReadAll(rr.Body)
+				Expect(err).ToNot(HaveOccurred())
+
+				Expect(string(resp)).To(ContainSubstring("Error parsing member@odata.bind values"))
+				Expect(rr.Code).To(Equal(http.StatusBadRequest))
+			})
+
+			It("fails on invalid user refs", func() {
+				updatedClass := libregraph.NewEducationClassWithDefaults()
+				updatedClass.SetDisplayName("class updated")
+				updatedClass.SetMembersodataBind([]string{"invalid"})
+				updatedClassJson, err := json.Marshal(updatedClass)
+				Expect(err).ToNot(HaveOccurred())
+
+				r := httptest.NewRequest(http.MethodPatch, "/graph/v1.0/education/classes", bytes.NewBuffer(updatedClassJson))
+				rctx := chi.NewRouteContext()
+				rctx.URLParams.Add("classID", *newClass.Id)
+				r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
+				svc.PatchEducationClass(rr, r)
+
+				Expect(rr.Code).To(Equal(http.StatusBadRequest))
+			})
+
+			It("fails when the adding non-users users", func() {
+				updatedClass := libregraph.NewEducationClassWithDefaults()
+				updatedClass.SetDisplayName("group1 updated")
+				updatedClass.SetMembersodataBind([]string{"/non-users/user1"})
+				updatedClassJson, err := json.Marshal(updatedClass)
+				Expect(err).ToNot(HaveOccurred())
+
+				r := httptest.NewRequest(http.MethodPatch, "/graph/v1.0/education/classes", bytes.NewBuffer(updatedClassJson))
+				rctx := chi.NewRouteContext()
+				rctx.URLParams.Add("classID", *newClass.Id)
+				r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
+				svc.PatchEducationClass(rr, r)
+
+				Expect(rr.Code).To(Equal(http.StatusBadRequest))
+			})
+
+			It("adds members to the class", func() {
+				identityBackend.On("AddMembersToGroup", mock.Anything, mock.Anything, mock.Anything).Return(nil)
+
+				updatedClass := libregraph.NewEducationClassWithDefaults()
+				updatedClass.SetDisplayName("Class updated")
+				updatedClass.SetMembersodataBind([]string{"/users/user1"})
+				updatedClassJson, err := json.Marshal(updatedClass)
+				Expect(err).ToNot(HaveOccurred())
+
+				r := httptest.NewRequest(http.MethodPatch, "/graph/v1.0/education/classes", bytes.NewBuffer(updatedClassJson))
+				rctx := chi.NewRouteContext()
+				rctx.URLParams.Add("classID", *newClass.Id)
+				r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
+				svc.PatchEducationClass(rr, r)
+
+				Expect(rr.Code).To(Equal(http.StatusNoContent))
+				identityBackend.AssertNumberOfCalls(GinkgoT(), "AddMembersToGroup", 1)
+			})
+		})
+	})
+
+	Describe("DeleteEducationClass", func() {
+		Context("with an existing EducationClass", func() {
+			BeforeEach(func() {
+				identityEducationBackend.On("GetEducationClass", mock.Anything, mock.Anything, mock.Anything).Return(newClass, nil)
+			})
+		})
+
+		It("deletes the EducationClass", func() {
+			identityEducationBackend.On("DeleteEducationClass", mock.Anything, mock.Anything, mock.Anything).Return(nil)
+			r := httptest.NewRequest(http.MethodPatch, "/graph/v1.0/education/classes", nil)
+			rctx := chi.NewRouteContext()
+			rctx.URLParams.Add("classID", *newClass.Id)
+			r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
+			svc.DeleteEducationClass(rr, r)
+
+			Expect(rr.Code).To(Equal(http.StatusNoContent))
+			identityEducationBackend.AssertNumberOfCalls(GinkgoT(), "DeleteEducationClass", 1)
+			//eventsPublisher.AssertNumberOfCalls(GinkgoT(), "Publish", 1)
+		})
+	})
+
+	Describe("GetEducationClassMembers", func() {
+		It("gets the list of members", func() {
+			user := libregraph.NewEducationUser()
+			user.SetId("user")
+			identityEducationBackend.On("GetEducationClassMembers", mock.Anything, mock.Anything, mock.Anything).
+				Return([]*libregraph.EducationUser{user}, nil)
+
+			r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/classes/{classID}/members", nil)
+			rctx := chi.NewRouteContext()
+			rctx.URLParams.Add("classID", *newClass.Id)
+			r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
+			svc.GetEducationClassMembers(rr, r)
+			Expect(rr.Code).To(Equal(http.StatusOK))
+
+			data, err := io.ReadAll(rr.Body)
+			Expect(err).ToNot(HaveOccurred())
+
+			var members []*libregraph.EducationUser
+			err = json.Unmarshal(data, &members)
+			Expect(err).ToNot(HaveOccurred())
+
+			Expect(len(members)).To(Equal(1))
+			Expect(members[0].GetId()).To(Equal("user"))
+		})
+	})
+
+	Describe("PostEducationClassMember", func() {
+		It("fails on invalid body", func() {
+			r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/classes/{classID}/members", bytes.NewBufferString("{invalid"))
+			rctx := chi.NewRouteContext()
+			rctx.URLParams.Add("classID", *newClass.Id)
+			r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
+			svc.PostEducationClassMember(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/classes/{classID}/members", bytes.NewBuffer(data))
+			rctx := chi.NewRouteContext()
+			rctx.URLParams.Add("classID", *newClass.Id)
+			r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
+			svc.PostEducationClassMember(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/classes/{classID}/members", bytes.NewBuffer(data))
+			rctx := chi.NewRouteContext()
+			rctx.URLParams.Add("classID", *newClass.Id)
+			r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
+			svc.PostEducationClassMember(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())
+			identityBackend.On("AddMembersToGroup", mock.Anything, mock.Anything, mock.Anything).Return(nil)
+
+			r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/classes/{classID}/members", bytes.NewBuffer(data))
+			rctx := chi.NewRouteContext()
+			rctx.URLParams.Add("classID", *newClass.Id)
+			r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
+			svc.PostEducationClassMember(rr, r)
+			Expect(rr.Code).To(Equal(http.StatusNoContent))
+
+			identityBackend.AssertNumberOfCalls(GinkgoT(), "AddMembersToGroup", 1)
+		})
+	})
+
+	Describe("DeleteEducationClassMembers", func() {
+		It("handles missing or empty member id", func() {
+			r := httptest.NewRequest(http.MethodDelete, "/graph/v1.0/education/classes/{classID}/members/{memberID}/$ref", nil)
+			rctx := chi.NewRouteContext()
+			rctx.URLParams.Add("classID", *newClass.Id)
+			r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
+			svc.DeleteEducationClassMember(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/classes/{classID}/members/{memberID}/$ref", nil)
+			rctx := chi.NewRouteContext()
+			rctx.URLParams.Add("memberID", "/users/user")
+			r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
+			svc.DeleteEducationClassMember(rr, r)
+			Expect(rr.Code).To(Equal(http.StatusBadRequest))
+		})
+
+		It("deletes members", func() {
+			identityBackend.On("RemoveMemberFromGroup", mock.Anything, mock.Anything, mock.Anything).Return(nil)
+
+			r := httptest.NewRequest(http.MethodDelete, "/graph/v1.0/education/classes/{classID}/members/{memberID}/$ref", nil)
+			rctx := chi.NewRouteContext()
+			rctx.URLParams.Add("classID", *newClass.Id)
+			rctx.URLParams.Add("memberID", "/users/user1")
+			r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
+			svc.DeleteEducationClassMember(rr, r)
+			Expect(rr.Code).To(Equal(http.StatusNoContent))
+
+			identityBackend.AssertNumberOfCalls(GinkgoT(), "RemoveMemberFromGroup", 1)
+		})
+	})
+})
diff --git a/services/graph/pkg/service/v0/educationuser_test.go b/services/graph/pkg/service/v0/educationuser_test.go
index adc45de6e19..1e9e445cbb9 100644
--- a/services/graph/pkg/service/v0/educationuser_test.go
+++ b/services/graph/pkg/service/v0/educationuser_test.go
@@ -74,7 +74,7 @@ var _ = Describe("EducationUsers", func() {
 			service.WithGatewayClient(gatewayClient),
 			service.EventsPublisher(&eventsPublisher),
 			service.WithIdentityEducationBackend(identityEducationBackend),
-			//service.WithRoleService(roleService),
+			service.WithRoleService(roleService),
 		)
 	})
 
diff --git a/services/graph/pkg/service/v0/users_test.go b/services/graph/pkg/service/v0/users_test.go
index 070f547d816..77a7f48b0e8 100644
--- a/services/graph/pkg/service/v0/users_test.go
+++ b/services/graph/pkg/service/v0/users_test.go
@@ -17,11 +17,13 @@ import (
 	. "github.com/onsi/ginkgo/v2"
 	. "github.com/onsi/gomega"
 	"github.com/stretchr/testify/mock"
+	"go-micro.dev/v4/client"
 
 	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"
+	settingsmsg "github.com/owncloud/ocis/v2/protogen/gen/ocis/messages/settings/v0"
+	settings "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"
@@ -67,6 +69,7 @@ var _ = Describe("Users", func() {
 		cfg.TokenManager.JWTSecret = "loremipsum"
 		cfg.Commons = &shared.Commons{}
 		cfg.GRPCClientTLS = &shared.GRPCClientTLS{}
+		cfg.Application.ID = "some-application-ID"
 
 		_ = ogrpc.Configure(ogrpc.GetClientOptions(cfg.GRPCClientTLS)...)
 		svc, _ = service.NewService(
@@ -94,8 +97,13 @@ var _ = Describe("Users", func() {
 			Expect(rr.Code).To(Equal(http.StatusOK))
 		})
 
-		It("expands the user", func() {
-			user := &libregraph.User{}
+		It("expands the memberOf", func() {
+			user := &libregraph.User{
+				Id: libregraph.PtrString("user1"),
+				MemberOf: []libregraph.Group{
+					{DisplayName: libregraph.PtrString("somegroup")},
+				},
+			}
 			identityBackend.On("GetUser", mock.Anything, mock.Anything, mock.Anything).Return(user, nil)
 
 			r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/me?$expand=memberOf", nil)
@@ -103,6 +111,48 @@ var _ = Describe("Users", func() {
 			svc.GetMe(rr, r)
 
 			Expect(rr.Code).To(Equal(http.StatusOK))
+			data, err := io.ReadAll(rr.Body)
+			Expect(err).ToNot(HaveOccurred())
+
+			responseUser := &libregraph.User{}
+			err = json.Unmarshal(data, &responseUser)
+			Expect(err).ToNot(HaveOccurred())
+
+			Expect(responseUser.GetId()).To(Equal("user1"))
+			Expect(responseUser.GetMemberOf()).To(HaveLen(1))
+			Expect(responseUser.GetMemberOf()[0].GetDisplayName()).To(Equal("somegroup"))
+
+		})
+
+		It("expands the appRoleAssignments", func() {
+			assignments := []*settingsmsg.UserRoleAssignment{
+				{
+					Id:          "some-appRoleAssignment-ID",
+					AccountUuid: "user",
+					RoleId:      "some-appRole-ID",
+				},
+			}
+			roleService.On("ListRoleAssignments", mock.Anything, mock.Anything, mock.Anything).Return(&settings.ListRoleAssignmentsResponse{Assignments: assignments}, nil)
+
+			r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/me?$expand=appRoleAssignments", nil)
+			r = r.WithContext(revactx.ContextSetUser(ctx, currentUser))
+			svc.GetMe(rr, r)
+
+			Expect(rr.Code).To(Equal(http.StatusOK))
+
+			data, err := io.ReadAll(rr.Body)
+			Expect(err).ToNot(HaveOccurred())
+
+			responseUser := &libregraph.User{}
+			err = json.Unmarshal(data, &responseUser)
+			Expect(err).ToNot(HaveOccurred())
+
+			Expect(responseUser.GetId()).To(Equal("user"))
+			Expect(responseUser.GetAppRoleAssignments()).To(HaveLen(1))
+			Expect(responseUser.GetAppRoleAssignments()[0].GetId()).To(Equal("some-appRoleAssignment-ID"))
+			Expect(responseUser.GetAppRoleAssignments()[0].GetAppRoleId()).To(Equal("some-appRole-ID"))
+			Expect(responseUser.GetAppRoleAssignments()[0].GetPrincipalId()).To(Equal("user"))
+			Expect(responseUser.GetAppRoleAssignments()[0].GetResourceId()).To(Equal("some-application-ID"))
 		})
 	})
 
@@ -216,6 +266,62 @@ var _ = Describe("Users", func() {
 
 			Expect(rr.Code).To(Equal(http.StatusBadRequest))
 		})
+
+		It("expands the appRoleAssignments", func() {
+
+			user := &libregraph.User{}
+			user.SetId("user1")
+			user.SetMail("z@example.com")
+			user.SetDisplayName("9")
+			user.SetOnPremisesSamAccountName("9")
+			user2 := &libregraph.User{}
+			user2.SetId("user2")
+			user2.SetMail("a@example.com")
+			user2.SetDisplayName("1")
+			user2.SetOnPremisesSamAccountName("1")
+			users := []*libregraph.User{user, user2}
+			identityBackend.On("GetUsers", mock.Anything, mock.Anything, mock.Anything).Return(users, nil)
+
+			roleService.On("ListRoleAssignments", mock.Anything, mock.Anything, mock.Anything).Return(func(ctx context.Context, in *settings.ListRoleAssignmentsRequest, opts ...client.CallOption) *settings.ListRoleAssignmentsResponse {
+				return &settings.ListRoleAssignmentsResponse{Assignments: []*settingsmsg.UserRoleAssignment{
+					{
+						Id:          "some-appRoleAssignment-ID",
+						AccountUuid: in.GetAccountUuid(),
+						RoleId:      "some-appRole-ID",
+					},
+				}}
+			}, nil)
+
+			r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/users?$expand=appRoleAssignments", nil)
+			r = r.WithContext(revactx.ContextSetUser(ctx, currentUser))
+			svc.GetUsers(rr, r)
+
+			Expect(rr.Code).To(Equal(http.StatusOK))
+
+			data, err := io.ReadAll(rr.Body)
+			Expect(err).ToNot(HaveOccurred())
+
+			res := userList{}
+			err = json.Unmarshal(data, &res)
+			Expect(err).ToNot(HaveOccurred())
+
+			responseUsers := res.Value
+			Expect(len(responseUsers)).To(Equal(2))
+			Expect(responseUsers[0].GetId()).To(Equal("user1"))
+			Expect(responseUsers[0].GetAppRoleAssignments()).To(HaveLen(1))
+			Expect(responseUsers[0].GetAppRoleAssignments()[0].GetId()).To(Equal("some-appRoleAssignment-ID"))
+			Expect(responseUsers[0].GetAppRoleAssignments()[0].GetAppRoleId()).To(Equal("some-appRole-ID"))
+			Expect(responseUsers[0].GetAppRoleAssignments()[0].GetPrincipalId()).To(Equal("user1"))
+			Expect(responseUsers[0].GetAppRoleAssignments()[0].GetResourceId()).To(Equal("some-application-ID"))
+
+			Expect(responseUsers[1].GetId()).To(Equal("user2"))
+			Expect(responseUsers[1].GetAppRoleAssignments()).To(HaveLen(1))
+			Expect(responseUsers[1].GetAppRoleAssignments()[0].GetId()).To(Equal("some-appRoleAssignment-ID"))
+			Expect(responseUsers[1].GetAppRoleAssignments()[0].GetAppRoleId()).To(Equal("some-appRole-ID"))
+			Expect(responseUsers[1].GetAppRoleAssignments()[0].GetPrincipalId()).To(Equal("user2"))
+			Expect(responseUsers[1].GetAppRoleAssignments()[0].GetResourceId()).To(Equal("some-application-ID"))
+
+		})
 	})
 
 	Describe("GetUser", func() {
@@ -322,6 +428,44 @@ var _ = Describe("Users", func() {
 			Expect(responseUser.GetId()).To(Equal("user1"))
 			Expect(len(responseUser.GetDrives())).To(Equal(1))
 		})
+
+		It("expands the appRoleAssignments", func() {
+			user := &libregraph.User{}
+			user.SetId("user1")
+
+			identityBackend.On("GetUser", mock.Anything, mock.Anything, mock.Anything).Return(user, nil)
+
+			assignments := []*settingsmsg.UserRoleAssignment{
+				{
+					Id:          "some-appRoleAssignment-ID",
+					AccountUuid: "user1",
+					RoleId:      "some-appRole-ID",
+				},
+			}
+			roleService.On("ListRoleAssignments", mock.Anything, mock.Anything, mock.Anything).Return(&settings.ListRoleAssignmentsResponse{Assignments: assignments}, nil)
+
+			r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/users/user1?$expand=appRoleAssignments", nil)
+			rctx := chi.NewRouteContext()
+			rctx.URLParams.Add("userID", user.GetId())
+			r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
+			svc.GetUser(rr, r)
+
+			Expect(rr.Code).To(Equal(http.StatusOK))
+
+			data, err := io.ReadAll(rr.Body)
+			Expect(err).ToNot(HaveOccurred())
+
+			responseUser := &libregraph.User{}
+			err = json.Unmarshal(data, &responseUser)
+			Expect(err).ToNot(HaveOccurred())
+
+			Expect(responseUser.GetId()).To(Equal("user1"))
+			Expect(responseUser.GetAppRoleAssignments()).To(HaveLen(1))
+			Expect(responseUser.GetAppRoleAssignments()[0].GetId()).To(Equal("some-appRoleAssignment-ID"))
+			Expect(responseUser.GetAppRoleAssignments()[0].GetAppRoleId()).To(Equal("some-appRole-ID"))
+			Expect(responseUser.GetAppRoleAssignments()[0].GetPrincipalId()).To(Equal("user1"))
+			Expect(responseUser.GetAppRoleAssignments()[0].GetResourceId()).To(Equal("some-application-ID"))
+		})
 	})
 
 	Describe("PostUser", func() {
@@ -381,7 +525,7 @@ var _ = Describe("Users", func() {
 		})
 
 		It("creates a user", func() {
-			roleService.On("AssignRoleToUser", mock.Anything, mock.Anything).Return(&settingssvc.AssignRoleToUserResponse{}, nil)
+			roleService.On("AssignRoleToUser", mock.Anything, mock.Anything).Return(&settings.AssignRoleToUserResponse{}, nil)
 			identityBackend.On("CreateUser", mock.Anything, mock.Anything).Return(func(ctx context.Context, user libregraph.User) *libregraph.User {
 				user.SetId("/users/user")
 				return &user
diff --git a/services/notifications/pkg/service/notification_suite_test.go b/services/notifications/pkg/service/notification_suite_test.go
new file mode 100644
index 00000000000..5735c73ff4c
--- /dev/null
+++ b/services/notifications/pkg/service/notification_suite_test.go
@@ -0,0 +1,13 @@
+package service_test
+
+import (
+	"testing"
+
+	. "github.com/onsi/ginkgo/v2"
+	. "github.com/onsi/gomega"
+)
+
+func TestNotifications(t *testing.T) {
+	RegisterFailHandler(Fail)
+	RunSpecs(t, "Notification Suite")
+}
diff --git a/services/notifications/pkg/service/service_test.go b/services/notifications/pkg/service/service_test.go
new file mode 100644
index 00000000000..340b2f14922
--- /dev/null
+++ b/services/notifications/pkg/service/service_test.go
@@ -0,0 +1,153 @@
+package service_test
+
+import (
+	"context"
+	"time"
+
+	gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
+	group "github.com/cs3org/go-cs3apis/cs3/identity/group/v1beta1"
+	user "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
+	rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
+	provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
+	"github.com/cs3org/reva/v2/pkg/events"
+	"github.com/cs3org/reva/v2/pkg/utils"
+	cs3mocks "github.com/cs3org/reva/v2/tests/cs3mocks/mocks"
+	. "github.com/onsi/ginkgo/v2"
+	. "github.com/onsi/gomega"
+	"github.com/owncloud/ocis/v2/ocis-pkg/log"
+	"github.com/owncloud/ocis/v2/services/notifications/pkg/service"
+	"github.com/test-go/testify/mock"
+)
+
+var _ = Describe("Notifications", func() {
+	var (
+		gwc    *cs3mocks.GatewayAPIClient
+		sharer = &user.User{
+			Id: &user.UserId{
+				OpaqueId: "sharer",
+			},
+			DisplayName: "Dr. S. Harer",
+		}
+		sharee = &user.User{
+			Id: &user.UserId{
+				OpaqueId: "sharee",
+			},
+			DisplayName: "Eric Expireling",
+		}
+		resourceid = &provider.ResourceId{
+			StorageId: "storageid",
+			SpaceId:   "spaceid",
+			OpaqueId:  "itemid",
+		}
+	)
+
+	BeforeEach(func() {
+		gwc = &cs3mocks.GatewayAPIClient{}
+		gwc.On("GetUser", mock.Anything, mock.Anything).Return(&user.GetUserResponse{Status: &rpc.Status{Code: rpc.Code_CODE_OK}, User: sharer}, nil)
+		gwc.On("Authenticate", mock.Anything, mock.Anything).Return(&gateway.AuthenticateResponse{Status: &rpc.Status{Code: rpc.Code_CODE_OK}, User: sharer}, nil)
+		gwc.On("Stat", mock.Anything, mock.Anything).Return(&provider.StatResponse{Status: &rpc.Status{Code: rpc.Code_CODE_OK}, Info: &provider.ResourceInfo{Name: "secrets of the board", Space: &provider.StorageSpace{Name: "secret space"}}}, nil)
+	})
+
+	DescribeTable("Sending notifications",
+		func(tc testChannel, ev interface{}) {
+			ch := make(chan interface{})
+			evts := service.NewEventsNotifier(ch, tc, log.NewLogger(), gwc, "", "", "")
+			go evts.Run()
+
+			ch <- ev
+			select {
+			case <-tc.done:
+				// finished
+			case <-time.Tick(3 * time.Second):
+				Fail("timeout waiting for notification")
+			}
+		},
+
+		Entry("Share Created", testChannel{
+			expectedReceipients: map[string]bool{sharee.GetId().GetOpaqueId(): true},
+			expectedSubject:     "Dr. S. Harer shared 'secrets of the board' with you",
+			expectedSender:      sharer.GetDisplayName(),
+			done:                make(chan struct{}),
+		}, events.ShareCreated{
+			Sharer:        sharer.GetId(),
+			GranteeUserID: sharee.GetId(),
+			CTime:         utils.TimeToTS(time.Date(2023, 4, 17, 16, 42, 0, 0, time.UTC)),
+			ItemID:        resourceid,
+		}),
+
+		Entry("Share Expired", testChannel{
+			expectedReceipients: map[string]bool{sharee.GetId().GetOpaqueId(): true},
+			expectedSubject:     "Share to 'secrets of the board' expired at 2023-04-17 16:42:00",
+			expectedSender:      sharer.GetDisplayName(),
+			done:                make(chan struct{}),
+		}, events.ShareExpired{
+			ShareOwner:    sharer.GetId(),
+			GranteeUserID: sharee.GetId(),
+			ExpiredAt:     time.Date(2023, 4, 17, 16, 42, 0, 0, time.UTC),
+			ItemID:        resourceid,
+		}),
+
+		Entry("Added to Space", testChannel{
+			expectedReceipients: map[string]bool{sharee.GetId().GetOpaqueId(): true},
+			expectedSubject:     "Dr. S. Harer invited you to join secret space",
+			expectedSender:      sharer.GetDisplayName(),
+			done:                make(chan struct{}),
+		}, events.SpaceShared{
+			Executant:     sharer.GetId(),
+			Creator:       sharer.GetId(),
+			GranteeUserID: sharee.GetId(),
+			ID:            &provider.StorageSpaceId{OpaqueId: "spaceid"},
+		}),
+
+		Entry("Removed from Space", testChannel{
+			expectedReceipients: map[string]bool{sharee.GetId().GetOpaqueId(): true},
+			expectedSubject:     "Dr. S. Harer removed you from secret space",
+			expectedSender:      sharer.GetDisplayName(),
+			done:                make(chan struct{}),
+		}, events.SpaceUnshared{
+			Executant:     sharer.GetId(),
+			GranteeUserID: sharee.GetId(),
+			ID:            &provider.StorageSpaceId{OpaqueId: "spaceid"},
+		}),
+
+		Entry("Space Expired", testChannel{
+			expectedReceipients: map[string]bool{sharee.GetId().GetOpaqueId(): true},
+			expectedSubject:     "Membership of 'secret space' expired at 2023-04-17 16:42:00",
+			expectedSender:      sharer.GetDisplayName(),
+			done:                make(chan struct{}),
+		}, events.SpaceMembershipExpired{
+			SpaceOwner:    sharer.GetId(),
+			GranteeUserID: sharee.GetId(),
+			SpaceID:       &provider.StorageSpaceId{OpaqueId: "spaceid"},
+			SpaceName:     "secret space",
+			ExpiredAt:     time.Date(2023, 4, 17, 16, 42, 0, 0, time.UTC),
+		}),
+	)
+})
+
+// NOTE: This is explictitly not testing the message itself. Should we?
+type testChannel struct {
+	expectedReceipients map[string]bool
+	expectedSubject     string
+	expectedSender      string
+	done                chan struct{}
+}
+
+func (tc testChannel) SendMessage(ctx context.Context, userIDs []string, msg, subject, senderDisplayName string) error {
+	defer GinkgoRecover()
+
+	for _, u := range userIDs {
+		Expect(tc.expectedReceipients[u]).To(Equal(true))
+	}
+
+	// TODO: test the message?
+	//Expect(msg).To(Equal(tc.expectedMessage))
+	Expect(subject).To(Equal(tc.expectedSubject))
+	Expect(senderDisplayName).To(Equal(tc.expectedSender))
+	tc.done <- struct{}{}
+	return nil
+}
+
+func (tc testChannel) SendMessageToGroup(ctx context.Context, groupID *group.GroupId, msg, subject, senderDisplayName string) error {
+	return tc.SendMessage(ctx, []string{groupID.GetOpaqueId()}, msg, subject, senderDisplayName)
+}
diff --git a/services/settings/pkg/store/metadata/store_test.go b/services/settings/pkg/store/metadata/store_test.go
index bb1c75ff892..0859d38b524 100644
--- a/services/settings/pkg/store/metadata/store_test.go
+++ b/services/settings/pkg/store/metadata/store_test.go
@@ -4,6 +4,7 @@ import (
 	"context"
 	"strings"
 
+	"github.com/cs3org/reva/v2/pkg/errtypes"
 	"github.com/owncloud/ocis/v2/services/settings/pkg/config/defaults"
 )
 
@@ -53,9 +54,12 @@ func NewMDC(s *Store) error {
 	return s.initMetadataClient(mdc)
 }
 
-// SimpleDownload returns nil if not found
+// SimpleDownload returns errtypes.NotFound if not found
 func (m *MockedMetadataClient) SimpleDownload(_ context.Context, id string) ([]byte, error) {
-	return m.data[id], nil
+	if data, ok := m.data[id]; ok {
+		return data, nil
+	}
+	return nil, errtypes.NotFound("not found")
 }
 
 // SimpleUpload can't error
diff --git a/tests/acceptance/expected-failures-localAPI-on-OCIS-storage.md b/tests/acceptance/expected-failures-localAPI-on-OCIS-storage.md
index 45a4d8bdcc2..3e4b0074e36 100644
--- a/tests/acceptance/expected-failures-localAPI-on-OCIS-storage.md
+++ b/tests/acceptance/expected-failures-localAPI-on-OCIS-storage.md
@@ -88,9 +88,6 @@ The expected failures in this file are from features in the owncloud/ocis repo.
 - [apiSpacesShares/shareSubItemOfSpaceViaPublicLink.feature:161](https://github.com/owncloud/ocis/blob/master/tests/acceptance/features/apiSpacesShares/shareSubItemOfSpaceViaPublicLink.feature#L161)
 - [apiSpacesShares/shareSubItemOfSpaceViaPublicLink.feature:162](https://github.com/owncloud/ocis/blob/master/tests/acceptance/features/apiSpacesShares/shareSubItemOfSpaceViaPublicLink.feature#L162)
 
-#### [Public cannot download folder via the public link of the folder inside the project space](https://github.com/owncloud/ocis/issues/5229)
-- [apiSpacesShares/publicLinkDownload.feature:30](https://github.com/owncloud/ocis/blob/master/tests/acceptance/features/apiSpacesShares/publicLinkDownload.feature#L30)
-
 #### [A User can get information of another user with Graph API](https://github.com/owncloud/ocis/issues/5125)
 - [apiGraph/getUser.feature:23](https://github.com/owncloud/ocis/blob/master/tests/acceptance/features/apiGraph/getUser.feature#L23)
 
diff --git a/tests/acceptance/features/coreApiVersions/fileVersions.feature b/tests/acceptance/features/coreApiVersions/fileVersions.feature
index e2f6a0ce6f2..38a5ef2a7aa 100644
--- a/tests/acceptance/features/coreApiVersions/fileVersions.feature
+++ b/tests/acceptance/features/coreApiVersions/fileVersions.feature
@@ -531,3 +531,26 @@ Feature: dav-versions
     When user "Alice" restores version index "1" of file "/davtest.txt" using the WebDAV API
     Then the HTTP status code should be "204"
     And the content of file "/davtest.txt" for user "Alice" should be "Old Test Content."
+
+  @issue-5010
+  Scenario: Upload the same file twice with the same mtime and a version is available
+    Given user "Alice" has uploaded file "filesForUpload/textfile.txt" to "file.txt" with mtime "Thu, 08 Aug 2019 04:18:13 GMT" using the WebDAV API
+    When user "Alice" uploads file "filesForUpload/textfile.txt" to "file.txt" with mtime "Thu, 08 Aug 2019 04:18:13 GMT" using the WebDAV API
+    Then the HTTP status code should be "204"
+    And the version folder of file "/file.txt" for user "Alice" should contain "1" element
+
+  @issue-5010
+  Scenario: Upload the same file more than twice with the same mtime and only one version is available
+    Given user "Alice" has uploaded file "filesForUpload/textfile.txt" to "file.txt" with mtime "Thu, 08 Aug 2019 04:18:13 GMT" using the WebDAV API
+    And user "Alice" has uploaded file "filesForUpload/textfile.txt" to "file.txt" with mtime "Thu, 08 Aug 2019 04:18:13 GMT" using the WebDAV API
+    When user "Alice" uploads file "filesForUpload/textfile.txt" to "file.txt" with mtime "Thu, 08 Aug 2019 04:18:13 GMT" using the WebDAV API
+    Then the HTTP status code should be "204"
+    And the version folder of file "/file.txt" for user "Alice" should contain "1" element
+
+  @issue-5010
+  Scenario: Upload the same file twice with the same mtime and no version after restoring
+    Given user "Alice" has uploaded file "filesForUpload/textfile.txt" to "file.txt" with mtime "Thu, 08 Aug 2019 04:18:13 GMT" using the WebDAV API
+    And user "Alice" has uploaded file "filesForUpload/textfile.txt" to "file.txt" with mtime "Thu, 08 Aug 2019 04:18:13 GMT" using the WebDAV API
+    When user "Alice" restores version index "1" of file "/file.txt" using the WebDAV API
+    Then the HTTP status code should be "204"
+    And the version folder of file "/file.txt" for user "Alice" should contain "0" element
\ No newline at end of file
diff --git a/tests/config/drone/setup-for-litmus.sh b/tests/config/drone/setup-for-litmus.sh
index 12f6d9b0ced..92e0831bc77 100644
--- a/tests/config/drone/setup-for-litmus.sh
+++ b/tests/config/drone/setup-for-litmus.sh
@@ -5,7 +5,7 @@ SHARE_ENDPOINT="ocs/v2.php/apps/files_sharing/api/v1/shares"
 # envs
 ENV="SPACE_ID="
 # get space id
-SPACE_ID=$(curl -ks -uadmin:admin "${TEST_SERVER_URL}/graph/v1.0/me/drives" | grep -Po '(?<="webDavUrl":").*?(?="})' | cut -d"/" -f6 | sed "s/\\$/\\\\$/g")
+SPACE_ID=$(curl -ks -uadmin:admin "${TEST_SERVER_URL}/graph/v1.0/me/drives" | jq -r '.value[] | select(.driveType == "personal") | .root.webDavUrl' | cut -d"/" -f6 | sed "s/\\$/\\\\$/g")
 ENV+=${SPACE_ID}
 
 # create a folder