Repository: owncloud/ocis
Pull request #5958 (2023-03-29 13:45:24)

[api-test] first notification test
- [x] user gets notification about sharing space test


Patch:
diff --git a/.drone.env b/.drone.env
index 8d5d2452e2a..4bd3ed2b837 100644
--- a/.drone.env
+++ b/.drone.env
@@ -1,3 +1,3 @@
 # The test runner source for UI tests
-WEB_COMMITID=368ed567877a4e9d3db520cd8d2022eec3c1b210
+WEB_COMMITID=005b2c667105bca61f6d28e5c129e5076f3fd642
 WEB_BRANCH=master
diff --git a/CHANGELOG.md b/CHANGELOG.md
index af42e82a010..2d68b1b3160 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -38,6 +38,7 @@ The following sections list the changes for unreleased.
 * Enhancement - Allow users to be disabled: [#5588](https://github.com/owncloud/ocis/pull/5588)
 * 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 - 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 - No Notifications for own actions: [#5871](https://github.com/owncloud/ocis/pull/5871)
diff --git a/changelog/unreleased/gdpr-export.md b/changelog/unreleased/gdpr-export.md
new file mode 100644
index 00000000000..9d6740f554e
--- /dev/null
+++ b/changelog/unreleased/gdpr-export.md
@@ -0,0 +1,5 @@
+Enhancement: GDPR Export
+
+Adds an endpoint to collect all data that is related to a user
+
+https://github.com/owncloud/ocis/pull/5950
diff --git a/go.mod b/go.mod
index 2f52950bff5..69abd9a8637 100644
--- a/go.mod
+++ b/go.mod
@@ -13,7 +13,7 @@ require (
 	github.com/blevesearch/bleve/v2 v2.3.6
 	github.com/coreos/go-oidc/v3 v3.4.0
 	github.com/cs3org/go-cs3apis v0.0.0-20221012090518-ef2996678965
-	github.com/cs3org/reva/v2 v2.12.1-0.20230324102706-36c2ef95b29d
+	github.com/cs3org/reva/v2 v2.12.1-0.20230330074121-1f0e39b47ee7
 	github.com/disintegration/imaging v1.6.2
 	github.com/dutchcoders/go-clamd v0.0.0-20170520113014-b970184f4d9e
 	github.com/egirna/icap-client v0.1.1
@@ -65,7 +65,7 @@ require (
 	github.com/onsi/ginkgo v1.16.5
 	github.com/onsi/ginkgo/v2 v2.9.1
 	github.com/onsi/gomega v1.27.4
-	github.com/open-policy-agent/opa v0.50.0
+	github.com/open-policy-agent/opa v0.50.2
 	github.com/orcaman/concurrent-map v1.0.0
 	github.com/owncloud/libre-graph-api-go v1.0.2-0.20230309112802-ff71ba8c90aa
 	github.com/pkg/errors v0.9.1
@@ -114,7 +114,6 @@ require (
 	github.com/Masterminds/sprig v2.22.0+incompatible // indirect
 	github.com/Microsoft/go-winio v0.6.0 // indirect
 	github.com/OneOfOne/xxhash v1.2.8 // indirect
-	github.com/Nerzal/gocloak/v13 v13.1.0 // indirect
 	github.com/ProtonMail/go-crypto v0.0.0-20220930113650-c6815a8c17ad // indirect
 	github.com/RoaringBitmap/roaring v0.9.4 // indirect
 	github.com/acomagu/bufpipe v1.0.3 // indirect
@@ -151,7 +150,7 @@ require (
 	github.com/ceph/go-ceph v0.18.0 // indirect
 	github.com/cespare/xxhash/v2 v2.2.0 // indirect
 	github.com/cevaris/ordered_map v0.0.0-20190319150403-3adeae072e73 // indirect
-	github.com/cilium/ebpf v0.4.0 // indirect
+	github.com/cilium/ebpf v0.7.0 // indirect
 	github.com/cloudflare/circl v1.2.0 // indirect
 	github.com/containerd/cgroups v1.0.4 // indirect
 	github.com/coreos/go-oidc v2.2.1+incompatible // indirect
@@ -187,7 +186,6 @@ require (
 	github.com/go-logfmt/logfmt v0.5.1 // indirect
 	github.com/go-logr/logr v1.2.3 // indirect
 	github.com/go-logr/stdr v1.2.2 // indirect
-	github.com/go-redis/redis/v8 v8.11.5 // indirect
 	github.com/go-resty/resty/v2 v2.7.0 // indirect
 	github.com/go-sql-driver/mysql v1.6.0 // indirect
 	github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 // indirect
@@ -195,7 +193,7 @@ require (
 	github.com/gobwas/httphead v0.1.0 // indirect
 	github.com/gobwas/pool v0.2.1 // indirect
 	github.com/gobwas/ws v1.0.4 // indirect
-	github.com/godbus/dbus/v5 v5.0.4 // indirect
+	github.com/godbus/dbus/v5 v5.0.6 // indirect
 	github.com/gofrs/flock v0.8.1 // indirect
 	github.com/gogo/protobuf v1.3.2 // indirect
 	github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
@@ -213,6 +211,7 @@ require (
 	github.com/gorilla/schema v1.2.0 // indirect
 	github.com/grpc-ecosystem/go-grpc-middleware v1.3.0 // indirect
 	github.com/hashicorp/consul/api v1.15.2 // indirect
+	github.com/hashicorp/errwrap v1.1.0 // indirect
 	github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
 	github.com/hashicorp/go-hclog v1.3.1 // indirect
 	github.com/hashicorp/go-immutable-radix v1.3.1 // indirect
@@ -263,7 +262,7 @@ require (
 	github.com/nats-io/nkeys v0.3.0 // indirect
 	github.com/nats-io/nuid v1.0.1 // indirect
 	github.com/nxadm/tail v1.4.8 // indirect
-	github.com/opencontainers/runtime-spec v1.0.2 // indirect
+	github.com/opencontainers/runtime-spec v1.0.3-0.20210326190908-1c3f411f0417 // indirect
 	github.com/opentracing/opentracing-go v1.2.0 // indirect
 	github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c // indirect
 	github.com/patrickmn/go-cache v2.1.0+incompatible // indirect
@@ -304,6 +303,7 @@ require (
 	go.etcd.io/etcd/client/pkg/v3 v3.5.7 // indirect
 	go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.36.4 // indirect
 	go.uber.org/atomic v1.10.0 // indirect
+	go.uber.org/goleak v1.2.1 // indirect
 	go.uber.org/multierr v1.8.0 // indirect
 	go.uber.org/zap v1.23.0 // indirect
 	golang.org/x/mod v0.9.0 // indirect
diff --git a/go.sum b/go.sum
index 7f75182fecf..dbf82371489 100644
--- a/go.sum
+++ b/go.sum
@@ -582,8 +582,8 @@ github.com/cevaris/ordered_map v0.0.0-20190319150403-3adeae072e73/go.mod h1:507v
 github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
 github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
 github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
-github.com/cilium/ebpf v0.4.0 h1:QlHdikaxALkqWasW8hAC1mfR0jdmvbfaBdBPFmRSglA=
-github.com/cilium/ebpf v0.4.0/go.mod h1:4tRaxcgiL706VnOzHOdBlY8IEAIdxINsQBcU4xJJXRs=
+github.com/cilium/ebpf v0.7.0 h1:1k/q3ATgxSXRdrmPfH8d7YK0GfqVsEKZAX9dQZvs56k=
+github.com/cilium/ebpf v0.7.0/go.mod h1:/oI2+1shJiTGAMgl6/RgJr36Eo1jzrRcAWbcXO2usCA=
 github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag=
 github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I=
 github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
@@ -625,8 +625,8 @@ github.com/crewjam/httperr v0.2.0 h1:b2BfXR8U3AlIHwNeFFvZ+BV1LFvKLlzMjzaTnZMybNo
 github.com/crewjam/httperr v0.2.0/go.mod h1:Jlz+Sg/XqBQhyMjdDiC+GNNRzZTD7x39Gu3pglZ5oH4=
 github.com/crewjam/saml v0.4.13 h1:TYHggH/hwP7eArqiXSJUvtOPNzQDyQ7vwmwEqlFWhMc=
 github.com/crewjam/saml v0.4.13/go.mod h1:igEejV+fihTIlHXYP8zOec3V5A8y3lws5bQBFsTm4gA=
-github.com/cs3org/reva/v2 v2.12.1-0.20230324102706-36c2ef95b29d h1:mPPDuyDcu18OchMKgstZnNvvpFGKK4mci0cdrRbG7IA=
-github.com/cs3org/reva/v2 v2.12.1-0.20230324102706-36c2ef95b29d/go.mod h1:FNAYs5H3xs8v0OFmNgZtiMAzIMXd/6TJmO0uZuNn8pQ=
+github.com/cs3org/reva/v2 v2.12.1-0.20230330074121-1f0e39b47ee7 h1:iwNv/IBHh9UsBj79WwM10z4NOau6T6x/jyUr2AsETjs=
+github.com/cs3org/reva/v2 v2.12.1-0.20230330074121-1f0e39b47ee7/go.mod h1:FNAYs5H3xs8v0OFmNgZtiMAzIMXd/6TJmO0uZuNn8pQ=
 github.com/cubewise-code/go-mime v0.0.0-20200519001935-8c5762b177d8 h1:Z9lwXumT5ACSmJ7WGnFl+OMLLjpz5uR2fyz7dC255FI=
 github.com/cubewise-code/go-mime v0.0.0-20200519001935-8c5762b177d8/go.mod h1:4abs/jPXcmJzYoYGF91JF9Uq9s/KL5n1jvFDix8KcqY=
 github.com/cyberdelia/templates v0.0.0-20141128023046-ca7fffd4298c/go.mod h1:GyV+0YP4qX0UQ7r2MoYZ+AvYDp12OF5yg4q8rGnyNh4=
@@ -869,8 +869,9 @@ github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6Wezm
 github.com/gobwas/ws v1.0.4 h1:5eXU1CZhpQdq5kXbKb+sECH5Ia5KiO6CYzIzdlVx6Bs=
 github.com/gobwas/ws v1.0.4/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM=
 github.com/goccy/go-yaml v1.9.5/go.mod h1:U/jl18uSupI5rdI2jmuCswEA2htH9eXfferR3KfscvA=
-github.com/godbus/dbus/v5 v5.0.4 h1:9349emZab16e7zQvpmsbtjc18ykshndd8y2PG3sgJbA=
 github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
+github.com/godbus/dbus/v5 v5.0.6 h1:mkgN1ofwASrYnJ5W6U/BxG15eXXXjirgZc7CLqkcaro=
+github.com/godbus/dbus/v5 v5.0.6/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
 github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw=
 github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU=
 github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
@@ -1048,8 +1049,9 @@ github.com/hashicorp/consul/api v1.15.2/go.mod h1:v6nvB10borjOuIwNRZYPZiHKrTM/Ay
 github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8=
 github.com/hashicorp/consul/sdk v0.11.0 h1:HRzj8YSCln2yGgCumN5CL8lYlD3gBurnervJRJAZyC4=
 github.com/hashicorp/consul/sdk v0.11.0/go.mod h1:yPkX5Q6CsxTFMjQQDJwzeNmUUF5NUGGbrDsv9wTb8cw=
-github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA=
 github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
+github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
+github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
 github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
 github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
 github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
@@ -1361,10 +1363,10 @@ github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7J
 github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
 github.com/onsi/gomega v1.27.4 h1:Z2AnStgsdSayCMDiCU42qIz+HLqEPcgiOCXjAU/w+8E=
 github.com/onsi/gomega v1.27.4/go.mod h1:riYq/GJKh8hhoM01HN6Vmuy93AarCXCBGpvFDK3q3fQ=
-github.com/open-policy-agent/opa v0.50.0 h1:CBRj7lJ9DFDHvlx2SRP6uFOCD9ooxDdNW9fYK2IIW+0=
-github.com/open-policy-agent/opa v0.50.0/go.mod h1:9jKfDk0L5b9rnhH4M0nq10cGHbYOxqygxzTT3dsvhec=
-github.com/opencontainers/runtime-spec v1.0.2 h1:UfAcuLBJB9Coz72x1hgl8O5RVzTdNiaglX6v2DM6FI0=
-github.com/opencontainers/runtime-spec v1.0.2/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0=
+github.com/open-policy-agent/opa v0.50.2 h1:iD2kKLFkflgSCTMtrC/3jLmOQ7IWyDXMg6+VQA0tSC0=
+github.com/open-policy-agent/opa v0.50.2/go.mod h1:9jKfDk0L5b9rnhH4M0nq10cGHbYOxqygxzTT3dsvhec=
+github.com/opencontainers/runtime-spec v1.0.3-0.20210326190908-1c3f411f0417 h1:3snG66yBm59tKhhSPQrQ/0bCrv1LQbKt40LnUPiUxdc=
+github.com/opencontainers/runtime-spec v1.0.3-0.20210326190908-1c3f411f0417/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0=
 github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o=
 github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs=
 github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc=
@@ -1678,7 +1680,8 @@ go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
 go.uber.org/atomic v1.10.0 h1:9qC72Qh0+3MqyJbAn8YU5xVq1frD8bn3JtD2oXtafVQ=
 go.uber.org/atomic v1.10.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
 go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A=
-go.uber.org/goleak v1.1.12 h1:gZAh5/EyT/HQwlpkCy6wTpqfH9H8Lz8zbm3dZh+OyzA=
+go.uber.org/goleak v1.2.1 h1:NBol2c7O1ZokfZ0LEU9K6Whx/KnwvepVetCUhtKja4A=
+go.uber.org/goleak v1.2.1/go.mod h1:qlT2yGI9QafXHhZZLxlSuNsMw3FFLxBr+tBRlmO1xH4=
 go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
 go.uber.org/multierr v1.8.0 h1:dg6GjLku4EH+249NNmoIciG9N/jURbDG+pFlTkhzIC8=
 go.uber.org/multierr v1.8.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak=
@@ -1972,6 +1975,7 @@ golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210906170528-6f6e22806c34/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210908233432-aa78b53d3365/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
diff --git a/ocis-pkg/keycloak/client.go b/ocis-pkg/keycloak/client.go
new file mode 100644
index 00000000000..8f39dda5385
--- /dev/null
+++ b/ocis-pkg/keycloak/client.go
@@ -0,0 +1,211 @@
+// Package keycloak is a package for keycloak utility functions.
+package keycloak
+
+import (
+	"context"
+	"crypto/tls"
+	"fmt"
+
+	"github.com/Nerzal/gocloak/v13"
+	libregraph "github.com/owncloud/libre-graph-api-go"
+)
+
+// Some attribute constants.
+// TODO: Make these configurable in the future.
+const (
+	_idAttr       = "OWNCLOUD_ID"
+	_userTypeAttr = "OWNCLOUD_USER_TYPE"
+)
+
+// ConcreteClient represents a concrete implementation of a keycloak client
+type ConcreteClient struct {
+	keycloak     GoCloak
+	clientID     string
+	clientSecret string
+	realm        string
+	baseURL      string
+}
+
+// New instantiates a new keycloak.Backend with a default gocloak client.
+func New(
+	baseURL, clientID, clientSecret, realm string,
+	insecureSkipVerify bool,
+) *ConcreteClient {
+	gc := gocloak.NewClient(baseURL)
+	restyClient := gc.RestyClient()
+	restyClient.SetTLSClientConfig(&tls.Config{InsecureSkipVerify: insecureSkipVerify}) //nolint:gosec
+	return NewWithClient(gc, baseURL, clientID, clientSecret, realm)
+}
+
+// NewWithClient instantiates a new keycloak.Backend with a custom
+func NewWithClient(
+	gocloakClient GoCloak,
+	baseURL, clientID, clientSecret, realm string,
+) *ConcreteClient {
+	return &ConcreteClient{
+		keycloak:     gocloakClient,
+		baseURL:      baseURL,
+		clientID:     clientID,
+		clientSecret: clientSecret,
+		realm:        realm,
+	}
+}
+
+// CreateUser creates a user from a libregraph user and returns its *keycloak* ID.
+// TODO: For now we only call this from the invitation service where all the attributes are set correctly.
+//
+//	For more wider use, do some sanity checking on the user instance.
+func (c *ConcreteClient) CreateUser(ctx context.Context, realm string, user *libregraph.User, userActions []UserAction) (string, error) {
+	token, err := c.getToken(ctx)
+	if err != nil {
+		return "", err
+	}
+
+	req := gocloak.User{
+		Email:     user.Mail,
+		Enabled:   user.AccountEnabled,
+		Username:  user.OnPremisesSamAccountName,
+		FirstName: user.GivenName,
+		LastName:  user.Surname,
+		Attributes: &map[string][]string{
+			_idAttr:       {user.GetId()},
+			_userTypeAttr: {user.GetUserType()},
+		},
+		RequiredActions: convertUserActions(userActions),
+	}
+	return c.keycloak.CreateUser(ctx, token.AccessToken, realm, req)
+}
+
+// SendActionsMail sends a mail to the user with userID instructing them to do the actions defined in userActions.
+func (c *ConcreteClient) SendActionsMail(ctx context.Context, realm, userID string, userActions []UserAction) error {
+	token, err := c.getToken(ctx)
+	if err != nil {
+		return err
+	}
+	params := gocloak.ExecuteActionsEmail{
+		UserID:  &userID,
+		Actions: convertUserActions(userActions),
+	}
+
+	return c.keycloak.ExecuteActionsEmail(ctx, token.AccessToken, realm, params)
+}
+
+// GetUserByEmail looks up a user by email.
+func (c *ConcreteClient) GetUserByEmail(ctx context.Context, realm, mail string) (*libregraph.User, error) {
+	token, err := c.getToken(ctx)
+	if err != nil {
+		return nil, err
+	}
+
+	users, err := c.keycloak.GetUsers(ctx, token.AccessToken, realm, gocloak.GetUsersParams{
+		Email: &mail,
+	})
+	if err != nil {
+		return nil, err
+	}
+
+	if len(users) == 0 {
+		return nil, fmt.Errorf("no users found with mail address %s", mail)
+	}
+
+	if len(users) > 1 {
+		return nil, fmt.Errorf("%d users found with mail address %s, expected 1", len(users), mail)
+	}
+
+	return c.keycloakUserToLibregraph(users[0]), nil
+}
+
+// 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)
+	if err != nil {
+		return nil, err
+	}
+
+	token, err := c.getToken(ctx)
+	if err != nil {
+		return nil, err
+	}
+
+	keycloakID, err := c.getKeyCloakID(u)
+	if err != nil {
+		return nil, err
+	}
+
+	creds, err := c.keycloak.GetCredentials(ctx, token.AccessToken, realm, keycloakID)
+	if err != nil {
+		return nil, err
+	}
+
+	return &PIIReport{
+		UserData:    u,
+		Credentials: creds,
+	}, nil
+}
+
+// getToken gets a fresh token for the request.
+// TODO: set a token on the struct and check if it's still valid before requesting a new one.
+func (c *ConcreteClient) getToken(ctx context.Context) (*gocloak.JWT, error) {
+	token, err := c.keycloak.LoginClient(ctx, c.clientID, c.clientSecret, c.realm)
+	if err != nil {
+		return nil, fmt.Errorf("failed to get token: %w", err)
+	}
+
+	rRes, err := c.keycloak.RetrospectToken(ctx, token.AccessToken, c.clientID, c.clientSecret, c.realm)
+	if err != nil {
+		return nil, fmt.Errorf("failed to retrospect token: %w", err)
+	}
+
+	if !*rRes.Active {
+		return nil, fmt.Errorf("token is not active")
+	}
+
+	return token, nil
+}
+
+func (c *ConcreteClient) keycloakUserToLibregraph(u *gocloak.User) *libregraph.User {
+	attrs := *u.Attributes
+	ldapID := ""
+	ldapIDs, ok := attrs[_idAttr]
+	if ok {
+		ldapID = ldapIDs[0]
+	}
+
+	var userType *string
+	userTypes, ok := attrs[_userTypeAttr]
+	if ok {
+		userType = &userTypes[0]
+	}
+
+	return &libregraph.User{
+		Id:             &ldapID,
+		Mail:           u.Email,
+		GivenName:      u.FirstName,
+		Surname:        u.LastName,
+		AccountEnabled: u.Enabled,
+		UserType:       userType,
+		Identities: []libregraph.ObjectIdentity{
+			{
+				Issuer:           &c.baseURL,
+				IssuerAssignedId: u.ID,
+			},
+		},
+	}
+}
+
+func (c *ConcreteClient) getKeyCloakID(u *libregraph.User) (string, error) {
+	for _, i := range u.Identities {
+		if *i.Issuer == c.baseURL {
+			return *i.IssuerAssignedId, nil
+		}
+	}
+	return "", fmt.Errorf("could not find identity for issuer: %s", c.baseURL)
+}
+
+func convertUserActions(userActions []UserAction) *[]string {
+	stringActions := make([]string, len(userActions))
+	for i, a := range userActions {
+		stringActions[i] = userActionsToString[a]
+	}
+	return &stringActions
+}
diff --git a/ocis-pkg/keycloak/gocloak.go b/ocis-pkg/keycloak/gocloak.go
new file mode 100644
index 00000000000..c4a4de0cdac
--- /dev/null
+++ b/ocis-pkg/keycloak/gocloak.go
@@ -0,0 +1,19 @@
+package keycloak
+
+import (
+	"context"
+
+	"github.com/Nerzal/gocloak/v13"
+)
+
+// GoCloak represents the parts of gocloak.GoCloak that we use, mainly here for mockery.
+//
+//go:generate mockery --name=GoCloak
+type GoCloak interface {
+	CreateUser(ctx context.Context, token, realm string, user gocloak.User) (string, error)
+	GetUsers(ctx context.Context, token, realm string, params gocloak.GetUsersParams) ([]*gocloak.User, error)
+	ExecuteActionsEmail(ctx context.Context, token, realm string, params gocloak.ExecuteActionsEmail) error
+	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)
+}
diff --git a/ocis-pkg/keycloak/types.go b/ocis-pkg/keycloak/types.go
new file mode 100644
index 00000000000..1f5ddfa6423
--- /dev/null
+++ b/ocis-pkg/keycloak/types.go
@@ -0,0 +1,39 @@
+package keycloak
+
+import (
+	"context"
+
+	"github.com/Nerzal/gocloak/v13"
+	libregraph "github.com/owncloud/libre-graph-api-go"
+)
+
+// UserAction defines a type for user actions
+type UserAction int8
+
+// An incomplete list of UserActions
+const (
+	// UserActionUpdatePassword sets it that the user needs to change their password.
+	UserActionUpdatePassword UserAction = iota
+	// UserActionVerifyEmail sets it that the user needs to verify their email address.
+	UserActionVerifyEmail
+)
+
+// A lookup table to translate user actions to their string equivalents
+var userActionsToString = map[UserAction]string{
+	UserActionUpdatePassword: "UPDATE_PASSWORD",
+	UserActionVerifyEmail:    "VERIFY_EMAIL",
+}
+
+// PIIReport is a structure of all the PersonalIdentifiableInformation contained in keycloak.
+type PIIReport struct {
+	UserData    *libregraph.User
+	Credentials []*gocloak.CredentialRepresentation
+}
+
+// Client represents a keycloak client.
+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)
+}
diff --git a/services/graph/Makefile b/services/graph/Makefile
index 382f03f125d..34176900550 100644
--- a/services/graph/Makefile
+++ b/services/graph/Makefile
@@ -25,7 +25,6 @@ include ../../.make/generate.mk
 
 .PHONY: ci-go-generate
 ci-go-generate: $(MOCKERY) # CI runs ci-node-generate automatically before this target
-	$(MOCKERY) --dir pkg/service/v0 --case underscore --name GatewayClient
 	$(MOCKERY) --dir pkg/service/v0 --case underscore --name HTTPClient
 	$(MOCKERY) --dir pkg/service/v0 --case underscore --name Publisher
 	$(MOCKERY) --dir pkg/service/v0 --case underscore --name Permissions
diff --git a/services/graph/mocks/gateway_client.go b/services/graph/mocks/gateway_client.go
index bc60230b667..49d6b0867f4 100644
--- a/services/graph/mocks/gateway_client.go
+++ b/services/graph/mocks/gateway_client.go
@@ -1,4 +1,4 @@
-// Code generated by mockery v2.14.1. DO NOT EDIT.
+// Code generated by mockery v2.22.1. DO NOT EDIT.
 
 package mocks
 
@@ -30,6 +30,10 @@ func (_m *GatewayClient) Authenticate(ctx context.Context, in *gatewayv1beta1.Au
 	ret := _m.Called(_ca...)
 
 	var r0 *gatewayv1beta1.AuthenticateResponse
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, *gatewayv1beta1.AuthenticateRequest, ...grpc.CallOption) (*gatewayv1beta1.AuthenticateResponse, error)); ok {
+		return rf(ctx, in, opts...)
+	}
 	if rf, ok := ret.Get(0).(func(context.Context, *gatewayv1beta1.AuthenticateRequest, ...grpc.CallOption) *gatewayv1beta1.AuthenticateResponse); ok {
 		r0 = rf(ctx, in, opts...)
 	} else {
@@ -38,7 +42,6 @@ func (_m *GatewayClient) Authenticate(ctx context.Context, in *gatewayv1beta1.Au
 		}
 	}
 
-	var r1 error
 	if rf, ok := ret.Get(1).(func(context.Context, *gatewayv1beta1.AuthenticateRequest, ...grpc.CallOption) error); ok {
 		r1 = rf(ctx, in, opts...)
 	} else {
@@ -60,6 +63,10 @@ func (_m *GatewayClient) CreateStorageSpace(ctx context.Context, in *providerv1b
 	ret := _m.Called(_ca...)
 
 	var r0 *providerv1beta1.CreateStorageSpaceResponse
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, *providerv1beta1.CreateStorageSpaceRequest, ...grpc.CallOption) (*providerv1beta1.CreateStorageSpaceResponse, error)); ok {
+		return rf(ctx, in, opts...)
+	}
 	if rf, ok := ret.Get(0).(func(context.Context, *providerv1beta1.CreateStorageSpaceRequest, ...grpc.CallOption) *providerv1beta1.CreateStorageSpaceResponse); ok {
 		r0 = rf(ctx, in, opts...)
 	} else {
@@ -68,7 +75,6 @@ func (_m *GatewayClient) CreateStorageSpace(ctx context.Context, in *providerv1b
 		}
 	}
 
-	var r1 error
 	if rf, ok := ret.Get(1).(func(context.Context, *providerv1beta1.CreateStorageSpaceRequest, ...grpc.CallOption) error); ok {
 		r1 = rf(ctx, in, opts...)
 	} else {
@@ -90,6 +96,10 @@ func (_m *GatewayClient) DeleteStorageSpace(ctx context.Context, in *providerv1b
 	ret := _m.Called(_ca...)
 
 	var r0 *providerv1beta1.DeleteStorageSpaceResponse
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, *providerv1beta1.DeleteStorageSpaceRequest, ...grpc.CallOption) (*providerv1beta1.DeleteStorageSpaceResponse, error)); ok {
+		return rf(ctx, in, opts...)
+	}
 	if rf, ok := ret.Get(0).(func(context.Context, *providerv1beta1.DeleteStorageSpaceRequest, ...grpc.CallOption) *providerv1beta1.DeleteStorageSpaceResponse); ok {
 		r0 = rf(ctx, in, opts...)
 	} else {
@@ -98,7 +108,6 @@ func (_m *GatewayClient) DeleteStorageSpace(ctx context.Context, in *providerv1b
 		}
 	}
 
-	var r1 error
 	if rf, ok := ret.Get(1).(func(context.Context, *providerv1beta1.DeleteStorageSpaceRequest, ...grpc.CallOption) error); ok {
 		r1 = rf(ctx, in, opts...)
 	} else {
@@ -120,6 +129,10 @@ func (_m *GatewayClient) GetHome(ctx context.Context, in *providerv1beta1.GetHom
 	ret := _m.Called(_ca...)
 
 	var r0 *providerv1beta1.GetHomeResponse
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, *providerv1beta1.GetHomeRequest, ...grpc.CallOption) (*providerv1beta1.GetHomeResponse, error)); ok {
+		return rf(ctx, in, opts...)
+	}
 	if rf, ok := ret.Get(0).(func(context.Context, *providerv1beta1.GetHomeRequest, ...grpc.CallOption) *providerv1beta1.GetHomeResponse); ok {
 		r0 = rf(ctx, in, opts...)
 	} else {
@@ -128,7 +141,6 @@ func (_m *GatewayClient) GetHome(ctx context.Context, in *providerv1beta1.GetHom
 		}
 	}
 
-	var r1 error
 	if rf, ok := ret.Get(1).(func(context.Context, *providerv1beta1.GetHomeRequest, ...grpc.CallOption) error); ok {
 		r1 = rf(ctx, in, opts...)
 	} else {
@@ -150,6 +162,10 @@ func (_m *GatewayClient) GetPath(ctx context.Context, in *providerv1beta1.GetPat
 	ret := _m.Called(_ca...)
 
 	var r0 *providerv1beta1.GetPathResponse
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, *providerv1beta1.GetPathRequest, ...grpc.CallOption) (*providerv1beta1.GetPathResponse, error)); ok {
+		return rf(ctx, in, opts...)
+	}
 	if rf, ok := ret.Get(0).(func(context.Context, *providerv1beta1.GetPathRequest, ...grpc.CallOption) *providerv1beta1.GetPathResponse); ok {
 		r0 = rf(ctx, in, opts...)
 	} else {
@@ -158,7 +174,6 @@ func (_m *GatewayClient) GetPath(ctx context.Context, in *providerv1beta1.GetPat
 		}
 	}
 
-	var r1 error
 	if rf, ok := ret.Get(1).(func(context.Context, *providerv1beta1.GetPathRequest, ...grpc.CallOption) error); ok {
 		r1 = rf(ctx, in, opts...)
 	} else {
@@ -180,6 +195,10 @@ func (_m *GatewayClient) GetQuota(ctx context.Context, in *gatewayv1beta1.GetQuo
 	ret := _m.Called(_ca...)
 
 	var r0 *providerv1beta1.GetQuotaResponse
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, *gatewayv1beta1.GetQuotaRequest, ...grpc.CallOption) (*providerv1beta1.GetQuotaResponse, error)); ok {
+		return rf(ctx, in, opts...)
+	}
 	if rf, ok := ret.Get(0).(func(context.Context, *gatewayv1beta1.GetQuotaRequest, ...grpc.CallOption) *providerv1beta1.GetQuotaResponse); ok {
 		r0 = rf(ctx, in, opts...)
 	} else {
@@ -188,7 +207,6 @@ func (_m *GatewayClient) GetQuota(ctx context.Context, in *gatewayv1beta1.GetQuo
 		}
 	}
 
-	var r1 error
 	if rf, ok := ret.Get(1).(func(context.Context, *gatewayv1beta1.GetQuotaRequest, ...grpc.CallOption) error); ok {
 		r1 = rf(ctx, in, opts...)
 	} else {
@@ -210,6 +228,10 @@ func (_m *GatewayClient) InitiateFileDownload(ctx context.Context, in *providerv
 	ret := _m.Called(_ca...)
 
 	var r0 *gatewayv1beta1.InitiateFileDownloadResponse
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, *providerv1beta1.InitiateFileDownloadRequest, ...grpc.CallOption) (*gatewayv1beta1.InitiateFileDownloadResponse, error)); ok {
+		return rf(ctx, in, opts...)
+	}
 	if rf, ok := ret.Get(0).(func(context.Context, *providerv1beta1.InitiateFileDownloadRequest, ...grpc.CallOption) *gatewayv1beta1.InitiateFileDownloadResponse); ok {
 		r0 = rf(ctx, in, opts...)
 	} else {
@@ -218,7 +240,6 @@ func (_m *GatewayClient) InitiateFileDownload(ctx context.Context, in *providerv
 		}
 	}
 
-	var r1 error
 	if rf, ok := ret.Get(1).(func(context.Context, *providerv1beta1.InitiateFileDownloadRequest, ...grpc.CallOption) error); ok {
 		r1 = rf(ctx, in, opts...)
 	} else {
@@ -240,6 +261,10 @@ func (_m *GatewayClient) ListContainer(ctx context.Context, in *providerv1beta1.
 	ret := _m.Called(_ca...)
 
 	var r0 *providerv1beta1.ListContainerResponse
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, *providerv1beta1.ListContainerRequest, ...grpc.CallOption) (*providerv1beta1.ListContainerResponse, error)); ok {
+		return rf(ctx, in, opts...)
+	}
 	if rf, ok := ret.Get(0).(func(context.Context, *providerv1beta1.ListContainerRequest, ...grpc.CallOption) *providerv1beta1.ListContainerResponse); ok {
 		r0 = rf(ctx, in, opts...)
 	} else {
@@ -248,7 +273,6 @@ func (_m *GatewayClient) ListContainer(ctx context.Context, in *providerv1beta1.
 		}
 	}
 
-	var r1 error
 	if rf, ok := ret.Get(1).(func(context.Context, *providerv1beta1.ListContainerRequest, ...grpc.CallOption) error); ok {
 		r1 = rf(ctx, in, opts...)
 	} else {
@@ -270,6 +294,10 @@ func (_m *GatewayClient) ListStorageSpaces(ctx context.Context, in *providerv1be
 	ret := _m.Called(_ca...)
 
 	var r0 *providerv1beta1.ListStorageSpacesResponse
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, *providerv1beta1.ListStorageSpacesRequest, ...grpc.CallOption) (*providerv1beta1.ListStorageSpacesResponse, error)); ok {
+		return rf(ctx, in, opts...)
+	}
 	if rf, ok := ret.Get(0).(func(context.Context, *providerv1beta1.ListStorageSpacesRequest, ...grpc.CallOption) *providerv1beta1.ListStorageSpacesResponse); ok {
 		r0 = rf(ctx, in, opts...)
 	} else {
@@ -278,7 +306,6 @@ func (_m *GatewayClient) ListStorageSpaces(ctx context.Context, in *providerv1be
 		}
 	}
 
-	var r1 error
 	if rf, ok := ret.Get(1).(func(context.Context, *providerv1beta1.ListStorageSpacesRequest, ...grpc.CallOption) error); ok {
 		r1 = rf(ctx, in, opts...)
 	} else {
@@ -300,6 +327,10 @@ func (_m *GatewayClient) SetArbitraryMetadata(ctx context.Context, request *prov
 	ret := _m.Called(_ca...)
 
 	var r0 *providerv1beta1.SetArbitraryMetadataResponse
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, *providerv1beta1.SetArbitraryMetadataRequest, ...grpc.CallOption) (*providerv1beta1.SetArbitraryMetadataResponse, error)); ok {
+		return rf(ctx, request, opts...)
+	}
 	if rf, ok := ret.Get(0).(func(context.Context, *providerv1beta1.SetArbitraryMetadataRequest, ...grpc.CallOption) *providerv1beta1.SetArbitraryMetadataResponse); ok {
 		r0 = rf(ctx, request, opts...)
 	} else {
@@ -308,7 +339,6 @@ func (_m *GatewayClient) SetArbitraryMetadata(ctx context.Context, request *prov
 		}
 	}
 
-	var r1 error
 	if rf, ok := ret.Get(1).(func(context.Context, *providerv1beta1.SetArbitraryMetadataRequest, ...grpc.CallOption) error); ok {
 		r1 = rf(ctx, request, opts...)
 	} else {
@@ -330,6 +360,10 @@ func (_m *GatewayClient) Stat(ctx context.Context, in *providerv1beta1.StatReque
 	ret := _m.Called(_ca...)
 
 	var r0 *providerv1beta1.StatResponse
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, *providerv1beta1.StatRequest, ...grpc.CallOption) (*providerv1beta1.StatResponse, error)); ok {
+		return rf(ctx, in, opts...)
+	}
 	if rf, ok := ret.Get(0).(func(context.Context, *providerv1beta1.StatRequest, ...grpc.CallOption) *providerv1beta1.StatResponse); ok {
 		r0 = rf(ctx, in, opts...)
 	} else {
@@ -338,7 +372,6 @@ func (_m *GatewayClient) Stat(ctx context.Context, in *providerv1beta1.StatReque
 		}
 	}
 
-	var r1 error
 	if rf, ok := ret.Get(1).(func(context.Context, *providerv1beta1.StatRequest, ...grpc.CallOption) error); ok {
 		r1 = rf(ctx, in, opts...)
 	} else {
@@ -348,6 +381,32 @@ func (_m *GatewayClient) Stat(ctx context.Context, in *providerv1beta1.StatReque
 	return r0, r1
 }
 
+// TouchFile provides a mock function with given fields: ctx, req
+func (_m *GatewayClient) TouchFile(ctx context.Context, req *providerv1beta1.TouchFileRequest) (*providerv1beta1.TouchFileResponse, error) {
+	ret := _m.Called(ctx, req)
+
+	var r0 *providerv1beta1.TouchFileResponse
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, *providerv1beta1.TouchFileRequest) (*providerv1beta1.TouchFileResponse, error)); ok {
+		return rf(ctx, req)
+	}
+	if rf, ok := ret.Get(0).(func(context.Context, *providerv1beta1.TouchFileRequest) *providerv1beta1.TouchFileResponse); ok {
+		r0 = rf(ctx, req)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(*providerv1beta1.TouchFileResponse)
+		}
+	}
+
+	if rf, ok := ret.Get(1).(func(context.Context, *providerv1beta1.TouchFileRequest) error); ok {
+		r1 = rf(ctx, req)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
 // UpdateStorageSpace provides a mock function with given fields: ctx, in, opts
 func (_m *GatewayClient) UpdateStorageSpace(ctx context.Context, in *providerv1beta1.UpdateStorageSpaceRequest, opts ...grpc.CallOption) (*providerv1beta1.UpdateStorageSpaceResponse, error) {
 	_va := make([]interface{}, len(opts))
@@ -360,6 +419,10 @@ func (_m *GatewayClient) UpdateStorageSpace(ctx context.Context, in *providerv1b
 	ret := _m.Called(_ca...)
 
 	var r0 *providerv1beta1.UpdateStorageSpaceResponse
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, *providerv1beta1.UpdateStorageSpaceRequest, ...grpc.CallOption) (*providerv1beta1.UpdateStorageSpaceResponse, error)); ok {
+		return rf(ctx, in, opts...)
+	}
 	if rf, ok := ret.Get(0).(func(context.Context, *providerv1beta1.UpdateStorageSpaceRequest, ...grpc.CallOption) *providerv1beta1.UpdateStorageSpaceResponse); ok {
 		r0 = rf(ctx, in, opts...)
 	} else {
@@ -368,7 +431,6 @@ func (_m *GatewayClient) UpdateStorageSpace(ctx context.Context, in *providerv1b
 		}
 	}
 
-	var r1 error
 	if rf, ok := ret.Get(1).(func(context.Context, *providerv1beta1.UpdateStorageSpaceRequest, ...grpc.CallOption) error); ok {
 		r1 = rf(ctx, in, opts...)
 	} else {
diff --git a/services/graph/mocks/http_client.go b/services/graph/mocks/http_client.go
index 0b46538ab5c..a46a482762f 100644
--- a/services/graph/mocks/http_client.go
+++ b/services/graph/mocks/http_client.go
@@ -1,4 +1,4 @@
-// Code generated by mockery v2.14.1. DO NOT EDIT.
+// Code generated by mockery v2.22.1. DO NOT EDIT.
 
 package mocks
 
@@ -18,6 +18,10 @@ func (_m *HTTPClient) Do(req *http.Request) (*http.Response, error) {
 	ret := _m.Called(req)
 
 	var r0 *http.Response
+	var r1 error
+	if rf, ok := ret.Get(0).(func(*http.Request) (*http.Response, error)); ok {
+		return rf(req)
+	}
 	if rf, ok := ret.Get(0).(func(*http.Request) *http.Response); ok {
 		r0 = rf(req)
 	} else {
@@ -26,7 +30,6 @@ func (_m *HTTPClient) Do(req *http.Request) (*http.Response, error) {
 		}
 	}
 
-	var r1 error
 	if rf, ok := ret.Get(1).(func(*http.Request) error); ok {
 		r1 = rf(req)
 	} else {
diff --git a/services/graph/mocks/ldapclient.go b/services/graph/mocks/ldapclient.go
index 7893c5dcca6..a90b562032c 100644
--- a/services/graph/mocks/ldapclient.go
+++ b/services/graph/mocks/ldapclient.go
@@ -1,4 +1,4 @@
-// Code generated by mockery v2.14.1. DO NOT EDIT.
+// Code generated by mockery v2.22.1. DO NOT EDIT.
 
 package mocks
 
@@ -54,13 +54,16 @@ func (_m *Client) Compare(dn string, attribute string, value string) (bool, erro
 	ret := _m.Called(dn, attribute, value)
 
 	var r0 bool
+	var r1 error
+	if rf, ok := ret.Get(0).(func(string, string, string) (bool, error)); ok {
+		return rf(dn, attribute, value)
+	}
 	if rf, ok := ret.Get(0).(func(string, string, string) bool); ok {
 		r0 = rf(dn, attribute, value)
 	} else {
 		r0 = ret.Get(0).(bool)
 	}
 
-	var r1 error
 	if rf, ok := ret.Get(1).(func(string, string, string) error); ok {
 		r1 = rf(dn, attribute, value)
 	} else {
@@ -145,6 +148,10 @@ func (_m *Client) ModifyWithResult(_a0 *ldap.ModifyRequest) (*ldap.ModifyResult,
 	ret := _m.Called(_a0)
 
 	var r0 *ldap.ModifyResult
+	var r1 error
+	if rf, ok := ret.Get(0).(func(*ldap.ModifyRequest) (*ldap.ModifyResult, error)); ok {
+		return rf(_a0)
+	}
 	if rf, ok := ret.Get(0).(func(*ldap.ModifyRequest) *ldap.ModifyResult); ok {
 		r0 = rf(_a0)
 	} else {
@@ -153,7 +160,6 @@ func (_m *Client) ModifyWithResult(_a0 *ldap.ModifyRequest) (*ldap.ModifyResult,
 		}
 	}
 
-	var r1 error
 	if rf, ok := ret.Get(1).(func(*ldap.ModifyRequest) error); ok {
 		r1 = rf(_a0)
 	} else {
@@ -182,6 +188,10 @@ func (_m *Client) PasswordModify(_a0 *ldap.PasswordModifyRequest) (*ldap.Passwor
 	ret := _m.Called(_a0)
 
 	var r0 *ldap.PasswordModifyResult
+	var r1 error
+	if rf, ok := ret.Get(0).(func(*ldap.PasswordModifyRequest) (*ldap.PasswordModifyResult, error)); ok {
+		return rf(_a0)
+	}
 	if rf, ok := ret.Get(0).(func(*ldap.PasswordModifyRequest) *ldap.PasswordModifyResult); ok {
 		r0 = rf(_a0)
 	} else {
@@ -190,7 +200,6 @@ func (_m *Client) PasswordModify(_a0 *ldap.PasswordModifyRequest) (*ldap.Passwor
 		}
 	}
 
-	var r1 error
 	if rf, ok := ret.Get(1).(func(*ldap.PasswordModifyRequest) error); ok {
 		r1 = rf(_a0)
 	} else {
@@ -205,6 +214,10 @@ func (_m *Client) Search(_a0 *ldap.SearchRequest) (*ldap.SearchResult, error) {
 	ret := _m.Called(_a0)
 
 	var r0 *ldap.SearchResult
+	var r1 error
+	if rf, ok := ret.Get(0).(func(*ldap.SearchRequest) (*ldap.SearchResult, error)); ok {
+		return rf(_a0)
+	}
 	if rf, ok := ret.Get(0).(func(*ldap.SearchRequest) *ldap.SearchResult); ok {
 		r0 = rf(_a0)
 	} else {
@@ -213,7 +226,6 @@ func (_m *Client) Search(_a0 *ldap.SearchRequest) (*ldap.SearchResult, error) {
 		}
 	}
 
-	var r1 error
 	if rf, ok := ret.Get(1).(func(*ldap.SearchRequest) error); ok {
 		r1 = rf(_a0)
 	} else {
@@ -228,6 +240,10 @@ func (_m *Client) SearchWithPaging(searchRequest *ldap.SearchRequest, pagingSize
 	ret := _m.Called(searchRequest, pagingSize)
 
 	var r0 *ldap.SearchResult
+	var r1 error
+	if rf, ok := ret.Get(0).(func(*ldap.SearchRequest, uint32) (*ldap.SearchResult, error)); ok {
+		return rf(searchRequest, pagingSize)
+	}
 	if rf, ok := ret.Get(0).(func(*ldap.SearchRequest, uint32) *ldap.SearchResult); ok {
 		r0 = rf(searchRequest, pagingSize)
 	} else {
@@ -236,7 +252,6 @@ func (_m *Client) SearchWithPaging(searchRequest *ldap.SearchRequest, pagingSize
 		}
 	}
 
-	var r1 error
 	if rf, ok := ret.Get(1).(func(*ldap.SearchRequest, uint32) error); ok {
 		r1 = rf(searchRequest, pagingSize)
 	} else {
@@ -256,6 +271,10 @@ func (_m *Client) SimpleBind(_a0 *ldap.SimpleBindRequest) (*ldap.SimpleBindResul
 	ret := _m.Called(_a0)
 
 	var r0 *ldap.SimpleBindResult
+	var r1 error
+	if rf, ok := ret.Get(0).(func(*ldap.SimpleBindRequest) (*ldap.SimpleBindResult, error)); ok {
+		return rf(_a0)
+	}
 	if rf, ok := ret.Get(0).(func(*ldap.SimpleBindRequest) *ldap.SimpleBindResult); ok {
 		r0 = rf(_a0)
 	} else {
@@ -264,7 +283,6 @@ func (_m *Client) SimpleBind(_a0 *ldap.SimpleBindRequest) (*ldap.SimpleBindResul
 		}
 	}
 
-	var r1 error
 	if rf, ok := ret.Get(1).(func(*ldap.SimpleBindRequest) error); ok {
 		r1 = rf(_a0)
 	} else {
@@ -298,13 +316,16 @@ func (_m *Client) TLSConnectionState() (tls.ConnectionState, bool) {
 	ret := _m.Called()
 
 	var r0 tls.ConnectionState
+	var r1 bool
+	if rf, ok := ret.Get(0).(func() (tls.ConnectionState, bool)); ok {
+		return rf()
+	}
 	if rf, ok := ret.Get(0).(func() tls.ConnectionState); ok {
 		r0 = rf()
 	} else {
 		r0 = ret.Get(0).(tls.ConnectionState)
 	}
 
-	var r1 bool
 	if rf, ok := ret.Get(1).(func() bool); ok {
 		r1 = rf()
 	} else {
diff --git a/services/graph/mocks/permissions.go b/services/graph/mocks/permissions.go
index 422e0c0a4bf..7a0dd07cd9f 100644
--- a/services/graph/mocks/permissions.go
+++ b/services/graph/mocks/permissions.go
@@ -1,4 +1,4 @@
-// Code generated by mockery v2.14.1. DO NOT EDIT.
+// Code generated by mockery v2.22.1. DO NOT EDIT.
 
 package mocks
 
@@ -29,6 +29,10 @@ func (_m *Permissions) GetPermissionByID(ctx context.Context, request *v0.GetPer
 	ret := _m.Called(_ca...)
 
 	var r0 *v0.GetPermissionByIDResponse
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, *v0.GetPermissionByIDRequest, ...client.CallOption) (*v0.GetPermissionByIDResponse, error)); ok {
+		return rf(ctx, request, opts...)
+	}
 	if rf, ok := ret.Get(0).(func(context.Context, *v0.GetPermissionByIDRequest, ...client.CallOption) *v0.GetPermissionByIDResponse); ok {
 		r0 = rf(ctx, request, opts...)
 	} else {
@@ -37,7 +41,6 @@ func (_m *Permissions) GetPermissionByID(ctx context.Context, request *v0.GetPer
 		}
 	}
 
-	var r1 error
 	if rf, ok := ret.Get(1).(func(context.Context, *v0.GetPermissionByIDRequest, ...client.CallOption) error); ok {
 		r1 = rf(ctx, request, opts...)
 	} else {
@@ -59,6 +62,10 @@ func (_m *Permissions) ListPermissions(ctx context.Context, req *v0.ListPermissi
 	ret := _m.Called(_ca...)
 
 	var r0 *v0.ListPermissionsResponse
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, *v0.ListPermissionsRequest, ...client.CallOption) (*v0.ListPermissionsResponse, error)); ok {
+		return rf(ctx, req, opts...)
+	}
 	if rf, ok := ret.Get(0).(func(context.Context, *v0.ListPermissionsRequest, ...client.CallOption) *v0.ListPermissionsResponse); ok {
 		r0 = rf(ctx, req, opts...)
 	} else {
@@ -67,7 +74,6 @@ func (_m *Permissions) ListPermissions(ctx context.Context, req *v0.ListPermissi
 		}
 	}
 
-	var r1 error
 	if rf, ok := ret.Get(1).(func(context.Context, *v0.ListPermissionsRequest, ...client.CallOption) error); ok {
 		r1 = rf(ctx, req, opts...)
 	} else {
@@ -89,6 +95,10 @@ func (_m *Permissions) ListPermissionsByResource(ctx context.Context, in *v0.Lis
 	ret := _m.Called(_ca...)
 
 	var r0 *v0.ListPermissionsByResourceResponse
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, *v0.ListPermissionsByResourceRequest, ...client.CallOption) (*v0.ListPermissionsByResourceResponse, error)); ok {
+		return rf(ctx, in, opts...)
+	}
 	if rf, ok := ret.Get(0).(func(context.Context, *v0.ListPermissionsByResourceRequest, ...client.CallOption) *v0.ListPermissionsByResourceResponse); ok {
 		r0 = rf(ctx, in, opts...)
 	} else {
@@ -97,7 +107,6 @@ func (_m *Permissions) ListPermissionsByResource(ctx context.Context, in *v0.Lis
 		}
 	}
 
-	var r1 error
 	if rf, ok := ret.Get(1).(func(context.Context, *v0.ListPermissionsByResourceRequest, ...client.CallOption) error); ok {
 		r1 = rf(ctx, in, opts...)
 	} else {
diff --git a/services/graph/mocks/publisher.go b/services/graph/mocks/publisher.go
index 4c5b115b83b..40dd7e5f2c3 100644
--- a/services/graph/mocks/publisher.go
+++ b/services/graph/mocks/publisher.go
@@ -1,4 +1,4 @@
-// Code generated by mockery v2.14.1. DO NOT EDIT.
+// Code generated by mockery v2.22.1. DO NOT EDIT.
 
 package mocks
 
diff --git a/services/graph/mocks/role_service.go b/services/graph/mocks/role_service.go
index b2d3063dbbf..3729b66ccbb 100644
--- a/services/graph/mocks/role_service.go
+++ b/services/graph/mocks/role_service.go
@@ -1,4 +1,4 @@
-// Code generated by mockery v2.14.1. DO NOT EDIT.
+// Code generated by mockery v2.22.1. DO NOT EDIT.
 
 package mocks
 
@@ -31,6 +31,10 @@ func (_m *RoleService) AssignRoleToUser(ctx context.Context, in *v0.AssignRoleTo
 	ret := _m.Called(_ca...)
 
 	var r0 *v0.AssignRoleToUserResponse
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, *v0.AssignRoleToUserRequest, ...client.CallOption) (*v0.AssignRoleToUserResponse, error)); ok {
+		return rf(ctx, in, opts...)
+	}
 	if rf, ok := ret.Get(0).(func(context.Context, *v0.AssignRoleToUserRequest, ...client.CallOption) *v0.AssignRoleToUserResponse); ok {
 		r0 = rf(ctx, in, opts...)
 	} else {
@@ -39,7 +43,6 @@ func (_m *RoleService) AssignRoleToUser(ctx context.Context, in *v0.AssignRoleTo
 		}
 	}
 
-	var r1 error
 	if rf, ok := ret.Get(1).(func(context.Context, *v0.AssignRoleToUserRequest, ...client.CallOption) error); ok {
 		r1 = rf(ctx, in, opts...)
 	} else {
@@ -61,6 +64,10 @@ func (_m *RoleService) ListRoleAssignments(ctx context.Context, in *v0.ListRoleA
 	ret := _m.Called(_ca...)
 
 	var r0 *v0.ListRoleAssignmentsResponse
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, *v0.ListRoleAssignmentsRequest, ...client.CallOption) (*v0.ListRoleAssignmentsResponse, error)); ok {
+		return rf(ctx, in, opts...)
+	}
 	if rf, ok := ret.Get(0).(func(context.Context, *v0.ListRoleAssignmentsRequest, ...client.CallOption) *v0.ListRoleAssignmentsResponse); ok {
 		r0 = rf(ctx, in, opts...)
 	} else {
@@ -69,7 +76,6 @@ func (_m *RoleService) ListRoleAssignments(ctx context.Context, in *v0.ListRoleA
 		}
 	}
 
-	var r1 error
 	if rf, ok := ret.Get(1).(func(context.Context, *v0.ListRoleAssignmentsRequest, ...client.CallOption) error); ok {
 		r1 = rf(ctx, in, opts...)
 	} else {
@@ -91,6 +97,10 @@ func (_m *RoleService) ListRoles(ctx context.Context, in *v0.ListBundlesRequest,
 	ret := _m.Called(_ca...)
 
 	var r0 *v0.ListBundlesResponse
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, *v0.ListBundlesRequest, ...client.CallOption) (*v0.ListBundlesResponse, error)); ok {
+		return rf(ctx, in, opts...)
+	}
 	if rf, ok := ret.Get(0).(func(context.Context, *v0.ListBundlesRequest, ...client.CallOption) *v0.ListBundlesResponse); ok {
 		r0 = rf(ctx, in, opts...)
 	} else {
@@ -99,7 +109,6 @@ func (_m *RoleService) ListRoles(ctx context.Context, in *v0.ListBundlesRequest,
 		}
 	}
 
-	var r1 error
 	if rf, ok := ret.Get(1).(func(context.Context, *v0.ListBundlesRequest, ...client.CallOption) error); ok {
 		r1 = rf(ctx, in, opts...)
 	} else {
@@ -121,6 +130,10 @@ func (_m *RoleService) RemoveRoleFromUser(ctx context.Context, in *v0.RemoveRole
 	ret := _m.Called(_ca...)
 
 	var r0 *emptypb.Empty
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, *v0.RemoveRoleFromUserRequest, ...client.CallOption) (*emptypb.Empty, error)); ok {
+		return rf(ctx, in, opts...)
+	}
 	if rf, ok := ret.Get(0).(func(context.Context, *v0.RemoveRoleFromUserRequest, ...client.CallOption) *emptypb.Empty); ok {
 		r0 = rf(ctx, in, opts...)
 	} else {
@@ -129,7 +142,6 @@ func (_m *RoleService) RemoveRoleFromUser(ctx context.Context, in *v0.RemoveRole
 		}
 	}
 
-	var r1 error
 	if rf, ok := ret.Get(1).(func(context.Context, *v0.RemoveRoleFromUserRequest, ...client.CallOption) error); ok {
 		r1 = rf(ctx, in, opts...)
 	} else {
diff --git a/services/graph/pkg/config/config.go b/services/graph/pkg/config/config.go
index 718d8c63e44..15b1d966cc5 100644
--- a/services/graph/pkg/config/config.go
+++ b/services/graph/pkg/config/config.go
@@ -30,6 +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."`
+
 	Context context.Context `yaml:"-"`
 }
 
diff --git a/services/graph/pkg/config/defaults/defaultconfig.go b/services/graph/pkg/config/defaults/defaultconfig.go
index 3cbbaaac2c2..ae9bfab02f2 100644
--- a/services/graph/pkg/config/defaults/defaultconfig.go
+++ b/services/graph/pkg/config/defaults/defaultconfig.go
@@ -149,6 +149,10 @@ func EnsureDefaults(cfg *config.Config) {
 	if cfg.Commons != nil {
 		cfg.HTTP.TLS = cfg.Commons.HTTPServiceTLS
 	}
+
+	if cfg.MachineAuthAPIKey == "" && cfg.Commons != nil && cfg.Commons.MachineAuthAPIKey != "" {
+		cfg.MachineAuthAPIKey = cfg.Commons.MachineAuthAPIKey
+	}
 }
 
 // Sanitize sanitized the configuration
diff --git a/services/graph/pkg/identity/ldap_group.go b/services/graph/pkg/identity/ldap_group.go
index 040ac9423d1..820d44804ee 100644
--- a/services/graph/pkg/identity/ldap_group.go
+++ b/services/graph/pkg/identity/ldap_group.go
@@ -258,7 +258,7 @@ func (i *LDAP) UpdateGroupName(ctx context.Context, groupID string, groupName st
 func (i *LDAP) AddMembersToGroup(ctx context.Context, groupID string, memberIDs []string) error {
 	logger := i.logger.SubloggerWithRequestID(ctx)
 	logger.Debug().Str("backend", "ldap").Msg("AddMembersToGroup")
-	ge, err := i.getLDAPGroupByID(groupID, true)
+	ge, err := i.getLDAPGroupByNameOrID(groupID, true)
 	if err != nil {
 		return err
 	}
diff --git a/services/graph/pkg/identity/mocks/education_backend.go b/services/graph/pkg/identity/mocks/education_backend.go
index 1de863c454c..da0db3e5304 100644
--- a/services/graph/pkg/identity/mocks/education_backend.go
+++ b/services/graph/pkg/identity/mocks/education_backend.go
@@ -1,4 +1,4 @@
-// Code generated by mockery v2.14.1. DO NOT EDIT.
+// Code generated by mockery v2.22.1. DO NOT EDIT.
 
 package mocks
 
@@ -62,6 +62,10 @@ func (_m *EducationBackend) CreateEducationClass(ctx context.Context, class libr
 	ret := _m.Called(ctx, class)
 
 	var r0 *libregraph.EducationClass
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, libregraph.EducationClass) (*libregraph.EducationClass, error)); ok {
+		return rf(ctx, class)
+	}
 	if rf, ok := ret.Get(0).(func(context.Context, libregraph.EducationClass) *libregraph.EducationClass); ok {
 		r0 = rf(ctx, class)
 	} else {
@@ -70,7 +74,6 @@ func (_m *EducationBackend) CreateEducationClass(ctx context.Context, class libr
 		}
 	}
 
-	var r1 error
 	if rf, ok := ret.Get(1).(func(context.Context, libregraph.EducationClass) error); ok {
 		r1 = rf(ctx, class)
 	} else {
@@ -85,6 +88,10 @@ func (_m *EducationBackend) CreateEducationSchool(ctx context.Context, group lib
 	ret := _m.Called(ctx, group)
 
 	var r0 *libregraph.EducationSchool
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, libregraph.EducationSchool) (*libregraph.EducationSchool, error)); ok {
+		return rf(ctx, group)
+	}
 	if rf, ok := ret.Get(0).(func(context.Context, libregraph.EducationSchool) *libregraph.EducationSchool); ok {
 		r0 = rf(ctx, group)
 	} else {
@@ -93,7 +100,6 @@ func (_m *EducationBackend) CreateEducationSchool(ctx context.Context, group lib
 		}
 	}
 
-	var r1 error
 	if rf, ok := ret.Get(1).(func(context.Context, libregraph.EducationSchool) error); ok {
 		r1 = rf(ctx, group)
 	} else {
@@ -108,6 +114,10 @@ func (_m *EducationBackend) CreateEducationUser(ctx context.Context, user libreg
 	ret := _m.Called(ctx, user)
 
 	var r0 *libregraph.EducationUser
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, libregraph.EducationUser) (*libregraph.EducationUser, error)); ok {
+		return rf(ctx, user)
+	}
 	if rf, ok := ret.Get(0).(func(context.Context, libregraph.EducationUser) *libregraph.EducationUser); ok {
 		r0 = rf(ctx, user)
 	} else {
@@ -116,7 +126,6 @@ func (_m *EducationBackend) CreateEducationUser(ctx context.Context, user libreg
 		}
 	}
 
-	var r1 error
 	if rf, ok := ret.Get(1).(func(context.Context, libregraph.EducationUser) error); ok {
 		r1 = rf(ctx, user)
 	} else {
@@ -173,6 +182,10 @@ func (_m *EducationBackend) GetEducationClass(ctx context.Context, namedOrID str
 	ret := _m.Called(ctx, namedOrID)
 
 	var r0 *libregraph.EducationClass
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, string) (*libregraph.EducationClass, error)); ok {
+		return rf(ctx, namedOrID)
+	}
 	if rf, ok := ret.Get(0).(func(context.Context, string) *libregraph.EducationClass); ok {
 		r0 = rf(ctx, namedOrID)
 	} else {
@@ -181,7 +194,6 @@ func (_m *EducationBackend) GetEducationClass(ctx context.Context, namedOrID str
 		}
 	}
 
-	var r1 error
 	if rf, ok := ret.Get(1).(func(context.Context, string) error); ok {
 		r1 = rf(ctx, namedOrID)
 	} else {
@@ -196,6 +208,10 @@ func (_m *EducationBackend) GetEducationClassMembers(ctx context.Context, nameOr
 	ret := _m.Called(ctx, nameOrID)
 
 	var r0 []*libregraph.EducationUser
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, string) ([]*libregraph.EducationUser, error)); ok {
+		return rf(ctx, nameOrID)
+	}
 	if rf, ok := ret.Get(0).(func(context.Context, string) []*libregraph.EducationUser); ok {
 		r0 = rf(ctx, nameOrID)
 	} else {
@@ -204,7 +220,6 @@ func (_m *EducationBackend) GetEducationClassMembers(ctx context.Context, nameOr
 		}
 	}
 
-	var r1 error
 	if rf, ok := ret.Get(1).(func(context.Context, string) error); ok {
 		r1 = rf(ctx, nameOrID)
 	} else {
@@ -219,6 +234,10 @@ func (_m *EducationBackend) GetEducationClassTeachers(ctx context.Context, class
 	ret := _m.Called(ctx, classID)
 
 	var r0 []*libregraph.EducationUser
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, string) ([]*libregraph.EducationUser, error)); ok {
+		return rf(ctx, classID)
+	}
 	if rf, ok := ret.Get(0).(func(context.Context, string) []*libregraph.EducationUser); ok {
 		r0 = rf(ctx, classID)
 	} else {
@@ -227,7 +246,6 @@ func (_m *EducationBackend) GetEducationClassTeachers(ctx context.Context, class
 		}
 	}
 
-	var r1 error
 	if rf, ok := ret.Get(1).(func(context.Context, string) error); ok {
 		r1 = rf(ctx, classID)
 	} else {
@@ -242,6 +260,10 @@ func (_m *EducationBackend) GetEducationClasses(ctx context.Context) ([]*libregr
 	ret := _m.Called(ctx)
 
 	var r0 []*libregraph.EducationClass
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context) ([]*libregraph.EducationClass, error)); ok {
+		return rf(ctx)
+	}
 	if rf, ok := ret.Get(0).(func(context.Context) []*libregraph.EducationClass); ok {
 		r0 = rf(ctx)
 	} else {
@@ -250,7 +272,6 @@ func (_m *EducationBackend) GetEducationClasses(ctx context.Context) ([]*libregr
 		}
 	}
 
-	var r1 error
 	if rf, ok := ret.Get(1).(func(context.Context) error); ok {
 		r1 = rf(ctx)
 	} else {
@@ -265,6 +286,10 @@ func (_m *EducationBackend) GetEducationSchool(ctx context.Context, nameOrID str
 	ret := _m.Called(ctx, nameOrID)
 
 	var r0 *libregraph.EducationSchool
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, string) (*libregraph.EducationSchool, error)); ok {
+		return rf(ctx, nameOrID)
+	}
 	if rf, ok := ret.Get(0).(func(context.Context, string) *libregraph.EducationSchool); ok {
 		r0 = rf(ctx, nameOrID)
 	} else {
@@ -273,7 +298,6 @@ func (_m *EducationBackend) GetEducationSchool(ctx context.Context, nameOrID str
 		}
 	}
 
-	var r1 error
 	if rf, ok := ret.Get(1).(func(context.Context, string) error); ok {
 		r1 = rf(ctx, nameOrID)
 	} else {
@@ -288,6 +312,10 @@ func (_m *EducationBackend) GetEducationSchoolClasses(ctx context.Context, schoo
 	ret := _m.Called(ctx, schoolNumberOrID)
 
 	var r0 []*libregraph.EducationClass
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, string) ([]*libregraph.EducationClass, error)); ok {
+		return rf(ctx, schoolNumberOrID)
+	}
 	if rf, ok := ret.Get(0).(func(context.Context, string) []*libregraph.EducationClass); ok {
 		r0 = rf(ctx, schoolNumberOrID)
 	} else {
@@ -296,7 +324,6 @@ func (_m *EducationBackend) GetEducationSchoolClasses(ctx context.Context, schoo
 		}
 	}
 
-	var r1 error
 	if rf, ok := ret.Get(1).(func(context.Context, string) error); ok {
 		r1 = rf(ctx, schoolNumberOrID)
 	} else {
@@ -311,6 +338,10 @@ func (_m *EducationBackend) GetEducationSchoolUsers(ctx context.Context, id stri
 	ret := _m.Called(ctx, id)
 
 	var r0 []*libregraph.EducationUser
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, string) ([]*libregraph.EducationUser, error)); ok {
+		return rf(ctx, id)
+	}
 	if rf, ok := ret.Get(0).(func(context.Context, string) []*libregraph.EducationUser); ok {
 		r0 = rf(ctx, id)
 	} else {
@@ -319,7 +350,6 @@ func (_m *EducationBackend) GetEducationSchoolUsers(ctx context.Context, id stri
 		}
 	}
 
-	var r1 error
 	if rf, ok := ret.Get(1).(func(context.Context, string) error); ok {
 		r1 = rf(ctx, id)
 	} else {
@@ -334,6 +364,10 @@ func (_m *EducationBackend) GetEducationSchools(ctx context.Context) ([]*libregr
 	ret := _m.Called(ctx)
 
 	var r0 []*libregraph.EducationSchool
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context) ([]*libregraph.EducationSchool, error)); ok {
+		return rf(ctx)
+	}
 	if rf, ok := ret.Get(0).(func(context.Context) []*libregraph.EducationSchool); ok {
 		r0 = rf(ctx)
 	} else {
@@ -342,7 +376,6 @@ func (_m *EducationBackend) GetEducationSchools(ctx context.Context) ([]*libregr
 		}
 	}
 
-	var r1 error
 	if rf, ok := ret.Get(1).(func(context.Context) error); ok {
 		r1 = rf(ctx)
 	} else {
@@ -357,6 +390,10 @@ func (_m *EducationBackend) GetEducationUser(ctx context.Context, nameOrID strin
 	ret := _m.Called(ctx, nameOrID)
 
 	var r0 *libregraph.EducationUser
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, string) (*libregraph.EducationUser, error)); ok {
+		return rf(ctx, nameOrID)
+	}
 	if rf, ok := ret.Get(0).(func(context.Context, string) *libregraph.EducationUser); ok {
 		r0 = rf(ctx, nameOrID)
 	} else {
@@ -365,7 +402,6 @@ func (_m *EducationBackend) GetEducationUser(ctx context.Context, nameOrID strin
 		}
 	}
 
-	var r1 error
 	if rf, ok := ret.Get(1).(func(context.Context, string) error); ok {
 		r1 = rf(ctx, nameOrID)
 	} else {
@@ -380,6 +416,10 @@ func (_m *EducationBackend) GetEducationUsers(ctx context.Context) ([]*libregrap
 	ret := _m.Called(ctx)
 
 	var r0 []*libregraph.EducationUser
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context) ([]*libregraph.EducationUser, error)); ok {
+		return rf(ctx)
+	}
 	if rf, ok := ret.Get(0).(func(context.Context) []*libregraph.EducationUser); ok {
 		r0 = rf(ctx)
 	} else {
@@ -388,7 +428,6 @@ func (_m *EducationBackend) GetEducationUsers(ctx context.Context) ([]*libregrap
 		}
 	}
 
-	var r1 error
 	if rf, ok := ret.Get(1).(func(context.Context) error); ok {
 		r1 = rf(ctx)
 	} else {
@@ -445,6 +484,10 @@ func (_m *EducationBackend) UpdateEducationClass(ctx context.Context, id string,
 	ret := _m.Called(ctx, id, class)
 
 	var r0 *libregraph.EducationClass
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, string, libregraph.EducationClass) (*libregraph.EducationClass, error)); ok {
+		return rf(ctx, id, class)
+	}
 	if rf, ok := ret.Get(0).(func(context.Context, string, libregraph.EducationClass) *libregraph.EducationClass); ok {
 		r0 = rf(ctx, id, class)
 	} else {
@@ -453,7 +496,6 @@ func (_m *EducationBackend) UpdateEducationClass(ctx context.Context, id string,
 		}
 	}
 
-	var r1 error
 	if rf, ok := ret.Get(1).(func(context.Context, string, libregraph.EducationClass) error); ok {
 		r1 = rf(ctx, id, class)
 	} else {
@@ -468,6 +510,10 @@ func (_m *EducationBackend) UpdateEducationSchool(ctx context.Context, numberOrI
 	ret := _m.Called(ctx, numberOrID, school)
 
 	var r0 *libregraph.EducationSchool
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, string, libregraph.EducationSchool) (*libregraph.EducationSchool, error)); ok {
+		return rf(ctx, numberOrID, school)
+	}
 	if rf, ok := ret.Get(0).(func(context.Context, string, libregraph.EducationSchool) *libregraph.EducationSchool); ok {
 		r0 = rf(ctx, numberOrID, school)
 	} else {
@@ -476,7 +522,6 @@ func (_m *EducationBackend) UpdateEducationSchool(ctx context.Context, numberOrI
 		}
 	}
 
-	var r1 error
 	if rf, ok := ret.Get(1).(func(context.Context, string, libregraph.EducationSchool) error); ok {
 		r1 = rf(ctx, numberOrID, school)
 	} else {
@@ -491,6 +536,10 @@ func (_m *EducationBackend) UpdateEducationUser(ctx context.Context, nameOrID st
 	ret := _m.Called(ctx, nameOrID, user)
 
 	var r0 *libregraph.EducationUser
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, string, libregraph.EducationUser) (*libregraph.EducationUser, error)); ok {
+		return rf(ctx, nameOrID, user)
+	}
 	if rf, ok := ret.Get(0).(func(context.Context, string, libregraph.EducationUser) *libregraph.EducationUser); ok {
 		r0 = rf(ctx, nameOrID, user)
 	} else {
@@ -499,7 +548,6 @@ func (_m *EducationBackend) UpdateEducationUser(ctx context.Context, nameOrID st
 		}
 	}
 
-	var r1 error
 	if rf, ok := ret.Get(1).(func(context.Context, string, libregraph.EducationUser) error); ok {
 		r1 = rf(ctx, nameOrID, user)
 	} else {
diff --git a/services/graph/pkg/server/http/server.go b/services/graph/pkg/server/http/server.go
index 6f6965630f1..c7d520842d6 100644
--- a/services/graph/pkg/server/http/server.go
+++ b/services/graph/pkg/server/http/server.go
@@ -7,6 +7,7 @@ import (
 	stdhttp "net/http"
 	"os"
 
+	gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
 	"github.com/cs3org/reva/v2/pkg/events/stream"
 	"github.com/cs3org/reva/v2/pkg/rgrpc/todo/pool"
 	chimiddleware "github.com/go-chi/chi/v5/middleware"
@@ -101,7 +102,7 @@ func Server(opts ...Option) (http.Service, error) {
 	// how do we secure the api?
 	var requireAdminMiddleware func(stdhttp.Handler) stdhttp.Handler
 	var roleService svc.RoleService
-	var gatewayClient svc.GatewayClient
+	var gatewayClient gateway.GatewayAPIClient
 	if options.Config.HTTP.APIToken == "" {
 		middlewares = append(middlewares,
 			graphMiddleware.Auth(
diff --git a/services/graph/pkg/service/v0/graph.go b/services/graph/pkg/service/v0/graph.go
index 79b52fdd583..e4fc0d8f5bc 100644
--- a/services/graph/pkg/service/v0/graph.go
+++ b/services/graph/pkg/service/v0/graph.go
@@ -9,7 +9,6 @@ import (
 	"strings"
 
 	gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
-	provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
 	"github.com/cs3org/reva/v2/pkg/events"
 	"github.com/go-chi/chi/v5"
 	"github.com/jellydator/ttlcache/v3"
@@ -21,49 +20,11 @@ import (
 	"github.com/owncloud/ocis/v2/services/graph/pkg/identity"
 	"go-micro.dev/v4/client"
 	mevents "go-micro.dev/v4/events"
-	"google.golang.org/grpc"
 	"google.golang.org/protobuf/types/known/emptypb"
 )
 
 //go:generate make -C ../../.. generate
 
-// GatewayClient is the subset of the gateway.GatewayAPIClient that is being used to interact with the gateway
-type GatewayClient interface {
-	//gateway.GatewayAPIClient
-
-	// Authenticates a user.
-	Authenticate(ctx context.Context, in *gateway.AuthenticateRequest, opts ...grpc.CallOption) (*gateway.AuthenticateResponse, error)
-	// Returns the home path for the given authenticated user.
-	// When a user has access to multiple storage providers, one of them is the home.
-	GetHome(ctx context.Context, in *provider.GetHomeRequest, opts ...grpc.CallOption) (*provider.GetHomeResponse, error)
-	// GetPath does a path lookup for a resource by ID
-	GetPath(ctx context.Context, in *provider.GetPathRequest, opts ...grpc.CallOption) (*provider.GetPathResponse, error)
-	// Returns a list of resource information
-	// for the provided reference.
-	// MUST return CODE_NOT_FOUND if the reference does not exists.
-	ListContainer(ctx context.Context, in *provider.ListContainerRequest, opts ...grpc.CallOption) (*provider.ListContainerResponse, error)
-	// Returns the resource information at the provided reference.
-	// MUST return CODE_NOT_FOUND if the reference does not exist.
-	Stat(ctx context.Context, in *provider.StatRequest, opts ...grpc.CallOption) (*provider.StatResponse, error)
-	// Initiates the download of a file using an
-	// out-of-band data transfer mechanism.
-	InitiateFileDownload(ctx context.Context, in *provider.InitiateFileDownloadRequest, opts ...grpc.CallOption) (*gateway.InitiateFileDownloadResponse, error)
-	// Creates a storage space.
-	CreateStorageSpace(ctx context.Context, in *provider.CreateStorageSpaceRequest, opts ...grpc.CallOption) (*provider.CreateStorageSpaceResponse, error)
-	// Lists storage spaces.
-	ListStorageSpaces(ctx context.Context, in *provider.ListStorageSpacesRequest, opts ...grpc.CallOption) (*provider.ListStorageSpacesResponse, error)
-	// Updates a storage space.
-	UpdateStorageSpace(ctx context.Context, in *provider.UpdateStorageSpaceRequest, opts ...grpc.CallOption) (*provider.UpdateStorageSpaceResponse, error)
-	// Deletes a storage space.
-	DeleteStorageSpace(ctx context.Context, in *provider.DeleteStorageSpaceRequest, opts ...grpc.CallOption) (*provider.DeleteStorageSpaceResponse, error)
-	// Returns the quota available under the provided
-	// reference.
-	// MUST return CODE_NOT_FOUND if the reference does not exist
-	// MUST return CODE_RESOURCE_EXHAUSTED on exceeded quota limits.
-	GetQuota(ctx context.Context, in *gateway.GetQuotaRequest, opts ...grpc.CallOption) (*provider.GetQuotaResponse, error)
-	SetArbitraryMetadata(ctx context.Context, request *provider.SetArbitraryMetadataRequest, opts ...grpc.CallOption) (*provider.SetArbitraryMetadataResponse, error)
-}
-
 // Publisher is the interface for events publisher
 type Publisher interface {
 	Publish(string, interface{}, ...mevents.PublishOption) error
@@ -82,7 +43,7 @@ type HTTPClient interface {
 }
 
 // GetGatewayServiceClientFunc is a callback used to pass in a mock during testing
-type GetGatewayServiceClientFunc func() (GatewayClient, error)
+type GetGatewayServiceClientFunc func() (gateway.GatewayAPIClient, error)
 
 // RoleService is the interface used to access the role service
 type RoleService interface {
@@ -99,7 +60,7 @@ type Graph struct {
 	logger                   *log.Logger
 	identityBackend          identity.Backend
 	identityEducationBackend identity.EducationBackend
-	gatewayClient            GatewayClient
+	gatewayClient            gateway.GatewayAPIClient
 	roleService              RoleService
 	permissionsService       Permissions
 	spacePropertiesCache     *ttlcache.Cache[string, interface{}]
@@ -115,7 +76,7 @@ func (g Graph) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 }
 
 // GetGatewayClient returns a gateway client to talk to reva
-func (g Graph) GetGatewayClient() GatewayClient {
+func (g Graph) GetGatewayClient() gateway.GatewayAPIClient {
 	return g.gatewayClient
 }
 
diff --git a/services/graph/pkg/service/v0/option.go b/services/graph/pkg/service/v0/option.go
index b159b806115..ccbde28b4d9 100644
--- a/services/graph/pkg/service/v0/option.go
+++ b/services/graph/pkg/service/v0/option.go
@@ -3,6 +3,7 @@ package svc
 import (
 	"net/http"
 
+	gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
 	"github.com/cs3org/reva/v2/pkg/events"
 	"github.com/owncloud/ocis/v2/ocis-pkg/log"
 	"github.com/owncloud/ocis/v2/ocis-pkg/roles"
@@ -21,7 +22,7 @@ type Options struct {
 	Config                   *config.Config
 	Middleware               []func(http.Handler) http.Handler
 	RequireAdminMiddleware   func(http.Handler) http.Handler
-	GatewayClient            GatewayClient
+	GatewayClient            gateway.GatewayAPIClient
 	IdentityBackend          identity.Backend
 	IdentityEducationBackend identity.EducationBackend
 	RoleService              RoleService
@@ -71,7 +72,7 @@ func WithRequireAdminMiddleware(val func(http.Handler) http.Handler) Option {
 }
 
 // WithGatewayClient provides a function to set the gateway client option.
-func WithGatewayClient(val GatewayClient) Option {
+func WithGatewayClient(val gateway.GatewayAPIClient) Option {
 	return func(o *Options) {
 		o.GatewayClient = val
 	}
diff --git a/services/graph/pkg/service/v0/personaldata.go b/services/graph/pkg/service/v0/personaldata.go
new file mode 100644
index 00000000000..b934acb96cc
--- /dev/null
+++ b/services/graph/pkg/service/v0/personaldata.go
@@ -0,0 +1,216 @@
+package svc
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"path/filepath"
+	"strconv"
+	"time"
+
+	gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
+	user "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
+	rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
+	provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
+	revactx "github.com/cs3org/reva/v2/pkg/ctx"
+	"github.com/cs3org/reva/v2/pkg/events"
+	"github.com/cs3org/reva/v2/pkg/rhttp"
+	"github.com/cs3org/reva/v2/pkg/utils"
+)
+
+var (
+	_backupFileName = "personal_data_export.json"
+
+	// TokenTransportHeader holds the header key for the reva transfer token
+	TokenTransportHeader = "X-Reva-Transfer"
+)
+
+// Marshaller is the common interface for a marshaller
+type Marshaller func(any) ([]byte, error)
+
+// ExportPersonalDataRequest is the body of the request
+type ExportPersonalDataRequest struct {
+	StorageLocation string `json:"storageLocation"`
+}
+
+// ExportPersonalData exports all personal data the system holds
+func (g Graph) ExportPersonalData(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+	u := revactx.ContextMustGetUser(ctx)
+	// Get location from request
+	loc := getLocation(r)
+
+	// prepare marshaller
+	var marsh Marshaller
+	switch filepath.Ext(loc) {
+	default:
+		g.logger.Info().Str("path", loc).Msg("invalid location")
+		w.WriteHeader(http.StatusBadRequest)
+		_, _ = w.Write([]byte("only json format is supported for personal data export"))
+		return
+	case ".json":
+		marsh = json.Marshal
+	}
+
+	ref := &provider.Reference{
+		ResourceId: &provider.ResourceId{SpaceId: u.GetId().GetOpaqueId(), OpaqueId: u.GetId().GetOpaqueId()},
+		Path:       loc,
+	}
+
+	// touch file
+	if err := mustTouchFile(ctx, ref, g.GetGatewayClient()); err != nil {
+		g.logger.Error().Err(err).Msg("error touching file")
+		w.WriteHeader(http.StatusInternalServerError)
+		return
+	}
+
+	// go start gathering
+	go g.GatherPersonalData(u, ref, r.Header.Get(revactx.TokenHeader), marsh)
+
+	w.WriteHeader(http.StatusCreated)
+}
+
+// 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) {
+	// create data
+	data := make(map[string]interface{})
+
+	// reva user
+	data["user"] = usr
+
+	// marshal
+	by, err := marsh(data)
+	if err != nil {
+		g.logger.Error().Err(err).Msg("cannot marshal personal user data")
+		return
+	}
+
+	// upload
+	var errmsg string
+	if err := g.upload(usr, by, ref, token); err != nil {
+		g.logger.Error().Err(err).Msg("failed uploading personal data export")
+		errmsg = err.Error()
+	}
+
+	if err := events.Publish(g.eventsPublisher, events.PersonalDataExtracted{
+		Executant: usr.GetId(),
+		Timestamp: time.Now(),
+		ErrorMsg:  errmsg,
+	}); err != nil {
+		g.logger.Error().Err(err).Msg("cannot publish event")
+	}
+}
+
+func (g Graph) upload(u *user.User, data []byte, ref *provider.Reference, th string) error {
+	uReq := &provider.InitiateFileUploadRequest{
+		Ref:    ref,
+		Opaque: utils.AppendPlainToOpaque(nil, "Upload-Length", strconv.FormatUint(uint64(len(data)), 10)),
+	}
+
+	gwc := g.GetGatewayClient()
+	ctx, err := utils.ImpersonateUser(u, gwc, g.config.MachineAuthAPIKey)
+	if err != nil {
+		return err
+	}
+	ctx = revactx.ContextSetToken(ctx, th)
+	uRes, err := gwc.InitiateFileUpload(ctx, uReq)
+	if err != nil {
+		return err
+	}
+
+	if uRes.Status.Code != rpc.Code_CODE_OK {
+		return fmt.Errorf("wrong status code while initiating upload: %s", uRes.GetStatus().GetMessage())
+	}
+
+	var uploadEP, uploadToken string
+	for _, p := range uRes.Protocols {
+		if p.Protocol == "simple" {
+			uploadEP, uploadToken = p.UploadEndpoint, p.Token
+		}
+	}
+
+	httpUploadReq, err := rhttp.NewRequest(ctx, "PUT", uploadEP, bytes.NewBuffer(data))
+	if err != nil {
+		return err
+	}
+	httpUploadReq.Header.Set(TokenTransportHeader, uploadToken)
+
+	httpUploadRes, err := rhttp.GetHTTPClient(rhttp.Insecure(true)).Do(httpUploadReq)
+	if err != nil {
+		return err
+	}
+	defer httpUploadRes.Body.Close()
+	if httpUploadRes.StatusCode != http.StatusOK {
+		return fmt.Errorf("wrong status uploading file: %d", httpUploadRes.StatusCode)
+	}
+
+	return nil
+}
+
+// touches the file, creating folders if necessary
+func mustTouchFile(ctx context.Context, ref *provider.Reference, gwc gateway.GatewayAPIClient) error {
+	if err := touchFile(ctx, ref, gwc); err == nil {
+		return nil
+	}
+
+	if err := createFolders(ctx, ref, gwc); err != nil {
+		return err
+	}
+
+	return touchFile(ctx, ref, gwc)
+}
+
+func touchFile(ctx context.Context, ref *provider.Reference, gwc gateway.GatewayAPIClient) error {
+	resp, err := gwc.TouchFile(ctx, &provider.TouchFileRequest{
+		Opaque: utils.AppendPlainToOpaque(nil, "markprocessing", "true"),
+		Ref:    ref,
+	})
+	if err != nil {
+		return err
+	}
+	if resp.GetStatus().GetCode() != rpc.Code_CODE_OK {
+		return fmt.Errorf("unexpected statuscode while touching file: %d %s", resp.GetStatus().GetCode(), resp.GetStatus().GetMessage())
+	}
+	return nil
+}
+
+func createFolders(ctx context.Context, ref *provider.Reference, gwc gateway.GatewayAPIClient) error {
+	var paths []string
+	p := filepath.Dir(ref.GetPath())
+	for p != "." {
+		paths = append([]string{p}, paths...)
+		p = filepath.Dir(p)
+	}
+
+	for _, p := range paths {
+		r := &provider.Reference{ResourceId: ref.GetResourceId(), Path: p}
+		resp, err := gwc.CreateContainer(ctx, &provider.CreateContainerRequest{Ref: r})
+		if err != nil {
+			return err
+		}
+
+		code := resp.GetStatus().GetCode()
+		if code != rpc.Code_CODE_OK && code != rpc.Code_CODE_ALREADY_EXISTS {
+			return fmt.Errorf("unexpected statuscode while creating folder: %d %s", code, resp.GetStatus().GetMessage())
+		}
+	}
+	return nil
+
+}
+
+func getLocation(r *http.Request) string {
+	// from body
+	var req ExportPersonalDataRequest
+	if b, err := io.ReadAll(r.Body); err == nil {
+		if err := json.Unmarshal(b, &req); err == nil && req.StorageLocation != "" {
+			return req.StorageLocation
+		}
+	}
+
+	// from header?
+
+	return _backupFileName
+}
diff --git a/services/graph/pkg/service/v0/service.go b/services/graph/pkg/service/v0/service.go
index 7ed796eb419..8541167af6c 100644
--- a/services/graph/pkg/service/v0/service.go
+++ b/services/graph/pkg/service/v0/service.go
@@ -207,6 +207,7 @@ func NewService(opts ...Option) (Graph, error) {
 				r.Route("/{userID}", func(r chi.Router) {
 					r.Get("/", svc.GetUser)
 					r.Get("/drive", svc.GetUserDrive)
+					r.Post("/exportPersonalData", svc.ExportPersonalData)
 					r.With(requireAdmin).Delete("/", svc.DeleteUser)
 					r.With(requireAdmin).Patch("/", svc.PatchUser)
 					if svc.roleService != nil {
diff --git a/services/invitations/Makefile b/services/invitations/Makefile
index 717e7ff5d23..5e06194da78 100644
--- a/services/invitations/Makefile
+++ b/services/invitations/Makefile
@@ -25,7 +25,7 @@ include ../../.make/generate.mk
 
 .PHONY: ci-go-generate
 ci-go-generate: $(MOCKERY) # CI runs ci-node-generate automatically before this target
-	$(MOCKERY) --dir pkg/backends/keycloak --output pkg/mocks --case underscore --name GoCloak
+	$(MOCKERY) --output pkg/mocks --case underscore --name Client  --srcpkg "github.com/owncloud/ocis/v2/ocis-pkg/keycloak"
 
 
 .PHONY: ci-node-generate
diff --git a/services/invitations/pkg/backends/keycloak/backend.go b/services/invitations/pkg/backends/keycloak/backend.go
index fa42f2c51d9..7d718de4d4d 100644
--- a/services/invitations/pkg/backends/keycloak/backend.go
+++ b/services/invitations/pkg/backends/keycloak/backend.go
@@ -3,31 +3,28 @@ package keycloak
 
 import (
 	"context"
-	"crypto/tls"
-	"fmt"
 
-	"github.com/Nerzal/gocloak/v13"
 	"github.com/google/uuid"
+	libregraph "github.com/owncloud/libre-graph-api-go"
+	"github.com/owncloud/ocis/v2/ocis-pkg/keycloak"
 	"github.com/owncloud/ocis/v2/ocis-pkg/log"
 	"github.com/owncloud/ocis/v2/services/invitations/pkg/invitations"
 )
 
 const (
-	idAttr       = "OWNCLOUD_ID"
-	userTypeAttr = "OWNCLOUD_USER_TYPE"
-	userTypeVal  = "Guest"
+	userType = "Guest"
 )
 
-var userRequiredActions = []string{"UPDATE_PASSWORD", "VERIFY_EMAIL"}
+var userRequiredActions = []keycloak.UserAction{
+	keycloak.UserActionUpdatePassword,
+	keycloak.UserActionVerifyEmail,
+}
 
 // Backend represents the keycloak backend.
 type Backend struct {
-	logger       log.Logger
-	client       GoCloak
-	clientID     string
-	clientSecret string
-	clientRealm  string
-	userRealm    string
+	logger    log.Logger
+	client    keycloak.Client
+	userRealm string
 }
 
 // New instantiates a new keycloak.Backend with a default gocloak client.
@@ -36,62 +33,50 @@ func New(
 	baseURL, clientID, clientSecret, clientRealm, userRealm string,
 	insecureSkipVerify bool,
 ) *Backend {
-	client := gocloak.NewClient(baseURL)
-	restyClient := client.RestyClient()
-	restyClient.SetTLSClientConfig(&tls.Config{InsecureSkipVerify: insecureSkipVerify}) //nolint:gosec
-	return NewWithClient(logger, client, clientID, clientSecret, clientRealm, userRealm)
+	logger = log.Logger{
+		Logger: logger.With().
+			Str("invitationBackend", "keycloak").
+			Str("clientID", clientID).
+			Str("clientRealm", clientRealm).
+			Str("userRealm", userRealm).
+			Logger(),
+	}
+	client := keycloak.New(baseURL, clientID, clientSecret, clientRealm, insecureSkipVerify)
+	return NewWithClient(logger, client, userRealm)
 }
 
-// NewWithClient creates a new backend with the supplied GoCloak client.
+// NewWithClient creates a new backend with the supplied keycloak client.
 func NewWithClient(
 	logger log.Logger,
-	client GoCloak,
-	clientID, clientSecret, clientRealm, userRealm string,
+	client keycloak.Client,
+	userRealm string,
 ) *Backend {
 	return &Backend{
-		logger: log.Logger{
-			Logger: logger.With().
-				Str("invitationBackend", "keycloak").
-				Str("clientID", clientID).
-				Str("clientRealm", clientRealm).
-				Str("userRealm", userRealm).
-				Logger(),
-		},
-		client:       client,
-		clientID:     clientID,
-		clientSecret: clientSecret,
-		clientRealm:  clientRealm,
-		userRealm:    userRealm,
+		logger:    logger,
+		client:    client,
+		userRealm: userRealm,
 	}
 }
 
 // CreateUser creates a user in the keycloak backend.
 func (b Backend) CreateUser(ctx context.Context, invitation *invitations.Invitation) (string, error) {
-	token, err := b.getToken(ctx)
-	if err != nil {
-		return "", err
-	}
 	u := uuid.New()
 
 	b.logger.Info().
-		Str(idAttr, u.String()).
 		Str("email", invitation.InvitedUserEmailAddress).
 		Msg("Creating new user")
-	user := gocloak.User{
-		Email:    &invitation.InvitedUserEmailAddress,
-		Enabled:  gocloak.BoolP(true),
-		Username: &invitation.InvitedUserEmailAddress,
-		Attributes: &map[string][]string{
-			idAttr:       {u.String()},
-			userTypeAttr: {userTypeVal},
-		},
-		RequiredActions: &userRequiredActions,
+	user := &libregraph.User{
+		Mail:                     &invitation.InvitedUserEmailAddress,
+		AccountEnabled:           boolP(true),
+		OnPremisesSamAccountName: &invitation.InvitedUserEmailAddress,
+		Id:                       stringP(u.String()),
+		UserType:                 stringP(userType),
 	}
 
-	id, err := b.client.CreateUser(ctx, token.AccessToken, b.userRealm, user)
+	id, err := b.client.CreateUser(ctx, b.userRealm, user, userRequiredActions)
 	if err != nil {
 		b.logger.Error().
-			Str(idAttr, u.String()).
+			Str("userID", u.String()).
 			Str("email", invitation.InvitedUserEmailAddress).
 			Err(err).
 			Msg("Failed to create user")
@@ -106,35 +91,8 @@ func (b Backend) CanSendMail() bool { return true }
 
 // SendMail sends a mail to the user with details on how to reedeem the invitation.
 func (b Backend) SendMail(ctx context.Context, id string) error {
-	token, err := b.getToken(ctx)
-	if err != nil {
-		return err
-	}
-	params := gocloak.ExecuteActionsEmail{
-		UserID:  &id,
-		Actions: &userRequiredActions,
-	}
-	return b.client.ExecuteActionsEmail(ctx, token.AccessToken, b.userRealm, params)
+	return b.client.SendActionsMail(ctx, b.userRealm, id, userRequiredActions)
 }
 
-func (b Backend) getToken(ctx context.Context) (*gocloak.JWT, error) {
-	b.logger.Debug().Msg("Logging into keycloak")
-	token, err := b.client.LoginClient(ctx, b.clientID, b.clientSecret, b.clientRealm)
-	if err != nil {
-		b.logger.Error().Err(err).Msg("failed to get token")
-		return nil, fmt.Errorf("failed to get token: %w", err)
-	}
-
-	rRes, err := b.client.RetrospectToken(ctx, token.AccessToken, b.clientID, b.clientSecret, b.clientRealm)
-	if err != nil {
-		b.logger.Error().Err(err).Msg("failed to introspect token")
-		return nil, fmt.Errorf("failed to retrospect token: %w", err)
-	}
-
-	if !*rRes.Active {
-		b.logger.Error().Msg("token not active")
-		return nil, fmt.Errorf("token is not active")
-	}
-
-	return token, nil
-}
+func boolP(b bool) *bool       { return &b }
+func stringP(s string) *string { return &s }
diff --git a/services/invitations/pkg/mocks/client.go b/services/invitations/pkg/mocks/client.go
new file mode 100644
index 00000000000..eddaf9cec80
--- /dev/null
+++ b/services/invitations/pkg/mocks/client.go
@@ -0,0 +1,122 @@
+// Code generated by mockery v2.22.1. DO NOT EDIT.
+
+package mocks
+
+import (
+	context "context"
+
+	libregraph "github.com/owncloud/libre-graph-api-go"
+	keycloak "github.com/owncloud/ocis/v2/ocis-pkg/keycloak"
+
+	mock "github.com/stretchr/testify/mock"
+)
+
+// Client is an autogenerated mock type for the Client type
+type Client struct {
+	mock.Mock
+}
+
+// CreateUser provides a mock function with given fields: ctx, realm, user, userActions
+func (_m *Client) CreateUser(ctx context.Context, realm string, user *libregraph.User, userActions []keycloak.UserAction) (string, error) {
+	ret := _m.Called(ctx, realm, user, userActions)
+
+	var r0 string
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, string, *libregraph.User, []keycloak.UserAction) (string, error)); ok {
+		return rf(ctx, realm, user, userActions)
+	}
+	if rf, ok := ret.Get(0).(func(context.Context, string, *libregraph.User, []keycloak.UserAction) string); ok {
+		r0 = rf(ctx, realm, user, userActions)
+	} else {
+		r0 = ret.Get(0).(string)
+	}
+
+	if rf, ok := ret.Get(1).(func(context.Context, string, *libregraph.User, []keycloak.UserAction) error); ok {
+		r1 = rf(ctx, realm, user, userActions)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	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)
+
+	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, *libregraph.User) *keycloak.PIIReport); ok {
+		r0 = rf(ctx, realm, user)
+	} 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)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// GetUserByEmail provides a mock function with given fields: ctx, realm, email
+func (_m *Client) GetUserByEmail(ctx context.Context, realm string, email string) (*libregraph.User, error) {
+	ret := _m.Called(ctx, realm, email)
+
+	var r0 *libregraph.User
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, string, string) (*libregraph.User, error)); ok {
+		return rf(ctx, realm, email)
+	}
+	if rf, ok := ret.Get(0).(func(context.Context, string, string) *libregraph.User); ok {
+		r0 = rf(ctx, realm, email)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(*libregraph.User)
+		}
+	}
+
+	if rf, ok := ret.Get(1).(func(context.Context, string, string) error); ok {
+		r1 = rf(ctx, realm, email)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// SendActionsMail provides a mock function with given fields: ctx, realm, userID, userActions
+func (_m *Client) SendActionsMail(ctx context.Context, realm string, userID string, userActions []keycloak.UserAction) error {
+	ret := _m.Called(ctx, realm, userID, userActions)
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(context.Context, string, string, []keycloak.UserAction) error); ok {
+		r0 = rf(ctx, realm, userID, userActions)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+type mockConstructorTestingTNewClient interface {
+	mock.TestingT
+	Cleanup(func())
+}
+
+// NewClient creates a new instance of Client. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
+func NewClient(t mockConstructorTestingTNewClient) *Client {
+	mock := &Client{}
+	mock.Mock.Test(t)
+
+	t.Cleanup(func() { mock.AssertExpectations(t) })
+
+	return mock
+}
diff --git a/services/invitations/pkg/mocks/go_cloak.go b/services/invitations/pkg/mocks/go_cloak.go
deleted file mode 100644
index 6289046719c..00000000000
--- a/services/invitations/pkg/mocks/go_cloak.go
+++ /dev/null
@@ -1,112 +0,0 @@
-// Code generated by mockery v2.14.1. DO NOT EDIT.
-
-package mocks
-
-import (
-	context "context"
-
-	gocloak "github.com/Nerzal/gocloak/v13"
-
-	mock "github.com/stretchr/testify/mock"
-)
-
-// GoCloak is an autogenerated mock type for the GoCloak type
-type GoCloak struct {
-	mock.Mock
-}
-
-// CreateUser provides a mock function with given fields: ctx, token, realm, user
-func (_m *GoCloak) CreateUser(ctx context.Context, token string, realm string, user gocloak.User) (string, error) {
-	ret := _m.Called(ctx, token, realm, user)
-
-	var r0 string
-	if rf, ok := ret.Get(0).(func(context.Context, string, string, gocloak.User) string); ok {
-		r0 = rf(ctx, token, realm, user)
-	} else {
-		r0 = ret.Get(0).(string)
-	}
-
-	var r1 error
-	if rf, ok := ret.Get(1).(func(context.Context, string, string, gocloak.User) error); ok {
-		r1 = rf(ctx, token, realm, user)
-	} else {
-		r1 = ret.Error(1)
-	}
-
-	return r0, r1
-}
-
-// ExecuteActionsEmail provides a mock function with given fields: ctx, token, realm, params
-func (_m *GoCloak) ExecuteActionsEmail(ctx context.Context, token string, realm string, params gocloak.ExecuteActionsEmail) error {
-	ret := _m.Called(ctx, token, realm, params)
-
-	var r0 error
-	if rf, ok := ret.Get(0).(func(context.Context, string, string, gocloak.ExecuteActionsEmail) error); ok {
-		r0 = rf(ctx, token, realm, params)
-	} else {
-		r0 = ret.Error(0)
-	}
-
-	return r0
-}
-
-// LoginClient provides a mock function with given fields: ctx, clientID, clientSecret, realm
-func (_m *GoCloak) LoginClient(ctx context.Context, clientID string, clientSecret string, realm string) (*gocloak.JWT, error) {
-	ret := _m.Called(ctx, clientID, clientSecret, realm)
-
-	var r0 *gocloak.JWT
-	if rf, ok := ret.Get(0).(func(context.Context, string, string, string) *gocloak.JWT); ok {
-		r0 = rf(ctx, clientID, clientSecret, realm)
-	} else {
-		if ret.Get(0) != nil {
-			r0 = ret.Get(0).(*gocloak.JWT)
-		}
-	}
-
-	var r1 error
-	if rf, ok := ret.Get(1).(func(context.Context, string, string, string) error); ok {
-		r1 = rf(ctx, clientID, clientSecret, realm)
-	} else {
-		r1 = ret.Error(1)
-	}
-
-	return r0, r1
-}
-
-// RetrospectToken provides a mock function with given fields: ctx, accessToken, clientID, clientSecret, realm
-func (_m *GoCloak) RetrospectToken(ctx context.Context, accessToken string, clientID string, clientSecret string, realm string) (*gocloak.IntroSpectTokenResult, error) {
-	ret := _m.Called(ctx, accessToken, clientID, clientSecret, realm)
-
-	var r0 *gocloak.IntroSpectTokenResult
-	if rf, ok := ret.Get(0).(func(context.Context, string, string, string, string) *gocloak.IntroSpectTokenResult); ok {
-		r0 = rf(ctx, accessToken, clientID, clientSecret, realm)
-	} else {
-		if ret.Get(0) != nil {
-			r0 = ret.Get(0).(*gocloak.IntroSpectTokenResult)
-		}
-	}
-
-	var r1 error
-	if rf, ok := ret.Get(1).(func(context.Context, string, string, string, string) error); ok {
-		r1 = rf(ctx, accessToken, clientID, clientSecret, realm)
-	} else {
-		r1 = ret.Error(1)
-	}
-
-	return r0, r1
-}
-
-type mockConstructorTestingTNewGoCloak interface {
-	mock.TestingT
-	Cleanup(func())
-}
-
-// NewGoCloak creates a new instance of GoCloak. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
-func NewGoCloak(t mockConstructorTestingTNewGoCloak) *GoCloak {
-	mock := &GoCloak{}
-	mock.Mock.Test(t)
-
-	t.Cleanup(func() { mock.AssertExpectations(t) })
-
-	return mock
-}
diff --git a/services/web/Makefile b/services/web/Makefile
index e27b932c9a4..ec06b47cb1e 100644
--- a/services/web/Makefile
+++ b/services/web/Makefile
@@ -1,6 +1,6 @@
 SHELL := bash
 NAME := web
-WEB_ASSETS_VERSION = v7.0.0-rc.24
+WEB_ASSETS_VERSION = v7.0.0-rc.26
 
 include ../../.make/recursion.mk
 


Test patch:
diff --git a/services/graph/pkg/service/v0/application_test.go b/services/graph/pkg/service/v0/application_test.go
index 028ab59408c..851e2ebed3b 100644
--- a/services/graph/pkg/service/v0/application_test.go
+++ b/services/graph/pkg/service/v0/application_test.go
@@ -13,6 +13,7 @@ import (
 	libregraph "github.com/owncloud/libre-graph-api-go"
 	"github.com/stretchr/testify/mock"
 
+	cs3mocks "github.com/cs3org/reva/v2/tests/cs3mocks/mocks"
 	ogrpc "github.com/owncloud/ocis/v2/ocis-pkg/service/grpc"
 	"github.com/owncloud/ocis/v2/ocis-pkg/shared"
 	settingsmsg "github.com/owncloud/ocis/v2/protogen/gen/ocis/messages/settings/v0"
@@ -33,7 +34,7 @@ var _ = Describe("Applications", func() {
 		svc             service.Service
 		ctx             context.Context
 		cfg             *config.Config
-		gatewayClient   *mocks.GatewayClient
+		gatewayClient   *cs3mocks.GatewayAPIClient
 		eventsPublisher mocks.Publisher
 		roleService     *mocks.RoleService
 		identityBackend *identitymocks.Backend
@@ -46,7 +47,7 @@ var _ = Describe("Applications", func() {
 
 		identityBackend = &identitymocks.Backend{}
 		roleService = &mocks.RoleService{}
-		gatewayClient = &mocks.GatewayClient{}
+		gatewayClient = &cs3mocks.GatewayAPIClient{}
 
 		rr = httptest.NewRecorder()
 		ctx = context.Background()
diff --git a/services/graph/pkg/service/v0/approleassignments_test.go b/services/graph/pkg/service/v0/approleassignments_test.go
index 807bbc1ceda..7f37975a54d 100644
--- a/services/graph/pkg/service/v0/approleassignments_test.go
+++ b/services/graph/pkg/service/v0/approleassignments_test.go
@@ -10,6 +10,7 @@ import (
 
 	userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
 	revactx "github.com/cs3org/reva/v2/pkg/ctx"
+	cs3mocks "github.com/cs3org/reva/v2/tests/cs3mocks/mocks"
 	"github.com/go-chi/chi/v5"
 	"github.com/golang/protobuf/ptypes/empty"
 	. "github.com/onsi/ginkgo/v2"
@@ -37,7 +38,7 @@ var _ = Describe("AppRoleAssignments", func() {
 		svc             service.Service
 		ctx             context.Context
 		cfg             *config.Config
-		gatewayClient   *mocks.GatewayClient
+		gatewayClient   *cs3mocks.GatewayAPIClient
 		eventsPublisher mocks.Publisher
 		roleService     *mocks.RoleService
 		identityBackend *identitymocks.Backend
@@ -56,7 +57,7 @@ var _ = Describe("AppRoleAssignments", func() {
 
 		identityBackend = &identitymocks.Backend{}
 		roleService = &mocks.RoleService{}
-		gatewayClient = &mocks.GatewayClient{}
+		gatewayClient = &cs3mocks.GatewayAPIClient{}
 
 		rr = httptest.NewRecorder()
 		ctx = context.Background()
diff --git a/services/graph/pkg/service/v0/driveitems_test.go b/services/graph/pkg/service/v0/driveitems_test.go
index 2feb50ae54b..b830cb14a9c 100644
--- a/services/graph/pkg/service/v0/driveitems_test.go
+++ b/services/graph/pkg/service/v0/driveitems_test.go
@@ -11,6 +11,7 @@ import (
 	provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
 	"github.com/cs3org/reva/v2/pkg/rgrpc/status"
 	"github.com/cs3org/reva/v2/pkg/utils"
+	cs3mocks "github.com/cs3org/reva/v2/tests/cs3mocks/mocks"
 	. "github.com/onsi/ginkgo/v2"
 	. "github.com/onsi/gomega"
 	"github.com/stretchr/testify/mock"
@@ -34,7 +35,7 @@ var _ = Describe("Driveitems", func() {
 		svc             service.Service
 		ctx             context.Context
 		cfg             *config.Config
-		gatewayClient   *mocks.GatewayClient
+		gatewayClient   *cs3mocks.GatewayAPIClient
 		eventsPublisher mocks.Publisher
 		identityBackend *identitymocks.Backend
 
@@ -49,7 +50,7 @@ var _ = Describe("Driveitems", func() {
 		rr = httptest.NewRecorder()
 
 		identityBackend = &identitymocks.Backend{}
-		gatewayClient = &mocks.GatewayClient{}
+		gatewayClient = &cs3mocks.GatewayAPIClient{}
 		newGroup = libregraph.NewGroup()
 		newGroup.SetMembersodataBind([]string{"/users/user1"})
 		newGroup.SetId("group1")
diff --git a/services/graph/pkg/service/v0/educationclasses_test.go b/services/graph/pkg/service/v0/educationclasses_test.go
index 4f8431862e3..50564cf8e99 100644
--- a/services/graph/pkg/service/v0/educationclasses_test.go
+++ b/services/graph/pkg/service/v0/educationclasses_test.go
@@ -11,6 +11,7 @@ import (
 
 	userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
 	revactx "github.com/cs3org/reva/v2/pkg/ctx"
+	cs3mocks "github.com/cs3org/reva/v2/tests/cs3mocks/mocks"
 	"github.com/go-chi/chi/v5"
 	. "github.com/onsi/ginkgo/v2"
 	. "github.com/onsi/gomega"
@@ -33,7 +34,7 @@ var _ = Describe("EducationClass", func() {
 		svc                      service.Service
 		ctx                      context.Context
 		cfg                      *config.Config
-		gatewayClient            *mocks.GatewayClient
+		gatewayClient            *cs3mocks.GatewayAPIClient
 		eventsPublisher          mocks.Publisher
 		identityBackend          *identitymocks.Backend
 		identityEducationBackend *identitymocks.EducationBackend
@@ -53,7 +54,7 @@ var _ = Describe("EducationClass", func() {
 
 		identityEducationBackend = &identitymocks.EducationBackend{}
 		identityBackend = &identitymocks.Backend{}
-		gatewayClient = &mocks.GatewayClient{}
+		gatewayClient = &cs3mocks.GatewayAPIClient{}
 		newClass = libregraph.NewEducationClass("math", "course")
 		newClass.SetMembersodataBind([]string{"/users/user1"})
 		newClass.SetId("math")
diff --git a/services/graph/pkg/service/v0/educationschools_test.go b/services/graph/pkg/service/v0/educationschools_test.go
index 4a30b407f54..3bf5157faff 100644
--- a/services/graph/pkg/service/v0/educationschools_test.go
+++ b/services/graph/pkg/service/v0/educationschools_test.go
@@ -11,6 +11,7 @@ import (
 
 	userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
 	ctxpkg "github.com/cs3org/reva/v2/pkg/ctx"
+	cs3mocks "github.com/cs3org/reva/v2/tests/cs3mocks/mocks"
 	"github.com/go-chi/chi/v5"
 	. "github.com/onsi/ginkgo/v2"
 	. "github.com/onsi/gomega"
@@ -19,7 +20,6 @@ import (
 	libregraph "github.com/owncloud/libre-graph-api-go"
 	ogrpc "github.com/owncloud/ocis/v2/ocis-pkg/service/grpc"
 	"github.com/owncloud/ocis/v2/ocis-pkg/shared"
-	"github.com/owncloud/ocis/v2/services/graph/mocks"
 	"github.com/owncloud/ocis/v2/services/graph/pkg/config"
 	"github.com/owncloud/ocis/v2/services/graph/pkg/config/defaults"
 	identitymocks "github.com/owncloud/ocis/v2/services/graph/pkg/identity/mocks"
@@ -36,7 +36,7 @@ var _ = Describe("Schools", func() {
 		svc                      service.Service
 		ctx                      context.Context
 		cfg                      *config.Config
-		gatewayClient            *mocks.GatewayClient
+		gatewayClient            *cs3mocks.GatewayAPIClient
 		identityEducationBackend *identitymocks.EducationBackend
 
 		rr *httptest.ResponseRecorder
@@ -51,7 +51,7 @@ var _ = Describe("Schools", func() {
 
 	BeforeEach(func() {
 		identityEducationBackend = &identitymocks.EducationBackend{}
-		gatewayClient = &mocks.GatewayClient{}
+		gatewayClient = &cs3mocks.GatewayAPIClient{}
 		newSchool = libregraph.NewEducationSchool()
 		newSchool.SetId("school1")
 
diff --git a/services/graph/pkg/service/v0/educationuser_test.go b/services/graph/pkg/service/v0/educationuser_test.go
index 586c8abdb50..1d4a10407de 100644
--- a/services/graph/pkg/service/v0/educationuser_test.go
+++ b/services/graph/pkg/service/v0/educationuser_test.go
@@ -13,6 +13,7 @@ import (
 	typesv1beta1 "github.com/cs3org/go-cs3apis/cs3/types/v1beta1"
 	revactx "github.com/cs3org/reva/v2/pkg/ctx"
 	"github.com/cs3org/reva/v2/pkg/rgrpc/status"
+	cs3mocks "github.com/cs3org/reva/v2/tests/cs3mocks/mocks"
 	"github.com/go-chi/chi/v5"
 	. "github.com/onsi/ginkgo/v2"
 	. "github.com/onsi/gomega"
@@ -38,7 +39,7 @@ var _ = Describe("EducationUsers", func() {
 		svc                      service.Service
 		ctx                      context.Context
 		cfg                      *config.Config
-		gatewayClient            *mocks.GatewayClient
+		gatewayClient            *cs3mocks.GatewayAPIClient
 		eventsPublisher          mocks.Publisher
 		roleService              *mocks.RoleService
 		identityEducationBackend *identitymocks.EducationBackend
@@ -57,7 +58,7 @@ var _ = Describe("EducationUsers", func() {
 
 		identityEducationBackend = &identitymocks.EducationBackend{}
 		roleService = &mocks.RoleService{}
-		gatewayClient = &mocks.GatewayClient{}
+		gatewayClient = &cs3mocks.GatewayAPIClient{}
 
 		rr = httptest.NewRecorder()
 		ctx = context.Background()
diff --git a/services/graph/pkg/service/v0/graph_test.go b/services/graph/pkg/service/v0/graph_test.go
index 717f6f6f8cc..7f63e681533 100644
--- a/services/graph/pkg/service/v0/graph_test.go
+++ b/services/graph/pkg/service/v0/graph_test.go
@@ -17,6 +17,7 @@ import (
 	revactx "github.com/cs3org/reva/v2/pkg/ctx"
 	"github.com/cs3org/reva/v2/pkg/rgrpc/status"
 	"github.com/cs3org/reva/v2/pkg/utils"
+	cs3mocks "github.com/cs3org/reva/v2/tests/cs3mocks/mocks"
 	"github.com/go-chi/chi/v5"
 	. "github.com/onsi/ginkgo/v2"
 	. "github.com/onsi/gomega"
@@ -38,7 +39,7 @@ import (
 var _ = Describe("Graph", func() {
 	var (
 		svc               service.Service
-		gatewayClient     *mocks.GatewayClient
+		gatewayClient     *cs3mocks.GatewayAPIClient
 		eventsPublisher   mocks.Publisher
 		permissionService mocks.Permissions
 		ctx               context.Context
@@ -63,7 +64,7 @@ var _ = Describe("Graph", func() {
 		cfg.GRPCClientTLS = &shared.GRPCClientTLS{}
 
 		_ = ogrpc.Configure(ogrpc.GetClientOptions(cfg.GRPCClientTLS)...)
-		gatewayClient = &mocks.GatewayClient{}
+		gatewayClient = &cs3mocks.GatewayAPIClient{}
 		eventsPublisher = mocks.Publisher{}
 		permissionService = mocks.Permissions{}
 		svc, _ = service.NewService(
diff --git a/services/graph/pkg/service/v0/groups_test.go b/services/graph/pkg/service/v0/groups_test.go
index 298e1922aac..2712641b624 100644
--- a/services/graph/pkg/service/v0/groups_test.go
+++ b/services/graph/pkg/service/v0/groups_test.go
@@ -11,6 +11,7 @@ import (
 
 	userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
 	revactx "github.com/cs3org/reva/v2/pkg/ctx"
+	cs3mocks "github.com/cs3org/reva/v2/tests/cs3mocks/mocks"
 	"github.com/go-chi/chi/v5"
 	. "github.com/onsi/ginkgo/v2"
 	. "github.com/onsi/gomega"
@@ -36,7 +37,7 @@ var _ = Describe("Groups", func() {
 		svc             service.Service
 		ctx             context.Context
 		cfg             *config.Config
-		gatewayClient   *mocks.GatewayClient
+		gatewayClient   *cs3mocks.GatewayAPIClient
 		eventsPublisher mocks.Publisher
 		identityBackend *identitymocks.Backend
 
@@ -54,7 +55,7 @@ var _ = Describe("Groups", func() {
 		eventsPublisher.On("Publish", mock.Anything, mock.Anything, mock.Anything).Return(nil)
 
 		identityBackend = &identitymocks.Backend{}
-		gatewayClient = &mocks.GatewayClient{}
+		gatewayClient = &cs3mocks.GatewayAPIClient{}
 		newGroup = libregraph.NewGroup()
 		newGroup.SetMembersodataBind([]string{"/users/user1"})
 		newGroup.SetId("group1")
diff --git a/services/graph/pkg/service/v0/password_test.go b/services/graph/pkg/service/v0/password_test.go
index 188e9e4def1..4a92da952ba 100644
--- a/services/graph/pkg/service/v0/password_test.go
+++ b/services/graph/pkg/service/v0/password_test.go
@@ -12,6 +12,7 @@ import (
 	userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
 	revactx "github.com/cs3org/reva/v2/pkg/ctx"
 	"github.com/cs3org/reva/v2/pkg/rgrpc/status"
+	cs3mocks "github.com/cs3org/reva/v2/tests/cs3mocks/mocks"
 	"github.com/go-ldap/ldap/v3"
 	. "github.com/onsi/ginkgo/v2"
 	. "github.com/onsi/gomega"
@@ -28,7 +29,7 @@ import (
 var _ = Describe("Users changing their own password", func() {
 	var (
 		svc             service.Service
-		gatewayClient   *mocks.GatewayClient
+		gatewayClient   *cs3mocks.GatewayAPIClient
 		ldapClient      *mocks.Client
 		ldapConfig      config.LDAP
 		identityBackend identity.Backend
@@ -44,7 +45,7 @@ var _ = Describe("Users changing their own password", func() {
 		cfg = defaults.FullDefaultConfig()
 		cfg.TokenManager.JWTSecret = "loremipsum"
 
-		gatewayClient = &mocks.GatewayClient{}
+		gatewayClient = &cs3mocks.GatewayAPIClient{}
 		ldapClient = mockedLDAPClient()
 
 		ldapConfig = config.LDAP{
diff --git a/services/graph/pkg/service/v0/users_test.go b/services/graph/pkg/service/v0/users_test.go
index 95b50f6d684..4719f366e6f 100644
--- a/services/graph/pkg/service/v0/users_test.go
+++ b/services/graph/pkg/service/v0/users_test.go
@@ -14,6 +14,7 @@ import (
 	typesv1beta1 "github.com/cs3org/go-cs3apis/cs3/types/v1beta1"
 	revactx "github.com/cs3org/reva/v2/pkg/ctx"
 	"github.com/cs3org/reva/v2/pkg/rgrpc/status"
+	cs3mocks "github.com/cs3org/reva/v2/tests/cs3mocks/mocks"
 	"github.com/go-chi/chi/v5"
 	. "github.com/onsi/ginkgo/v2"
 	. "github.com/onsi/gomega"
@@ -41,7 +42,7 @@ var _ = Describe("Users", func() {
 		svc             service.Service
 		ctx             context.Context
 		cfg             *config.Config
-		gatewayClient   *mocks.GatewayClient
+		gatewayClient   *cs3mocks.GatewayAPIClient
 		eventsPublisher mocks.Publisher
 		roleService     *mocks.RoleService
 		identityBackend *identitymocks.Backend
@@ -60,7 +61,7 @@ var _ = Describe("Users", func() {
 
 		identityBackend = &identitymocks.Backend{}
 		roleService = &mocks.RoleService{}
-		gatewayClient = &mocks.GatewayClient{}
+		gatewayClient = &cs3mocks.GatewayAPIClient{}
 
 		rr = httptest.NewRecorder()
 		ctx = context.Background()
diff --git a/services/invitations/pkg/backends/keycloak/backend_test.go b/services/invitations/pkg/backends/keycloak/backend_test.go
index 6b8ef808058..019469b5292 100644
--- a/services/invitations/pkg/backends/keycloak/backend_test.go
+++ b/services/invitations/pkg/backends/keycloak/backend_test.go
@@ -7,7 +7,7 @@ import (
 	"context"
 	"testing"
 
-	"github.com/Nerzal/gocloak/v13"
+	kcpkg "github.com/owncloud/ocis/v2/ocis-pkg/keycloak"
 	"github.com/owncloud/ocis/v2/ocis-pkg/log"
 	"github.com/owncloud/ocis/v2/services/invitations/pkg/backends/keycloak"
 	"github.com/owncloud/ocis/v2/services/invitations/pkg/invitations"
@@ -34,11 +34,11 @@ func TestBackend_CreateUser(t *testing.T) {
 		returns  []interface{}
 	}
 	tests := []struct {
-		name          string
-		args          args
-		want          string
-		keycloakMocks []mockInputs
-		assertion     assert.ErrorAssertionFunc
+		name        string
+		args        args
+		want        string
+		clientMocks []mockInputs
+		assertion   assert.ErrorAssertionFunc
 	}{
 		{
 			name: "Test without diplay name",
@@ -48,46 +48,17 @@ func TestBackend_CreateUser(t *testing.T) {
 				},
 			},
 			want: "test-id",
-			keycloakMocks: []mockInputs{
-				{
-					funcName: "LoginClient",
-					args: []interface{}{
-						mock.Anything,
-						clientID,
-						clientSecret,
-						clientRealm,
-					},
-					returns: []interface{}{
-						&gocloak.JWT{
-							AccessToken: jwtToken,
-						},
-						nil,
-					},
-				},
-				{
-					funcName: "RetrospectToken",
-					args: []interface{}{
-						mock.Anything,
-						jwtToken,
-						clientID,
-						clientSecret,
-						clientRealm,
-					},
-					returns: []interface{}{
-						&gocloak.IntroSpectTokenResult{
-							Active: gocloak.BoolP(true),
-						},
-						nil,
-					},
-				},
+			clientMocks: []mockInputs{
 				{
 					funcName: "CreateUser",
 					args: []interface{}{
 						mock.Anything,
-						jwtToken,
 						userRealm,
 						mock.Anything, // can't match on the user because it generates a UUID internally.
-						// might be worth refactoring the UUID generation to outside of the func
+						[]kcpkg.UserAction{
+							kcpkg.UserActionUpdatePassword,
+							kcpkg.UserActionVerifyEmail,
+						},
 					},
 					returns: []interface{}{
 						"test-id",
@@ -103,11 +74,11 @@ func TestBackend_CreateUser(t *testing.T) {
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
 			ctx := context.Background()
-			c := &mocks.GoCloak{}
-			for _, m := range tt.keycloakMocks {
+			c := &mocks.Client{}
+			for _, m := range tt.clientMocks {
 				c.On(m.funcName, m.args...).Return(m.returns...)
 			}
-			b := keycloak.NewWithClient(log.NopLogger(), c, clientID, clientSecret, clientRealm, userRealm)
+			b := keycloak.NewWithClient(log.NopLogger(), c, userRealm)
 			got, err := b.CreateUser(ctx, tt.args.invitation)
 			tt.assertion(t, err)
 			assert.Equal(t, tt.want, got)
@@ -125,57 +96,26 @@ func TestBackend_SendMail(t *testing.T) {
 		returns  []interface{}
 	}
 	tests := []struct {
-		name          string
-		args          args
-		keycloakMocks []mockInputs
-		assertion     assert.ErrorAssertionFunc
+		name        string
+		args        args
+		clientMocks []mockInputs
+		assertion   assert.ErrorAssertionFunc
 	}{
 		{
 			name: "Mail successfully sent",
 			args: args{
 				id: "test-id",
 			},
-			keycloakMocks: []mockInputs{
+			clientMocks: []mockInputs{
 				{
-					funcName: "LoginClient",
+					funcName: "SendActionsMail",
 					args: []interface{}{
 						mock.Anything,
-						clientID,
-						clientSecret,
-						clientRealm,
-					},
-					returns: []interface{}{
-						&gocloak.JWT{
-							AccessToken: jwtToken,
-						},
-						nil,
-					},
-				},
-				{
-					funcName: "RetrospectToken",
-					args: []interface{}{
-						mock.Anything,
-						jwtToken,
-						clientID,
-						clientSecret,
-						clientRealm,
-					},
-					returns: []interface{}{
-						&gocloak.IntroSpectTokenResult{
-							Active: gocloak.BoolP(true),
-						},
-						nil,
-					},
-				},
-				{
-					funcName: "ExecuteActionsEmail",
-					args: []interface{}{
-						mock.Anything,
-						jwtToken,
 						userRealm,
-						gocloak.ExecuteActionsEmail{
-							UserID:  gocloak.StringP("test-id"),
-							Actions: &[]string{"UPDATE_PASSWORD", "VERIFY_EMAIL"},
+						"test-id",
+						[]kcpkg.UserAction{
+							kcpkg.UserActionUpdatePassword,
+							kcpkg.UserActionVerifyEmail,
 						},
 					},
 					returns: []interface{}{
@@ -191,11 +131,11 @@ func TestBackend_SendMail(t *testing.T) {
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
 			ctx := context.Background()
-			c := &mocks.GoCloak{}
-			for _, m := range tt.keycloakMocks {
+			c := &mocks.Client{}
+			for _, m := range tt.clientMocks {
 				c.On(m.funcName, m.args...).Return(m.returns...)
 			}
-			b := keycloak.NewWithClient(log.NopLogger(), c, clientID, clientSecret, clientRealm, userRealm)
+			b := keycloak.NewWithClient(log.NopLogger(), c, userRealm)
 			tt.assertion(t, b.SendMail(ctx, tt.args.id))
 		})
 	}
diff --git a/tests/acceptance/config/behat.yml b/tests/acceptance/config/behat.yml
index 7b97371de48..73aa64c21ed 100644
--- a/tests/acceptance/config/behat.yml
+++ b/tests/acceptance/config/behat.yml
@@ -26,6 +26,7 @@ default:
         - ChecksumContext:
         - FavoritesContext:
         - FilesVersionsContext:
+        - NotificationContext:
         - OCSContext:
         - PublicWebDavContext:
         - TrashbinContext:
@@ -43,6 +44,7 @@ default:
         - ChecksumContext:
         - FavoritesContext:
         - FilesVersionsContext:
+        - NotificationContext:
         - OCSContext:
         - PublicWebDavContext:
         - SearchContext:
diff --git a/tests/acceptance/expected-failures-localAPI-on-OCIS-storage.md b/tests/acceptance/expected-failures-localAPI-on-OCIS-storage.md
index 65056b0654e..76fe277399a 100644
--- a/tests/acceptance/expected-failures-localAPI-on-OCIS-storage.md
+++ b/tests/acceptance/expected-failures-localAPI-on-OCIS-storage.md
@@ -57,13 +57,13 @@ The expected failures in this file are from features in the owncloud/ocis repo.
 - [apiGraph/deleteGroup.feature:49](https://github.com/owncloud/ocis/blob/master/tests/acceptance/features/apiGraph/deleteGroup.feature#L49)
 - [apiGraph/deleteGroup.feature:50](https://github.com/owncloud/ocis/blob/master/tests/acceptance/features/apiGraph/deleteGroup.feature#L50)
 - [apiGraph/deleteGroup.feature:51](https://github.com/owncloud/ocis/blob/master/tests/acceptance/features/apiGraph/deleteGroup.feature#L51)
+- [apiGraph/editGroup.feature:25](https://github.com/owncloud/ocis/blob/master/tests/acceptance/features/apiGraph/editGroup.feature#L25)
 
 #### [Share lists deleted user as 'user'](https://github.com/owncloud/ocis/issues/903)
 - [apiGraph/deleteGroup.feature:68](https://github.com/owncloud/ocis/blob/master/tests/acceptance/features/apiGraph/deleteGroup.feature#L68)
 
-#### [Updating group displayName request seems OK but group is not being renamed](https://github.com/owncloud/ocis/issues/5099)
+#### [Renaming the group with = character in it doesn't work](https://github.com/owncloud/ocis/issues/5969)
 - [apiGraph/editGroup.feature:23](https://github.com/owncloud/ocis/blob/master/tests/acceptance/features/apiGraph/editGroup.feature#L23)
-- [apiGraph/editGroup.feature:25](https://github.com/owncloud/ocis/blob/master/tests/acceptance/features/apiGraph/editGroup.feature#L25)
 
 #### [CORS headers are not identical with oC10 headers](https://github.com/owncloud/ocis/issues/5195)
 - [apiCors/cors.feature:25](https://github.com/owncloud/ocis/blob/master/tests/acceptance/features/apiCors/cors.feature#L25)
@@ -161,6 +161,8 @@ The expected failures in this file are from features in the owncloud/ocis repo.
 - [apiGraph/removeUserFromGroup.feature:172](https://github.com/owncloud/ocis/blob/master/tests/acceptance/features/apiGraph/removeUserFromGroup.feature#L172)
 - [apiGraph/removeUserFromGroup.feature:173](https://github.com/owncloud/ocis/blob/master/tests/acceptance/features/apiGraph/removeUserFromGroup.feature#L173)
 - [apiGraph/removeUserFromGroup.feature:174](https://github.com/owncloud/ocis/blob/master/tests/acceptance/features/apiGraph/removeUserFromGroup.feature#L174)
+- [apiSpaces/createSpace.feature:18](https://github.com/owncloud/ocis/blob/master/tests/acceptance/features/apiSpaces/createSpace.feature#L18)
+- [apiSpaces/createSpace.feature:19](https://github.com/owncloud/ocis/blob/master/tests/acceptance/features/apiSpaces/createSpace.feature#L19)
 
 #### [API requests for a non-existent resources should return 404](https://github.com/owncloud/ocis/issues/5939)
 - [apiGraph/addUserToGroup.feature:162](https://github.com/owncloud/ocis/blob/master/tests/acceptance/features/apiGraph/addUserToGroup.feature#L162)
@@ -174,9 +176,8 @@ The expected failures in this file are from features in the owncloud/ocis repo.
 #### [Admin user can't restore other user spaces](https://github.com/owncloud/ocis/issues/5872)
 - [apiSpaces/restoreSpaces.feature:94](https://github.com/owncloud/ocis/blob/master/tests/acceptance/features/apiSpaces/restoreSpaces.feature#L94)
 
-#### [https://github.com/owncloud/ocis/issues/5938](https://github.com/owncloud/ocis/issues/5938)
-- [apiSpaces/createSpace.feature:18](https://github.com/owncloud/ocis/blob/master/tests/acceptance/features/apiSpaces/createSpace.feature#L18)
-- [apiSpaces/createSpace.feature:19](https://github.com/owncloud/ocis/blob/master/tests/acceptance/features/apiSpaces/createSpace.feature#L19)
+### [Adding the same user as multiple members in a single request results in listing the same user twice in the group](https://github.com/owncloud/ocis/issues/5855)
+- [apiGraph/addUserToGroup.feature:367](https://github.com/owncloud/ocis/blob/master/tests/acceptance/features/apiGraph/addUserToGroup.feature#L367)
 
 Note: always have an empty line at the end of this file.
 The bash script that processes this file requires that the last line has a newline on the end.
diff --git a/tests/acceptance/features/apiGraph/addUserToGroup.feature b/tests/acceptance/features/apiGraph/addUserToGroup.feature
index 5c8ec22c620..9b1d9fd0a45 100644
--- a/tests/acceptance/features/apiGraph/addUserToGroup.feature
+++ b/tests/acceptance/features/apiGraph/addUserToGroup.feature
@@ -335,3 +335,44 @@ Feature: add users to group
     And user "Alice" has created a group "grp1" using the Graph API
     When user "Alice" tries to add user "Brian" to group "grp1" with an invalid host using the Graph API
     Then the HTTP status code should be "400"
+
+
+  Scenario Outline: try to add invalid user id to a group
+    Given the administrator has given "Alice" the role "Admin" using the settings api
+    And user "Alice" has created a group "grp1" using the Graph API
+    When the administrator "Alice" tries to add an invalid user id "<invalid-uuidv4>" to a group "grp1" using the Graph API
+    Then the HTTP status code should be "404"
+    Examples:
+      | invalid-uuidv4                        | comment                                                |
+      | �ϰ�Ϧ-@$@^-¶Ëøœ-ɧɸɱʨΌϖЁϿ               | UTF characters                                         |
+      | 4c510ada-c86b-4815-8820-42cdf82c3d511 | adding an extra character at end of valid UUID pattern |
+      | 4c510adac8-6b-4815-882042cdf-82c3d51  | invalid UUID pattern                                   |
+
+
+  Scenario Outline: try to add invalid user ids to a group at once
+    Given the administrator has given "Alice" the role "Admin" using the settings api
+    And user "Alice" has created a group "grp1" using the Graph API
+    When the administrator "Alice" tries to add the following invalid user ids to a group "grp1" at once using the Graph API
+      | user-id          |
+      | <invalid-uuidv4> |
+      | <invalid-uuidv4> |
+    Then the HTTP status code should be "404"
+    Examples:
+      | invalid-uuidv4                        | comment                                                |
+      | �ϰ�Ϧ-@$@^-¶Ëøœ-ɧɸɱʨΌϖЁϿ               | UTF characters                                         |
+      | 4c510ada-c86b-4815-8820-42cdf82c3d511 | adding an extra character at end of valid UUID pattern |
+      | 4c510adac8-6b-4815-882042cdf-82c3d51  | invalid UUID pattern                                   |
+
+  @issue-5855
+  Scenario: add same user twice to a group at once
+    Given the administrator has given "Alice" the role "Admin" using the settings api
+    And these users have been created with default attributes and without skeleton files:
+      | username |
+      | Brian    |
+    And user "Alice" has created a group "grp1" using the Graph API
+    When the administrator "Alice" adds the following users to a group "grp1" at once using the Graph API
+      | username |
+      | Brian    |
+      | Brian    |
+    Then the HTTP status code should be "204"
+    And the user "Brian" should be listed once in the group "grp1"
diff --git a/tests/acceptance/features/apiSpaces/notification.feature b/tests/acceptance/features/apiSpaces/notification.feature
new file mode 100644
index 00000000000..f88a72c3f80
--- /dev/null
+++ b/tests/acceptance/features/apiSpaces/notification.feature
@@ -0,0 +1,127 @@
+@api @skipOnOcV10
+Feature: Notification
+  As a user
+  I want to be notified of actions related to me
+
+  Background:
+    Given these users have been created with default attributes and without skeleton files:
+      | username |
+      | Alice    |
+      | Brian    |
+      | Carol    |
+    And the administrator has given "Alice" the role "Space Admin" using the settings api
+
+
+  Scenario: user gets a notification of space sharing
+    Given user "Alice" has created a space "notificaton checking" with the default quota using the GraphApi
+    And user "Alice" has shared a space "notificaton checking" with settings:
+      | shareWith | Brian  |
+      | role      | editor |
+    When user "Brian" lists all notifications
+    Then the HTTP status code should be "200"
+    And the JSON response should contain a notification message with the subject "Space shared" and the message-details should match
+    """
+    {
+    "type": "object",
+      "required": [
+        "app",
+        "datetime",
+        "message",
+        "messageRich",
+        "messageRichParameters",
+        "notification_id",
+        "object_id",
+        "object_type",
+        "subject",
+        "subjectRich",
+        "user"
+      ],
+      "properties": {
+        "app": {
+          "type": "string",
+          "enum": ["userlog"]
+        },
+        "message": {
+          "type": "string",
+          "enum": ["Alice Hansen added you to Space notificaton checking"]
+        },
+        "messageRich": {
+          "type": "string",
+          "enum": ["{user} added you to Space {space}"]
+        },
+        "messageRichParameters": {
+          "type": "object",
+          "required": [
+            "space",
+            "user"
+          ],
+          "properties": {
+            "space": {
+              "type": "object",
+              "required": [
+                "id",
+                "name"
+              ],
+              "properties": {
+                "id": {
+                  "type": "string",
+                  "pattern": "^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}\\$[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}![a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$"
+                },
+                "name": {
+                  "type": "string",
+                  "enum": ["notificaton checking"]
+                }
+              }
+            },
+            "user": {
+              "type": "object",
+              "required": [
+                "displayname",
+                "id",
+                "name"
+              ],
+              "properties": {
+                "displayname": {
+                  "type": "string",
+                  "enum": ["Alice Hansen"]
+                },
+                "id": {
+                  "type": "string",
+                  "enim": ["%user_id%"]
+                },
+                "name": {
+                  "type": "string",
+                  "enum": ["Alice"]
+                }
+              }
+            }
+          }
+        },
+        "notification_id": {
+          "type": "string"
+
+        },
+        "object_id": {
+          "type": "string"
+          
+        },
+        "object_type": {
+          "type": "string",
+          "enum": ["storagespace"]
+        },
+        "subject": {
+          "type": "string",
+          "enum": ["Space shared"]
+        },
+        "subjectRich": {
+          "type": "string",
+          "enum": ["Space shared"]
+        },
+        "user": {
+          "type": "string",
+          "enum": ["Alice"]
+        }
+      }
+    }
+    """
+
diff --git a/tests/acceptance/features/bootstrap/GraphContext.php b/tests/acceptance/features/bootstrap/GraphContext.php
index 56622a08811..efe666e7137 100644
--- a/tests/acceptance/features/bootstrap/GraphContext.php
+++ b/tests/acceptance/features/bootstrap/GraphContext.php
@@ -1792,7 +1792,7 @@ class GraphContext implements Context {
 		$groupId = $this->featureContext->getAttributeOfCreatedGroup($group, "id");
 		$credentials = $this->getAdminOrUserCredentials($user);
 		$this->featureContext->verifyTableNodeColumns($table, ['username']);
-		
+
 		foreach ($table->getHash() as $row) {
 			$userIds[] = $this->featureContext->getAttributeOfCreatedUser($row['username'], "id");
 		}
@@ -2356,4 +2356,86 @@ class GraphContext implements Context {
 			)
 		);
 	}
+
+	/**
+	 * @When /^the administrator "([^"]*)" tries to add the following invalid user ids to a group "([^"]*)" at once using the Graph API$/
+	 *
+	 * @param string $user
+	 * @param string $group
+	 * @param TableNode $table
+	 *
+	 * @return void
+	 * @throws GuzzleException
+	 * @throws Exception
+	 */
+	public function theAdministratorTriesToAddTheFollowingUserIdWithInvalidCharacterToAGroup(string $user, string $group, TableNode $table) {
+		$userIds = [];
+		$credentials = $this->getAdminOrUserCredentials($user);
+		$groupId = $this->featureContext->getAttributeOfCreatedGroup($group, "id");
+		foreach ($table->getHash() as $row) {
+			$userIds[] = \implode(" ", $row);
+		}
+		$this->featureContext->setResponse(
+			GraphHelper::addUsersToGroup(
+				$this->featureContext->getBaseUrl(),
+				$this->featureContext->getStepLineRef(),
+				$credentials["username"],
+				$credentials["password"],
+				$groupId,
+				$userIds
+			)
+		);
+	}
+
+	/**
+	 * @When /^the administrator "([^"]*)" tries to add an invalid user id "([^"]*)" to a group "([^"]*)" using the Graph API$/
+	 *
+	 * @param string $user
+	 * @param string $userId
+	 * @param string $group
+	 *
+	 * @return void
+	 * @throws GuzzleException
+	 * @throws Exception
+	 */
+	public function theAdministratorTriesToAddUserIdWithInvalidCharactersToAGroup(string $user, string $userId, string $group): void {
+		$credentials = $this->getAdminOrUserCredentials($user);
+		$groupId = $this->featureContext->getAttributeOfCreatedGroup($group, "id");
+		$this->featureContext->setResponse(
+			GraphHelper::addUserToGroup(
+				$this->featureContext->getBaseUrl(),
+				$this->featureContext->getStepLineRef(),
+				$credentials['username'],
+				$credentials['password'],
+				$userId,
+				$groupId
+			)
+		);
+	}
+
+	/**
+	 * @Then the user :user should be listed once in the group :group
+	 *
+	 * @param string $user
+	 * @param string $group
+	 *
+	 * @return void
+	 * @throws GuzzleException
+	 */
+	public function theUsersShouldBeListedOnceToAGroup(string $user, string $group): void {
+		$count = 0;
+		$members = $this->listGroupMembers($group);
+		$members = $this->featureContext->getJsonDecodedResponse($members);
+
+		foreach ($members as $member) {
+			if ($member['onPremisesSamAccountName'] === $user) {
+				$count++;
+			}
+		}
+		Assert::assertEquals(
+			1,
+			$count,
+			"Expected user '" . $user . "' to be added once to group '" . $group . "' but the user is listed '" . $count . "' times"
+		);
+	}
 }
diff --git a/tests/acceptance/features/bootstrap/NotificationContext.php b/tests/acceptance/features/bootstrap/NotificationContext.php
new file mode 100644
index 00000000000..1cd68b9905e
--- /dev/null
+++ b/tests/acceptance/features/bootstrap/NotificationContext.php
@@ -0,0 +1,148 @@
+<?php declare(strict_types=1);
+/**
+ * ownCloud
+ *
+ * @author Viktor Scharf <vscharf@owncloud.com>
+ * @copyright Copyright (c) 2023 Viktor Scharf vscharf@owncloud.com
+ */
+
+use Behat\Behat\Context\Context;
+use Behat\Behat\Hook\Scope\BeforeScenarioScope;
+use TestHelpers\OcsApiHelper;
+use Behat\Gherkin\Node\PyStringNode;
+use Helmich\JsonAssert\JsonAssertions;
+
+require_once 'bootstrap.php';
+
+/**
+ * Defines application features from the specific context.
+ */
+class NotificationContext implements Context {
+	/**
+	 * @var FeatureContext
+	 */
+	private $featureContext;
+
+	/**
+	 * @var string
+	 */
+	private string $notificationEndpointPath = '/apps/notifications/api/v1/notifications?format=json';
+
+	/**
+	 * @var array[]
+	 */
+	private $notificationIds;
+
+	/**
+	 * @return array[]
+	 */
+	public function getNotificationIds():array {
+		return $this->notificationIds;
+	}
+
+	/**
+	 * @return array[]
+	 */
+	public function getLastNotificationId():array {
+		return \end($this->notificationIds);
+	}
+
+	/**
+	 * @var string
+	 */
+	private string $userRecipient;
+
+	/**
+	 * @param string $userRecipient
+	 *
+	 * @return void
+	 */
+	public function setUserRecipient(string $userRecipient): void {
+		$this->userRecipient = $userRecipient;
+	}
+
+	/**
+	 * @return string
+	 */
+	public function getUserRecipient(): string {
+		return $this->userRecipient;
+	}
+
+	/**
+	 * @BeforeScenario
+	 *
+	 * @param BeforeScenarioScope $scope
+	 *
+	 * @return void
+	 * @throws Exception
+	 */
+	public function setUpScenario(BeforeScenarioScope $scope):void {
+		// Get the environment
+		$environment = $scope->getEnvironment();
+		// Get all the contexts you need in this context
+		$this->featureContext = $environment->getContext('FeatureContext');
+	}
+
+	/**
+	 * @Then /^user "([^"]*)" lists all notifications$/
+	 *
+	 * @param string $user
+	 *
+	 * @return void
+	 */
+	public function userListAllNotifications(string $user):void {
+		$this->setUserRecipient($user);
+		$response = OcsApiHelper::sendRequest(
+			$this->featureContext->getBaseUrl(),
+			$this->featureContext->getActualUsername($user),
+			$this->featureContext->getPasswordForUser($user),
+			'GET',
+			$this->notificationEndpointPath,
+			$this->featureContext->getStepLineRef()
+		);
+		$this->featureContext->setResponse($response);
+	}
+
+	/**
+	 * @Then /^the JSON response should contain a notification message with the subject "([^"]*)" and the message-details should match$/
+	 *
+	 * @param string $subject
+	 * @param PyStringNode $schemaString
+	 *
+	 * @return void
+	 * @throws Exception
+	 */
+	public function theJsonDataFromLastResponseShouldMatch(
+		string $subject,
+		PyStringNode $schemaString
+	): void {
+		if (isset($this->featureContext->getJsonDecodedResponseBodyContent()->ocs->data)) {
+			$responseBody = $this->featureContext->getJsonDecodedResponseBodyContent()->ocs->data;
+			foreach ($responseBody as $value) {
+				if (isset($value->subject) && $value->subject === $subject) {
+					$responseBody = $value;
+					// set notificationId
+					$this->notificationIds[] = $value->notification_id;
+					break;
+				}
+			}
+		} else {
+			$responseBody = $this->featureContext->getJsonDecodedResponseBodyContent();
+		}
+
+		// substitute the value here
+		$schemaString = $schemaString->getRaw();
+		$schemaString = $this->featureContext->substituteInLineCodes(
+			$schemaString,
+			$this->featureContext->getCurrentUser(),
+			[],
+			[],
+			null,
+			$this->getUserRecipient(),
+		);
+		JsonAssertions::assertJsonDocumentMatchesSchema(
+			$responseBody,
+			$this->featureContext->getJSONSchema($schemaString)
+		);
+	}
+}