Repository: owncloud/ocis
Pull request #6048 (2023-04-13 11:41:20)

proxy: Rework oidc role mapper to allow multiple matching roles
If multiple claims values have a valid matching for ocis roles, we'll pick the ocis role that appears first in the mapping configuration.




Patch:
diff --git a/.drone.env b/.drone.env
index cf7322d5a3a..9aa4baf45a1 100644
--- a/.drone.env
+++ b/.drone.env
@@ -1,4 +1,4 @@
 # The test runner source for UI tests
 
-WEB_COMMITID=b98a538a42acdfc3e5af182441656d628dcf37de
+WEB_COMMITID=02d76de0c7dcda3d3bf9255649246b25e88c99b7
 WEB_BRANCH=master
diff --git a/.drone.star b/.drone.star
index 45b480f6b8f..c71d5b7bf33 100644
--- a/.drone.star
+++ b/.drone.star
@@ -477,6 +477,14 @@ def testOcisModule(ctx, module):
                 "make -C %s ci-golangci-lint" % (module),
                 "mv %s/checkstyle.xml cache/checkstyle/$(basename %s)_checkstyle.xml" % (module, module),
             ],
+            "environment": {
+                "HTTP_PROXY": {
+                    "from_secret": "drone_http_proxy",
+                },
+                "HTTPS_PROXY": {
+                    "from_secret": "drone_http_proxy",
+                },
+            },
             "volumes": [stepVolumeGo],
         },
         {
@@ -1920,6 +1928,7 @@ def docs():
                     "pages_directory": "docs/hugo/content/",
                     "copy_contents": "true",
                     "target_branch": "docs",
+                    "delete": "true",
                 },
                 "when": {
                     "ref": {
@@ -1946,7 +1955,7 @@ def docs():
                         "from_secret": "drone_token",
                     },
                     "repositories": [
-                        "owncloud/owncloud.github.io@source",
+                        "owncloud/owncloud.github.io@main",
                     ],
                 },
                 "when": {
@@ -1998,6 +2007,14 @@ def makeGoGenerate(module):
             "commands": [
                 "retry -t 3 '%s ci-go-generate'" % (make),
             ],
+            "environment": {
+                "HTTP_PROXY": {
+                    "from_secret": "drone_http_proxy",
+                },
+                "HTTPS_PROXY": {
+                    "from_secret": "drone_http_proxy",
+                },
+            },
             "volumes": [stepVolumeGo],
         },
     ]
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0c012fa0083..9a36eb82e7a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -45,11 +45,13 @@ The following sections list the changes for unreleased.
 * Enhancement - Extended search: [#5221](https://github.com/owncloud/ocis/pull/5221)
 * Enhancement - Resource tags: [#5227](https://github.com/owncloud/ocis/pull/5227)
 * Enhancement - Allow users to be disabled: [#5588](https://github.com/owncloud/ocis/pull/5588)
+* Enhancement - Web config additions: [#6032](https://github.com/owncloud/ocis/pull/6032)
 * Enhancement - Eventhistory service: [#5600](https://github.com/owncloud/ocis/pull/5600)
 * Enhancement - Expiration Notifications: [#5330](https://github.com/owncloud/ocis/pull/5330)
-* Enhancement - GDPR Export: [#5950](https://github.com/owncloud/ocis/pull/5950)
+* Enhancement - GDPR Export: [#6064](https://github.com/owncloud/ocis/pull/6064)
 * Enhancement - Make graph/education API errors more consistent: [#5682](https://github.com/owncloud/ocis/pull/5682)
 * Enhancement - Add endpoint to list permissions: [#5594](https://github.com/owncloud/ocis/pull/5594)
+* Enhancement - Notifications: [#6038](https://github.com/owncloud/ocis/pull/6038)
 * Enhancement - No Notifications for own actions: [#5871](https://github.com/owncloud/ocis/pull/5871)
 * Enhancement - Notify about policies: [#5912](https://github.com/owncloud/ocis/pull/5912)
 * Enhancement - Add a capability for the Personal Data export: [#5984](https://github.com/owncloud/ocis/pull/5984)
@@ -58,6 +60,7 @@ The following sections list the changes for unreleased.
 * Enhancement - Add config option to enforce passwords on public links: [#5848](https://github.com/owncloud/ocis/pull/5848)
 * Enhancement - Add new permission for public links: [#5690](https://github.com/owncloud/ocis/pull/5690)
 * Enhancement - Added option to configure default quota per role: [#5616](https://github.com/owncloud/ocis/pull/5616)
+* Enhancement - Add optional services to the runtime: [#6071](https://github.com/owncloud/ocis/pull/6071)
 * Enhancement - Add new SetProjectSpaceQuota permission: [#5660](https://github.com/owncloud/ocis/pull/5660)
 * Enhancement - Add expiration to user and group shares: [#5389](https://github.com/owncloud/ocis/pull/5389)
 * Enhancement - Space Management permissions: [#5441](https://github.com/owncloud/ocis/pull/5441)
diff --git a/changelog/unreleased/enhancement-web-config-additions.md b/changelog/unreleased/enhancement-web-config-additions.md
new file mode 100644
index 00000000000..698c0e209a9
--- /dev/null
+++ b/changelog/unreleased/enhancement-web-config-additions.md
@@ -0,0 +1,5 @@
+Enhancement: Web config additions
+
+We've added config keys for defining additional css, scripts and translations for ownCloud Web.
+
+https://github.com/owncloud/ocis/pull/6032
diff --git a/changelog/unreleased/gdpr-export.md b/changelog/unreleased/gdpr-export.md
index 9d6740f554e..467f93c43af 100644
--- a/changelog/unreleased/gdpr-export.md
+++ b/changelog/unreleased/gdpr-export.md
@@ -2,4 +2,5 @@ Enhancement: GDPR Export
 
 Adds an endpoint to collect all data that is related to a user
 
+https://github.com/owncloud/ocis/pull/6064
 https://github.com/owncloud/ocis/pull/5950
diff --git a/changelog/unreleased/make-emails-translatable-via-transifex.md b/changelog/unreleased/make-emails-translatable-via-transifex.md
new file mode 100644
index 00000000000..d7b40f862b7
--- /dev/null
+++ b/changelog/unreleased/make-emails-translatable-via-transifex.md
@@ -0,0 +1,9 @@
+Enhancement: Notifications
+
+Make Emails translatable via transifex
+The transifex translation add in to the email templates.
+The optional environment variable NOTIFICATIONS_TRANSLATION_PATH added to config.
+The optional global environment variable OCIS_TRANSLATION_PATH added to notifications and userlog config.
+
+https://github.com/owncloud/ocis/pull/6038
+https://github.com/owncloud/ocis/issues/6025
diff --git a/changelog/unreleased/role-assignment-from-oidc.md b/changelog/unreleased/role-assignment-from-oidc.md
new file mode 100644
index 00000000000..15c3b19b4e2
--- /dev/null
+++ b/changelog/unreleased/role-assignment-from-oidc.md
@@ -0,0 +1,23 @@
+Enhancement: Added possiblity to assign roles based on OIDC claims
+
+oCIS can now be configured to update a user's role assignment from the values of a claim provided
+via the IDPs userinfo endpoint. The claim name and the mapping between claim values and ocis role
+name can be configured via the configuration of the proxy service. Example:
+
+```yaml
+role_assignment:
+    driver: oidc
+    oidc_role_mapper:
+        role_claim: ocisRoles
+        role_mapping:
+            - role_name: admin
+              claim_value: myAdminRole
+            - role_name: spaceadmin
+              claim_value: mySpaceAdminRole
+            - role_name: user
+              claim_value: myUserRole
+            - role_name: guest:
+              claim_value: myGuestRole
+```
+
+https://github.com/owncloud/ocis/pull/6048
diff --git a/changelog/unreleased/runtime.md b/changelog/unreleased/runtime.md
new file mode 100644
index 00000000000..aa096595371
--- /dev/null
+++ b/changelog/unreleased/runtime.md
@@ -0,0 +1,5 @@
+Enhancement: Add optional services to the runtime
+
+Make it possible to start optional services in the ocis runtime. Instead of using `OCIS_RUN_SERVICES` to define all services we can now use `OCIS_ADD_RUN_SERVICES` to add a comma separated list of additional services which are not started in the single process by default.
+
+https://github.com/owncloud/ocis/pull/6071
diff --git a/docs/Makefile b/docs/Makefile
index 069350aa1a7..74ae42d39f4 100644
--- a/docs/Makefile
+++ b/docs/Makefile
@@ -20,7 +20,7 @@ docs-init:
 	@cd hugo && git remote rm origin || true
 	@cd hugo && git remote add origin https://github.com/owncloud/owncloud.github.io
 	@cd hugo && git fetch --depth=1
-	@cd hugo && git checkout origin/source -f
+	@cd hugo && git checkout origin/main -f
 	@$(MAKE) -C hugo theme
 
 .PHONY: docs-serve
diff --git a/docs/helpers/adoc-generator.go.tmpl b/docs/helpers/adoc-generator.go.tmpl
index da0503d1a39..74e185c5660 100644
--- a/docs/helpers/adoc-generator.go.tmpl
+++ b/docs/helpers/adoc-generator.go.tmpl
@@ -9,6 +9,7 @@ import (
 	"regexp"
 	"strings"
 	"text/template"
+	"time"
 	"sort"
 
     {{- range $key, $value := .}}
@@ -48,6 +49,7 @@ type templateData struct {
 	Fields          []ConfigField
 	Deprecations    []DeprecationField
 	HasDeprecations bool
+	Timestamp 	string
 }
 
 func main() {
@@ -83,7 +85,8 @@ func main() {
 	all := make(map[string]EnvVar)
 	for pkg, conf := range m {
 	    service := replacer.Replace(pkg)
-	    fields, deprecations = GetAnnotatedVariables(conf)
+	    timestamp := time.Now().Format("2006-01-02-15-04-05")
+	    fields, deprecations = GetAnnotatedVariables(conf, timestamp)
 	    var hasDeprecations bool
 	    if len(deprecations) > 0 {
 		    hasDeprecations = true
@@ -115,7 +118,8 @@ func main() {
 			ExtensionName: service,
 			Fields:        fields,
 			Deprecations: deprecations,
-			HasDeprecations: hasDeprecations ,
+			HasDeprecations: hasDeprecations,
+			Timestamp: timestamp,
 		}
 		adocFile, err = os.Create(filepath.Join(targetFolder, service + "_configvars.adoc"))
 		if err != nil {
@@ -176,7 +180,7 @@ func main() {
 	fmt.Println("done")
 }
 
-func GetAnnotatedVariables(s interface{}) ([]ConfigField, []DeprecationField) {
+func GetAnnotatedVariables(s interface{}, timestamp string) ([]ConfigField, []DeprecationField) {
 	t := reflect.TypeOf(s)
 	v := reflect.ValueOf(s)
 
@@ -202,7 +206,7 @@ func GetAnnotatedVariables(s interface{}) ([]ConfigField, []DeprecationField) {
 				removalVersion != "" ||
 				deprecationInfo != "" ||
 				deprecationReplacement != "" {
-				deprecationLink = "xref:deprecation-note[Deprecation Note]"
+				deprecationLink = fmt.Sprintf("xref:deprecation-note-%s[Deprecation Note]", timestamp)
 			}
 			v := fmt.Sprintf("%v", value.Interface())
 			td := strings.Split(env, ";")
@@ -239,12 +243,12 @@ func GetAnnotatedVariables(s interface{}) ([]ConfigField, []DeprecationField) {
 			// they are probably added to the Sanitize() function
 			// and this should not be an issue then
 			if !value.IsZero() && value.Elem().CanInterface() {
-				f, d := GetAnnotatedVariables(value.Elem().Interface())
+				f, d := GetAnnotatedVariables(value.Elem().Interface(), timestamp)
 				fields = append(fields, f...)
 				deprecations = append(deprecations, d...)
 			}
 		case reflect.Struct:
-			f, d := GetAnnotatedVariables(value.Interface())
+			f, d := GetAnnotatedVariables(value.Interface(), timestamp)
 			fields = append(fields, f...)
 			deprecations = append(deprecations, d...)
 		}
diff --git a/docs/templates/ADOC.tmpl b/docs/templates/ADOC.tmpl
index e49ed19d8ab..7ba5d3b1263 100644
--- a/docs/templates/ADOC.tmpl
+++ b/docs/templates/ADOC.tmpl
@@ -4,7 +4,7 @@
 
 ifeval::[{show-deprecation} == true]
 
-[[deprecation-note]]
+[#deprecation-note-{{ .Timestamp }}]
 [caption=]
 .Deprecation notes for the {{ .ExtensionName }} service
 [width="100%",cols="~,~,~,~",options="header"]
@@ -12,7 +12,7 @@ ifeval::[{show-deprecation} == true]
 | Deprecation Info
 | Deprecation Version
 | Removal Version
-| Deprecation Replacment
+| Deprecation Replacement
 
 {{- range .Deprecations }}
 
diff --git a/go.mod b/go.mod
index 6d5c559ef9f..0f0670f4f67 100644
--- a/go.mod
+++ b/go.mod
@@ -80,7 +80,7 @@ require (
 	github.com/test-go/testify v1.1.4
 	github.com/thejerf/suture/v4 v4.0.2
 	github.com/tus/tusd v1.10.1
-	github.com/urfave/cli/v2 v2.24.4
+	github.com/urfave/cli/v2 v2.25.1
 	github.com/xhit/go-simple-mail/v2 v2.13.0
 	go-micro.dev/v4 v4.9.0
 	go.etcd.io/bbolt v1.3.7
@@ -96,7 +96,7 @@ require (
 	golang.org/x/net v0.8.0
 	golang.org/x/oauth2 v0.6.0
 	golang.org/x/term v0.6.0
-	golang.org/x/text v0.8.0
+	golang.org/x/text v0.9.0
 	google.golang.org/genproto v0.0.0-20230110181048-76db0878b65f
 	google.golang.org/grpc v1.54.0
 	google.golang.org/protobuf v1.28.1
diff --git a/go.sum b/go.sum
index fc5f4d0b7d0..9280e5ce4a0 100644
--- a/go.sum
+++ b/go.sum
@@ -1268,8 +1268,6 @@ github.com/maxymania/go-system v0.0.0-20170110133659-647cc364bf0b h1:Q53idHrTuQD
 github.com/maxymania/go-system v0.0.0-20170110133659-647cc364bf0b/go.mod h1:KirJrATYGbTyUwVR26xIkaipRqRcMRXBf8N5dacvGus=
 github.com/mendsley/gojwk v0.0.0-20141217222730-4d5ec6e58103 h1:Z/i1e+gTZrmcGeZyWckaLfucYG6KYOXLWo4co8pZYNY=
 github.com/mendsley/gojwk v0.0.0-20141217222730-4d5ec6e58103/go.mod h1:o9YPB5aGP8ob35Vy6+vyq3P3bWe7NQWzf+JLiXCiMaE=
-github.com/micbar/reva/v2 v2.0.0-20230403215034-66e24325ae82 h1:ivuBEObF9BlpArq363tS2qB9w4YO+b+uO59GueepZhk=
-github.com/micbar/reva/v2 v2.0.0-20230403215034-66e24325ae82/go.mod h1:FNAYs5H3xs8v0OFmNgZtiMAzIMXd/6TJmO0uZuNn8pQ=
 github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
 github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso=
 github.com/miekg/dns v1.1.40/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM=
@@ -1591,8 +1589,8 @@ github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqri
 github.com/uber-go/atomic v1.3.2/go.mod h1:/Ct5t2lcmbJ4OSe/waGBoaVvVqtO0bmtfVNex1PFV8g=
 github.com/urfave/cli v1.22.4/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
 github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI=
-github.com/urfave/cli/v2 v2.24.4 h1:0gyJJEBYtCV87zI/x2nZCPyDxD51K6xM8SkwjHFCNEU=
-github.com/urfave/cli/v2 v2.24.4/go.mod h1:GHupkWPMM0M/sj1a2b4wUrWBPzazNrIjouW6fmdJLxc=
+github.com/urfave/cli/v2 v2.25.1 h1:zw8dSP7ghX0Gmm8vugrs6q9Ku0wzweqPyshy+syu9Gw=
+github.com/urfave/cli/v2 v2.25.1/go.mod h1:GHupkWPMM0M/sj1a2b4wUrWBPzazNrIjouW6fmdJLxc=
 github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
 github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8=
 github.com/valyala/fasttemplate v1.1.0/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8=
@@ -2035,8 +2033,9 @@ golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
 golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
 golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
 golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
-golang.org/x/text v0.8.0 h1:57P1ETyNKtuIjB4SRd15iJxuhj8Gc416Y78H3qgMh68=
 golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
+golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
+golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
 golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
diff --git a/ocis-pkg/config/config.go b/ocis-pkg/config/config.go
index 626b4b6928c..8fb9c7a5d14 100644
--- a/ocis-pkg/config/config.go
+++ b/ocis-pkg/config/config.go
@@ -44,10 +44,11 @@ type Mode int
 
 // Runtime configures the oCIS runtime when running in supervised mode.
 type Runtime struct {
-	Port     string `yaml:"port" env:"OCIS_RUNTIME_PORT"`
-	Host     string `yaml:"host" env:"OCIS_RUNTIME_HOST"`
-	Services string `yaml:"services" env:"OCIS_RUN_EXTENSIONS;OCIS_RUN_SERVICES" desc:"A comma-separated list of service names. Will start only the listed services."`
-	Disabled string `yaml:"disabled_services" env:"OCIS_EXCLUDE_RUN_SERVICES" desc:"A comma-separated list of service names. Will start all services except of the ones listed. Has no effect when OCIS_RUN_SERVICES is set."`
+	Port       string   `yaml:"port" env:"OCIS_RUNTIME_PORT"`
+	Host       string   `yaml:"host" env:"OCIS_RUNTIME_HOST"`
+	Services   []string `yaml:"services" env:"OCIS_RUN_EXTENSIONS;OCIS_RUN_SERVICES" desc:"A comma-separated list of service names. Will start only the listed services."`
+	Disabled   []string `yaml:"disabled_services" env:"OCIS_EXCLUDE_RUN_SERVICES" desc:"A comma-separated list of service names. Will start all services except of the ones listed. Has no effect when OCIS_RUN_SERVICES is set."`
+	Additional []string `yaml:"add_services" env:"OCIS_ADD_RUN_SERVICES" desc:"A comma-separated list of service names. Will add the listed services to the default configuration. Has no effect when OCIS_RUN_SERVICES is set."`
 }
 
 // Config combines all available configuration parts.
diff --git a/ocis-pkg/config/envdecode/envdecode.go b/ocis-pkg/config/envdecode/envdecode.go
index 8a26b84dff1..60d030dd2f1 100644
--- a/ocis-pkg/config/envdecode/envdecode.go
+++ b/ocis-pkg/config/envdecode/envdecode.go
@@ -214,7 +214,7 @@ func decode(target interface{}, strict bool) (int, error) {
 }
 
 func decodeSlice(f *reflect.Value, env string) error {
-	parts := strings.Split(env, ";")
+	parts := strings.Split(env, ",")
 
 	values := parts[:0]
 	for _, x := range parts {
diff --git a/ocis-pkg/keycloak/client.go b/ocis-pkg/keycloak/client.go
index 8f39dda5385..f88f2b1421e 100644
--- a/ocis-pkg/keycloak/client.go
+++ b/ocis-pkg/keycloak/client.go
@@ -116,8 +116,8 @@ func (c *ConcreteClient) GetUserByEmail(ctx context.Context, realm, mail string)
 }
 
 // GetPIIReport returns a structure with all the PII for the user.
-func (c *ConcreteClient) GetPIIReport(ctx context.Context, realm string, user *libregraph.User) (*PIIReport, error) {
-	u, err := c.GetUserByEmail(ctx, realm, *user.Mail)
+func (c *ConcreteClient) GetPIIReport(ctx context.Context, realm string, email string) (*PIIReport, error) {
+	u, err := c.GetUserByEmail(ctx, realm, email)
 	if err != nil {
 		return nil, err
 	}
@@ -132,14 +132,14 @@ func (c *ConcreteClient) GetPIIReport(ctx context.Context, realm string, user *l
 		return nil, err
 	}
 
-	creds, err := c.keycloak.GetCredentials(ctx, token.AccessToken, realm, keycloakID)
+	sessions, err := c.keycloak.GetUserSessions(ctx, token.AccessToken, realm, keycloakID)
 	if err != nil {
 		return nil, err
 	}
 
 	return &PIIReport{
-		UserData:    u,
-		Credentials: creds,
+		UserData: u,
+		Sessions: sessions,
 	}, nil
 }
 
@@ -164,17 +164,20 @@ func (c *ConcreteClient) getToken(ctx context.Context) (*gocloak.JWT, error) {
 }
 
 func (c *ConcreteClient) keycloakUserToLibregraph(u *gocloak.User) *libregraph.User {
-	attrs := *u.Attributes
-	ldapID := ""
-	ldapIDs, ok := attrs[_idAttr]
-	if ok {
-		ldapID = ldapIDs[0]
-	}
-
+	var ldapID string
 	var userType *string
-	userTypes, ok := attrs[_userTypeAttr]
-	if ok {
-		userType = &userTypes[0]
+
+	if u.Attributes != nil {
+		attrs := *u.Attributes
+		ldapIDs, ok := attrs[_idAttr]
+		if ok {
+			ldapID = ldapIDs[0]
+		}
+
+		userTypes, ok := attrs[_userTypeAttr]
+		if ok {
+			userType = &userTypes[0]
+		}
 	}
 
 	return &libregraph.User{
diff --git a/ocis-pkg/keycloak/gocloak.go b/ocis-pkg/keycloak/gocloak.go
index c4a4de0cdac..52af2e053ba 100644
--- a/ocis-pkg/keycloak/gocloak.go
+++ b/ocis-pkg/keycloak/gocloak.go
@@ -16,4 +16,5 @@ type GoCloak interface {
 	LoginClient(ctx context.Context, clientID, clientSecret, realm string) (*gocloak.JWT, error)
 	RetrospectToken(ctx context.Context, accessToken, clientID, clientSecret, realm string) (*gocloak.IntroSpectTokenResult, error)
 	GetCredentials(ctx context.Context, accessToken, realm, userID string) ([]*gocloak.CredentialRepresentation, error)
+	GetUserSessions(ctx context.Context, token, realm, userID string) ([]*gocloak.UserSessionRepresentation, error)
 }
diff --git a/ocis-pkg/keycloak/types.go b/ocis-pkg/keycloak/types.go
index 1f5ddfa6423..9018938232d 100644
--- a/ocis-pkg/keycloak/types.go
+++ b/ocis-pkg/keycloak/types.go
@@ -26,8 +26,8 @@ var userActionsToString = map[UserAction]string{
 
 // PIIReport is a structure of all the PersonalIdentifiableInformation contained in keycloak.
 type PIIReport struct {
-	UserData    *libregraph.User
-	Credentials []*gocloak.CredentialRepresentation
+	UserData *libregraph.User
+	Sessions []*gocloak.UserSessionRepresentation
 }
 
 // Client represents a keycloak client.
@@ -35,5 +35,5 @@ type Client interface {
 	CreateUser(ctx context.Context, realm string, user *libregraph.User, userActions []UserAction) (string, error)
 	SendActionsMail(ctx context.Context, realm, userID string, userActions []UserAction) error
 	GetUserByEmail(ctx context.Context, realm, email string) (*libregraph.User, error)
-	GetPIIReport(ctx context.Context, realm string, user *libregraph.User) (*PIIReport, error)
+	GetPIIReport(ctx context.Context, realm string, email string) (*PIIReport, error)
 }
diff --git a/ocis/pkg/runtime/service/service.go b/ocis/pkg/runtime/service/service.go
index 06cc367ed94..eec8b87e9c0 100644
--- a/ocis/pkg/runtime/service/service.go
+++ b/ocis/pkg/runtime/service/service.go
@@ -18,8 +18,10 @@ import (
 	ociscfg "github.com/owncloud/ocis/v2/ocis-pkg/config"
 	"github.com/owncloud/ocis/v2/ocis-pkg/log"
 	"github.com/owncloud/ocis/v2/ocis-pkg/shared"
+	antivirus "github.com/owncloud/ocis/v2/services/antivirus/pkg/command"
 	appProvider "github.com/owncloud/ocis/v2/services/app-provider/pkg/command"
 	appRegistry "github.com/owncloud/ocis/v2/services/app-registry/pkg/command"
+	audit "github.com/owncloud/ocis/v2/services/audit/pkg/command"
 	authbasic "github.com/owncloud/ocis/v2/services/auth-basic/pkg/command"
 	authmachine "github.com/owncloud/ocis/v2/services/auth-machine/pkg/command"
 	eventhistory "github.com/owncloud/ocis/v2/services/eventhistory/pkg/command"
@@ -34,6 +36,7 @@ import (
 	notifications "github.com/owncloud/ocis/v2/services/notifications/pkg/command"
 	ocdav "github.com/owncloud/ocis/v2/services/ocdav/pkg/command"
 	ocs "github.com/owncloud/ocis/v2/services/ocs/pkg/command"
+	policies "github.com/owncloud/ocis/v2/services/policies/pkg/command"
 	postprocessing "github.com/owncloud/ocis/v2/services/postprocessing/pkg/command"
 	proxy "github.com/owncloud/ocis/v2/services/proxy/pkg/command"
 	search "github.com/owncloud/ocis/v2/services/search/pkg/command"
@@ -65,6 +68,7 @@ type Service struct {
 	Supervisor       *suture.Supervisor
 	ServicesRegistry serviceFuncMap
 	Delayed          serviceFuncMap
+	Additional       serviceFuncMap
 	Log              log.Logger
 
 	serviceToken map[string][]suture.ServiceToken
@@ -96,6 +100,7 @@ func NewService(options ...Option) (*Service, error) {
 	s := &Service{
 		ServicesRegistry: make(serviceFuncMap),
 		Delayed:          make(serviceFuncMap),
+		Additional:       make(serviceFuncMap),
 		Log:              l,
 
 		serviceToken: make(map[string][]suture.ServiceToken),
@@ -134,6 +139,11 @@ func NewService(options ...Option) (*Service, error) {
 	s.ServicesRegistry[opts.Config.EventHistory.Service.Name] = eventhistory.NewSutureService
 	s.ServicesRegistry[opts.Config.Userlog.Service.Name] = userlog.NewSutureService
 
+	// populate optional services
+	s.Additional[opts.Config.Antivirus.Service.Name] = antivirus.NewSutureService
+	s.Additional[opts.Config.Audit.Service.Name] = audit.NewSutureService
+	s.Additional[opts.Config.Policies.Service.Name] = policies.NewSutureService
+
 	// populate delayed services
 	s.Delayed[opts.Config.Sharing.Service.Name] = sharing.NewSutureService
 	s.Delayed[opts.Config.Proxy.Service.Name] = proxy.NewSutureService
@@ -212,6 +222,9 @@ func Start(o ...Option) error {
 	// schedule services that we are sure don't have interdependencies.
 	scheduleServiceTokens(s, s.ServicesRegistry)
 
+	// schedule services that are optional
+	scheduleServiceTokens(s, s.Additional)
+
 	// there are reasons not to do this, but we have race conditions ourselves. Until we resolve them, mind the following disclaimer:
 	// Calling ServeBackground will CORRECTLY start the supervisor running in a new goroutine. It is risky to directly run
 	// go supervisor.Serve()
@@ -245,9 +258,8 @@ func scheduleServiceTokens(s *Service, funcSet serviceFuncMap) {
 // the runtime.
 func (s *Service) generateRunSet(cfg *ociscfg.Config) {
 	runset = make(map[string]struct{})
-	if cfg.Runtime.Services != "" {
-		e := strings.Split(strings.ReplaceAll(cfg.Runtime.Services, " ", ""), ",")
-		for _, name := range e {
+	if cfg.Runtime.Services != nil {
+		for _, name := range cfg.Runtime.Services {
 			runset[name] = struct{}{}
 		}
 		return
@@ -261,12 +273,16 @@ func (s *Service) generateRunSet(cfg *ociscfg.Config) {
 		runset[name] = struct{}{}
 	}
 
-	if cfg.Runtime.Disabled != "" {
-		e := strings.Split(strings.ReplaceAll(cfg.Runtime.Disabled, " ", ""), ",")
-		for _, name := range e {
-			delete(runset, name)
-		}
+	// add additional services if explicitly added by config
+	for _, name := range cfg.Runtime.Additional {
+		runset[name] = struct{}{}
 	}
+
+	// remove services if explicitly excluded by config
+	for _, name := range cfg.Runtime.Disabled {
+		delete(runset, name)
+	}
+
 }
 
 // List running processes for the Service Controller.
diff --git a/services/audit/README.md b/services/audit/README.md
index 744f51197ba..112a5d60ee8 100644
--- a/services/audit/README.md
+++ b/services/audit/README.md
@@ -18,7 +18,7 @@ Example json:
 {"RemoteAddr":"","User":"user_id","URL":"","Method":"","UserAgent":"","Time":"","App":"admin_audit","Message":"user 'user_id' removed file 'item_id' from trashbin","Action":"file_trash_delete","CLI":false,"Level":1,"Path":"path","Owner":"user_id","FileID":"item_id"}
 ```
 
-The autit service is not started automatically when running as single binary started via `ocis server` or when running as docker container and must be started and stopped manually on demand.
+The audit service is not started automatically when running as single binary started via `ocis server` or when running as docker container and must be started and stopped manually on demand.
 
 The audit service logs:
 
diff --git a/services/graph/README.md b/services/graph/README.md
index 0bcc40a4d33..6813c2f48f5 100644
--- a/services/graph/README.md
+++ b/services/graph/README.md
@@ -30,3 +30,27 @@ The `graph` service can use a configured store via `GRAPH_STORE_TYPE`. Possible
 2.  Though usually not necessary, a database name and a database table can be configured for event stores if the event store supports this. Generally not applicapable for stores of type `in-memory`. These settings are blank by default which means that the standard settings of the configured store applies.
 3.  The graph service can be scaled if not using `in-memory` stores and the stores are configured identically over all instances.
 4.  When using `redis-sentinel`, the Redis master to use is configured via `GRAPH_CACHE_STORE_NODES` in the form of `<sentinel-host>:<sentinel-port>/<redis-master>` like `10.10.0.200:26379/mymaster`.
+
+## Keycloak Configuration For The Personal Data Export
+
+If Keycloak is used for authentication, GDPR regulations require to add all personal identifiable information that Keycloak has about the user to the personal data export. To do this, the following environment variables must be set:
+
+*   `OCIS_KEYCLOAK_BASE_PATH` - The URL to the keycloak instance.
+*   `OCIS_KEYCLOAK_CLIENT_ID` - The client ID of the client that is used to authenticate with keycloak, this client has to be able to list users and get the credential data. 
+*   `OCIS_KEYCLOAK_CLIENT_SECRET` - The client secret of the client that is used to authenticate with keycloak.
+*   `OCIS_KEYCLOAK_CLIENT_REALM` - The realm the client is defined in.
+*   `OCIS_KEYCLOAK_USER_REALM` - The realm the oCIS users are defined in.
+*   `OCIS_KEYCLOAK_INSECURE_SKIP_VERIFY` - If set to true, the TLS certificate of the keycloak instance is not verified.
+
+### Keycloak Client Configuration
+
+The client that is used to authenticate with keycloak has to be able to list users and get the credential data. To do this, the following  roles have to be assigned to the client and they have to be about the realm that contains the oCIS users:
+
+*   `view-users`
+*   `view-identity-providers`
+*   `view-realm`
+*   `view-clients`
+*   `view-events`
+*   `view-authorization`
+
+Note that these roles are only available to assign if the client is in the `master` realm.
diff --git a/services/graph/pkg/config/config.go b/services/graph/pkg/config/config.go
index 0dac204c242..cd9623b81e9 100644
--- a/services/graph/pkg/config/config.go
+++ b/services/graph/pkg/config/config.go
@@ -30,7 +30,8 @@ type Config struct {
 	Identity    Identity    `yaml:"identity"`
 	Events      Events      `yaml:"events"`
 
-	MachineAuthAPIKey string `yaml:"machine_auth_api_key" env:"OCIS_MACHINE_AUTH_API_KEY;USERLOG_MACHINE_AUTH_API_KEY" desc:"Machine auth API key used to validate internal requests necessary to access resources from other services."`
+	MachineAuthAPIKey string   `yaml:"machine_auth_api_key" env:"OCIS_MACHINE_AUTH_API_KEY;USERLOG_MACHINE_AUTH_API_KEY" desc:"Machine auth API key used to validate internal requests necessary to access resources from other services."`
+	Keycloak          Keycloak `yaml:"keycloak"`
 
 	Context context.Context `yaml:"-"`
 }
@@ -121,3 +122,13 @@ type CORS struct {
 	AllowedHeaders   []string `yaml:"allow_headers" env:"OCIS_CORS_ALLOW_HEADERS;GRAPH_CORS_ALLOW_HEADERS" desc:"A comma-separated list of allowed CORS headers. See following chapter for more details: *Access-Control-Request-Headers* at https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Request-Headers."`
 	AllowCredentials bool     `yaml:"allow_credentials" env:"OCIS_CORS_ALLOW_CREDENTIALS;GRAPH_CORS_ALLOW_CREDENTIALS" desc:"Allow credentials for CORS.See following chapter for more details: *Access-Control-Allow-Credentials* at https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Credentials."`
 }
+
+// Keycloak configuration
+type Keycloak struct {
+	BasePath           string `yaml:"base_path" env:"OCIS_KEYCLOAK_BASE_PATH;GRAPH_KEYCLOAK_BASE_PATH" desc:"The URL to access keycloak."`
+	ClientID           string `yaml:"client_id" env:"OCIS_KEYCLOAK_CLIENT_ID;GRAPH_KEYCLOAK_CLIENT_ID" desc:"The client id to authenticate with keycloak."`
+	ClientSecret       string `yaml:"client_secret" env:"OCIS_KEYCLOAK_CLIENT_SECRET;GRAPH_KEYCLOAK_CLIENT_SECRET" desc:"The client secret to use in authentication."`
+	ClientRealm        string `yaml:"client_realm" env:"OCIS_KEYCLOAK_CLIENT_REALM;GRAPH_KEYCLOAK_CLIENT_REALM" desc:"The realm the client is defined in."`
+	UserRealm          string `yaml:"user_realm" env:"OCIS_KEYCLOAK_USER_REALM;GRAPH_KEYCLOAK_USER_REALM" desc:"The realm users are defined."`
+	InsecureSkipVerify bool   `yaml:"insecure_skip_verify" env:"OCIS_KEYCLOAK_INSECURE_SKIP_VERIFY;GRAPH_KEYCLOAK_INSECURE_SKIP_VERIFY" desc:"Disable TLS certificate validation for Keycloak connections. Do not set this in production environments."`
+}
diff --git a/services/graph/pkg/server/http/server.go b/services/graph/pkg/server/http/server.go
index 83643968a1d..fe9e34d75fc 100644
--- a/services/graph/pkg/server/http/server.go
+++ b/services/graph/pkg/server/http/server.go
@@ -15,10 +15,13 @@ import (
 	"github.com/owncloud/ocis/v2/ocis-pkg/account"
 	"github.com/owncloud/ocis/v2/ocis-pkg/cors"
 	ociscrypto "github.com/owncloud/ocis/v2/ocis-pkg/crypto"
+	"github.com/owncloud/ocis/v2/ocis-pkg/keycloak"
 	"github.com/owncloud/ocis/v2/ocis-pkg/middleware"
 	"github.com/owncloud/ocis/v2/ocis-pkg/service/grpc"
+	ogrpc "github.com/owncloud/ocis/v2/ocis-pkg/service/grpc"
 	"github.com/owncloud/ocis/v2/ocis-pkg/service/http"
 	"github.com/owncloud/ocis/v2/ocis-pkg/version"
+	ehsvc "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/eventhistory/v0"
 	searchsvc "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/search/v0"
 	settingssvc "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/settings/v0"
 	graphMiddleware "github.com/owncloud/ocis/v2/services/graph/pkg/middleware"
@@ -132,6 +135,18 @@ func Server(opts ...Option) (http.Service, error) {
 		// no gatewayclient needed
 	}
 
+	// Keycloak client is optional, so if it stays nil, it's fine.
+	var keyCloakClient keycloak.Client
+	if options.Config.Keycloak.BasePath != "" {
+		kcc := options.Config.Keycloak
+		if kcc.ClientID == "" || kcc.ClientSecret == "" || kcc.ClientRealm == "" || kcc.UserRealm == "" {
+			return http.Service{}, errors.New("keycloak client id, secret, client realm and user realm must be set")
+		}
+		keyCloakClient = keycloak.New(kcc.BasePath, kcc.ClientID, kcc.ClientSecret, kcc.ClientRealm, kcc.InsecureSkipVerify)
+	}
+
+	hClient := ehsvc.NewEventHistoryService("com.owncloud.api.eventhistory", ogrpc.DefaultClient())
+
 	var handle svc.Service
 	handle, err = svc.NewService(
 		svc.Logger(options.Logger),
@@ -142,6 +157,8 @@ func Server(opts ...Option) (http.Service, error) {
 		svc.WithRequireAdminMiddleware(requireAdminMiddleware),
 		svc.WithGatewayClient(gatewayClient),
 		svc.WithSearchService(searchsvc.NewSearchProviderService("com.owncloud.api.search", grpc.DefaultClient())),
+		svc.KeycloakClient(keyCloakClient),
+		svc.EventHistoryClient(hClient),
 	)
 
 	if err != nil {
diff --git a/services/graph/pkg/service/v0/graph.go b/services/graph/pkg/service/v0/graph.go
index e4fc0d8f5bc..d5bd379b319 100644
--- a/services/graph/pkg/service/v0/graph.go
+++ b/services/graph/pkg/service/v0/graph.go
@@ -13,7 +13,9 @@ import (
 	"github.com/go-chi/chi/v5"
 	"github.com/jellydator/ttlcache/v3"
 	libregraph "github.com/owncloud/libre-graph-api-go"
+	"github.com/owncloud/ocis/v2/ocis-pkg/keycloak"
 	"github.com/owncloud/ocis/v2/ocis-pkg/log"
+	ehsvc "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/eventhistory/v0"
 	searchsvc "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/search/v0"
 	settingssvc "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/settings/v0"
 	"github.com/owncloud/ocis/v2/services/graph/pkg/config"
@@ -68,6 +70,8 @@ type Graph struct {
 	groupsCache              *ttlcache.Cache[string, libregraph.Group]
 	eventsPublisher          events.Publisher
 	searchService            searchsvc.SearchProviderService
+	keycloakClient           keycloak.Client
+	historyClient            ehsvc.EventHistoryService
 }
 
 // ServeHTTP implements the Service interface.
diff --git a/services/graph/pkg/service/v0/option.go b/services/graph/pkg/service/v0/option.go
index ccbde28b4d9..51581ade76a 100644
--- a/services/graph/pkg/service/v0/option.go
+++ b/services/graph/pkg/service/v0/option.go
@@ -5,8 +5,10 @@ import (
 
 	gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
 	"github.com/cs3org/reva/v2/pkg/events"
+	"github.com/owncloud/ocis/v2/ocis-pkg/keycloak"
 	"github.com/owncloud/ocis/v2/ocis-pkg/log"
 	"github.com/owncloud/ocis/v2/ocis-pkg/roles"
+	ehsvc "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/eventhistory/v0"
 	searchsvc "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/search/v0"
 	settingssvc "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/settings/v0"
 	"github.com/owncloud/ocis/v2/services/graph/pkg/config"
@@ -30,6 +32,8 @@ type Options struct {
 	RoleManager              *roles.Manager
 	EventsPublisher          events.Publisher
 	SearchService            searchsvc.SearchProviderService
+	KeycloakClient           keycloak.Client
+	EventHistoryClient       ehsvc.EventHistoryService
 }
 
 // newOptions initializes the available default options.
@@ -126,3 +130,17 @@ func EventsPublisher(val events.Publisher) Option {
 		o.EventsPublisher = val
 	}
 }
+
+// KeycloakClient provides a function to set the KeycloakCient option.
+func KeycloakClient(val keycloak.Client) Option {
+	return func(o *Options) {
+		o.KeycloakClient = val
+	}
+}
+
+// EventHistoryClient provides a function to set the EventHistoryClient option.
+func EventHistoryClient(val ehsvc.EventHistoryService) Option {
+	return func(o *Options) {
+		o.EventHistoryClient = val
+	}
+}
diff --git a/services/graph/pkg/service/v0/personaldata.go b/services/graph/pkg/service/v0/personaldata.go
index b934acb96cc..2017c603147 100644
--- a/services/graph/pkg/service/v0/personaldata.go
+++ b/services/graph/pkg/service/v0/personaldata.go
@@ -19,6 +19,8 @@ import (
 	"github.com/cs3org/reva/v2/pkg/events"
 	"github.com/cs3org/reva/v2/pkg/rhttp"
 	"github.com/cs3org/reva/v2/pkg/utils"
+	ehmsg "github.com/owncloud/ocis/v2/protogen/gen/ocis/messages/eventhistory/v0"
+	ehsvc "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/eventhistory/v0"
 )
 
 var (
@@ -75,12 +77,35 @@ func (g Graph) ExportPersonalData(w http.ResponseWriter, r *http.Request) {
 
 // GatherPersonalData will all gather all personal data of the user and save it to a file in the users personal space
 func (g Graph) GatherPersonalData(usr *user.User, ref *provider.Reference, token string, marsh Marshaller) {
+	// the context might already be cancelled. We need to impersonate the acting user again
+	ctx, err := utils.ImpersonateUser(usr, g.gatewayClient, g.config.MachineAuthAPIKey)
+	if err != nil {
+		g.logger.Error().Err(err).Str("userID", usr.GetId().GetOpaqueId()).Msg("cannot impersonate user")
+	}
 	// create data
 	data := make(map[string]interface{})
 
 	// reva user
 	data["user"] = usr
 
+	// Check if we have a keycloak client, and if so, get the keycloak export.
+	if ctx != nil && g.keycloakClient != nil {
+		kcd, err := g.keycloakClient.GetPIIReport(ctx, g.config.Keycloak.UserRealm, usr.GetMail())
+		if err != nil {
+			g.logger.Error().Err(err).Str("userID", usr.GetId().GetOpaqueId()).Msg("cannot get keycloak personal data")
+		}
+		data["keycloak"] = kcd
+	}
+
+	// get event data
+	if ctx != nil && g.historyClient != nil {
+		resp, err := g.historyClient.GetEventsForUser(ctx, &ehsvc.GetEventsForUserRequest{UserID: usr.GetId().GetOpaqueId()})
+		if err != nil {
+			g.logger.Error().Err(err).Str("userID", usr.GetId().GetOpaqueId()).Msg("cannot get event personal data")
+		}
+		data["events"] = convertEvents(resp.GetEvents())
+	}
+
 	// marshal
 	by, err := marsh(data)
 	if err != nil {
@@ -198,7 +223,6 @@ func createFolders(ctx context.Context, ref *provider.Reference, gwc gateway.Gat
 		}
 	}
 	return nil
-
 }
 
 func getLocation(r *http.Request) string {
@@ -214,3 +238,18 @@ func getLocation(r *http.Request) string {
 
 	return _backupFileName
 }
+
+// we want the events to look nice in the file, don't we?
+func convertEvents(evs []*ehmsg.Event) []map[string]interface{} {
+	out := make([]map[string]interface{}, len(evs))
+	for i, e := range evs {
+		var content map[string]interface{}
+		_ = json.Unmarshal(e.GetEvent(), &content)
+		out[i] = map[string]interface{}{
+			"id":    e.GetId(),
+			"type":  e.GetType(),
+			"event": content,
+		}
+	}
+	return out
+}
diff --git a/services/graph/pkg/service/v0/service.go b/services/graph/pkg/service/v0/service.go
index 8541167af6c..8ed8f641969 100644
--- a/services/graph/pkg/service/v0/service.go
+++ b/services/graph/pkg/service/v0/service.go
@@ -143,6 +143,8 @@ func NewService(opts ...Option) (Graph, error) {
 		gatewayClient:            options.GatewayClient,
 		searchService:            options.SearchService,
 		identityEducationBackend: options.IdentityEducationBackend,
+		keycloakClient:           options.KeycloakClient,
+		historyClient:            options.EventHistoryClient,
 	}
 
 	if err := setIdentityBackends(options, &svc); err != nil {
diff --git a/services/idm/pkg/config/config.go b/services/idm/pkg/config/config.go
index 23c0cf4ac63..011f213c535 100644
--- a/services/idm/pkg/config/config.go
+++ b/services/idm/pkg/config/config.go
@@ -17,7 +17,7 @@ type Config struct {
 	Debug   Debug    `yaml:"debug"`
 
 	IDM             Settings `yaml:"idm"`
-	CreateDemoUsers bool     `yaml:"create_demo_users" env:"IDM_CREATE_DEMO_USERS;OCIS_ACCOUNTS_DEMO_USERS_AND_GROUPS;ACCOUNTS_DEMO_USERS_AND_GROUPS" desc:"Flag to enable or disable the creation of the demo users." deprecationVersion:"3.0" removalVersion:"3.1" deprecationInfo:"ACCOUNTS_DEMO_USERS_AND_GROUPS changing name for consistency" deprecationReplacement:"OCIS_ACCOUNTS_DEMO_USERS_AND_GROUPS"`
+	CreateDemoUsers bool     `yaml:"create_demo_users" env:"IDM_CREATE_DEMO_USERS" desc:"Flag to enable or disable the creation of the demo users."`
 
 	ServiceUserPasswords ServiceUserPasswords `yaml:"service_user_passwords"`
 	AdminUserID          string               `yaml:"admin_user_id" env:"OCIS_ADMIN_USER_ID;IDM_ADMIN_USER_ID" desc:"ID of the user that should receive admin privileges. Consider that the UUID can be encoded in some LDAP deployment configurations like in .ldif files. These need to be decoded beforehand."`
diff --git a/services/invitations/pkg/config/config.go b/services/invitations/pkg/config/config.go
index ca499aeaed4..27391684c77 100644
--- a/services/invitations/pkg/config/config.go
+++ b/services/invitations/pkg/config/config.go
@@ -26,10 +26,10 @@ type Config struct {
 
 // Keycloak configuration
 type Keycloak struct {
-	BasePath           string `yaml:"base_path" env:"INVITATIONS_KEYCLOAK_BASE_PATH" desc:"The URL to access keycloak."`
-	ClientID           string `yaml:"client_id" env:"INVITATIONS_KEYCLOAK_CLIENT_ID" desc:"The client id to authenticate with keycloak."`
-	ClientSecret       string `yaml:"client_secret" env:"INVITATIONS_KEYCLOAK_CLIENT_SECRET" desc:"The client secret to use in authentication."`
-	ClientRealm        string `yaml:"client_realm" env:"INVITATIONS_KEYCLOAK_CLIENT_REALM" desc:"The realm the client is defined in."`
-	UserRealm          string `yaml:"user_realm" env:"INVITATIONS_KEYCLOAK_USER_REALM" desc:"The realm users are defined."`
-	InsecureSkipVerify bool   `yaml:"insecure_skip_verify" env:"INVITATIONS_KEYCLOAK_INSECURE_SKIP_VERIFY" desc:"Disable TLS certificate validation for Keycloak connections. Do not set this in production environments."`
+	BasePath           string `yaml:"base_path" env:"OCIS_KEYCLOAK_BASE_PATH;INVITATIONS_KEYCLOAK_BASE_PATH" desc:"The URL to access keycloak."`
+	ClientID           string `yaml:"client_id" env:"OCIS_KEYCLOAK_CLIENT_ID;INVITATIONS_KEYCLOAK_CLIENT_ID" desc:"The client id to authenticate with keycloak."`
+	ClientSecret       string `yaml:"client_secret" env:"OCIS_KEYCLOAK_CLIENT_SECRET;INVITATIONS_KEYCLOAK_CLIENT_SECRET" desc:"The client secret to use in authentication."`
+	ClientRealm        string `yaml:"client_realm" env:"OCIS_KEYCLOAK_CLIENT_REALM;INVITATIONS_KEYCLOAK_CLIENT_REALM" desc:"The realm the client is defined in."`
+	UserRealm          string `yaml:"user_realm" env:"OCIS_KEYCLOAK_USER_REALM;INVITATIONS_KEYCLOAK_USER_REALM" desc:"The realm users are defined."`
+	InsecureSkipVerify bool   `yaml:"insecure_skip_verify" env:"OCIS_KEYCLOAK_INSECURE_SKIP_VERIFY;INVITATIONS_KEYCLOAK_INSECURE_SKIP_VERIFY" desc:"Disable TLS certificate validation for Keycloak connections. Do not set this in production environments."`
 }
diff --git a/services/invitations/pkg/mocks/client.go b/services/invitations/pkg/mocks/client.go
index eddaf9cec80..42efdfbf05f 100644
--- a/services/invitations/pkg/mocks/client.go
+++ b/services/invitations/pkg/mocks/client.go
@@ -40,25 +40,25 @@ func (_m *Client) CreateUser(ctx context.Context, realm string, user *libregraph
 	return r0, r1
 }
 
-// GetPIIReport provides a mock function with given fields: ctx, realm, user
-func (_m *Client) GetPIIReport(ctx context.Context, realm string, user *libregraph.User) (*keycloak.PIIReport, error) {
-	ret := _m.Called(ctx, realm, user)
+// GetPIIReport provides a mock function with given fields: ctx, realm, email
+func (_m *Client) GetPIIReport(ctx context.Context, realm string, email string) (*keycloak.PIIReport, error) {
+	ret := _m.Called(ctx, realm, email)
 
 	var r0 *keycloak.PIIReport
 	var r1 error
-	if rf, ok := ret.Get(0).(func(context.Context, string, *libregraph.User) (*keycloak.PIIReport, error)); ok {
-		return rf(ctx, realm, user)
+	if rf, ok := ret.Get(0).(func(context.Context, string, string) (*keycloak.PIIReport, error)); ok {
+		return rf(ctx, realm, email)
 	}
-	if rf, ok := ret.Get(0).(func(context.Context, string, *libregraph.User) *keycloak.PIIReport); ok {
-		r0 = rf(ctx, realm, user)
+	if rf, ok := ret.Get(0).(func(context.Context, string, string) *keycloak.PIIReport); ok {
+		r0 = rf(ctx, realm, email)
 	} else {
 		if ret.Get(0) != nil {
 			r0 = ret.Get(0).(*keycloak.PIIReport)
 		}
 	}
 
-	if rf, ok := ret.Get(1).(func(context.Context, string, *libregraph.User) error); ok {
-		r1 = rf(ctx, realm, user)
+	if rf, ok := ret.Get(1).(func(context.Context, string, string) error); ok {
+		r1 = rf(ctx, realm, email)
 	} else {
 		r1 = ret.Error(1)
 	}
diff --git a/services/notifications/Makefile b/services/notifications/Makefile
index 2982566e5b1..b64d1a6f536 100644
--- a/services/notifications/Makefile
+++ b/services/notifications/Makefile
@@ -1,6 +1,10 @@
 SHELL := bash
 NAME := notifications
 
+# Where to write the files generated by this makefile.
+OUTPUT_DIR = ./pkg/email/l10n
+TEMPLATE_FILE = ./pkg/email/l10n/notifications.pot
+
 include ../../.make/recursion.mk
 
 ############ tooling ############
@@ -29,6 +33,26 @@ ci-go-generate: # CI runs ci-node-generate automatically before this target
 .PHONY: ci-node-generate
 ci-node-generate:
 
+############ translations ########
+.PHONY: l10n-pull
+l10n-pull:
+	cd $(OUTPUT_DIR) && tx pull -a --skip --minimum-perc=75
+
+.PHONY: l10n-push
+l10n-push:
+	cd $(OUTPUT_DIR) && tx push -s --skip
+
+.PHONY: l10n-read
+l10n-read: $(GO_XGETTEXT)
+	go-xgettext -o $(OUTPUT_DIR)/notifications.pot --keyword=Template --add-comments -s pkg/email/l10n/templates.go
+
+.PHONY: l10n-write
+l10n-write:
+
+.PHONY: l10n-clean
+l10n-clean:
+	rm -f $(TEMPLATE_FILE);
+
 ############ licenses ############
 .PHONY: ci-node-check-licenses
 ci-node-check-licenses:
diff --git a/services/notifications/README.md b/services/notifications/README.md
index 51d58bc9b88..81b25c4b999 100644
--- a/services/notifications/README.md
+++ b/services/notifications/README.md
@@ -2,3 +2,66 @@
 
 The notification service is responsible for sending emails to users informing them about events that happened. To do this it hooks into the event system and listens for certain events that the users need to be informed about.
 
+## Email Notification Templates
+
+The `notifications` service has embedded email body templates.
+The email templates contain placeholders `{{ .Greeting }}`, `{{ .MessageBody }}`, `{{ .CallToAction }}` that are
+replaced with translations, see the [Translations](#translations) section.
+These embedded templates are available for all deployment scenarios. In addition, the service supports custom
+templates.
+The custom email template takes precedence over the embedded one. If a custom email template exists, the embedded ones
+are not used. To configure custom email templates,
+the `NOTIFICATIONS_EMAIL_TEMPLATE_PATH` environment variable needs to point to a base folder that will contain the email
+templates. The source template files provided by ocis are located
+in [https://github.com/owncloud/ocis/tree/master/services/notifications/pkg/email/templates](https://github.com/owncloud/ocis/tree/master/services/notifications/pkg/email/templates) in the `shares`
+and `spaces` subfolders:
+[shares/shareCreated.email.body.tmpl](https://github.com/owncloud/ocis/blob/master/services/notifications/pkg/email/templates/shares/shareCreated.email.body.tmpl)
+[shares/shareExpired.email.body.tmpl](https://github.com/owncloud/ocis/blob/master/services/notifications/pkg/email/templates/shares/shareExpired.email.body.tmpl)
+[spaces/membershipExpired.email.body.tmpl](https://github.com/owncloud/ocis/blob/master/services/notifications/pkg/email/templates/spaces/membershipExpired.email.body.tmpl)
+[spaces/sharedSpace.email.body.tmpl](https://github.com/owncloud/ocis/blob/master/services/notifications/pkg/email/templates/spaces/sharedSpace.email.body.tmpl)
+[spaces/unsharedSpace.email.body.tmpl](https://github.com/owncloud/ocis/blob/master/services/notifications/pkg/email/templates/spaces/unsharedSpace.email.body.tmpl)
+
+Custom Email templates referenced via `NOTIFICATIONS_EMAIL_TEMPLATE_PATH` must be located in subfolders `shares`
+and `spaces` and have the same names as the embedded templates. This naming must match the embedded ones.
+```text
+templates
+│
+└───shares
+│   │   shareCreated.email.body.tmpl
+│   │   shareExpired.email.body.tmpl
+│
+└───spaces
+    │   membershipExpired.email.body.tmpl
+    │   sharedSpace.email.body.tmpl
+    │   unsharedSpace.email.body.tmpl
+```
+
+## Translations
+
+The `notifications` service has embedded translations sourced via transifex to provide a basic set of translated languages.
+These embedded translations are available for all deployment scenarios. In addition, the service supports custom
+translations, though it is currently not possible to just add custom translations to embedded ones. If custom
+translations are configured, the embedded ones are not used. To configure custom translations,
+the `NOTIFICATIONS_TRANSLATION_PATH` environment variable needs to point to a base folder that will contain the translation
+files. This path must be available from all instances of the notifications service, a shared storage is recommended.
+Translation files must be of type  [.po](https://www.gnu.org/software/gettext/manual/html_node/PO-Files.html#PO-Files)
+or [.mo](https://www.gnu.org/software/gettext/manual/html_node/Binaries.html). For each language, the filename needs to
+be `translations.po` (or `translations.mo`) and stored in a folder structure defining the language code. In general the path/name
+pattern for a translation file needs to be:
+
+```text
+{NOTIFICATIONS_TRANSLATION_PATH}/{language-code}/LC_MESSAGES/translations.po
+```
+
+The language code pattern is composed of `language[_territory]` where  `language` is the base language and `_territory`
+is optional and defines a country.
+
+For example, for the language `de_DE`, one needs to place the corresponding translation files
+to `{NOTIFICATIONS_TRANSLATION_PATH}/de_DE/LC_MESSAGES/translations.po`.
+
+### Translation Rules
+
+*   If a requested language code is not available, the service tries to fall back to the base language if available.
+For example, if `de_DE` is not available, the service tries to fall back to translations in the `de` folder.
+*   If the base language `de` is also not available, the service falls back to the system's default English (`en`),
+which is the source of the texts provided by the code.
diff --git a/services/notifications/pkg/config/config.go b/services/notifications/pkg/config/config.go
index 90afa48b469..65ebf1c76b4 100644
--- a/services/notifications/pkg/config/config.go
+++ b/services/notifications/pkg/config/config.go
@@ -28,6 +28,7 @@ type Notifications struct {
 	Events            Events                `yaml:"events"`
 	MachineAuthAPIKey string                `yaml:"machine_auth_api_key" env:"OCIS_MACHINE_AUTH_API_KEY;NOTIFICATIONS_MACHINE_AUTH_API_KEY" desc:"Machine auth API key used to validate internal requests necessary to access resources from other services."`
 	EmailTemplatePath string                `yaml:"email_template_path" env:"OCIS_EMAIL_TEMPLATE_PATH;NOTIFICATIONS_EMAIL_TEMPLATE_PATH" desc:"Path to Email notification templates overriding embedded ones."`
+	TranslationPath   string                `yaml:"translation_path" env:"OCIS_TRANSLATION_PATH,NOTIFICATIONS_TRANSLATION_PATH" desc:"(optional) Set this to a path with custom translations to overwrite the builtin translations. Note that file and folder naming rules apply, see the documentation for more details."`
 	RevaGateway       string                `yaml:"reva_gateway" env:"OCIS_REVA_GATEWAY;REVA_GATEWAY" desc:"CS3 gateway used to look up user metadata" deprecationVersion:"3.0" removalVersion:"3.1" deprecationInfo:"REVA_GATEWAY changing name for consistency" deprecationReplacement:"OCIS_REVA_GATEWAY"`
 	GRPCClientTLS     *shared.GRPCClientTLS `yaml:"grpc_client_tls"`
 }
diff --git a/services/notifications/pkg/email/composer.go b/services/notifications/pkg/email/composer.go
new file mode 100644
index 00000000000..ece92e0a21c
--- /dev/null
+++ b/services/notifications/pkg/email/composer.go
@@ -0,0 +1,41 @@
+package email
+
+import (
+	"embed"
+	"io/fs"
+	"strings"
+
+	"github.com/leonelquinteros/gotext"
+)
+
+var (
+	//go:embed l10n/locale
+	_translationFS embed.FS
+	_domain        = "notifications"
+)
+
+// ComposeMessage renders the message based on template
+func ComposeMessage(template, locale string, path string) string {
+	raw := loadTemplate(template, locale, path)
+	return replacePlaceholders(raw)
+}
+
+func loadTemplate(template, locale string, path string) string {
+	// Create Locale with library path and language code and load default domain
+	var l *gotext.Locale
+	if path == "" {
+		filesystem, _ := fs.Sub(_translationFS, "l10n/locale")
+		l = gotext.NewLocaleFS(locale, filesystem)
+	} else { // use custom path instead
+		l = gotext.NewLocale(path, locale)
+	}
+	l.AddDomain(_domain) // make domain configurable only if needed
+	return l.Get(template)
+}
+
+func replacePlaceholders(raw string) string {
+	for o, n := range _placeholders {
+		raw = strings.ReplaceAll(raw, o, n)
+	}
+	return raw
+}
diff --git a/services/notifications/pkg/email/email.go b/services/notifications/pkg/email/email.go
index d3302ccdf74..283559b9c5f 100644
--- a/services/notifications/pkg/email/email.go
+++ b/services/notifications/pkg/email/email.go
@@ -1,8 +1,12 @@
+// Package email implements utility for rendering the Email.
+//
+// The email package supports transifex translation for email templates.
 package email
 
 import (
 	"bytes"
 	"embed"
+	"html"
 	"html/template"
 	"path/filepath"
 )
@@ -13,22 +17,63 @@ var (
 )
 
 // RenderEmailTemplate renders the email template for a new share
-func RenderEmailTemplate(templateName string, templateVariables map[string]string, emailTemplatePath string) (string, error) {
+func RenderEmailTemplate(mt MessageTemplate, locale string, emailTemplatePath string, translationPath string, vars map[string]interface{}) (string, string, error) {
+	// translate a message
+	mt.Subject = ComposeMessage(mt.Subject, locale, translationPath)
+	mt.Greeting = ComposeMessage(mt.Greeting, locale, translationPath)
+	mt.MessageBody = ComposeMessage(mt.MessageBody, locale, translationPath)
+	mt.CallToAction = ComposeMessage(mt.CallToAction, locale, translationPath)
+
+	// replace the body email placeholders with the values
+	subject, err := executeRaw(mt.Subject, vars)
+	if err != nil {
+		return "", "", err
+	}
+
+	// replace the body email template placeholders with the translated template
+	rawBody, err := executeEmailTemplate(emailTemplatePath, mt)
+	if err != nil {
+		return "", "", err
+	}
+	// replace the body email placeholders with the values
+	body, err := executeRaw(rawBody, vars)
+	if err != nil {
+		return "", "", err
+	}
+	return subject, body, nil
+}
+
+func executeEmailTemplate(emailTemplatePath string, mt MessageTemplate) (string, error) {
 	var err error
 	var tpl *template.Template
 	// try to lookup the files in the filesystem
-	tpl, err = template.ParseFiles(filepath.Join(emailTemplatePath, templateName))
+	tpl, err = template.ParseFiles(filepath.Join(emailTemplatePath, mt.bodyTemplate))
 	if err != nil {
 		// template has not been found in the fs, or path has not been specified => use embed templates
-		tpl, err = template.ParseFS(templatesFS, filepath.Join("templates/", templateName))
+		tpl, err = template.ParseFS(templatesFS, filepath.Join("templates/", mt.bodyTemplate))
 		if err != nil {
 			return "", err
 		}
 	}
-	var writer bytes.Buffer
-	err = tpl.Execute(&writer, templateVariables)
+	str, err := executeTemplate(tpl, mt)
 	if err != nil {
 		return "", err
 	}
+	return html.UnescapeString(str), err
+}
+
+func executeRaw(raw string, vars map[string]interface{}) (string, error) {
+	tpl, err := template.New("").Parse(raw)
+	if err != nil {
+		return "", err
+	}
+	return executeTemplate(tpl, vars)
+}
+
+func executeTemplate(tpl *template.Template, vars any) (string, error) {
+	var writer bytes.Buffer
+	if err := tpl.Execute(&writer, vars); err != nil {
+		return "", err
+	}
 	return writer.String(), nil
 }
diff --git a/services/notifications/pkg/email/l10n/.tx/config b/services/notifications/pkg/email/l10n/.tx/config
new file mode 100755
index 00000000000..b56891b8bb3
--- /dev/null
+++ b/services/notifications/pkg/email/l10n/.tx/config
@@ -0,0 +1,9 @@
+[main]
+host = https://www.transifex.com
+
+[o:owncloud-org:p:owncloud:r:ocis-notifications]
+file_filter = locale/<lang>/LC_MESSAGES/notifications.po
+minimum_perc = 75
+source_file = notifications.pot
+source_lang = en
+type = PO
diff --git a/services/notifications/pkg/email/l10n/locale/de_DE/LC_MESSAGES/notifications.po b/services/notifications/pkg/email/l10n/locale/de_DE/LC_MESSAGES/notifications.po
new file mode 100644
index 00000000000..a434807bea9
--- /dev/null
+++ b/services/notifications/pkg/email/l10n/locale/de_DE/LC_MESSAGES/notifications.po
@@ -0,0 +1,102 @@
+# SOME DESCRIPTIVE TITLE.
+# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
+# This file is distributed under the same license as the PACKAGE package.
+# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
+#
+#, fuzzy
+msgid   ""
+msgstr  "Project-Id-Version: \n"
+        "Report-Msgid-Bugs-To: EMAIL\n"
+        "POT-Creation-Date: 2023-04-14 16:58+0200\n"
+        "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
+        "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
+        "Language-Team: LANGUAGE <LL@li.org>\n"
+        "Language: \n"
+        "MIME-Version: 1.0\n"
+        "Content-Type: text/plain; charset=CHARSET\n"
+        "Content-Transfer-Encoding: 8bit\n"
+
+#. UnsharedSpace email template, CallToAction field
+#: pkg/email/templates.go:57
+msgid   "Click here to check it: {ShareLink}"
+msgstr  ""
+
+#. ShareCreated email template, CallToAction field
+#. SharedSpace email template, CallToAction field
+#: pkg/email/templates.go:18 pkg/email/templates.go:43
+msgid   "Click here to view it: {ShareLink}"
+msgstr  ""
+
+#. ShareCreated email template, Greeting field
+#: pkg/email/templates.go:14
+msgid   "Hello {ShareGrantee}"
+msgstr  ""
+
+#. ShareExpired email template, Greeting field
+#: pkg/email/templates.go:26
+msgid   "Hello {ShareGrantee},"
+msgstr  ""
+
+#. SharedSpace email template, Greeting field
+#. UnsharedSpace email template, Greeting field
+#. MembershipExpired email template, Greeting field
+#: pkg/email/templates.go:39 pkg/email/templates.go:51 pkg/email/templates.go:65
+msgid   "Hello {SpaceGrantee},"
+msgstr  ""
+
+#. MembershipExpired email template, Subject field
+#: pkg/email/templates.go:63
+msgid   "Membership of '{SpaceName}' expired at {ExpiredAt}"
+msgstr  ""
+
+#. ShareExpired email template, Subject field
+#: pkg/email/templates.go:24
+msgid   "Share to '{ShareFolder}' expired at {ExpiredAt}"
+msgstr  ""
+
+#. MembershipExpired email template, Message field
+#: pkg/email/templates.go:67
+msgid   "Your membership of space {SpaceName} has expired at {ExpiredAt}\n"
+        "\n"
+        "Even though this membership has expired you still might have access through other shares and/or space memberships"
+msgstr  ""
+
+#. ShareExpired email template, Message field
+#: pkg/email/templates.go:28
+msgid   "Your share to {ShareFolder} has expired at {ExpiredAt}\n"
+        "\n"
+        "Even though this share has been revoked you still might have access through other shares and/or space memberships."
+msgstr  ""
+
+#. ShareCreated email template, Message field
+#: pkg/email/templates.go:16
+msgid   "{ShareSharer} has shared \"{ShareFolder}\" with you."
+msgstr  ""
+
+#. ShareCreated email template, Subject field
+#: pkg/email/templates.go:12
+msgid   "{ShareSharer} shared '{ShareFolder}' with you"
+msgstr  ""
+
+#. SharedSpace email template, Message field
+#: pkg/email/templates.go:41
+msgid   "{SpaceSharer} has invited you to join \"{SpaceName}\"."
+msgstr  ""
+
+#. UnsharedSpace email template, Message field
+#: pkg/email/templates.go:53
+msgid   "{SpaceSharer} has removed you from \"{SpaceName}\".\n"
+        "\n"
+        "You might still have access through your other groups or direct membership."
+msgstr  ""
+
+#. SharedSpace email template, Subject field
+#: pkg/email/templates.go:37
+msgid   "{SpaceSharer} invited you to join {SpaceName}"
+msgstr  ""
+
+#. UnsharedSpace email template, Subject field
+#: pkg/email/templates.go:49
+msgid   "{SpaceSharer} removed you from {SpaceName}"
+msgstr  ""
+
diff --git a/services/notifications/pkg/email/templates.go b/services/notifications/pkg/email/templates.go
new file mode 100644
index 00000000000..c42f205a55d
--- /dev/null
+++ b/services/notifications/pkg/email/templates.go
@@ -0,0 +1,93 @@
+package email
+
+// Template marks the string as a translatable template
+func Template(s string) string { return s }
+
+// the available templates
+var (
+	// Shares
+	ShareCreated = MessageTemplate{
+		bodyTemplate: "shares/shareCreated.email.body.tmpl",
+		// ShareCreated email template, Subject field
+		Subject: Template(`{ShareSharer} shared '{ShareFolder}' with you`),
+		// ShareCreated email template, Greeting field
+		Greeting: Template(`Hello {ShareGrantee}`),
+		// ShareCreated email template, Message field
+		MessageBody: Template(`{ShareSharer} has shared "{ShareFolder}" with you.`),
+		// ShareCreated email template, CallToAction field
+		CallToAction: Template(`Click here to view it: {ShareLink}`),
+	}
+
+	ShareExpired = MessageTemplate{
+		bodyTemplate: "shares/shareExpired.email.body.tmpl",
+		// ShareExpired email template, Subject field
+		Subject: Template(`Share to '{ShareFolder}' expired at {ExpiredAt}`),
+		// ShareExpired email template, Greeting field
+		Greeting: Template(`Hello {ShareGrantee},`),
+		// ShareExpired email template, Message field
+		MessageBody: Template(`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.`),
+	}
+
+	// Spaces templates
+	SharedSpace = MessageTemplate{
+		bodyTemplate: "spaces/sharedSpace.email.body.tmpl",
+		// SharedSpace email template, Subject field
+		Subject: Template("{SpaceSharer} invited you to join {SpaceName}"),
+		// SharedSpace email template, Greeting field
+		Greeting: Template(`Hello {SpaceGrantee},`),
+		// SharedSpace email template, Message field
+		MessageBody: Template(`{SpaceSharer} has invited you to join "{SpaceName}".`),
+		// SharedSpace email template, CallToAction field
+		CallToAction: Template(`Click here to view it: {ShareLink}`),
+	}
+
+	UnsharedSpace = MessageTemplate{
+		bodyTemplate: "spaces/unsharedSpace.email.body.tmpl",
+		// UnsharedSpace email template, Subject field
+		Subject: Template(`{SpaceSharer} removed you from {SpaceName}`),
+		// UnsharedSpace email template, Greeting field
+		Greeting: Template(`Hello {SpaceGrantee},`),
+		// UnsharedSpace email template, Message field
+		MessageBody: Template(`{SpaceSharer} has removed you from "{SpaceName}".
+
+You might still have access through your other groups or direct membership.`),
+		// UnsharedSpace email template, CallToAction field
+		CallToAction: Template(`Click here to check it: {ShareLink}`),
+	}
+
+	MembershipExpired = MessageTemplate{
+		bodyTemplate: "spaces/membershipExpired.email.body.tmpl",
+		// MembershipExpired email template, Subject field
+		Subject: Template(`Membership of '{SpaceName}' expired at {ExpiredAt}`),
+		// MembershipExpired email template, Greeting field
+		Greeting: Template(`Hello {SpaceGrantee},`),
+		// MembershipExpired email template, Message field
+		MessageBody: Template(`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`),
+	}
+)
+
+// holds the information to turn the raw template into a parseable go template
+var _placeholders = map[string]string{
+	"{ShareSharer}":  "{{ .ShareSharer }}",
+	"{ShareFolder}":  "{{ .ShareFolder }}",
+	"{ShareGrantee}": "{{ .ShareGrantee }}",
+	"{ShareLink}":    "{{ .ShareLink }}",
+	"{SpaceName}":    "{{ .SpaceName }}",
+	"{SpaceGrantee}": "{{ .SpaceGrantee }}",
+	"{SpaceSharer}":  "{{ .SpaceSharer }}",
+	"{ExpiredAt}":    "{{ .ExpiredAt }}",
+}
+
+// MessageTemplate is the data structure for the email
+type MessageTemplate struct {
+	// bodyTemplate represent the path to .tmpl file
+	bodyTemplate string
+	Subject      string
+	Greeting     string
+	MessageBody  string
+	CallToAction string
+}
diff --git a/services/notifications/pkg/email/templates/shares/shareCreated.email.body.tmpl b/services/notifications/pkg/email/templates/shares/shareCreated.email.body.tmpl
index a13c63e3dcc..a09897c8198 100644
--- a/services/notifications/pkg/email/templates/shares/shareCreated.email.body.tmpl
+++ b/services/notifications/pkg/email/templates/shares/shareCreated.email.body.tmpl
@@ -1,16 +1,8 @@
-Hello {{ .ShareGrantee }},
+{{ .Greeting }}
 
-{{ .ShareSharer }} has shared "{{ .ShareFolder }}" with you.
+{{ .MessageBody }}
 
-Click here to view it: {{ .ShareLink }}
-
-----------------------------------------------------------
-
-Hallo {{ .ShareGrantee }},
-
-{{ .ShareSharer }} hat dich zu "{{ .ShareFolder }}" eingeladen.
-
-Klicke hier zum Anzeigen: {{ .ShareLink }}
+{{ .CallToAction }}
 
 
 ---
diff --git a/services/notifications/pkg/email/templates/shares/shareCreated.email.subject.tmpl b/services/notifications/pkg/email/templates/shares/shareCreated.email.subject.tmpl
deleted file mode 100644
index 09dccb9d801..00000000000
--- a/services/notifications/pkg/email/templates/shares/shareCreated.email.subject.tmpl
+++ /dev/null
@@ -1,1 +0,0 @@
-{{ .ShareSharer }} shared '{{ .ShareFolder }}' with you
\ No newline at end of file
diff --git a/services/notifications/pkg/email/templates/shares/shareExpired.email.body.tmpl b/services/notifications/pkg/email/templates/shares/shareExpired.email.body.tmpl
index 322f28e520d..34a55c95df9 100644
--- a/services/notifications/pkg/email/templates/shares/shareExpired.email.body.tmpl
+++ b/services/notifications/pkg/email/templates/shares/shareExpired.email.body.tmpl
@@ -1,16 +1,6 @@
-Hello {{ .ShareGrantee }},
+{{ .Greeting }}
 
-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.
+{{ .MessageBody }}
 
 
 ---
diff --git a/services/notifications/pkg/email/templates/shares/shareExpired.email.subject.tmpl b/services/notifications/pkg/email/templates/shares/shareExpired.email.subject.tmpl
deleted file mode 100644
index e9593dcd5af..00000000000
--- a/services/notifications/pkg/email/templates/shares/shareExpired.email.subject.tmpl
+++ /dev/null
@@ -1,1 +0,0 @@
-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
index 95ba3897bab..34a55c95df9 100644
--- a/services/notifications/pkg/email/templates/spaces/membershipExpired.email.body.tmpl
+++ b/services/notifications/pkg/email/templates/spaces/membershipExpired.email.body.tmpl
@@ -1,16 +1,6 @@
-Hello {{ .SpaceGrantee }},
+{{ .Greeting }}
 
-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.
+{{ .MessageBody }}
 
 
 ---
diff --git a/services/notifications/pkg/email/templates/spaces/membershipExpired.email.subject.tmpl b/services/notifications/pkg/email/templates/spaces/membershipExpired.email.subject.tmpl
deleted file mode 100644
index 93658183883..00000000000
--- a/services/notifications/pkg/email/templates/spaces/membershipExpired.email.subject.tmpl
+++ /dev/null
@@ -1,1 +0,0 @@
-Membership of '{{ .SpaceName }}' expired at {{ .ExpiredAt }}
\ No newline at end of file
diff --git a/services/notifications/pkg/email/templates/spaces/sharedSpace.email.body.tmpl b/services/notifications/pkg/email/templates/spaces/sharedSpace.email.body.tmpl
index 897e4eb66a3..a09897c8198 100644
--- a/services/notifications/pkg/email/templates/spaces/sharedSpace.email.body.tmpl
+++ b/services/notifications/pkg/email/templates/spaces/sharedSpace.email.body.tmpl
@@ -1,16 +1,8 @@
-Hello {{ .SpaceGrantee }},
+{{ .Greeting }}
 
-{{ .SpaceSharer }} has invited you to join "{{ .SpaceName }}".
+{{ .MessageBody }}
 
-Click here to view it: {{ .ShareLink }}
-
-----------------------------------------------------------
-
-Hallo {{ .SpaceGrantee }},
-
-{{ .SpaceSharer }} hat dich in den Space "{{ .SpaceName }}" eingeladen.
-
-Klicke hier zum Anzeigen: {{ .ShareLink }}
+{{ .CallToAction }}
 
 
 ---
diff --git a/services/notifications/pkg/email/templates/spaces/sharedSpace.email.subject.tmpl b/services/notifications/pkg/email/templates/spaces/sharedSpace.email.subject.tmpl
deleted file mode 100644
index e65e04292d1..00000000000
--- a/services/notifications/pkg/email/templates/spaces/sharedSpace.email.subject.tmpl
+++ /dev/null
@@ -1,1 +0,0 @@
-{{ .SpaceSharer }} invited you to join {{ .SpaceName }}
\ No newline at end of file
diff --git a/services/notifications/pkg/email/templates/spaces/unsharedSpace.email.body.tmpl b/services/notifications/pkg/email/templates/spaces/unsharedSpace.email.body.tmpl
index 4c6fa9ece4c..a09897c8198 100644
--- a/services/notifications/pkg/email/templates/spaces/unsharedSpace.email.body.tmpl
+++ b/services/notifications/pkg/email/templates/spaces/unsharedSpace.email.body.tmpl
@@ -1,16 +1,8 @@
-Hello {{ .SpaceGrantee }},
+{{ .Greeting }}
 
-{{ .SpaceSharer }} has removed you from "{{ .SpaceName }}".
+{{ .MessageBody }}
 
-You might still have access through your other groups or direct membership. Click here to check it: {{ .ShareLink }}
-
-----------------------------------------------------------
-
-Hallo {{ .SpaceGrantee }},
-
-{{ .SpaceSharer }} hat dich aus dem Space "{{ .SpaceName }}" entfernt.
-
-Du könntest über deine anderen Gruppen oder deiner direkten Mitgliedschaft noch Zugriff haben. Klicke hier zum Überprüfen: {{ .ShareLink }}
+{{ .CallToAction }}
 
 
 ---
diff --git a/services/notifications/pkg/email/templates/spaces/unsharedSpace.email.subject.tmpl b/services/notifications/pkg/email/templates/spaces/unsharedSpace.email.subject.tmpl
deleted file mode 100644
index c6628dfb3dd..00000000000
--- a/services/notifications/pkg/email/templates/spaces/unsharedSpace.email.subject.tmpl
+++ /dev/null
@@ -1,1 +0,0 @@
-{{ .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 9c3df81f91d..714696e720f 100644
--- a/services/notifications/pkg/service/service.go
+++ b/services/notifications/pkg/service/service.go
@@ -54,6 +54,7 @@ type eventsNotifier struct {
 	gwClient          gateway.GatewayAPIClient
 	machineAuthAPIKey string
 	emailTemplatePath string
+	translationPath   string
 	ocisURL           string
 }
 
@@ -86,18 +87,9 @@ func (s eventsNotifier) Run() error {
 	}
 }
 
-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 {
-		return "", "", err
-	}
-
-	sub, err := email.RenderEmailTemplate(subjTemplate, values, s.emailTemplatePath)
-	if err != nil {
-		return "", "", err
-	}
-
-	return msg, sub, nil
+func (s eventsNotifier) render(template email.MessageTemplate, values map[string]interface{}) (string, string, error) {
+	// The locate have to come from the user setting
+	return email.RenderEmailTemplate(template, "en", s.emailTemplatePath, s.translationPath, values)
 }
 
 func (s eventsNotifier) send(ctx context.Context, u *user.UserId, g *group.GroupId, msg, subj, sender string) error {
diff --git a/services/notifications/pkg/service/shares.go b/services/notifications/pkg/service/shares.go
index 67588494366..6451783bc2f 100644
--- a/services/notifications/pkg/service/shares.go
+++ b/services/notifications/pkg/service/shares.go
@@ -3,6 +3,7 @@ package service
 import (
 	"github.com/cs3org/reva/v2/pkg/events"
 	"github.com/cs3org/reva/v2/pkg/utils"
+	"github.com/owncloud/ocis/v2/services/notifications/pkg/email"
 	"google.golang.org/protobuf/types/known/fieldmaskpb"
 )
 
@@ -41,7 +42,7 @@ func (s eventsNotifier) handleShareCreated(e events.ShareCreated) {
 	}
 
 	sharerDisplayName := owner.GetDisplayName()
-	msg, subj, err := s.render("shares/shareCreated.email.body.tmpl", "shares/shareCreated.email.subject.tmpl", map[string]string{
+	subj, msg, err := s.render(email.ShareCreated, map[string]interface{}{
 		"ShareGrantee": shareGrantee,
 		"ShareSharer":  sharerDisplayName,
 		"ShareFolder":  resourceInfo.Name,
@@ -84,7 +85,7 @@ func (s eventsNotifier) handleShareExpired(e events.ShareExpired) {
 		return
 	}
 
-	msg, subj, err := s.render("shares/shareExpired.email.body.tmpl", "shares/shareExpired.email.subject.tmpl", map[string]string{
+	subj, msg, err := s.render(email.ShareExpired, map[string]interface{}{
 		"ShareGrantee": shareGrantee,
 		"ShareFolder":  resourceInfo.GetName(),
 		"ExpiredAt":    e.ExpiredAt.Format("2006-01-02 15:04:05"),
diff --git a/services/notifications/pkg/service/spaces.go b/services/notifications/pkg/service/spaces.go
index 44affdfcc46..bf7d7473428 100644
--- a/services/notifications/pkg/service/spaces.go
+++ b/services/notifications/pkg/service/spaces.go
@@ -4,6 +4,7 @@ import (
 	"github.com/cs3org/reva/v2/pkg/events"
 	"github.com/cs3org/reva/v2/pkg/storagespace"
 	"github.com/cs3org/reva/v2/pkg/utils"
+	"github.com/owncloud/ocis/v2/services/notifications/pkg/email"
 )
 
 func (s eventsNotifier) handleSpaceShared(e events.SpaceShared) {
@@ -54,7 +55,7 @@ func (s eventsNotifier) handleSpaceShared(e events.SpaceShared) {
 	}
 
 	sharerDisplayName := owner.GetDisplayName()
-	msg, subj, err := s.render("spaces/sharedSpace.email.body.tmpl", "spaces/sharedSpace.email.subject.tmpl", map[string]string{
+	subj, msg, err := s.render(email.SharedSpace, map[string]interface{}{
 		"SpaceGrantee": spaceGrantee,
 		"SpaceSharer":  sharerDisplayName,
 		"SpaceName":    resourceInfo.GetSpace().GetName(),
@@ -116,7 +117,7 @@ func (s eventsNotifier) handleSpaceUnshared(e events.SpaceUnshared) {
 	}
 
 	sharerDisplayName := owner.GetDisplayName()
-	msg, subj, err := s.render("spaces/unsharedSpace.email.body.tmpl", "spaces/unsharedSpace.email.subject.tmpl", map[string]string{
+	subj, msg, err := s.render(email.UnsharedSpace, map[string]interface{}{
 		"SpaceGrantee": spaceGrantee,
 		"SpaceSharer":  sharerDisplayName,
 		"SpaceName":    resourceInfo.GetSpace().Name,
@@ -151,7 +152,7 @@ func (s eventsNotifier) handleSpaceMembershipExpired(e events.SpaceMembershipExp
 		return
 	}
 
-	msg, subj, err := s.render("spaces/membershipExpired.email.body.tmpl", "spaces/membershipExpired.email.subject.tmpl", map[string]string{
+	subj, msg, err := s.render(email.MembershipExpired, map[string]interface{}{
 		"SpaceGrantee": shareGrantee,
 		"SpaceName":    e.SpaceName,
 		"ExpiredAt":    e.ExpiredAt.Format("2006-01-02 15:04:05"),
diff --git a/services/proxy/README.md b/services/proxy/README.md
index eae7359dd79..110d9266b55 100644
--- a/services/proxy/README.md
+++ b/services/proxy/README.md
@@ -50,10 +50,14 @@ role_assignment:
     oidc_role_mapper:
         role_claim: ocisRoles
         role_mapping:
-            admin: myAdminRole
-            user: myUserRole
-            spaceadmin: mySpaceAdminRole
-            guest: myGuestRole
+            - role_name: admin
+              claim_value: myAdminRole
+            - role_name: spaceadmin
+              claim_value: mySpaceAdminRole
+            - role_name: user
+              claim_value: myUserRole
+            - role_name: guest:
+              claim_value: myGuestRole
 ```
 
 This would assign the role `admin` to users with the value `myAdminRole` in the claim `ocisRoles`.
@@ -62,16 +66,27 @@ The role `user` to users with the values `myUserRole` in the claims `ocisRoles`
 Claim values that are not mapped to a specific ownCloud Infinite Scale role will be ignored.
 
 Note: An ownCloud Infinite Scale user can only have a single role assigned. If the configured
-`role_mapping` and a user's claim values result in multiple possible roles for a user, an error
-will be logged and the user will not be able to login.
+`role_mapping` and a user's claim values result in multiple possible roles for a user, the order in
+which the role mappings are defined in the configuration is important. The first role in the
+`role_mappings` where the `claim_value` matches a value from the user's roles claim will be assigned
+to the user. So if e.g. a user's `ocisRoles` claim has the values `myUserRole` and
+`mySpaceAdminRole` that user will get the ocis role `spaceadmin` assigned (because `spaceadmin`
+appears before `user` in the above sample configuration).
 
-The default `role_claim` (or `PROXY_ROLE_ASSIGNMENT_OIDC_CLAIM`) is `roles`. The `role_mapping` is:
+If a user's claim values don't match any of the configured role mappings an error will be logged and
+the user will not be able to login.
+
+The default `role_claim` (or `PROXY_ROLE_ASSIGNMENT_OIDC_CLAIM`) is `roles`. The default `role_mapping` is:
 
 ```yaml
-admin: ocisAdmin
-user: ocisUser
-spaceadmin: ocisSpaceAdmin
-guest: ocisGuest
+- role_name: admin
+  claim_value: ocisAdmin
+- role_name: spaceadmin
+  claim_value: ocisSpaceAdmin
+- role_name: user
+  claim_value: ocisUser
+- role_name: guest:
+  claim_value: ocisGuest
 ```
 
 ## Recommendations for Production Deployments
diff --git a/services/proxy/pkg/command/server.go b/services/proxy/pkg/command/server.go
index f51436121bc..d2aee9d878f 100644
--- a/services/proxy/pkg/command/server.go
+++ b/services/proxy/pkg/command/server.go
@@ -178,7 +178,7 @@ func loadMiddlewares(ctx context.Context, logger log.Logger, cfg *config.Config)
 			userroles.WithRoleService(rolesClient),
 			userroles.WithLogger(logger),
 			userroles.WithRolesClaim(cfg.RoleAssignment.OIDCRoleMapper.RoleClaim),
-			userroles.WithRoleMapping(cfg.RoleAssignment.OIDCRoleMapper.RoleMapping),
+			userroles.WithRoleMapping(cfg.RoleAssignment.OIDCRoleMapper.RolesMap),
 			userroles.WithAutoProvisonCreator(autoProvsionCreator),
 		)
 	default:
diff --git a/services/proxy/pkg/config/config.go b/services/proxy/pkg/config/config.go
index 4c72ef4c5ed..a2d6daed6fc 100644
--- a/services/proxy/pkg/config/config.go
+++ b/services/proxy/pkg/config/config.go
@@ -135,8 +135,14 @@ type RoleAssignment struct {
 
 // OIDCRoleMapper contains the configuration for the "oidc" role assignment driber
 type OIDCRoleMapper struct {
-	RoleClaim   string            `yaml:"role_claim" env:"PROXY_ROLE_ASSIGNMENT_OIDC_CLAIM" desc:"The OIDC claim used to create the users role assignment."`
-	RoleMapping map[string]string `yaml:"role_mapping" desc:"A mapping of ocis role names to PROXY_ROLE_ASSIGNMENT_OIDC_CLAIM claim values. This setting can only be configured in the configuration file and not via environment variables."`
+	RoleClaim string        `yaml:"role_claim" env:"PROXY_ROLE_ASSIGNMENT_OIDC_CLAIM" desc:"The OIDC claim used to create the users role assignment."`
+	RolesMap  []RoleMapping `yaml:"role_mapping" desc:"A list of mappings of ocis role names to PROXY_ROLE_ASSIGNMENT_OIDC_CLAIM claim values. This setting can only be configured in the configuration file and not via environment variables."`
+}
+
+// RoleMapping defines which ocis role matches a specific claim value
+type RoleMapping struct {
+	RoleName   string `yaml:"role_name" desc:"The name of an ocis role that this mapping should apply for."`
+	ClaimValue string `yaml:"claim_value" desc:"The value of the 'PROXY_ROLE_ASSIGNMENT_OIDC_CLAIM' that matches the role defined in 'role_name'."`
 }
 
 // PolicySelector is the toplevel-configuration for different selectors
diff --git a/services/proxy/pkg/config/defaults/defaultconfig.go b/services/proxy/pkg/config/defaults/defaultconfig.go
index 582dbd0da79..b3316083e15 100644
--- a/services/proxy/pkg/config/defaults/defaultconfig.go
+++ b/services/proxy/pkg/config/defaults/defaultconfig.go
@@ -60,11 +60,11 @@ func DefaultConfig() *config.Config {
 			// this default is only relevant when Driver is set to "oidc"
 			OIDCRoleMapper: config.OIDCRoleMapper{
 				RoleClaim: "roles",
-				RoleMapping: map[string]string{
-					"admin":      "ocisAdmin",
-					"spaceadmin": "ocisSpaceAdmin",
-					"user":       "ocisUser",
-					"guest":      "ocisGuest",
+				RolesMap: []config.RoleMapping{
+					config.RoleMapping{RoleName: "admin", ClaimValue: "ocisAdmin"},
+					config.RoleMapping{RoleName: "spaceadmin", ClaimValue: "ocisSpaceAdmin"},
+					config.RoleMapping{RoleName: "user", ClaimValue: "ocisUser"},
+					config.RoleMapping{RoleName: "guest", ClaimValue: "ocisGuest"},
 				},
 			},
 		},
diff --git a/services/proxy/pkg/userroles/oidcroles.go b/services/proxy/pkg/userroles/oidcroles.go
index 0aee52d873b..647b216cc7d 100644
--- a/services/proxy/pkg/userroles/oidcroles.go
+++ b/services/proxy/pkg/userroles/oidcroles.go
@@ -34,47 +34,55 @@ func NewOIDCRoleAssigner(opts ...Option) UserRoleAssigner {
 // already has a different role assigned.
 func (ra oidcRoleAssigner) UpdateUserRoleAssignment(ctx context.Context, user *cs3.User, claims map[string]interface{}) (*cs3.User, error) {
 	logger := ra.logger.SubloggerWithRequestID(ctx).With().Str("userid", user.GetId().GetOpaqueId()).Logger()
-	claimValueToRoleID, err := ra.oidcClaimvaluesToRoleIDs()
+	roleNamesToRoleIDs, err := ra.roleNamesToRoleIDs()
 	if err != nil {
-		logger.Error().Err(err).Msg("Error mapping claims to roles ids")
+		logger.Error().Err(err).Msg("Error mapping role names to role ids")
 		return nil, err
 	}
 
-	roleIDsFromClaim := make([]string, 0, 1)
-	claimRoles, ok := claims[ra.rolesClaim].([]interface{})
+	claimRolesRaw, ok := claims[ra.rolesClaim].([]interface{})
 	if !ok {
-		logger.Error().Err(err).Str("rolesClaim", ra.rolesClaim).Msg("No roles in user claims")
-		return nil, err
+		logger.Error().Str("rolesClaim", ra.rolesClaim).Msg("No roles in user claims")
+		return nil, errors.New("no roles in user claims")
 	}
+
 	logger.Debug().Str("rolesClaim", ra.rolesClaim).Interface("rolesInClaim", claims[ra.rolesClaim]).Msg("got roles in claim")
-	for _, cri := range claimRoles {
+	claimRoles := map[string]struct{}{}
+	for _, cri := range claimRolesRaw {
 		cr, ok := cri.(string)
 		if !ok {
 			err := errors.New("invalid role in claims")
 			logger.Error().Err(err).Interface("claimValue", cri).Msg("Is not a valid string.")
 			return nil, err
 		}
-		id, ok := claimValueToRoleID[cr]
-		if !ok {
-			logger.Debug().Str("role", cr).Msg("No mapping for claim role. Skipped.")
-			continue
-		}
-		roleIDsFromClaim = append(roleIDsFromClaim, id)
+
+		claimRoles[cr] = struct{}{}
 	}
-	logger.Debug().Interface("roleIDs", roleIDsFromClaim).Msg("Mapped claim roles to roleids")
 
-	switch len(roleIDsFromClaim) {
-	default:
-		err := errors.New("too many roles found in claims")
-		logger.Error().Err(err).Msg("Only one role per user is allowed.")
+	if len(claimRoles) == 0 {
+		err := errors.New("no roles set in claim")
+		logger.Error().Err(err).Msg("")
 		return nil, err
-	case 0:
-		err := errors.New("no role in claim, maps to a ocis role")
+	}
+
+	// the roleMapping config is supposed to have the role mappings ordered from the highest privileged role
+	// down to the lowest privileged role. Since ocis currently only can handle a single role assignment we
+	// pick the highest privileged role that matches a value from the claims
+	roleIDFromClaim := ""
+	for _, mapping := range ra.Options.roleMapping {
+		if _, ok := claimRoles[mapping.ClaimValue]; ok {
+			logger.Debug().Str("ocisRole", mapping.RoleName).Str("role id", roleNamesToRoleIDs[mapping.RoleName]).Msg("first matching role")
+			roleIDFromClaim = roleNamesToRoleIDs[mapping.RoleName]
+			break
+		}
+	}
+
+	if roleIDFromClaim == "" {
+		err := errors.New("no role in claim maps to an ocis role")
 		logger.Error().Err(err).Msg("")
 		return nil, err
-	case 1:
-		// exactly one mapping. This is right
 	}
+
 	assignedRoles, err := loadRolesIDs(ctx, user.GetId().GetOpaqueId(), ra.roleService)
 	if err != nil {
 		logger.Error().Err(err).Msg("Could not load roles")
@@ -86,8 +94,9 @@ func (ra oidcRoleAssigner) UpdateUserRoleAssignment(ctx context.Context, user *c
 		return nil, err
 	}
 	logger.Debug().Interface("assignedRoleIds", assignedRoles).Msg("Currently assigned roles")
-	if len(assignedRoles) == 0 || (assignedRoles[0] != roleIDsFromClaim[0]) {
-		logger.Debug().Interface("assignedRoleIds", assignedRoles).Interface("newRoleIds", roleIDsFromClaim).Msg("Updating role assignment for user")
+
+	if len(assignedRoles) == 0 || (assignedRoles[0] != roleIDFromClaim) {
+		logger.Debug().Interface("assignedRoleIds", assignedRoles).Interface("newRoleId", roleIDFromClaim).Msg("Updating role assignment for user")
 		newctx, err := ra.prepareAdminContext()
 		if err != nil {
 			logger.Error().Err(err).Msg("Error creating admin context")
@@ -95,14 +104,14 @@ func (ra oidcRoleAssigner) UpdateUserRoleAssignment(ctx context.Context, user *c
 		}
 		if _, err = ra.roleService.AssignRoleToUser(newctx, &settingssvc.AssignRoleToUserRequest{
 			AccountUuid: user.GetId().GetOpaqueId(),
-			RoleId:      roleIDsFromClaim[0],
+			RoleId:      roleIDFromClaim,
 		}); err != nil {
 			logger.Error().Err(err).Msg("Role assignment failed")
 			return nil, err
 		}
 	}
 
-	user.Opaque = utils.AppendJSONToOpaque(user.Opaque, "roles", roleIDsFromClaim)
+	user.Opaque = utils.AppendJSONToOpaque(user.Opaque, "roles", []string{roleIDFromClaim})
 	return user, nil
 }
 
@@ -136,32 +145,32 @@ func (ra oidcRoleAssigner) prepareAdminContext() (context.Context, error) {
 	return newctx, nil
 }
 
-type roleClaimToIDCache struct {
-	roleClaimToID map[string]string
-	lastRead      time.Time
-	lock          sync.RWMutex
+type roleNameToIDCache struct {
+	roleNameToID map[string]string
+	lastRead     time.Time
+	lock         sync.RWMutex
 }
 
-var roleClaimToID roleClaimToIDCache
+var roleNameToID roleNameToIDCache
 
-func (ra oidcRoleAssigner) oidcClaimvaluesToRoleIDs() (map[string]string, error) {
+func (ra oidcRoleAssigner) roleNamesToRoleIDs() (map[string]string, error) {
 	cacheTTL := 5 * time.Minute
-	roleClaimToID.lock.RLock()
+	roleNameToID.lock.RLock()
 
-	if !roleClaimToID.lastRead.IsZero() && time.Since(roleClaimToID.lastRead) < cacheTTL {
-		defer roleClaimToID.lock.RUnlock()
-		return roleClaimToID.roleClaimToID, nil
+	if !roleNameToID.lastRead.IsZero() && time.Since(roleNameToID.lastRead) < cacheTTL {
+		defer roleNameToID.lock.RUnlock()
+		return roleNameToID.roleNameToID, nil
 	}
 	ra.logger.Debug().Msg("refreshing roles ids")
 
 	// cache needs Refresh get a write lock
-	roleClaimToID.lock.RUnlock()
-	roleClaimToID.lock.Lock()
-	defer roleClaimToID.lock.Unlock()
+	roleNameToID.lock.RUnlock()
+	roleNameToID.lock.Lock()
+	defer roleNameToID.lock.Unlock()
 
 	// check again, another goroutine might have updated while we "upgraded" the lock
-	if !roleClaimToID.lastRead.IsZero() && time.Since(roleClaimToID.lastRead) < cacheTTL {
-		return roleClaimToID.roleClaimToID, nil
+	if !roleNameToID.lastRead.IsZero() && time.Since(roleNameToID.lastRead) < cacheTTL {
+		return roleNameToID.roleNameToID, nil
 	}
 
 	// Get all roles to find the role IDs.
@@ -183,16 +192,10 @@ func (ra oidcRoleAssigner) oidcClaimvaluesToRoleIDs() (map[string]string, error)
 	newIDs := map[string]string{}
 	for _, role := range res.Bundles {
 		ra.logger.Debug().Str("role", role.Name).Str("id", role.Id).Msg("Got Role")
-		roleClaim, ok := ra.roleMapping[role.Name]
-		if !ok {
-			err := errors.New("Incomplete role mapping")
-			ra.logger.Error().Err(err).Str("role", role.Name).Msg("Role not mapped to a claim value")
-			return map[string]string{}, err
-		}
-		newIDs[roleClaim] = role.Id
+		newIDs[role.Name] = role.Id
 	}
-	ra.logger.Debug().Interface("roleMap", newIDs).Msg("Claim Role to role ID map")
-	roleClaimToID.roleClaimToID = newIDs
-	roleClaimToID.lastRead = time.Now()
-	return roleClaimToID.roleClaimToID, nil
+	ra.logger.Debug().Interface("roleMap", newIDs).Msg("Role Name to role ID map")
+	roleNameToID.roleNameToID = newIDs
+	roleNameToID.lastRead = time.Now()
+	return roleNameToID.roleNameToID, nil
 }
diff --git a/services/proxy/pkg/userroles/userroles.go b/services/proxy/pkg/userroles/userroles.go
index 8c2dd431fe1..55d8afc7e13 100644
--- a/services/proxy/pkg/userroles/userroles.go
+++ b/services/proxy/pkg/userroles/userroles.go
@@ -7,6 +7,7 @@ import (
 	"github.com/owncloud/ocis/v2/ocis-pkg/log"
 	settingssvc "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/settings/v0"
 	"github.com/owncloud/ocis/v2/services/proxy/pkg/autoprovision"
+	"github.com/owncloud/ocis/v2/services/proxy/pkg/config"
 )
 
 //go:generate mockery --name=UserRoleAssigner
@@ -26,7 +27,7 @@ type UserRoleAssigner interface {
 type Options struct {
 	roleService         settingssvc.RoleService
 	rolesClaim          string
-	roleMapping         map[string]string
+	roleMapping         []config.RoleMapping
 	autoProvsionCreator autoprovision.Creator
 	logger              log.Logger
 }
@@ -56,7 +57,7 @@ func WithRolesClaim(claim string) Option {
 }
 
 // WithRoleMapping configures the map of ocis role names to claims values
-func WithRoleMapping(roleMap map[string]string) Option {
+func WithRoleMapping(roleMap []config.RoleMapping) Option {
 	return func(o *Options) {
 		o.roleMapping = roleMap
 	}
diff --git a/services/userlog/pkg/config/config.go b/services/userlog/pkg/config/config.go
index 41985cb7707..6998f826029 100644
--- a/services/userlog/pkg/config/config.go
+++ b/services/userlog/pkg/config/config.go
@@ -23,7 +23,7 @@ type Config struct {
 
 	MachineAuthAPIKey string      `yaml:"machine_auth_api_key" env:"OCIS_MACHINE_AUTH_API_KEY;USERLOG_MACHINE_AUTH_API_KEY" desc:"Machine auth API key used to validate internal requests necessary to access resources from other services."`
 	RevaGateway       string      `yaml:"reva_gateway" env:"OCIS_REVA_GATEWAY;REVA_GATEWAY" desc:"CS3 gateway used to look up user metadata" deprecationVersion:"3.0" removalVersion:"3.1" deprecationInfo:"REVA_GATEWAY changing name for consistency" deprecationReplacement:"OCIS_REVA_GATEWAY"`
-	TranslationPath   string      `yaml:"translation_path" env:"USERLOG_TRANSLATION_PATH" desc:"(optional) Set this to a path with custom translations to overwrite the builtin translations. See the documentation for more details."`
+	TranslationPath   string      `yaml:"translation_path" env:"OCIS_TRANSLATION_PATH,USERLOG_TRANSLATION_PATH" desc:"(optional) Set this to a path with custom translations to overwrite the builtin translations. Note that file and folder naming rules apply, see the documentation for more details."`
 	Events            Events      `yaml:"events"`
 	Persistence       Persistence `yaml:"persistence"`
 
diff --git a/services/userlog/pkg/service/conversion.go b/services/userlog/pkg/service/conversion.go
index c2cd6081a93..e1bce3525c6 100644
--- a/services/userlog/pkg/service/conversion.go
+++ b/services/userlog/pkg/service/conversion.go
@@ -356,7 +356,6 @@ func composeMessage(nt NotificationTemplate, locale string, path string, vars ma
 
 	message, err := executeTemplate(messageraw, vars)
 	return subject, subjectraw, message, messageraw, err
-
 }
 
 func loadTemplates(nt NotificationTemplate, locale string, path string) (string, string) {
diff --git a/services/web/Makefile b/services/web/Makefile
index c09e3e7527e..a63ed4801c5 100644
--- a/services/web/Makefile
+++ b/services/web/Makefile
@@ -1,6 +1,6 @@
 SHELL := bash
 NAME := web
-WEB_ASSETS_VERSION = v7.0.0-rc.29
+WEB_ASSETS_VERSION = v7.0.0-rc.30
 
 include ../../.make/recursion.mk
 
diff --git a/services/web/pkg/config/config.go b/services/web/pkg/config/config.go
index 81fcfb989e1..41e622fd19d 100644
--- a/services/web/pkg/config/config.go
+++ b/services/web/pkg/config/config.go
@@ -33,6 +33,22 @@ type Asset struct {
 	Path string `yaml:"path" env:"WEB_ASSET_PATH" desc:"Serve ownCloud Web assets from a path on the filesystem instead of the builtin assets."`
 }
 
+// CustomStyle references additional css to be loaded into ownCloud Web.
+type CustomStyle struct {
+	Href string `json:"href" yaml:"href"`
+}
+
+// CustomScript references an additional script to be loaded into ownCloud Web.
+type CustomScript struct {
+	Src   string `json:"src" yaml:"src"`
+	Async bool   `json:"async,omitempty" yaml:"async"`
+}
+
+// CustomTranslation references a json file for overwriting translations in ownCloud Web.
+type CustomTranslation struct {
+	Url string `json:"url" yaml:"url"`
+}
+
 // WebConfig defines the available web configuration for a dynamically rendered config.json.
 type WebConfig struct {
 	Server        string                 `json:"server,omitempty" yaml:"server" env:"OCIS_URL;WEB_UI_CONFIG_SERVER" desc:"URL, where the oCIS APIs are reachable for ownCloud Web."`
@@ -42,6 +58,9 @@ type WebConfig struct {
 	Applications  []Application          `json:"applications,omitempty" yaml:"applications"`
 	ExternalApps  []ExternalApp          `json:"external_apps,omitempty" yaml:"external_apps"`
 	Options       map[string]interface{} `json:"options,omitempty" yaml:"options"`
+	Styles        []CustomStyle          `json:"styles,omitempty" yaml:"styles"`
+	Scripts       []CustomScript         `json:"scripts,omitempty" yaml:"scripts"`
+	Translations  []CustomTranslation    `json:"customTranslations,omitempty" yaml:"custom_translations"`
 }
 
 // OIDC defines the available oidc configuration


Test patch:
diff --git a/ocis-pkg/config/envdecode/envdecode_test.go b/ocis-pkg/config/envdecode/envdecode_test.go
index cb3274c1c64..03eb2638587 100644
--- a/ocis-pkg/config/envdecode/envdecode_test.go
+++ b/ocis-pkg/config/envdecode/envdecode_test.go
@@ -58,7 +58,7 @@ type testConfig struct {
 	UnmarshalerNumber unmarshalerNumber `env:"TEST_UNMARSHALER_NUMBER"`
 
 	DefaultInt      int           `env:"TEST_UNSET,asdf=asdf,default=1234"`
-	DefaultSliceInt []int         `env:"TEST_UNSET,asdf=asdf,default=1;2;3"`
+	DefaultSliceInt []int         `env:"TEST_UNSET,asdf=asdf,default=1"`
 	DefaultDuration time.Duration `env:"TEST_UNSET,asdf=asdf,default=24h"`
 	DefaultURL      *url.URL      `env:"TEST_UNSET,default=http://example.com"`
 }
@@ -137,12 +137,12 @@ func TestDecode(t *testing.T) {
 	os.Setenv("TEST_DURATION", "10m")
 	os.Setenv("TEST_URL", "https://example.com")
 	os.Setenv("TEST_INVALID_INT64", "asdf")
-	os.Setenv("TEST_STRING_SLICE", "foo;bar")
-	os.Setenv("TEST_INT64_SLICE", int64AsString+";"+int64AsString)
-	os.Setenv("TEST_UINT16_SLICE", "60000;50000")
-	os.Setenv("TEST_FLOAT64_SLICE", piAsString+";"+piAsString)
-	os.Setenv("TEST_BOOL_SLICE", "true; false; true")
-	os.Setenv("TEST_DURATION_SLICE", "10m; 20m")
+	os.Setenv("TEST_STRING_SLICE", "foo,bar")
+	os.Setenv("TEST_INT64_SLICE", int64AsString+","+int64AsString)
+	os.Setenv("TEST_UINT16_SLICE", "60000,50000")
+	os.Setenv("TEST_FLOAT64_SLICE", piAsString+","+piAsString)
+	os.Setenv("TEST_BOOL_SLICE", "true, false, true")
+	os.Setenv("TEST_DURATION_SLICE", "10m, 20m")
 	os.Setenv("TEST_URL_SLICE", "https://example.com")
 	os.Setenv("TEST_DECODER_STRUCT", "{\"string\":\"foo\"}")
 	os.Setenv("TEST_DECODER_STRUCT_PTR", "{\"string\":\"foo\"}")
@@ -274,7 +274,7 @@ func TestDecode(t *testing.T) {
 		t.Fatalf("Expected 1234, got %d", tc.DefaultInt)
 	}
 
-	expectedDefaultSlice := []int{1, 2, 3}
+	expectedDefaultSlice := []int{1}
 	if !reflect.DeepEqual(tc.DefaultSliceInt, expectedDefaultSlice) {
 		t.Fatalf("Expected %d, got %d", expectedDefaultSlice, tc.DefaultSliceInt)
 	}
@@ -517,7 +517,7 @@ type testConfigExport struct {
 	DefaultDuration time.Duration `env:"TEST_DEFAULT_DURATION,default=24h"`
 	DefaultURL      *url.URL      `env:"TEST_DEFAULT_URL,default=http://example.com"`
 	DefaultIntSet   int           `env:"TEST_DEFAULT_INT_SET,default=99"`
-	DefaultIntSlice []int         `env:"TEST_DEFAULT_INT_SLICE,default=99;33"`
+	DefaultIntSlice []int         `env:"TEST_DEFAULT_INT_SLICE,default=99"`
 }
 
 type nestedConfigExport struct {
@@ -603,13 +603,13 @@ func TestExport(t *testing.T) {
 	os.Setenv("TEST_BOOL", "true")
 	os.Setenv("TEST_DURATION", "10m")
 	os.Setenv("TEST_URL", "https://example.com")
-	os.Setenv("TEST_STRING_SLICE", "foo;bar")
+	os.Setenv("TEST_STRING_SLICE", "foo,bar")
 	os.Setenv("TEST_NESTED_STRING", "nest_foo")
 	os.Setenv("TEST_NESTED_STRING_POINTER", "nest_foo_ptr")
 	os.Setenv("TEST_NESTED_TWICE_STRING", "nest_twice_foo")
 	os.Setenv("TEST_REQUIRED_INT", "101")
 	os.Setenv("TEST_DEFAULT_INT_SET", "102")
-	os.Setenv("TEST_DEFAULT_INT_SLICE", "1;2;3")
+	os.Setenv("TEST_DEFAULT_INT_SLICE", "1,2,3")
 
 	var tc testConfigExport
 	tc.NestedPtr = &nestedConfigExportPointer{}
@@ -769,7 +769,7 @@ func TestExport(t *testing.T) {
 			Field:        "DefaultIntSlice",
 			EnvVar:       "TEST_DEFAULT_INT_SLICE",
 			Value:        "[1 2 3]",
-			DefaultValue: "99;33",
+			DefaultValue: "99",
 			HasDefault:   true,
 			UsesEnv:      true,
 		},
diff --git a/services/notifications/pkg/service/service_test.go b/services/notifications/pkg/service/service_test.go
index a9c323473a9..aad56181995 100644
--- a/services/notifications/pkg/service/service_test.go
+++ b/services/notifications/pkg/service/service_test.go
@@ -66,8 +66,19 @@ var _ = Describe("Notifications", func() {
 		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{}),
+			expectedMessage: `Hello Dr. S. Harer
+
+Dr. S. Harer has shared "secrets of the board" with you.
+
+Click here to view it: files/shares/with-me
+
+
+---
+ownCloud - Store. Share. Work.
+https://owncloud.com
+`,
+			expectedSender: sharer.GetDisplayName(),
+			done:           make(chan struct{}),
 		}, events.Event{
 			Event: events.ShareCreated{
 				Sharer:        sharer.GetId(),
@@ -80,8 +91,19 @@ var _ = Describe("Notifications", func() {
 		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{}),
+			expectedMessage: `Hello Dr. S. Harer,
+
+Your share to secrets of the board has expired at 2023-04-17 16:42:00
+
+Even though this share has been revoked you still might have access through other shares and/or space memberships.
+
+
+---
+ownCloud - Store. Share. Work.
+https://owncloud.com
+`,
+			expectedSender: sharer.GetDisplayName(),
+			done:           make(chan struct{}),
 		}, events.Event{
 			Event: events.ShareExpired{
 				ShareOwner:    sharer.GetId(),
@@ -94,8 +116,19 @@ var _ = Describe("Notifications", func() {
 		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{}),
+			expectedMessage: `Hello Dr. S. Harer,
+
+Dr. S. Harer has invited you to join "secret space".
+
+Click here to view it: f/spaceid
+
+
+---
+ownCloud - Store. Share. Work.
+https://owncloud.com
+`,
+			expectedSender: sharer.GetDisplayName(),
+			done:           make(chan struct{}),
 		}, events.Event{
 			Event: events.SpaceShared{
 				Executant:     sharer.GetId(),
@@ -108,8 +141,21 @@ var _ = Describe("Notifications", func() {
 		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{}),
+			expectedMessage: `Hello Dr. S. Harer,
+
+Dr. S. Harer has removed you from "secret space".
+
+You might still have access through your other groups or direct membership.
+
+Click here to check it: f/spaceid
+
+
+---
+ownCloud - Store. Share. Work.
+https://owncloud.com
+`,
+			expectedSender: sharer.GetDisplayName(),
+			done:           make(chan struct{}),
 		}, events.Event{
 			Event: events.SpaceUnshared{
 				Executant:     sharer.GetId(),
@@ -121,8 +167,19 @@ var _ = Describe("Notifications", func() {
 		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{}),
+			expectedMessage: `Hello Dr. S. Harer,
+
+Your membership of space secret space has expired at 2023-04-17 16:42:00
+
+Even though this membership has expired you still might have access through other shares and/or space memberships
+
+
+---
+ownCloud - Store. Share. Work.
+https://owncloud.com
+`,
+			expectedSender: sharer.GetDisplayName(),
+			done:           make(chan struct{}),
 		}, events.Event{
 			Event: events.SpaceMembershipExpired{
 				SpaceOwner:    sharer.GetId(),
@@ -139,6 +196,7 @@ var _ = Describe("Notifications", func() {
 type testChannel struct {
 	expectedReceipients map[string]bool
 	expectedSubject     string
+	expectedMessage     string
 	expectedSender      string
 	done                chan struct{}
 }
@@ -150,8 +208,7 @@ func (tc testChannel) SendMessage(ctx context.Context, userIDs []string, msg, su
 		Expect(tc.expectedReceipients[u]).To(Equal(true))
 	}
 
-	// TODO: test the message?
-	//Expect(msg).To(Equal(tc.expectedMessage))
+	Expect(msg).To(Equal(tc.expectedMessage))
 	Expect(subject).To(Equal(tc.expectedSubject))
 	Expect(senderDisplayName).To(Equal(tc.expectedSender))
 	tc.done <- struct{}{}
diff --git a/tests/acceptance/features/apiContract/sharesReport.feature b/tests/acceptance/features/apiContract/sharesReport.feature
index dc9ff1af86f..44b21502b3b 100644
--- a/tests/acceptance/features/apiContract/sharesReport.feature
+++ b/tests/acceptance/features/apiContract/sharesReport.feature
@@ -11,11 +11,11 @@ Feature: REPORT request to Shares space
     And user "Alice" has created folder "/folderMain/SubFolder1"
     And user "Alice" has created folder "/folderMain/SubFolder1/subFOLDER2"
     And user "Alice" has shared entry "/folderMain" with user "Brian" with permissions "17"
-    And user "Brian" has accepted share "/folderMain" offered by user "Alice"
 
 
   Scenario Outline: Check the REPORT response of the found folder
     Given using <dav_version> DAV path
+    And user "Brian" has accepted share "/folderMain" offered by user "Alice"
     When user "Brian" searches for "SubFolder1" using the WebDAV API
     Then the HTTP status code should be "207"
     And the "REPORT" response to user "Brian" should contain a mountpoint "folderMain" with these key and value pairs:
@@ -34,6 +34,7 @@ Feature: REPORT request to Shares space
 
   Scenario Outline: Check the REPORT response of the found file
     Given using <dav_version> DAV path
+    And user "Brian" has accepted share "/folderMain" offered by user "Alice"
     And user "Alice" has uploaded file with content "Not all those who wander are lost." to "/folderMain/SubFolder1/subFOLDER2/frodo.txt"
     When user "Brian" searches for "frodo.txt" using the WebDAV API
     Then the HTTP status code should be "207"
@@ -50,3 +51,14 @@ Feature: REPORT request to Shares space
       | dav_version |
       | old         |
       | new         |
+
+
+  Scenario Outline: search for the shared folder when share is not accepted
+    Given using <dav_version> DAV path
+    When user "Brian" searches for "folderMain" using the WebDAV API
+    Then the HTTP status code should be "207"
+    And the search result should contain "0" entries
+    Examples:
+      | dav_version |
+      | old         |
+      | new         |
diff --git a/tests/acceptance/features/apiContract/spacesReport.feature b/tests/acceptance/features/apiContract/spacesSharesReport.feature
similarity index 87%
rename from tests/acceptance/features/apiContract/spacesReport.feature
rename to tests/acceptance/features/apiContract/spacesSharesReport.feature
index b9c9aa12552..651e9f6e6f1 100644
--- a/tests/acceptance/features/apiContract/spacesReport.feature
+++ b/tests/acceptance/features/apiContract/spacesSharesReport.feature
@@ -54,3 +54,13 @@ Feature: Report test
       | d:getcontenttype   | text/plain                                       |
       | oc:permissions     | SD                                               |
       | d:getcontentlength | 12                                               |
+
+    
+  Scenario: search for the shared folder when the share is not accepted
+    Given user "Alice" has created a share inside of space "find data" with settings:
+      | path      | folderMain |
+      | shareWith | Brian      |
+      | role      | viewer     |
+    When user "Brian" searches for "folderMain" using the WebDAV API
+    Then the HTTP status code should be "207"
+    And the search result should contain "0" entries