Repository: owncloud/ocis
Pull request #5963 (2023-03-30 08:20:41)

[tests-only] [full-ci] Forward port  5864 PR
## Description
This PR forward port tests related to adding multiple user at once in a group

## Related Issue
- https://github.com/owncloud/ocis/pull/5864/

## Motivation and Context
<!--- Why is this change required? What problem does it solve? -->

## How Has This Been Tested?
<!--- Please describe in detail how you tested your changes. -->
<!--- Include details of your testing environment, and the tests you ran to -->
<!--- see how your change affects other areas of the code, etc. -->
- test environment:
- test case 1:
- test case 2:
- ...

## Screenshots (if appropriate):

## Types of changes
<!--- What types of changes does your code introduce? Put an `x` in all the boxes that apply: -->
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to change)
- [ ] Technical debt
- [x] Tests only (no source changes)

## Checklist:
<!-- Tick the checkboxes when done. -->
<!-- Raise documentation ticket in owncloud.github.io/ -->
- [ ] Code changes
- [ ] Unit tests added
- [x] Acceptance tests added
- [ ] Documentation ticket raised: <link> 



Patch:
diff --git a/.drone.env b/.drone.env
index cbde4707f66..4bd3ed2b837 100644
--- a/.drone.env
+++ b/.drone.env
@@ -1,3 +1,3 @@
 # The test runner source for UI tests
-WEB_COMMITID=a61e19a94f5247304ed8f2c4bc9209a9ab66c38e
+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 bece625b802..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
diff --git a/go.sum b/go.sum
index 6e4ade07f8a..dbf82371489 100644
--- a/go.sum
+++ b/go.sum
@@ -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=
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/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 3d1c811ae6b..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.25
+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/expected-failures-localAPI-on-OCIS-storage.md b/tests/acceptance/expected-failures-localAPI-on-OCIS-storage.md
index 65056b0654e..ba7e72b6f39 100644
--- a/tests/acceptance/expected-failures-localAPI-on-OCIS-storage.md
+++ b/tests/acceptance/expected-failures-localAPI-on-OCIS-storage.md
@@ -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/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"
+		);
+	}
 }