diff --git a/Jenkinsfile b/Jenkinsfile
index 4ae2de8d852246f9bf8464631f225e5837cd246b..ce7f72860a1c5fb91d5b0672ef59500f6c294b31 100644
--- a/Jenkinsfile
+++ b/Jenkinsfile
@@ -358,5 +358,5 @@ String generateHelmChartVersion() {
         chartVersion += "-${env.BRANCH_NAME}"
     }
 
-    return chartVersion.replaceAll("_", "-")
+    return chartVersion.replaceAll("_", "-").take(63 - "administration-".length())
 }
diff --git a/lombok.config b/lombok.config
new file mode 100644
index 0000000000000000000000000000000000000000..d07dd9b0e2b0281fbf514a968b9451cb6af62f93
--- /dev/null
+++ b/lombok.config
@@ -0,0 +1,30 @@
+#
+# Copyright (C) 2022 Das Land Schleswig-Holstein vertreten durch den
+# Ministerpräsidenten des Landes Schleswig-Holstein
+# Staatskanzlei
+# Abteilung Digitalisierung und zentrales IT-Management der Landesregierung
+#
+# Lizenziert unter der EUPL, Version 1.2 oder - sobald
+# diese von der Europäischen Kommission genehmigt wurden -
+# Folgeversionen der EUPL ("Lizenz");
+# Sie dürfen dieses Werk ausschließlich gemäß
+# dieser Lizenz nutzen.
+# Eine Kopie der Lizenz finden Sie hier:
+#
+# https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+#
+# Sofern nicht durch anwendbare Rechtsvorschriften
+# gefordert oder in schriftlicher Form vereinbart, wird
+# die unter der Lizenz verbreitete Software "so wie sie
+# ist", OHNE JEGLICHE GEWÄHRLEISTUNG ODER BEDINGUNGEN -
+# ausdrücklich oder stillschweigend - verbreitet.
+# Die sprachspezifischen Genehmigungen und Beschränkungen
+# unter der Lizenz sind dem Lizenztext zu entnehmen.
+#
+
+lombok.log.fieldName=LOG
+lombok.log.slf4j.flagUsage = ERROR
+lombok.log.log4j.flagUsage = ERROR
+lombok.data.flagUsage = ERROR
+lombok.nonNull.exceptionType = IllegalArgumentException
+lombok.addLombokGeneratedAnnotation = true
\ No newline at end of file
diff --git a/pom.xml b/pom.xml
index 1ba539b41d20ebebf73a96d7fa0109cc845021d3..915ccea3e9f6dfbca7d73078bb0a325dc625d97e 100644
--- a/pom.xml
+++ b/pom.xml
@@ -7,7 +7,7 @@
 		<groupId>de.ozgcloud.common</groupId>
 		<artifactId>ozgcloud-common-parent</artifactId>
 		<version>4.3.2</version>
-		<relativePath />
+		<relativePath/>
 	</parent>
 	<groupId>de.ozgcloud</groupId>
 	<artifactId>administration</artifactId>
@@ -21,15 +21,32 @@
 		<publishImage>false</publishImage>
 		<build.number>SET_BY_JENKINS</build.number>
 		<spring-cloud-config-server.version>4.1.2</spring-cloud-config-server.version>
-		<testcontainers-keycloak.version>3.2.0</testcontainers-keycloak.version>
-		<keycloak-admin-client.version>23.0.6</keycloak-admin-client.version>
+		<testcontainers-keycloak.version>3.3.1</testcontainers-keycloak.version>
+		<keycloak-admin-client.version>24.0.5</keycloak-admin-client.version>
 		<mongock.version>5.4.0</mongock.version>
 		<lombok-mapstruct-binding.version>0.2.0</lombok-mapstruct-binding.version>
 		<mapstruct-processor.version>${mapstruct.version}</mapstruct-processor.version>
+		<zufi-manager.version>1.5.0-SNAPSHOT</zufi-manager.version>
+		<shedlock.version>5.16.0</shedlock.version>
 	</properties>
 
 	<dependencies>
+		<!-- OZG Cloud API -->
+		<dependency>
+			<groupId>de.ozgcloud.zufi</groupId>
+			<artifactId>zufi-manager-interface</artifactId>
+			<version>${zufi-manager.version}</version>
+		</dependency>
+
 		<!-- Spring -->
+		<dependency>
+			<groupId>net.devh</groupId>
+			<artifactId>grpc-client-spring-boot-starter</artifactId>
+		</dependency>
+		<dependency>
+			<groupId>io.grpc</groupId>
+			<artifactId>grpc-inprocess</artifactId>
+		</dependency>
 		<dependency>
 			<groupId>org.springframework.boot</groupId>
 			<artifactId>spring-boot-starter-actuator</artifactId>
@@ -72,6 +89,26 @@
 			<artifactId>spring-boot-configuration-processor</artifactId>
 			<optional>true</optional>
 		</dependency>
+
+		<!-- ShedLock -->
+		<dependency>
+			<groupId>net.javacrumbs.shedlock</groupId>
+			<artifactId>shedlock-spring</artifactId>
+			<version>${shedlock.version}</version>
+		</dependency>
+		<dependency>
+			<groupId>net.javacrumbs.shedlock</groupId>
+			<artifactId>shedlock-provider-mongo</artifactId>
+			<version>${shedlock.version}</version>
+		</dependency>
+
+		<!-- Keycloak -->
+		<dependency>
+			<groupId>org.keycloak</groupId>
+			<artifactId>keycloak-admin-client</artifactId>
+			<version>${keycloak-admin-client.version}</version>
+		</dependency>
+
 		<!-- tools -->
 		<dependency>
 			<groupId>org.mapstruct</groupId>
@@ -83,6 +120,20 @@
 			<version>${mapstruct-processor.version}</version>
 		</dependency>
 
+		<!-- commons -->
+		<dependency>
+			<groupId>org.apache.commons</groupId>
+			<artifactId>commons-lang3</artifactId>
+		</dependency>
+		<dependency>
+			<groupId>commons-io</groupId>
+			<artifactId>commons-io</artifactId>
+		</dependency>
+		<dependency>
+			<groupId>commons-beanutils</groupId>
+			<artifactId>commons-beanutils</artifactId>
+		</dependency>
+
 		<!-- mongock -->
 		<dependency>
 			<groupId>io.mongock</groupId>
@@ -145,12 +196,6 @@
 			<version>${testcontainers-keycloak.version}</version>
 			<scope>test</scope>
 		</dependency>
-		<dependency>
-			<groupId>org.keycloak</groupId>
-			<artifactId>keycloak-admin-client</artifactId>
-			<version>${keycloak-admin-client.version}</version>
-			<scope>test</scope>
-		</dependency>
 	</dependencies>
 	<profiles>
 		<profile>
diff --git a/src/main/helm/templates/_helpers.tpl b/src/main/helm/templates/_helpers.tpl
index f7a32d59fcfec7e5137d0f7e80fd20cab36e6710..e1ad80c9e73a351f2cdaac3e730bfaa3b5995c60 100644
--- a/src/main/helm/templates/_helpers.tpl
+++ b/src/main/helm/templates/_helpers.tpl
@@ -23,10 +23,6 @@ app.kubernetes.io/name: {{ .Release.Name }}
 app.kubernetes.io/namespace: {{ include "app.namespace" . }}
 {{- end -}}
 
-{{- define "app.nameToIdentifier" -}}
-{{- trimAll "-" ( regexReplaceAll "[^a-zA-Z0-9-]" (lower .) "" ) | trunc 20 }}
-{{- end -}}
-
 {{- define "app.envSpringProfiles" }}
 {{- if (.Values.env).overrideSpringProfiles -}}
 {{ printf "%s" (.Values.env).overrideSpringProfiles }}
@@ -65,3 +61,19 @@ app.kubernetes.io/namespace: {{ include "app.namespace" . }}
 {{- define "app.ssoServerUrl" -}}
 {{- required "sso.serverUrl muss angegeben sein" (.Values.sso).serverUrl -}}
 {{- end -}}
+
+{{- define "app.nameToIdentifier" -}}
+{{- regexReplaceAll "[^a-zA-Z0-9]" (lower .) "" }}
+{{- end -}}
+
+{{- define "app.generateKeycloakUserRessourceName" -}}
+{{- printf "%s-keycloak-user" (include "app.nameToIdentifier" .) -}}
+{{- end -}}
+
+{{- define "app.generateKeycloakUserSecretName" -}}
+{{- printf "%s-credentials" (include "app.nameToIdentifier" .) -}}
+{{- end -}}
+
+{{- define "app.serviceAccountName" -}}
+{{ printf "%s" ( (.Values.serviceAccount).name | default "administration-service-account" ) }}
+{{- end -}}
diff --git a/src/main/helm/templates/api_password_secret.yaml b/src/main/helm/templates/api_password_secret.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..b0e61458e8140f199cdf636c76343842c2b8913f
--- /dev/null
+++ b/src/main/helm/templates/api_password_secret.yaml
@@ -0,0 +1,11 @@
+{{- if not (.Values.sso).api_user -}}
+apiVersion: v1
+kind: Secret
+metadata:
+  name: administration-api-password
+  labels:
+    {{- include "app.defaultLabels" . | indent 4 }}
+type: Opaque
+stringData:
+  password: {{ required "ozgcloud.keycloak.api.password must be set" .Values.ozgcloud.keycloak.api.password | quote }}
+{{- end -}}
\ No newline at end of file
diff --git a/src/main/helm/templates/deployment.yaml b/src/main/helm/templates/deployment.yaml
index 7d1d4762d2b94225e5bc46503dce5dd6c85449bd..7962926bad3084bd5537279b6c4fdc166e046db6 100644
--- a/src/main/helm/templates/deployment.yaml
+++ b/src/main/helm/templates/deployment.yaml
@@ -77,7 +77,33 @@ spec:
           - name: spring_data_mongodb_database
             value: {{ .Values.database.databaseName | default "administration-database" }}
           {{- end }}
-
+          {{- if not (.Values.sso).api_user }} # used by dataport
+          - name: ozgcloud_keycloak_api_password
+            valueFrom:
+              secretKeyRef:
+                name: administration-api-password
+                key: password
+                optional: false
+          - name: ozgcloud_keycloak_api_user
+            value: {{ .Values.ozgcloud.keycloak.api.user }}
+          {{- else }}
+          - name: ozgcloud_keycloak_api_password
+            valueFrom:
+              secretKeyRef:
+                name: {{ include "app.generateKeycloakUserSecretName" .Values.sso.api_user.name }}
+                key: password
+                optional: false
+          - name: ozgcloud_keycloak_api_user
+            valueFrom:
+              secretKeyRef:
+                name: {{ include "app.generateKeycloakUserSecretName" .Values.sso.api_user.name }}
+                key: name
+                optional: false
+          {{- end }}
+          {{- if (((.Values.ozgcloud).sync).organisationseinheiten).cron }}
+          - name: ozgcloud_administration_sync_organisationseinheiten_cron
+            value: {{ .Values.ozgcloud.sync.organisationseinheiten.cron | quote }}
+          {{- end }}
         envFrom:
           {{- if (.Values.database).useExternal }}
           - secretRef:
diff --git a/src/main/helm/templates/keycloak_user_crd.yaml b/src/main/helm/templates/keycloak_user_crd.yaml
index 0144559de10b2edb52816d3a1807415cf6a257c9..3ef79ad12ac25be335589329c3a9ce76a7331871 100644
--- a/src/main/helm/templates/keycloak_user_crd.yaml
+++ b/src/main/helm/templates/keycloak_user_crd.yaml
@@ -1,10 +1,10 @@
 {{- if not (.Values.sso).disableOzgOperator -}}
-{{ range $user := concat ((.Values.sso).api_users | default list) ((.Values.sso).keycloak_users | default list) }}
+{{ range $user := concat (empty (.Values.sso).api_user | ternary (list) (list .Values.sso.api_user)) ((.Values.sso).keycloak_users | default list) }}
 ---
 apiVersion: operator.ozgcloud.de/v1
 kind: OzgCloudKeycloakUser
 metadata:
-  name: {{ include "app.nameToIdentifier" $user.name }}-keycloak-user
+  name: {{ include "app.generateKeycloakUserRessourceName" $user.name }}
   namespace: {{ include "app.namespace" $ }}
 spec:
   keep_after_delete: {{ $.Values.sso.keep_after_delete | default false }}
diff --git a/src/main/helm/templates/ozgcloud_keycloak_operator_secrets_read_role.yaml b/src/main/helm/templates/ozgcloud_keycloak_operator_secrets_read_role.yaml
index 9d8961c699d854c92b3290e4a5cc002cdfa87c34..ffbc9e30e8395421b3cbea343d9ee8bfde24ade1 100644
--- a/src/main/helm/templates/ozgcloud_keycloak_operator_secrets_read_role.yaml
+++ b/src/main/helm/templates/ozgcloud_keycloak_operator_secrets_read_role.yaml
@@ -1,5 +1,5 @@
 {{- if not (.Values.sso).disableOzgOperator }}
-{{- if or ((.Values.sso).keycloak_users) ((.Values.sso).api_users) }}
+{{- if or ((.Values.sso).keycloak_users) ((.Values.sso).api_user) }}
 apiVersion: rbac.authorization.k8s.io/v1
 kind: Role
 metadata:
@@ -9,9 +9,9 @@ rules:
   - apiGroups:
       - "*"
     resourceNames:
-    {{ range $user := concat (.Values.sso.keycloak_users | default list) (.Values.sso.api_users | default list) }}
-      - {{ include "app.nameToIdentifier" $user.name }}-credentials
-    {{ end }}
+    {{- range $user := concat (.Values.sso.keycloak_users | default list) (empty (.Values.sso).api_user | ternary (list) (list .Values.sso.api_user)) }}
+      - {{ include "app.generateKeycloakUserSecretName" $user.name }}
+    {{- end }}
     resources:
       - secrets
     verbs:
diff --git a/src/main/helm/templates/ozgcloud_keycloak_operator_secrets_read_role_binding.yaml b/src/main/helm/templates/ozgcloud_keycloak_operator_secrets_read_role_binding.yaml
index 1250afd39cd7e2308aca88ce232d3955344dc578..79c3de8162bcf5072b0eb35e038da438b3d66e15 100644
--- a/src/main/helm/templates/ozgcloud_keycloak_operator_secrets_read_role_binding.yaml
+++ b/src/main/helm/templates/ozgcloud_keycloak_operator_secrets_read_role_binding.yaml
@@ -1,5 +1,5 @@
 {{- if not (.Values.sso).disableOzgOperator }}
-{{- if or ((.Values.sso).keycloak_users) ((.Values.sso).api_users) }}
+{{- if or ((.Values.sso).keycloak_users) ((.Values.sso).api_user) }}
 apiVersion: rbac.authorization.k8s.io/v1
 kind: RoleBinding
 metadata:
diff --git a/src/main/helm/templates/ozgcloud_keycloak_operator_secrets_write_role.yaml b/src/main/helm/templates/ozgcloud_keycloak_operator_secrets_write_role.yaml
index 0072fe3e27c98741f13c1b12e45d09eed8a46416..25e03aeee9f8e21fe3958c6484e0a9f6883b704c 100644
--- a/src/main/helm/templates/ozgcloud_keycloak_operator_secrets_write_role.yaml
+++ b/src/main/helm/templates/ozgcloud_keycloak_operator_secrets_write_role.yaml
@@ -1,5 +1,5 @@
 {{- if not (.Values.sso).disableOzgOperator }}
-{{- if or ((.Values.sso).keycloak_users) ((.Values.sso).api_users) }}
+{{- if or ((.Values.sso).keycloak_users) ((.Values.sso).api_user) }}
 apiVersion: rbac.authorization.k8s.io/v1
 kind: Role
 metadata:
diff --git a/src/main/helm/templates/ozgcloud_keycloak_operator_secrets_write_role_binding.yaml b/src/main/helm/templates/ozgcloud_keycloak_operator_secrets_write_role_binding.yaml
index ef069bf242a03deeeeece74f2fbcdb4fc3c33cfe..7baf4fc0113678eca8a12c2e7ab1a58944d707ca 100644
--- a/src/main/helm/templates/ozgcloud_keycloak_operator_secrets_write_role_binding.yaml
+++ b/src/main/helm/templates/ozgcloud_keycloak_operator_secrets_write_role_binding.yaml
@@ -1,5 +1,5 @@
 {{- if not (.Values.sso).disableOzgOperator }}
-{{- if or ((.Values.sso).keycloak_users) ((.Values.sso).api_users) }}
+{{- if or ((.Values.sso).keycloak_users) ((.Values.sso).api_user) }}
 apiVersion: rbac.authorization.k8s.io/v1
 kind: RoleBinding
 metadata:
diff --git a/src/main/helm/templates/service_account.yaml b/src/main/helm/templates/service_account.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..3bac8e223d1fd108b386d1f06ed4e9fb2284a67c
--- /dev/null
+++ b/src/main/helm/templates/service_account.yaml
@@ -0,0 +1,31 @@
+#
+# Copyright (C) 2024 Das Land Schleswig-Holstein vertreten durch den
+# Ministerpräsidenten des Landes Schleswig-Holstein
+# Staatskanzlei
+# Abteilung Digitalisierung und zentrales IT-Management der Landesregierung
+#
+# Lizenziert unter der EUPL, Version 1.2 oder - sobald
+# diese von der Europäischen Kommission genehmigt wurden -
+# Folgeversionen der EUPL ("Lizenz");
+# Sie dürfen dieses Werk ausschließlich gemäß
+# dieser Lizenz nutzen.
+# Eine Kopie der Lizenz finden Sie hier:
+#
+# https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+#
+# Sofern nicht durch anwendbare Rechtsvorschriften
+# gefordert oder in schriftlicher Form vereinbart, wird
+# die unter der Lizenz verbreitete Software "so wie sie
+# ist", OHNE JEGLICHE GEWÄHRLEISTUNG ODER BEDINGUNGEN -
+# ausdrücklich oder stillschweigend - verbreitet.
+# Die sprachspezifischen Genehmigungen und Beschränkungen
+# unter der Lizenz sind dem Lizenztext zu entnehmen.
+#
+
+{{- if (.Values.serviceAccount).create }}
+apiVersion: v1
+kind: ServiceAccount
+metadata:
+  name: {{ include "app.serviceAccountName" . }}
+  namespace: {{ include "app.namespace" . }}
+{{- end }}
\ No newline at end of file
diff --git a/src/main/helm/values.yaml b/src/main/helm/values.yaml
index d7a06c379a6fdd1f106d35ecdd3f0d81c00376ae..65c0a551f00d862d2432233e89c37a7051d3ee1d 100644
--- a/src/main/helm/values.yaml
+++ b/src/main/helm/values.yaml
@@ -29,6 +29,9 @@ ozgcloud:
   bezeichner: helm
   adminDomainSuffix: admin
   environment: dev
+  keycloak:
+    api:
+      user: administrationApiUser
 
 image:
   repo: docker.ozg-sh.de
@@ -39,4 +42,4 @@ database:
    databaseName: "administration-database"
    secretName: "ozg-mongodb-admin-administration-user"
    tls:
-      secretName: "ozg-mongodb-tls-cert"
\ No newline at end of file
+      secretName: "ozg-mongodb-tls-cert"
diff --git a/src/main/java/de/ozgcloud/admin/AdministrationRepositoryRestConfigurer.java b/src/main/java/de/ozgcloud/admin/AdministrationRepositoryRestConfigurer.java
new file mode 100644
index 0000000000000000000000000000000000000000..97507d2e32b0f4c835e2d408c94e24967f0c0569
--- /dev/null
+++ b/src/main/java/de/ozgcloud/admin/AdministrationRepositoryRestConfigurer.java
@@ -0,0 +1,16 @@
+package de.ozgcloud.admin;
+
+import org.springframework.context.annotation.Configuration;
+import org.springframework.data.rest.core.config.RepositoryRestConfiguration;
+import org.springframework.data.rest.webmvc.config.RepositoryRestConfigurer;
+import org.springframework.hateoas.server.core.DefaultLinkRelationProvider;
+import org.springframework.web.servlet.config.annotation.CorsRegistry;
+
+@Configuration
+public class AdministrationRepositoryRestConfigurer implements RepositoryRestConfigurer {
+
+	@Override
+	public void configureRepositoryRestConfiguration(RepositoryRestConfiguration config, CorsRegistry cors) {
+		config.setLinkRelationProvider(new DefaultLinkRelationProvider());
+	}
+}
diff --git a/src/main/java/de/ozgcloud/admin/GrpcConfiguration.java b/src/main/java/de/ozgcloud/admin/GrpcConfiguration.java
new file mode 100644
index 0000000000000000000000000000000000000000..81537a802b20132a52f2ee820948b91a91e4dbb7
--- /dev/null
+++ b/src/main/java/de/ozgcloud/admin/GrpcConfiguration.java
@@ -0,0 +1,22 @@
+package de.ozgcloud.admin;
+
+import org.springframework.boot.autoconfigure.ImportAutoConfiguration;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * A workaround for @GrpcClient to work i.e. inject dependency until full Spring 3 support is available.
+ * https://github.com/yidongnan/grpc-spring-boot-starter/pull/775
+ */
+@Configuration
+@ImportAutoConfiguration({
+		net.devh.boot.grpc.client.autoconfigure.GrpcClientAutoConfiguration.class,
+		net.devh.boot.grpc.client.autoconfigure.GrpcClientMetricAutoConfiguration.class,
+		net.devh.boot.grpc.client.autoconfigure.GrpcClientHealthAutoConfiguration.class,
+		net.devh.boot.grpc.client.autoconfigure.GrpcClientSecurityAutoConfiguration.class,
+		net.devh.boot.grpc.client.autoconfigure.GrpcClientTraceAutoConfiguration.class,
+		net.devh.boot.grpc.client.autoconfigure.GrpcDiscoveryClientAutoConfiguration.class,
+		net.devh.boot.grpc.common.autoconfigure.GrpcCommonCodecAutoConfiguration.class,
+		net.devh.boot.grpc.common.autoconfigure.GrpcCommonTraceAutoConfiguration.class
+})
+public class GrpcConfiguration {
+}
diff --git a/src/main/java/de/ozgcloud/admin/SchedulingConfiguration.java b/src/main/java/de/ozgcloud/admin/SchedulingConfiguration.java
new file mode 100644
index 0000000000000000000000000000000000000000..012b8680f0fe768a7def4303e525d33b308c07f9
--- /dev/null
+++ b/src/main/java/de/ozgcloud/admin/SchedulingConfiguration.java
@@ -0,0 +1,27 @@
+package de.ozgcloud.admin;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.scheduling.annotation.EnableScheduling;
+
+import com.mongodb.client.MongoClient;
+
+import net.javacrumbs.shedlock.core.LockProvider;
+import net.javacrumbs.shedlock.provider.mongo.MongoLockProvider;
+import net.javacrumbs.shedlock.spring.annotation.EnableSchedulerLock;
+
+@Configuration
+@EnableScheduling
+@EnableSchedulerLock(defaultLockAtMostFor = "PT23H")
+public class SchedulingConfiguration {
+
+	@Value("${spring.data.mongodb.database}")
+	private String database;
+
+	@Bean
+	LockProvider lockProvider(MongoClient mongoClient) {
+		return new MongoLockProvider(mongoClient.getDatabase(database));
+	}
+
+}
diff --git a/src/main/java/de/ozgcloud/admin/common/AbstractLinkedResourceDeserializer.java b/src/main/java/de/ozgcloud/admin/common/AbstractLinkedResourceDeserializer.java
new file mode 100644
index 0000000000000000000000000000000000000000..30747bdc41a6e09231c4c50e6a2429329b33ecb0
--- /dev/null
+++ b/src/main/java/de/ozgcloud/admin/common/AbstractLinkedResourceDeserializer.java
@@ -0,0 +1,112 @@
+package de.ozgcloud.admin.common;
+
+import java.io.IOException;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.net.URLDecoder;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.Objects;
+import java.util.Set;
+
+import org.apache.commons.lang3.StringUtils;
+
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.databind.BeanProperty;
+import com.fasterxml.jackson.databind.DeserializationContext;
+import com.fasterxml.jackson.databind.JavaType;
+import com.fasterxml.jackson.databind.deser.ContextualDeserializer;
+import com.fasterxml.jackson.databind.deser.std.StdDeserializer;
+
+import de.ozgcloud.common.datatype.StringBasedValue;
+import lombok.Getter;
+
+abstract class AbstractLinkedResourceDeserializer extends StdDeserializer<Object> implements ContextualDeserializer {
+
+	private static final long serialVersionUID = 1L;
+
+	@Getter
+	private BeanProperty beanProperty;
+
+	@Getter
+	private final JavaType targetType;
+
+	protected AbstractLinkedResourceDeserializer() {
+		super(Object.class);
+		targetType = null;
+	}
+
+	protected AbstractLinkedResourceDeserializer(BeanProperty beanProperty) {
+		super(Object.class);
+		this.beanProperty = beanProperty;
+		this.targetType = beanProperty.getType();
+	}
+
+	@Override
+	public Object deserialize(JsonParser jsonParser, DeserializationContext ctxt) throws IOException {
+		if (jsonParser.isExpectedStartArrayToken()) {
+			Collection<Object> idList = targetType.getRawClass().isAssignableFrom(Set.class) ? new HashSet<>() : new ArrayList<>();
+
+			while (!jsonParser.nextToken().isStructEnd()) {
+				idList.add(extractId(jsonParser.getText()));
+			}
+			return idList;
+		} else {
+			return extractId(jsonParser.getText());
+		}
+	}
+
+	Object extractId(String url) {
+		Class<?> type;
+		if (targetType.isCollectionLikeType()) {
+			type = targetType.getContentType().getRawClass();
+		} else {
+			type = targetType.getRawClass();
+		}
+
+		if (String.class.isAssignableFrom(type)) {
+			return extractStringId(url);
+		}
+		if (Long.class.isAssignableFrom(type) || Long.TYPE.isAssignableFrom(type)) {
+			return extractLongId(url);
+		}
+		if (StringBasedValue.class.isAssignableFrom(type)) {
+			return extractStringBasedValue(type, url);
+		}
+		return buildByBuilder(url);
+	}
+
+	abstract Object buildByBuilder(String url);
+
+	public static Long extractLongId(String uri) {
+		var trimedUri = StringUtils.trimToNull(uri);
+		if (Objects.isNull(trimedUri)) {
+			return null;
+		}
+		return Long.parseLong(URLDecoder.decode(trimedUri.substring(trimedUri.lastIndexOf('/') + 1), StandardCharsets.UTF_8));
+	}
+
+	private StringBasedValue extractStringBasedValue(Class<?> type, String url) {
+		String value = extractStringId(url);
+		Method fromMethod;
+		try {
+			fromMethod = type.getMethod("from", String.class);
+		} catch (NoSuchMethodException e) {
+			throw new IllegalStateException(
+					String.format("Cannot generate Id from type '%s'. Missing 'from' Method.", targetType.getRawClass().getSimpleName()));
+		}
+		try {
+			return (StringBasedValue) fromMethod.invoke(null, value);
+		} catch (IllegalAccessException | IllegalArgumentException | InvocationTargetException e) {
+			throw new IllegalStateException(
+					String.format("Cannot generate Id from type '%s'. Error calling 'from' Method.", targetType.getRawClass().getSimpleName()),
+					e);
+		}
+	}
+
+	public static String extractStringId(String url) {
+		return URLDecoder.decode(url.substring(url.lastIndexOf('/') + 1), StandardCharsets.UTF_8);
+	}
+}
diff --git a/src/main/java/de/ozgcloud/admin/common/AbstractLinkedResourceSerializer.java b/src/main/java/de/ozgcloud/admin/common/AbstractLinkedResourceSerializer.java
new file mode 100644
index 0000000000000000000000000000000000000000..c7938c6cf288761799bcb074e410ab8d81e0255c
--- /dev/null
+++ b/src/main/java/de/ozgcloud/admin/common/AbstractLinkedResourceSerializer.java
@@ -0,0 +1,37 @@
+package de.ozgcloud.admin.common;
+
+import java.io.IOException;
+import java.util.Collection;
+
+import com.fasterxml.jackson.core.JsonGenerator;
+import com.fasterxml.jackson.databind.JsonSerializer;
+import com.fasterxml.jackson.databind.SerializerProvider;
+import com.fasterxml.jackson.databind.ser.ContextualSerializer;
+
+import de.ozgcloud.common.errorhandling.TechnicalException;
+
+abstract class AbstractLinkedResourceSerializer extends JsonSerializer<Object> implements ContextualSerializer {
+	@Override
+	public void serialize(Object value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
+
+		if (value instanceof Collection) {
+			gen.writeStartArray();
+			((Collection<?>) value).forEach(val -> writeObject(gen, buildLink(val)));
+			gen.writeEndArray();
+		} else {
+			writeObject(gen, buildLink(value));
+		}
+	}
+
+	abstract String buildLink(Object id);
+
+	abstract IdExtractor<Object> getExtractor();
+
+	void writeObject(JsonGenerator gen, Object value) {
+		try {
+			gen.writeObject(value);
+		} catch (IOException e) {
+			throw new TechnicalException("Error writing String to json", e);
+		}
+	}
+}
diff --git a/src/main/java/de/ozgcloud/admin/common/CollectionModelBuilder.java b/src/main/java/de/ozgcloud/admin/common/CollectionModelBuilder.java
new file mode 100644
index 0000000000000000000000000000000000000000..8773c4fa77e2c10e5b7cfb3ae6bd3ca3bb539dd3
--- /dev/null
+++ b/src/main/java/de/ozgcloud/admin/common/CollectionModelBuilder.java
@@ -0,0 +1,63 @@
+package de.ozgcloud.admin.common;
+
+import java.util.LinkedList;
+import java.util.List;
+import java.util.function.BooleanSupplier;
+import java.util.function.Predicate;
+import java.util.stream.Stream;
+
+import org.springframework.hateoas.CollectionModel;
+import org.springframework.hateoas.Link;
+
+import lombok.RequiredArgsConstructor;
+
+public class CollectionModelBuilder<T> {
+
+	private final Iterable<T> entities;
+
+	private final List<Link> links = new LinkedList<>();
+
+	private CollectionModelBuilder(Iterable<T> entities) {
+		this.entities = entities;
+	}
+
+	public static <T> CollectionModelBuilder<T> fromEntities(Iterable<T> entities) {
+		return new CollectionModelBuilder<>(entities);
+	}
+
+	public static <T> CollectionModelBuilder<T> fromEntities(Stream<T> entities) {
+		return new CollectionModelBuilder<>(entities.toList());
+	}
+
+	public CollectionModelBuilder<T> addLink(Link link) {
+		links.add(link);
+		return this;
+	}
+
+	public ConditionalLinkAdder ifMatch(BooleanSupplier guard) {
+		return new ConditionalLinkAdder(guard.getAsBoolean());
+	}
+
+	public ConditionalLinkAdder ifMatch(Predicate<? super Iterable<T>> predicate) {
+		return new ConditionalLinkAdder(predicate.test(entities));
+	}
+
+	public CollectionModel<T> buildModel() {
+		var builtModel = CollectionModel.of(entities);
+		builtModel.add(links);
+		return builtModel;
+	}
+
+	@RequiredArgsConstructor
+	public class ConditionalLinkAdder {
+
+		public final boolean conditionFulfilled;
+
+		public CollectionModelBuilder<T> addLink(Link link) {
+			if (conditionFulfilled) {
+				links.add(link);
+			}
+			return CollectionModelBuilder.this;
+		}
+	}
+}
\ No newline at end of file
diff --git a/src/main/java/de/ozgcloud/admin/common/GrpcUtil.java b/src/main/java/de/ozgcloud/admin/common/GrpcUtil.java
new file mode 100644
index 0000000000000000000000000000000000000000..d78580fafc95ad3f547c8059cf8fb503cd6e2ce4
--- /dev/null
+++ b/src/main/java/de/ozgcloud/admin/common/GrpcUtil.java
@@ -0,0 +1,43 @@
+package de.ozgcloud.admin.common;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Collection;
+import java.util.Optional;
+import java.util.stream.StreamSupport;
+
+import io.grpc.Metadata;
+import io.grpc.Metadata.Key;
+import lombok.AccessLevel;
+import lombok.NoArgsConstructor;
+
+@NoArgsConstructor(access = AccessLevel.PRIVATE)
+public class GrpcUtil {
+
+	public static final String ZUFI_MANAGER_GRPC_CLIENT = "zufi-manager";
+
+	public static final String SERVICE_KEY = "GRPC_SERVICE";
+
+	public static Key<String> keyOfString(String key) {
+		return Key.of(key, Metadata.ASCII_STRING_MARSHALLER);
+	}
+
+	public static Key<byte[]> createKeyOf(String key) {
+		return Key.of(key, Metadata.BINARY_BYTE_MARSHALLER);
+	}
+
+	public static String getFromHeaders(String key, Metadata headers) {
+		return Optional.ofNullable(headers.get(createKeyOf(key)))
+				.map(GrpcUtil::byteToString)
+				.orElse(null);
+	}
+
+	public static Collection<String> getCollection(String key, Metadata headers) {
+		return StreamSupport.stream(headers.getAll(createKeyOf(key)).spliterator(), false)
+				.map(GrpcUtil::byteToString)
+				.toList();
+	}
+
+	private static String byteToString(byte[] bytes) {
+		return new String(bytes, StandardCharsets.UTF_8);
+	}
+}
\ No newline at end of file
diff --git a/src/main/java/de/ozgcloud/admin/common/IdBuilder.java b/src/main/java/de/ozgcloud/admin/common/IdBuilder.java
new file mode 100644
index 0000000000000000000000000000000000000000..d45ff3f1ff5fcaceb9e8aaed88fc24765c642055
--- /dev/null
+++ b/src/main/java/de/ozgcloud/admin/common/IdBuilder.java
@@ -0,0 +1,19 @@
+package de.ozgcloud.admin.common;
+
+import com.fasterxml.jackson.databind.BeanProperty;
+
+import lombok.NoArgsConstructor;
+
+@NoArgsConstructor
+class IdBuilder implements ObjectBuilder<Object> {
+
+	@Override
+	public Object build(Object id) {
+		return id;
+	}
+
+	@Override
+	public ObjectBuilder<Object> constructContextAware(BeanProperty property) {
+		return new IdBuilder();
+	}
+}
diff --git a/src/main/java/de/ozgcloud/admin/common/IdExtractor.java b/src/main/java/de/ozgcloud/admin/common/IdExtractor.java
new file mode 100644
index 0000000000000000000000000000000000000000..7ecd4667434c5a7fca90c3c0cba62b825c85312f
--- /dev/null
+++ b/src/main/java/de/ozgcloud/admin/common/IdExtractor.java
@@ -0,0 +1,6 @@
+package de.ozgcloud.admin.common;
+
+@FunctionalInterface
+public interface IdExtractor<T> {
+	String extractId(T object);
+}
\ No newline at end of file
diff --git a/src/main/java/de/ozgcloud/admin/common/LinkedResource.java b/src/main/java/de/ozgcloud/admin/common/LinkedResource.java
new file mode 100644
index 0000000000000000000000000000000000000000..789650d3e935c752cb34dbd1b531954ff3703c12
--- /dev/null
+++ b/src/main/java/de/ozgcloud/admin/common/LinkedResource.java
@@ -0,0 +1,30 @@
+package de.ozgcloud.admin.common;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Inherited;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+import org.springframework.core.annotation.AliasFor;
+
+import com.fasterxml.jackson.annotation.JacksonAnnotationsInside;
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
+import com.fasterxml.jackson.databind.annotation.JsonSerialize;
+
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.FIELD)
+@Inherited
+
+@JacksonAnnotationsInside
+@JsonSerialize(using = LinkedResourceSerializer.class)
+@JsonDeserialize(using = LinkedResourceDeserializer.class)
+public @interface LinkedResource {
+
+	Class<?> controllerClass();
+
+	Class<? extends IdExtractor<Object>> extractor() default ToStringExtractor.class;
+
+	@AliasFor(annotation = JsonDeserialize.class, attribute = "builder")
+	Class<? extends ObjectBuilder<Object>> builder() default IdBuilder.class;
+}
\ No newline at end of file
diff --git a/src/main/java/de/ozgcloud/admin/common/LinkedResourceDeserializer.java b/src/main/java/de/ozgcloud/admin/common/LinkedResourceDeserializer.java
new file mode 100644
index 0000000000000000000000000000000000000000..226f2798ac2d8892f395808f29503751b0bfc5f6
--- /dev/null
+++ b/src/main/java/de/ozgcloud/admin/common/LinkedResourceDeserializer.java
@@ -0,0 +1,44 @@
+package de.ozgcloud.admin.common;
+
+import java.lang.reflect.InvocationTargetException;
+
+import org.apache.commons.lang3.reflect.ConstructorUtils;
+
+import com.fasterxml.jackson.databind.BeanProperty;
+import com.fasterxml.jackson.databind.DeserializationContext;
+import com.fasterxml.jackson.databind.JsonDeserializer;
+
+import de.ozgcloud.common.errorhandling.TechnicalException;
+
+public class LinkedResourceDeserializer extends AbstractLinkedResourceDeserializer {
+
+	private static final long serialVersionUID = 1L;
+
+	private LinkedResource annotation;
+
+	protected LinkedResourceDeserializer() {
+		super();
+	}
+
+	protected LinkedResourceDeserializer(BeanProperty beanProperty) {
+		super(beanProperty);
+		this.annotation = beanProperty.getAnnotation(LinkedResource.class);
+	}
+
+	@Override
+	Object buildByBuilder(String url) {
+		ObjectBuilder<?> builder;
+		try {
+			builder = ConstructorUtils.invokeConstructor(annotation.builder()).constructContextAware(getBeanProperty());
+		} catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException | InstantiationException e) {
+			throw new TechnicalException("Error instanciating Builder.", e);
+		}
+		return builder.build(extractStringId(url));
+	}
+
+	@Override
+	public JsonDeserializer<?> createContextual(DeserializationContext ctxt, BeanProperty property) {
+		return new LinkedResourceDeserializer(property);
+	}
+
+}
diff --git a/src/main/java/de/ozgcloud/admin/common/LinkedResourceSerializer.java b/src/main/java/de/ozgcloud/admin/common/LinkedResourceSerializer.java
new file mode 100644
index 0000000000000000000000000000000000000000..706dbabe599e5cdf3dc53a934863a4c3218e0c88
--- /dev/null
+++ b/src/main/java/de/ozgcloud/admin/common/LinkedResourceSerializer.java
@@ -0,0 +1,43 @@
+package de.ozgcloud.admin.common;
+
+import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.*;
+
+import java.lang.reflect.InvocationTargetException;
+
+import org.apache.commons.lang3.reflect.ConstructorUtils;
+
+import com.fasterxml.jackson.databind.BeanProperty;
+import com.fasterxml.jackson.databind.JsonSerializer;
+import com.fasterxml.jackson.databind.SerializerProvider;
+
+import de.ozgcloud.common.errorhandling.TechnicalException;
+import lombok.NoArgsConstructor;
+
+@NoArgsConstructor
+public class LinkedResourceSerializer extends AbstractLinkedResourceSerializer {
+
+	private LinkedResource annotation;
+
+	private LinkedResourceSerializer(LinkedResource annotation) {
+		this.annotation = annotation;
+	}
+
+	@Override
+	public JsonSerializer<?> createContextual(SerializerProvider prov, BeanProperty property) {
+		return new LinkedResourceSerializer(property.getAnnotation(LinkedResource.class));
+	}
+
+	@Override
+	String buildLink(Object id) {
+		return linkTo(annotation.controllerClass()).slash(getExtractor().extractId(id)).toString();
+	}
+
+	@Override
+	IdExtractor<Object> getExtractor() {
+		try {
+			return ConstructorUtils.invokeConstructor(annotation.extractor());
+		} catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException | InstantiationException e) {
+			throw new TechnicalException("Error instanciating IdExtractor", e);
+		}
+	}
+}
diff --git a/src/main/java/de/ozgcloud/admin/common/ModelBuilder.java b/src/main/java/de/ozgcloud/admin/common/ModelBuilder.java
new file mode 100644
index 0000000000000000000000000000000000000000..bca40c5e31a53d7f1de560472067ab33b1f4538f
--- /dev/null
+++ b/src/main/java/de/ozgcloud/admin/common/ModelBuilder.java
@@ -0,0 +1,241 @@
+package de.ozgcloud.admin.common;
+
+import java.lang.annotation.Annotation;
+import java.lang.reflect.Field;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.function.BooleanSupplier;
+import java.util.function.Function;
+import java.util.function.Predicate;
+import java.util.function.Supplier;
+import java.util.function.UnaryOperator;
+import java.util.stream.Collectors;
+
+import org.apache.commons.collections.CollectionUtils;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.commons.lang3.reflect.FieldUtils;
+import org.springframework.hateoas.EntityModel;
+import org.springframework.hateoas.Link;
+import org.springframework.hateoas.server.mvc.WebMvcLinkBuilder;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.log4j.Log4j2;
+
+@Log4j2
+public class ModelBuilder<T> {
+
+	private static final Map<Class<?>, Map<Object, List<Field>>> ANNOTATED_FIELDS_BY_ANNOTATION = new ConcurrentHashMap<>();
+
+	private final T entity;
+	private final EntityModel<T> model;
+
+	private final List<Link> links = new LinkedList<>();
+	private final List<Function<EntityModel<T>, EntityModel<T>>> mapper = new LinkedList<>();
+
+	private ModelBuilder(T entity) {
+		this.entity = entity;
+		this.model = null;
+	}
+
+	private ModelBuilder(EntityModel<T> model) {
+		this.entity = null;
+		this.model = model;
+	}
+
+	public static <T> ModelBuilder<T> fromEntity(T entity) {
+		return new ModelBuilder<>(entity);
+	}
+
+	public static <T> ModelBuilder<T> fromModel(EntityModel<T> model) {
+		return new ModelBuilder<>(model);
+	}
+
+	public ModelBuilder<T> addLink(Link link) {
+		links.add(link);
+		return this;
+	}
+
+	public ModelBuilder<T> addLink(Optional<Link> link) {
+		link.ifPresent(links::add);
+		return this;
+	}
+
+	public ModelBuilder<T> addLinks(Link... links) {
+		this.links.addAll(Arrays.asList(links));
+		return this;
+	}
+
+	public ModelBuilder<T> addLinks(Collection<Link> links) {
+		this.links.addAll(links);
+		return this;
+	}
+
+	public ConditionalLinkAdder ifMatch(Predicate<T> predicate) {
+		return new ConditionalLinkAdder(predicate.test(Objects.isNull(entity) ? model.getContent() : entity));
+	}
+
+	public ConditionalLinkAdder ifMatch(BooleanSupplier guard) {
+		return new ConditionalLinkAdder(guard.getAsBoolean());
+	}
+
+	public ModelBuilder<T> map(UnaryOperator<EntityModel<T>> mapper) {
+		this.mapper.add(mapper);
+		return this;
+	}
+
+	public EntityModel<T> buildModel() {
+		var filteredLinks = links.stream().filter(Objects::nonNull).collect(Collectors.toSet());
+
+		EntityModel<T> buildedModel = Objects.isNull(model) ? EntityModel.of(entity) : model;
+		buildedModel = buildedModel.add(filteredLinks);
+
+		addLinkByLinkedResourceAnnotationIfMissing(buildedModel);
+
+		return applyMapper(buildedModel);
+	}
+
+	private EntityModel<T> applyMapper(EntityModel<T> resource) {
+		Iterator<Function<EntityModel<T>, EntityModel<T>>> i = mapper.iterator();
+		EntityModel<T> result = resource;
+		while (i.hasNext()) {
+			result = i.next().apply(result);
+		}
+		return result;
+	}
+
+	private void addLinkByLinkedResourceAnnotationIfMissing(EntityModel<T> resource) {
+		getFields(LinkedResource.class).stream()
+				.filter(field -> shouldAddLink(resource, field))
+				.forEach(field -> handleLinkedResourceField(resource, field));
+	}
+
+	private void handleLinkedResourceField(EntityModel<T> resource, Field field) {
+		getEntityFieldValue(field).map(Object::toString).filter(StringUtils::isNotBlank).ifPresent(val -> resource
+				.add(WebMvcLinkBuilder.linkTo(field.getAnnotation(LinkedResource.class).controllerClass()).slash(val)
+						.withRel(sanitizeName(field.getName()))));
+	}
+
+	private boolean shouldAddLink(EntityModel<T> resource, Field field) {
+		return !(field.getType().isArray() || Collection.class.isAssignableFrom(field.getType()) || resource.hasLink(sanitizeName(field.getName())));
+	}
+
+	private List<Field> getFields(Class<? extends Annotation> annotationClass) {
+		var fields = Optional.ofNullable(ANNOTATED_FIELDS_BY_ANNOTATION.get(getEntity().getClass()))
+				.map(fieldsByAnnotation -> fieldsByAnnotation.get(annotationClass))
+				.orElseGet(Collections::emptyList);
+
+		if (CollectionUtils.isEmpty(fields)) {
+			fields = FieldUtils.getFieldsListWithAnnotation(getEntity().getClass(), annotationClass);
+
+			updateFields(annotationClass, fields);
+		}
+		return fields;
+	}
+
+	private void updateFields(Class<? extends Annotation> annotationClass, List<Field> fields) {
+		var annotationMap = Optional.ofNullable(ANNOTATED_FIELDS_BY_ANNOTATION.get(getEntity().getClass())).orElseGet(HashMap::new);
+		annotationMap.put(annotationClass, fields);
+
+		ANNOTATED_FIELDS_BY_ANNOTATION.put(annotationClass, annotationMap);
+	}
+
+	private String sanitizeName(String fieldName) {
+		if (fieldName.endsWith("Id")) {
+			return fieldName.substring(0, fieldName.indexOf("Id"));
+		}
+		return fieldName;
+	}
+
+	private Optional<Object> getEntityFieldValue(Field field) {
+		try {
+			field.setAccessible(true);
+			Optional<Object> value = Optional.ofNullable(field.get(getEntity()));
+			field.setAccessible(false);
+			return value;
+		} catch (IllegalArgumentException | IllegalAccessException e) {
+			LOG.warn("Cannot access field value of LinkedResource field.", e);
+		}
+		return Optional.empty();
+	}
+
+	private T getEntity() {
+		return Optional.ofNullable(entity == null ? model.getContent() : entity)
+				.orElseThrow(() -> new IllegalStateException("Entity must not null for ModelBuilding"));
+	}
+
+	@RequiredArgsConstructor
+	public class ConditionalLinkAdder {
+
+		public final boolean conditionFulfilled;
+
+		public ModelBuilder<T> addLink(Supplier<Link> linkSupplier) {
+			if (conditionFulfilled) {
+				addLink(linkSupplier.get());
+			}
+			return ModelBuilder.this;
+		}
+
+		public ModelBuilder<T> addLinkIfPresent(Supplier<Optional<Link>> linkSupplier) {
+			if (conditionFulfilled) {
+				addLink(linkSupplier.get());
+			}
+			return ModelBuilder.this;
+		}
+
+		public ModelBuilder<T> addLink(Function<T, Link> linkBuilder) {
+			if (conditionFulfilled) {
+				addLink(linkBuilder.apply(getEntity()));
+			}
+			return ModelBuilder.this;
+		}
+
+		public ModelBuilder<T> addLink(Link link) {
+			if (conditionFulfilled) {
+				links.add(link);
+			}
+			return ModelBuilder.this;
+		}
+
+		public ModelBuilder<T> addLink(Optional<Link> link) {
+			if (conditionFulfilled) {
+				link.ifPresent(links::add);
+			}
+			return ModelBuilder.this;
+		}
+
+		public ModelBuilder<T> addLinks(Link... links) {
+			if (conditionFulfilled) {
+				ModelBuilder.this.links.addAll(Arrays.asList(links));
+			}
+			return ModelBuilder.this;
+		}
+
+		@SafeVarargs
+		public final ModelBuilder<T> addLinks(Supplier<Link>... linkSuppliers) {
+			if (conditionFulfilled) {
+				for (int i = 0; i < linkSuppliers.length; i++) {
+					ModelBuilder.this.links.add(linkSuppliers[i].get());
+				}
+			}
+			return ModelBuilder.this;
+		}
+
+		public final ModelBuilder<T> addLinks(Supplier<Collection<Link>> linksSupplier) {
+			if (conditionFulfilled) {
+				linksSupplier.get().forEach(ModelBuilder.this.links::add);
+			}
+
+			return ModelBuilder.this;
+		}
+
+	}
+}
\ No newline at end of file
diff --git a/src/main/java/de/ozgcloud/admin/common/ObjectBuilder.java b/src/main/java/de/ozgcloud/admin/common/ObjectBuilder.java
new file mode 100644
index 0000000000000000000000000000000000000000..649aa24e1ef13dd8c50104c6cf39b0fa8386e813
--- /dev/null
+++ b/src/main/java/de/ozgcloud/admin/common/ObjectBuilder.java
@@ -0,0 +1,10 @@
+package de.ozgcloud.admin.common;
+
+import com.fasterxml.jackson.databind.BeanProperty;
+
+public interface ObjectBuilder<T> {
+
+	T build(Object id);
+
+	ObjectBuilder<T> constructContextAware(BeanProperty property);
+}
diff --git a/src/main/java/de/ozgcloud/admin/common/ToStringExtractor.java b/src/main/java/de/ozgcloud/admin/common/ToStringExtractor.java
new file mode 100644
index 0000000000000000000000000000000000000000..3d474a013f6c28bad510fd9059c76dee7c842e75
--- /dev/null
+++ b/src/main/java/de/ozgcloud/admin/common/ToStringExtractor.java
@@ -0,0 +1,12 @@
+package de.ozgcloud.admin.common;
+
+import lombok.NoArgsConstructor;
+
+@NoArgsConstructor
+class ToStringExtractor implements IdExtractor<Object> {
+
+	@Override
+	public String extractId(Object object) {
+		return object.toString();
+	}
+}
diff --git a/src/main/java/de/ozgcloud/admin/keycloak/AddGroupData.java b/src/main/java/de/ozgcloud/admin/keycloak/AddGroupData.java
new file mode 100644
index 0000000000000000000000000000000000000000..bfef1afa5a70b06618372fcb6e7c1eb035f3e872
--- /dev/null
+++ b/src/main/java/de/ozgcloud/admin/keycloak/AddGroupData.java
@@ -0,0 +1,14 @@
+package de.ozgcloud.admin.keycloak;
+
+import lombok.Builder;
+import lombok.Getter;
+import lombok.ToString;
+
+@Getter
+@Builder
+@ToString
+public class AddGroupData {
+
+	private String name;
+	private String organisationsEinheitId;
+}
diff --git a/src/main/java/de/ozgcloud/admin/keycloak/Group.java b/src/main/java/de/ozgcloud/admin/keycloak/Group.java
new file mode 100644
index 0000000000000000000000000000000000000000..005a7ce23d0709ade874e9fad35e7be17906ceee
--- /dev/null
+++ b/src/main/java/de/ozgcloud/admin/keycloak/Group.java
@@ -0,0 +1,18 @@
+package de.ozgcloud.admin.keycloak;
+
+import java.util.List;
+
+import lombok.Builder;
+import lombok.Getter;
+import lombok.Singular;
+
+@Getter
+@Builder(toBuilder = true)
+public class Group {
+
+	private String id;
+	private String name;
+	private String organisationsEinheitId;
+	@Singular
+	private List<Group> subGroups;
+}
diff --git a/src/main/java/de/ozgcloud/admin/keycloak/GroupMapper.java b/src/main/java/de/ozgcloud/admin/keycloak/GroupMapper.java
new file mode 100644
index 0000000000000000000000000000000000000000..a18a572526e3e4da1ca1bbc66433fe17266acedc
--- /dev/null
+++ b/src/main/java/de/ozgcloud/admin/keycloak/GroupMapper.java
@@ -0,0 +1,61 @@
+package de.ozgcloud.admin.keycloak;
+
+import java.util.List;
+import java.util.Map;
+
+import org.apache.commons.lang3.StringUtils;
+import org.keycloak.representations.idm.GroupRepresentation;
+import org.mapstruct.AfterMapping;
+import org.mapstruct.Mapper;
+import org.mapstruct.Mapping;
+import org.mapstruct.MappingTarget;
+import org.mapstruct.NullValueCheckStrategy;
+import org.springframework.beans.factory.annotation.Autowired;
+
+import lombok.extern.log4j.Log4j2;
+
+@Mapper(nullValueCheckStrategy = NullValueCheckStrategy.ALWAYS)
+@Log4j2
+abstract class GroupMapper {
+
+	@Autowired
+	private KeycloakApiProperties keycloakApiProperties;
+
+	public abstract List<Group> fromGroupRepresentations(List<GroupRepresentation> groupRepresentations);
+
+	@Mapping(target = "organisationsEinheitId", source = "attributes")
+	public abstract Group fromGroupRepresentation(GroupRepresentation groupRepresentation);
+
+	String getOrganisationsEinheitId(Map<String, List<String>> attributes) {
+		if (attributes == null) {
+			return null;
+		}
+		var values = attributes.get(keycloakApiProperties.getOrganisationsEinheitIdKey());
+		if (values == null) {
+			return null;
+		}
+		if (values.size() > 1 && values.stream().distinct().count() > 1) {
+			throw new GroupRepresentationMappingException("Group contains multiple values for organisationsEinheitId: %s".formatted(values));
+		}
+		return values.getFirst();
+	}
+
+	@AfterMapping
+	protected void deleteGroupsWithoutOrganisationsEinheitId(@MappingTarget List<Group> groups) {
+		groups.removeIf(this::isMissingOrganisationsEinheitId);
+	}
+
+	protected boolean isMissingOrganisationsEinheitId(Group group) {
+		return StringUtils.isBlank(group.getOrganisationsEinheitId());
+	}
+
+	@Mapping(target = "attributes", expression = "java(buildGroupAttributes(groupToAdd))")
+	public abstract GroupRepresentation toGroupRepresentation(AddGroupData groupToAdd);
+
+	Map<String, List<String>> buildGroupAttributes(AddGroupData groupToAdd) {
+		var organisationsEinheitId = groupToAdd.getOrganisationsEinheitId();
+		return StringUtils.isNotBlank(organisationsEinheitId) ?
+				Map.of(keycloakApiProperties.getOrganisationsEinheitIdKey(), List.of(organisationsEinheitId)) :
+				Map.of();
+	}
+}
diff --git a/src/main/java/de/ozgcloud/admin/keycloak/GroupRepresentationMappingException.java b/src/main/java/de/ozgcloud/admin/keycloak/GroupRepresentationMappingException.java
new file mode 100644
index 0000000000000000000000000000000000000000..c07743ddf481a64e3d296309fbaa556fbab31f14
--- /dev/null
+++ b/src/main/java/de/ozgcloud/admin/keycloak/GroupRepresentationMappingException.java
@@ -0,0 +1,8 @@
+package de.ozgcloud.admin.keycloak;
+
+public class GroupRepresentationMappingException extends RuntimeException {
+
+	public GroupRepresentationMappingException(String message) {
+		super(message);
+	}
+}
diff --git a/src/main/java/de/ozgcloud/admin/keycloak/KeycloakApiProperties.java b/src/main/java/de/ozgcloud/admin/keycloak/KeycloakApiProperties.java
new file mode 100644
index 0000000000000000000000000000000000000000..57d23fe52be991d79a41c2ea50affdeb6825283d
--- /dev/null
+++ b/src/main/java/de/ozgcloud/admin/keycloak/KeycloakApiProperties.java
@@ -0,0 +1,51 @@
+/*
+ * Copyright (C) 2022 Das Land Schleswig-Holstein vertreten durch den
+ * Ministerpräsidenten des Landes Schleswig-Holstein
+ * Staatskanzlei
+ * Abteilung Digitalisierung und zentrales IT-Management der Landesregierung
+ *
+ * Lizenziert unter der EUPL, Version 1.2 oder - sobald
+ * diese von der Europäischen Kommission genehmigt wurden -
+ * Folgeversionen der EUPL ("Lizenz");
+ * Sie dürfen dieses Werk ausschließlich gemäß
+ * dieser Lizenz nutzen.
+ * Eine Kopie der Lizenz finden Sie hier:
+ *
+ * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * Sofern nicht durch anwendbare Rechtsvorschriften
+ * gefordert oder in schriftlicher Form vereinbart, wird
+ * die unter der Lizenz verbreitete Software "so wie sie
+ * ist", OHNE JEGLICHE GEWÄHRLEISTUNG ODER BEDINGUNGEN -
+ * ausdrücklich oder stillschweigend - verbreitet.
+ * Die sprachspezifischen Genehmigungen und Beschränkungen
+ * unter der Lizenz sind dem Lizenztext zu entnehmen.
+ */
+package de.ozgcloud.admin.keycloak;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.context.annotation.Configuration;
+
+import jakarta.validation.constraints.NotBlank;
+import lombok.Getter;
+import lombok.Setter;
+
+@Setter
+@Getter
+@Configuration
+@ConfigurationProperties("ozgcloud.keycloak.api")
+public class KeycloakApiProperties {
+
+	@NotBlank
+	private String url;
+	@NotBlank
+	private String user;
+	@NotBlank
+	private String password;
+	@NotBlank
+	private String realm;
+	@NotBlank
+	private String client;
+	@NotBlank
+	private String organisationsEinheitIdKey;
+}
\ No newline at end of file
diff --git a/src/main/java/de/ozgcloud/admin/keycloak/KeycloakApiService.java b/src/main/java/de/ozgcloud/admin/keycloak/KeycloakApiService.java
new file mode 100644
index 0000000000000000000000000000000000000000..888f98c8b9acbecc3eaecf16cfbacd9dca176587
--- /dev/null
+++ b/src/main/java/de/ozgcloud/admin/keycloak/KeycloakApiService.java
@@ -0,0 +1,40 @@
+package de.ozgcloud.admin.keycloak;
+
+import java.util.List;
+
+import org.keycloak.admin.client.resource.GroupsResource;
+import org.keycloak.representations.idm.GroupRepresentation;
+import org.springframework.stereotype.Service;
+
+import jakarta.ws.rs.core.Response;
+import lombok.RequiredArgsConstructor;
+
+@Service
+@RequiredArgsConstructor
+class KeycloakApiService {
+
+	private final GroupsResource groupsResource;
+
+	public List<GroupRepresentation> getAllGroups() {
+		return groupsResource.groups("", 0, Integer.MAX_VALUE, false);
+	}
+
+	public String addGroup(GroupRepresentation groupRepresentation) {
+		try (var response = groupsResource.add(groupRepresentation)) {
+			return getCreatedResourceIdFromResponse(response);
+		}
+	}
+
+	String getCreatedResourceIdFromResponse(Response response) {
+		if (response.getStatus() == Response.Status.CREATED.getStatusCode()) {
+			return extractResourceIdFromLocationHeader(response.getHeaderString("Location"));
+		}
+		throw new ResourceCreationException(
+				"Failed to add group - got response with status %d (%s) and headers %s".formatted(response.getStatus(), response.getStatusInfo()
+						.getReasonPhrase(), response.getHeaders()));
+	}
+
+	String extractResourceIdFromLocationHeader(String location) {
+		return location.substring(location.lastIndexOf('/') + 1);
+	}
+}
diff --git a/src/main/java/de/ozgcloud/admin/keycloak/KeycloakConfiguration.java b/src/main/java/de/ozgcloud/admin/keycloak/KeycloakConfiguration.java
new file mode 100644
index 0000000000000000000000000000000000000000..b72c4c2037cab6bcba65e8308b9e7ef8141aa83d
--- /dev/null
+++ b/src/main/java/de/ozgcloud/admin/keycloak/KeycloakConfiguration.java
@@ -0,0 +1,59 @@
+/*
+ * Copyright (C) 2022 Das Land Schleswig-Holstein vertreten durch den
+ * Ministerpräsidenten des Landes Schleswig-Holstein
+ * Staatskanzlei
+ * Abteilung Digitalisierung und zentrales IT-Management der Landesregierung
+ *
+ * Lizenziert unter der EUPL, Version 1.2 oder - sobald
+ * diese von der Europäischen Kommission genehmigt wurden -
+ * Folgeversionen der EUPL ("Lizenz");
+ * Sie dürfen dieses Werk ausschließlich gemäß
+ * dieser Lizenz nutzen.
+ * Eine Kopie der Lizenz finden Sie hier:
+ *
+ * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * Sofern nicht durch anwendbare Rechtsvorschriften
+ * gefordert oder in schriftlicher Form vereinbart, wird
+ * die unter der Lizenz verbreitete Software "so wie sie
+ * ist", OHNE JEGLICHE GEWÄHRLEISTUNG ODER BEDINGUNGEN -
+ * ausdrücklich oder stillschweigend - verbreitet.
+ * Die sprachspezifischen Genehmigungen und Beschränkungen
+ * unter der Lizenz sind dem Lizenztext zu entnehmen.
+ */
+package de.ozgcloud.admin.keycloak;
+
+import org.keycloak.admin.client.Keycloak;
+import org.keycloak.admin.client.KeycloakBuilder;
+import org.keycloak.admin.client.resource.GroupsResource;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import lombok.RequiredArgsConstructor;
+
+@Configuration
+@RequiredArgsConstructor
+public class KeycloakConfiguration {
+
+	private final KeycloakApiProperties keycloakApiProperties;
+
+	@Bean
+	Keycloak keycloak() {
+		return buildKeycloakInstance();
+	}
+
+	@Bean
+	GroupsResource groupsResource(Keycloak keycloak) {
+		return keycloak.realm(keycloakApiProperties.getRealm()).groups();
+	}
+
+	private Keycloak buildKeycloakInstance() {
+		return KeycloakBuilder.builder()
+				.serverUrl(keycloakApiProperties.getUrl())
+				.realm(keycloakApiProperties.getRealm())
+				.clientId(keycloakApiProperties.getClient())
+				.username(keycloakApiProperties.getUser())
+				.password(keycloakApiProperties.getPassword())
+				.build();
+	}
+}
diff --git a/src/main/java/de/ozgcloud/admin/keycloak/KeycloakRemoteService.java b/src/main/java/de/ozgcloud/admin/keycloak/KeycloakRemoteService.java
new file mode 100644
index 0000000000000000000000000000000000000000..1d0a854481d0db242dd5c21583ad4d4616fa593b
--- /dev/null
+++ b/src/main/java/de/ozgcloud/admin/keycloak/KeycloakRemoteService.java
@@ -0,0 +1,23 @@
+package de.ozgcloud.admin.keycloak;
+
+import java.util.stream.Stream;
+
+import org.springframework.stereotype.Service;
+
+import lombok.RequiredArgsConstructor;
+
+@Service
+@RequiredArgsConstructor
+public class KeycloakRemoteService {
+
+	private final KeycloakApiService apiService;
+	private final GroupMapper groupMapper;
+
+	public Stream<Group> getGroupsWithOrganisationsEinheitId() {
+		return groupMapper.fromGroupRepresentations(apiService.getAllGroups()).stream();
+	}
+
+	public String addGroup(AddGroupData addGroupData) {
+		return apiService.addGroup(groupMapper.toGroupRepresentation(addGroupData));
+	}
+}
diff --git a/src/main/java/de/ozgcloud/admin/keycloak/ResourceCreationException.java b/src/main/java/de/ozgcloud/admin/keycloak/ResourceCreationException.java
new file mode 100644
index 0000000000000000000000000000000000000000..dc6f8ebfebac48b57c2fb8b60d8056f5d4e23a1d
--- /dev/null
+++ b/src/main/java/de/ozgcloud/admin/keycloak/ResourceCreationException.java
@@ -0,0 +1,8 @@
+package de.ozgcloud.admin.keycloak;
+
+public class ResourceCreationException extends RuntimeException {
+
+	public ResourceCreationException(String message) {
+		super(message);
+	}
+}
diff --git a/src/main/java/de/ozgcloud/admin/migration/MongockFailedEventListener.java b/src/main/java/de/ozgcloud/admin/migration/MongockFailedEventListener.java
index 84e860cb2cbdee0312df576f903dd285e89e1bd9..c46364338fef285376784272d2f2275743a92f79 100644
--- a/src/main/java/de/ozgcloud/admin/migration/MongockFailedEventListener.java
+++ b/src/main/java/de/ozgcloud/admin/migration/MongockFailedEventListener.java
@@ -34,6 +34,6 @@ public class MongockFailedEventListener implements ApplicationListener<SpringMig
 
 	@Override
 	public void onApplicationEvent(SpringMigrationFailureEvent event) {
-		log.error("Mongock migration failed", event.getMigrationResult());
+		LOG.error("Mongock migration failed", event.getMigrationResult());
 	}
 }
\ No newline at end of file
diff --git a/src/main/java/de/ozgcloud/admin/migration/MongockStartEventListener.java b/src/main/java/de/ozgcloud/admin/migration/MongockStartEventListener.java
index ad1558351ae353658dbe653bfe2630e627373489..e66855af555eb96ad054bf3ae9601d0c432a9fbb 100644
--- a/src/main/java/de/ozgcloud/admin/migration/MongockStartEventListener.java
+++ b/src/main/java/de/ozgcloud/admin/migration/MongockStartEventListener.java
@@ -34,6 +34,6 @@ public class MongockStartEventListener implements ApplicationListener<SpringMigr
 
 	@Override
 	public void onApplicationEvent(SpringMigrationStartedEvent event) {
-		log.info("Mongock start migration...");
+		LOG.info("Mongock start migration...");
 	}
 }
\ No newline at end of file
diff --git a/src/main/java/de/ozgcloud/admin/migration/MongockSuccessEventListener.java b/src/main/java/de/ozgcloud/admin/migration/MongockSuccessEventListener.java
index 95a7f76ee988dc8f1e72b8db6341da77514e5b54..176bc14fdfbbac69e00b16a2e649cf8b5db1050d 100644
--- a/src/main/java/de/ozgcloud/admin/migration/MongockSuccessEventListener.java
+++ b/src/main/java/de/ozgcloud/admin/migration/MongockSuccessEventListener.java
@@ -34,6 +34,6 @@ public class MongockSuccessEventListener implements ApplicationListener<SpringMi
 
 	@Override
 	public void onApplicationEvent(SpringMigrationSuccessEvent event) {
-		log.info("Mongock migration successfull", event.getMigrationResult());
+		LOG.info("Mongock migration successfull", event.getMigrationResult());
 	}
 }
\ No newline at end of file
diff --git a/src/main/java/de/ozgcloud/admin/organisationseinheit/OrganisationsEinheit.java b/src/main/java/de/ozgcloud/admin/organisationseinheit/OrganisationsEinheit.java
new file mode 100644
index 0000000000000000000000000000000000000000..470a29e850ac9929ac8229c8211b036484f1c0f4
--- /dev/null
+++ b/src/main/java/de/ozgcloud/admin/organisationseinheit/OrganisationsEinheit.java
@@ -0,0 +1,45 @@
+package de.ozgcloud.admin.organisationseinheit;
+
+import java.util.List;
+
+import org.springframework.data.annotation.Id;
+import org.springframework.data.annotation.TypeAlias;
+import org.springframework.data.mongodb.core.mapping.Document;
+
+import com.fasterxml.jackson.annotation.JsonIgnore;
+
+import lombok.Builder;
+import lombok.Getter;
+import lombok.Singular;
+import lombok.extern.jackson.Jacksonized;
+
+@Getter
+@Builder(toBuilder = true)
+@Jacksonized
+@Document(language = "german", collection = OrganisationsEinheit.COLLECTION_NAME)
+@TypeAlias("OrganisationsEinheit")
+public class OrganisationsEinheit {
+
+	static final String COLLECTION_NAME = "organisationsEinheit";
+
+	@JsonIgnore
+	@Id
+	private String id;
+	@JsonIgnore
+	private String keycloakId;
+	@JsonIgnore
+	private String zufiId;
+	@JsonIgnore
+	private Long lastSyncTimestamp;
+	@JsonIgnore
+	private String parentId;
+	@JsonIgnore
+	@Singular
+	private List<OrganisationsEinheit> children;
+
+	private String name;
+	private String organisationsEinheitId;
+	private SyncResult syncResult;
+	@Builder.Default
+	private OrganisationsEinheitSettings settings = new OrganisationsEinheitSettings(null);
+}
\ No newline at end of file
diff --git a/src/main/java/de/ozgcloud/admin/organisationseinheit/OrganisationsEinheitController.java b/src/main/java/de/ozgcloud/admin/organisationseinheit/OrganisationsEinheitController.java
new file mode 100644
index 0000000000000000000000000000000000000000..3e40411d38eca6d07c75ad84db0c5164ba120d1a
--- /dev/null
+++ b/src/main/java/de/ozgcloud/admin/organisationseinheit/OrganisationsEinheitController.java
@@ -0,0 +1,39 @@
+package de.ozgcloud.admin.organisationseinheit;
+
+import org.springframework.hateoas.CollectionModel;
+import org.springframework.hateoas.EntityModel;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import lombok.RequiredArgsConstructor;
+
+@RestController
+@RequestMapping(OrganisationsEinheitController.PATH)
+@RequiredArgsConstructor
+public class OrganisationsEinheitController {
+
+	static final String PATH = "/api/organisationseinheits"; // NOSONAR
+
+	private final OrganisationsEinheitService organisationsEinheitService;
+
+	private final OrganisationsEinheitModelAssembler assembler;
+
+	@GetMapping
+	public CollectionModel<EntityModel<OrganisationsEinheit>> getAll() {
+		var organisationsEinheiten = organisationsEinheitService.getOrganisationsEinheiten();
+		return assembler.toCollectionModel(organisationsEinheiten);
+	}
+
+	@GetMapping("/{id}")
+	public EntityModel<OrganisationsEinheit> getById(@PathVariable String id) {
+		return assembler.toModel(organisationsEinheitService.getOrganisationsEinheitById(id));
+	}
+
+	@GetMapping("/{id}/children")
+	public CollectionModel<EntityModel<OrganisationsEinheit>> getChildren(@PathVariable String id) {
+		var children = organisationsEinheitService.getChildren(id);
+		return assembler.toChildrenCollectionModel(id, children);
+	}
+}
diff --git a/src/main/java/de/ozgcloud/admin/organisationseinheit/OrganisationsEinheitMapper.java b/src/main/java/de/ozgcloud/admin/organisationseinheit/OrganisationsEinheitMapper.java
new file mode 100644
index 0000000000000000000000000000000000000000..da1634ec80bf9a5169022184c50bba014dd735a4
--- /dev/null
+++ b/src/main/java/de/ozgcloud/admin/organisationseinheit/OrganisationsEinheitMapper.java
@@ -0,0 +1,17 @@
+package de.ozgcloud.admin.organisationseinheit;
+
+import org.mapstruct.Mapper;
+import org.mapstruct.Mapping;
+
+import de.ozgcloud.admin.keycloak.AddGroupData;
+import de.ozgcloud.zufi.grpc.organisationseinheit.GrpcOrganisationsEinheit;
+
+@Mapper
+interface OrganisationsEinheitMapper {
+
+	@Mapping(target = "organisationsEinheitId", source = "xzufiId.id")
+	@Mapping(target = "zufiId", source = "id")
+	OrganisationsEinheit fromGrpc(GrpcOrganisationsEinheit grpcOrganisationsEinheit);
+
+	AddGroupData toAddGroupData(OrganisationsEinheit organisationsEinheit);
+}
diff --git a/src/main/java/de/ozgcloud/admin/organisationseinheit/OrganisationsEinheitModelAssembler.java b/src/main/java/de/ozgcloud/admin/organisationseinheit/OrganisationsEinheitModelAssembler.java
new file mode 100644
index 0000000000000000000000000000000000000000..6ac15d9d36936471e22321829f262a55f5ad36fd
--- /dev/null
+++ b/src/main/java/de/ozgcloud/admin/organisationseinheit/OrganisationsEinheitModelAssembler.java
@@ -0,0 +1,59 @@
+package de.ozgcloud.admin.organisationseinheit;
+
+import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.*;
+
+import java.util.stream.StreamSupport;
+
+import org.apache.commons.collections.CollectionUtils;
+import org.springframework.hateoas.CollectionModel;
+import org.springframework.hateoas.EntityModel;
+import org.springframework.hateoas.LinkRelation;
+import org.springframework.hateoas.mediatype.hal.HalModelBuilder;
+import org.springframework.hateoas.server.RepresentationModelAssembler;
+import org.springframework.stereotype.Component;
+
+import de.ozgcloud.admin.common.CollectionModelBuilder;
+
+@Component
+class OrganisationsEinheitModelAssembler
+		implements RepresentationModelAssembler<OrganisationsEinheit, EntityModel<OrganisationsEinheit>> {
+
+	static final String REL_CHILD_ORGANISATIONS_EINHEITEN = "childList";
+
+	@Override
+	public EntityModel<OrganisationsEinheit> toModel(OrganisationsEinheit organisationsEinheit) {
+		var halModelBuilder = HalModelBuilder.halModelOf(organisationsEinheit);
+
+		embedChildOrganisationsEinheiten(organisationsEinheit, halModelBuilder);
+
+		return halModelBuilder
+				.<EntityModel<OrganisationsEinheit>>build()
+				.add(linkTo(methodOn(OrganisationsEinheitController.class).getById(organisationsEinheit.getId())).withSelfRel());
+	}
+
+	void embedChildOrganisationsEinheiten(OrganisationsEinheit organisationsEinheit, HalModelBuilder halModelBuilder) {
+		if (CollectionUtils.isNotEmpty(organisationsEinheit.getChildren())) {
+			var childrenModel = toChildrenCollectionModel(organisationsEinheit.getId(), organisationsEinheit.getChildren()).getContent();
+			halModelBuilder.embed(childrenModel, LinkRelation.of(REL_CHILD_ORGANISATIONS_EINHEITEN))
+					.link(linkTo(methodOn(OrganisationsEinheitController.class).getChildren(organisationsEinheit.getId())).withRel(
+							REL_CHILD_ORGANISATIONS_EINHEITEN));
+		}
+	}
+
+	@Override
+	public CollectionModel<EntityModel<OrganisationsEinheit>> toCollectionModel(
+			Iterable<? extends OrganisationsEinheit> organisationsEinheiten) {
+		var models = StreamSupport.stream(organisationsEinheiten.spliterator(), false).map(this::toModel);
+		return CollectionModelBuilder.fromEntities(models)
+				.addLink(linkTo(methodOn(OrganisationsEinheitController.class).getAll()).withSelfRel())
+				.buildModel();
+	}
+
+	public CollectionModel<EntityModel<OrganisationsEinheit>> toChildrenCollectionModel(String parentId,
+			Iterable<? extends OrganisationsEinheit> organisationsEinheiten) {
+		var models = StreamSupport.stream(organisationsEinheiten.spliterator(), false).map(this::toModel);
+		return CollectionModelBuilder.fromEntities(models)
+				.addLink(linkTo(methodOn(OrganisationsEinheitController.class).getChildren(parentId)).withSelfRel())
+				.buildModel();
+	}
+}
diff --git a/src/main/java/de/ozgcloud/admin/organisationseinheit/OrganisationsEinheitRemoteService.java b/src/main/java/de/ozgcloud/admin/organisationseinheit/OrganisationsEinheitRemoteService.java
new file mode 100644
index 0000000000000000000000000000000000000000..7847a92134a6a8bb82ec4c3f68ea33bc72201c2a
--- /dev/null
+++ b/src/main/java/de/ozgcloud/admin/organisationseinheit/OrganisationsEinheitRemoteService.java
@@ -0,0 +1,32 @@
+package de.ozgcloud.admin.organisationseinheit;
+
+import java.util.List;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import de.ozgcloud.admin.common.GrpcUtil;
+import de.ozgcloud.zufi.grpc.organisationseinheit.GrpcGetByOrganisationsEinheitIdRequest;
+import de.ozgcloud.zufi.grpc.organisationseinheit.OrganisationsEinheitServiceGrpc.OrganisationsEinheitServiceBlockingStub;
+import net.devh.boot.grpc.client.inject.GrpcClient;
+
+@Service
+class OrganisationsEinheitRemoteService {
+
+	@GrpcClient(GrpcUtil.ZUFI_MANAGER_GRPC_CLIENT)
+	private OrganisationsEinheitServiceBlockingStub serviceStub;
+
+	@Autowired
+	private OrganisationsEinheitMapper organisationsEinheitMapper;
+
+	public List<OrganisationsEinheit> getByOrganisationsEinheitId(String organisationsEinheitId) {
+		var request = buildGetByOrganisationsEinheitIdRequest(organisationsEinheitId);
+		var response = serviceStub.getByOrganisationsEinheitId(request);
+		return response.getOrganisationsEinheitenList().stream().map(organisationsEinheitMapper::fromGrpc).toList();
+	}
+
+	GrpcGetByOrganisationsEinheitIdRequest buildGetByOrganisationsEinheitIdRequest(String organisationsEinheitId) {
+		return GrpcGetByOrganisationsEinheitIdRequest.newBuilder().setOrganisationsEinheitId(organisationsEinheitId).build();
+	}
+
+}
diff --git a/src/main/java/de/ozgcloud/admin/organisationseinheit/OrganisationsEinheitRepository.java b/src/main/java/de/ozgcloud/admin/organisationseinheit/OrganisationsEinheitRepository.java
new file mode 100644
index 0000000000000000000000000000000000000000..f32cbadfe751ab671151de99142112f22ff02671
--- /dev/null
+++ b/src/main/java/de/ozgcloud/admin/organisationseinheit/OrganisationsEinheitRepository.java
@@ -0,0 +1,30 @@
+package de.ozgcloud.admin.organisationseinheit;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.stream.Stream;
+
+import org.springframework.data.mongodb.repository.MongoRepository;
+import org.springframework.data.mongodb.repository.Query;
+import org.springframework.data.mongodb.repository.Update;
+import org.springframework.stereotype.Repository;
+
+@Repository
+interface OrganisationsEinheitRepository extends MongoRepository<OrganisationsEinheit, String> {
+
+	@Query("{'syncResult': {$ne: 'DELETED'}}")
+	List<OrganisationsEinheit> findAllNotDeleted();
+
+	@Query("{'syncResult': {$ne: 'DELETED'}, 'parentId': ?0}")
+	List<OrganisationsEinheit> findChildren(String parentId);
+
+	@Query("{'syncResult': { $ne: null }, 'keycloakId': ?0}")
+	Optional<OrganisationsEinheit> findSyncedByKeycloakId(String keycloakId);
+
+	@Query("{'syncResult': { $ne: null }, 'lastSyncTimestamp': { $lt: ?0 }}")
+	@Update("{'$set':  {'syncResult':  'DELETED'}}")
+	void setUnsyncedAsDeleted(long lastSyncTimestamp);
+
+	@Query("{'syncResult': null }")
+	Stream<OrganisationsEinheit> findAllWithoutSyncResult();
+}
diff --git a/src/main/java/de/ozgcloud/admin/organisationseinheit/OrganisationsEinheitRootProcessor.java b/src/main/java/de/ozgcloud/admin/organisationseinheit/OrganisationsEinheitRootProcessor.java
new file mode 100644
index 0000000000000000000000000000000000000000..4c4bb9d279afa74858b92d21f38f57819d558beb
--- /dev/null
+++ b/src/main/java/de/ozgcloud/admin/organisationseinheit/OrganisationsEinheitRootProcessor.java
@@ -0,0 +1,20 @@
+package de.ozgcloud.admin.organisationseinheit;
+
+import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.*;
+
+import org.springframework.hateoas.EntityModel;
+import org.springframework.hateoas.server.RepresentationModelProcessor;
+import org.springframework.stereotype.Component;
+
+import de.ozgcloud.admin.Root;
+
+@Component
+class OrganisationsEinheitRootProcessor  implements RepresentationModelProcessor<EntityModel<Root>> {
+
+	static final String REL_ORGANISATIONS_EINHEITEN = "organisationsEinheiten";
+
+	@Override
+	public EntityModel<Root> process(EntityModel<Root> model) {
+		return model.add(linkTo(methodOn(OrganisationsEinheitController.class).getAll()).withRel(REL_ORGANISATIONS_EINHEITEN));
+	}
+}
diff --git a/src/main/java/de/ozgcloud/admin/organisationseinheit/OrganisationsEinheitService.java b/src/main/java/de/ozgcloud/admin/organisationseinheit/OrganisationsEinheitService.java
new file mode 100644
index 0000000000000000000000000000000000000000..dc0a1873b90d57cbcbdeda7c67fd61fbb68abeca
--- /dev/null
+++ b/src/main/java/de/ozgcloud/admin/organisationseinheit/OrganisationsEinheitService.java
@@ -0,0 +1,43 @@
+package de.ozgcloud.admin.organisationseinheit;
+
+import java.util.List;
+import java.util.Objects;
+
+import org.springframework.data.rest.webmvc.ResourceNotFoundException;
+import org.springframework.stereotype.Service;
+
+import lombok.RequiredArgsConstructor;
+
+@Service
+@RequiredArgsConstructor
+class OrganisationsEinheitService {
+
+	private final OrganisationsEinheitRepository repository;
+
+	public OrganisationsEinheit saveOrganisationsEinheit(OrganisationsEinheit organisationsEinheit) {
+		return repository.save(organisationsEinheit);
+	}
+
+	public List<OrganisationsEinheit> getOrganisationsEinheiten() {
+		var organisationsEinheiten = repository.findAllNotDeleted();
+		return organisationsEinheiten.stream()
+				.filter(organisationsEinheit -> Objects.isNull(organisationsEinheit.getParentId()))
+				.map(rootOrganisationsEinheit -> addChildren(rootOrganisationsEinheit, organisationsEinheiten))
+				.toList();
+	}
+
+	OrganisationsEinheit addChildren(OrganisationsEinheit rootOrganisationsEinheit, List<OrganisationsEinheit> allOrganisationsEinheiten) {
+		return rootOrganisationsEinheit.toBuilder()
+				.children(allOrganisationsEinheiten.stream()
+						.filter(organisationsEinheit -> rootOrganisationsEinheit.getId().equals(organisationsEinheit.getParentId())).toList())
+				.build();
+	}
+
+	public OrganisationsEinheit getOrganisationsEinheitById(String id) {
+		return repository.findById(id).orElseThrow(() -> new ResourceNotFoundException("Organisationseinheit with id " + id + " not found"));
+	}
+
+	public List<OrganisationsEinheit> getChildren(String parentId) {
+		return repository.findChildren(parentId);
+	}
+}
diff --git a/src/main/java/de/ozgcloud/admin/organisationseinheit/OrganisationsEinheitSettings.java b/src/main/java/de/ozgcloud/admin/organisationseinheit/OrganisationsEinheitSettings.java
new file mode 100644
index 0000000000000000000000000000000000000000..92e6cff4dc846668d7d546eeba4665e13f5bf6e3
--- /dev/null
+++ b/src/main/java/de/ozgcloud/admin/organisationseinheit/OrganisationsEinheitSettings.java
@@ -0,0 +1,14 @@
+package de.ozgcloud.admin.organisationseinheit;
+
+import lombok.Builder;
+import lombok.Getter;
+import lombok.extern.jackson.Jacksonized;
+
+@Getter
+@Builder(toBuilder = true)
+@Jacksonized
+public class OrganisationsEinheitSettings {
+
+	private String signatur;
+
+}
diff --git a/src/main/java/de/ozgcloud/admin/organisationseinheit/OrganisationsEinheitSynchronizationException.java b/src/main/java/de/ozgcloud/admin/organisationseinheit/OrganisationsEinheitSynchronizationException.java
new file mode 100644
index 0000000000000000000000000000000000000000..42944f0a41b1d5eb7cd1a724ab8e1792bc7da7de
--- /dev/null
+++ b/src/main/java/de/ozgcloud/admin/organisationseinheit/OrganisationsEinheitSynchronizationException.java
@@ -0,0 +1,12 @@
+package de.ozgcloud.admin.organisationseinheit;
+
+public class OrganisationsEinheitSynchronizationException extends RuntimeException {
+
+	public OrganisationsEinheitSynchronizationException(String message) {
+		super(message);
+	}
+
+	public OrganisationsEinheitSynchronizationException(String message, Throwable cause) {
+		super(message, cause);
+	}
+}
diff --git a/src/main/java/de/ozgcloud/admin/organisationseinheit/SyncResult.java b/src/main/java/de/ozgcloud/admin/organisationseinheit/SyncResult.java
new file mode 100644
index 0000000000000000000000000000000000000000..8a1d06bcfc138a88ec49ba10c660b0e232535fe3
--- /dev/null
+++ b/src/main/java/de/ozgcloud/admin/organisationseinheit/SyncResult.java
@@ -0,0 +1,5 @@
+package de.ozgcloud.admin.organisationseinheit;
+
+public enum SyncResult {
+	OK, NOT_FOUND_IN_PVOG, NAME_MISMATCH, ORGANISATIONSEINHEIT_ID_NOT_UNIQUE, DELETED
+}
diff --git a/src/main/java/de/ozgcloud/admin/organisationseinheit/SyncScheduler.java b/src/main/java/de/ozgcloud/admin/organisationseinheit/SyncScheduler.java
new file mode 100644
index 0000000000000000000000000000000000000000..116a7e0c61dd25b35b87dff650d47f5fc8566697
--- /dev/null
+++ b/src/main/java/de/ozgcloud/admin/organisationseinheit/SyncScheduler.java
@@ -0,0 +1,24 @@
+package de.ozgcloud.admin.organisationseinheit;
+
+import java.time.Instant;
+
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import lombok.RequiredArgsConstructor;
+import net.javacrumbs.shedlock.spring.annotation.SchedulerLock;
+
+@Component
+@RequiredArgsConstructor
+class SyncScheduler {
+
+	private final SyncService syncService;
+
+	@SchedulerLock(name = "SyncScheduler_syncOrganisationsEinheitenWithKeycloak", lockAtLeastFor = "PT5M", lockAtMostFor = "PT23H")
+	@Scheduled(cron = "${ozgcloud.administration.sync.organisationseinheiten.cron}")
+	public void syncOrganisationsEinheitenWithKeycloak() {
+		var syncTimestamp = Instant.now().toEpochMilli();
+		syncService.syncOrganisationsEinheitenFromKeycloak(syncTimestamp);
+		syncService.syncAddedOrganisationsEinheiten(syncTimestamp);
+	}
+}
diff --git a/src/main/java/de/ozgcloud/admin/organisationseinheit/SyncService.java b/src/main/java/de/ozgcloud/admin/organisationseinheit/SyncService.java
new file mode 100644
index 0000000000000000000000000000000000000000..a1c911a20d16d2cb4fdbbd707417180521c0bd9f
--- /dev/null
+++ b/src/main/java/de/ozgcloud/admin/organisationseinheit/SyncService.java
@@ -0,0 +1,126 @@
+package de.ozgcloud.admin.organisationseinheit;
+
+import java.util.List;
+import java.util.Optional;
+
+import org.springframework.stereotype.Service;
+
+import de.ozgcloud.admin.keycloak.AddGroupData;
+import de.ozgcloud.admin.keycloak.Group;
+import de.ozgcloud.admin.keycloak.KeycloakRemoteService;
+import de.ozgcloud.admin.keycloak.ResourceCreationException;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.log4j.Log4j2;
+
+@Service
+@RequiredArgsConstructor
+@Log4j2
+class SyncService {
+
+	private final OrganisationsEinheitRepository repository;
+	private final KeycloakRemoteService keycloakRemoteService;
+	private final OrganisationsEinheitRemoteService organisationsRemoteService;
+	private final OrganisationsEinheitMapper organisationsEinheitMapper;
+
+	public void syncOrganisationsEinheitenFromKeycloak(long syncTimestamp) {
+		keycloakRemoteService.getGroupsWithOrganisationsEinheitId().forEach(group -> syncGroupsWithSubGroups(group, null, syncTimestamp));
+		repository.setUnsyncedAsDeleted(syncTimestamp);
+	}
+
+	void syncGroupsWithSubGroups(Group group, OrganisationsEinheit parent, long syncTimestamp) {
+		var synced = syncGroup(group, parent, syncTimestamp);
+		var saved = saveSyncedOrganisationsEinheit(synced);
+		group.getSubGroups().forEach(subGroup -> syncGroupsWithSubGroups(subGroup, saved, syncTimestamp));
+	}
+
+	OrganisationsEinheit syncGroup(Group group, OrganisationsEinheit parent, long syncTimestamp) {
+		var pvogOrganisationsEinheiten = organisationsRemoteService.getByOrganisationsEinheitId(group.getOrganisationsEinheitId());
+		var syncedName = syncName(pvogOrganisationsEinheiten, group);
+		var syncResult = evaluateSyncResult(pvogOrganisationsEinheiten, group);
+		var zufiId = syncZufiId(pvogOrganisationsEinheiten);
+		var organisationsEinheitBuilder = OrganisationsEinheit.builder()
+				.keycloakId(group.getId())
+				.name(syncedName)
+				.organisationsEinheitId(group.getOrganisationsEinheitId())
+				.syncResult(syncResult)
+				.zufiId(zufiId)
+				.lastSyncTimestamp(syncTimestamp);
+		if (parent != null) {
+			organisationsEinheitBuilder.parentId(parent.getId());
+		}
+		return organisationsEinheitBuilder.build();
+	}
+
+	String syncName(List<OrganisationsEinheit> pvogOrganisationsEinheiten, Group group) {
+		if (pvogOrganisationsEinheiten.size() != 1) {
+			return group.getName();
+		}
+		return pvogOrganisationsEinheiten.getFirst().getName();
+	}
+
+	SyncResult evaluateSyncResult(List<OrganisationsEinheit> pvogOrganisationsEinheiten, Group group) {
+		if (pvogOrganisationsEinheiten.isEmpty()) {
+			return SyncResult.NOT_FOUND_IN_PVOG;
+		}
+		if (pvogOrganisationsEinheiten.size() > 1) {
+			return SyncResult.ORGANISATIONSEINHEIT_ID_NOT_UNIQUE;
+		}
+		if (!pvogOrganisationsEinheiten.getFirst().getName().equals(group.getName())) {
+			return SyncResult.NAME_MISMATCH;
+		}
+		return SyncResult.OK;
+	}
+
+	public String syncZufiId(List<OrganisationsEinheit> pvogOrganisationsEinheiten) {
+		if (pvogOrganisationsEinheiten.size() != 1) {
+			return null;
+		}
+		return pvogOrganisationsEinheiten.getFirst().getId();
+	}
+
+	OrganisationsEinheit saveSyncedOrganisationsEinheit(OrganisationsEinheit syncedOrganisationsEinheit) {
+		var existingOrganisationsEinheit = repository.findSyncedByKeycloakId(syncedOrganisationsEinheit.getKeycloakId());
+
+		return repository.save(existingOrganisationsEinheit
+				.map(organisationsEinheit -> syncedOrganisationsEinheit.toBuilder()
+						.id(organisationsEinheit.getId())
+						.settings(organisationsEinheit.getSettings()).build())
+				.orElse(syncedOrganisationsEinheit));
+	}
+
+	public void syncAddedOrganisationsEinheiten(long syncTimestamp) {
+		repository.findAllWithoutSyncResult().forEach(
+				organisationsEinheit -> syncAddedOrganisationsEinheit(organisationsEinheit, syncTimestamp));
+	}
+
+	void syncAddedOrganisationsEinheit(OrganisationsEinheit organisationsEinheit, long syncTimestamp) {
+		addAsGroupInKeycloak(organisationsEinheit).ifPresent(
+				addedGroupId -> updateAfterSuccessfulGroupCreation(organisationsEinheit, addedGroupId, syncTimestamp));
+	}
+
+	Optional<String> addAsGroupInKeycloak(OrganisationsEinheit organisationsEinheit) {
+		if (organisationsEinheit.getParentId() != null) {
+			throw new OrganisationsEinheitSynchronizationException(
+					"Organisationseinheit %s has parent".formatted(organisationsEinheit.getOrganisationsEinheitId()));
+		}
+		var addGroupData = organisationsEinheitMapper.toAddGroupData(organisationsEinheit);
+		return addGroupInKeycloak(addGroupData);
+	}
+
+	void updateAfterSuccessfulGroupCreation(OrganisationsEinheit organisationsEinheit, String keycloakId, long syncTimestamp) {
+		var updatedOrganisationsEinheit = organisationsEinheit.toBuilder()
+				.keycloakId(keycloakId)
+				.syncResult(SyncResult.OK)
+				.lastSyncTimestamp(syncTimestamp)
+				.build();
+		repository.save(updatedOrganisationsEinheit);
+	}
+
+	Optional<String> addGroupInKeycloak(AddGroupData addGroupData) {
+		try {
+			return Optional.of(keycloakRemoteService.addGroup(addGroupData));
+		} catch (ResourceCreationException e) {
+			throw new OrganisationsEinheitSynchronizationException("Error creating group %s in Keycloak".formatted(addGroupData), e);
+		}
+	}
+}
diff --git a/src/main/resources/application-dev.yaml b/src/main/resources/application-dev.yaml
index c3514d2f7c0a361129dde64e8f53fb137c55a67f..691b5933ad9270b73fe54d3f0779c5b692e49389 100644
--- a/src/main/resources/application-dev.yaml
+++ b/src/main/resources/application-dev.yaml
@@ -1,2 +1,6 @@
 ozgcloud:
-  production: false
\ No newline at end of file
+  production: false
+  administration:
+    sync:
+      organisationseinheiten:
+        cron: "* */5 * * * *"
\ No newline at end of file
diff --git a/src/main/resources/application-local.yaml b/src/main/resources/application-local.yaml
index c4a009f732ce241044969d32e6252ff0ee5e7091..b39daf33653aa0db748c56b8a2353f4614a84dde 100644
--- a/src/main/resources/application-local.yaml
+++ b/src/main/resources/application-local.yaml
@@ -2,9 +2,23 @@ spring:
   data:
     mongodb:
       uri: mongodb://localhost:27017/config-db
+      database: config-db
   security:
     user:
       name: user
       password: password
 ozgcloud:
-  production: false
\ No newline at end of file
+  production: false
+  oauth2:
+    auth-server-url: http://localhost:8088
+    realm: by-kiel-dev
+    resource: admin
+  keycloak:
+    api:
+      user: administrationApiUser
+      password: administrationApiUser
+
+grpc:
+  client:
+    zufi-manager:
+      negotiationType: PLAINTEXT
\ No newline at end of file
diff --git a/src/main/resources/application-remotekc.yaml b/src/main/resources/application-remotekc.yaml
index dab1f7331534b8c20a8af49afd14db0cdab86345..e64923222403088b888536380a901d46bb3fc442 100644
--- a/src/main/resources/application-remotekc.yaml
+++ b/src/main/resources/application-remotekc.yaml
@@ -2,4 +2,7 @@ ozgcloud:
   oauth2:
     auth-server-url: https://sso.dev.by.ozg-cloud.de
     realm: by-kiel-dev
-    resource: admin
\ No newline at end of file
+    resource: admin
+  keycloak:
+    api:
+      user: administrationApiUser
diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml
index 315e37b9756df692953d1c5761747bfc1e97f4ac..af6a93ebdb869b45fc3c0be85fa38dbe821a0683 100644
--- a/src/main/resources/application.yaml
+++ b/src/main/resources/application.yaml
@@ -71,4 +71,22 @@ spring:
     oauth2:
       resourceserver:
         jwt:
-          issuer-uri: ${ozgcloud.oauth2.auth-server-url}/realms/${ozgcloud.oauth2.realm}
\ No newline at end of file
+          issuer-uri: ${ozgcloud.oauth2.auth-server-url}/realms/${ozgcloud.oauth2.realm}
+
+ozgcloud:
+  keycloak:
+    api:
+      url: ${ozgcloud.oauth2.auth-server-url}
+      realm: ${ozgcloud.oauth2.realm}
+      client: ${ozgcloud.oauth2.resource}
+      organisations-einheit-id-key: organisationseinheitId
+  administration:
+    sync:
+      organisationseinheiten:
+        cron: "0 15 0 * * *"
+
+grpc:
+  client:
+    zufi-manager:
+      address: static://127.0.0.1:9190
+      negotiationType: TLS
\ No newline at end of file
diff --git a/src/test/helm-linter-values.yaml b/src/test/helm-linter-values.yaml
index 35b73c5d7532c489c04350074a6b6476b4f5fb20..49a84a11402b24a67c0b7a3525cc06fe535c7189 100644
--- a/src/test/helm-linter-values.yaml
+++ b/src/test/helm-linter-values.yaml
@@ -34,10 +34,10 @@ networkPolicy:
 sso:
   serverUrl: https://sso.company.local
   operatorNamespace: ozgcloud-keycloak-operator
-  api_users:
-    - name: dummy-user
-      first_name: dummy
-      last_name: user
+  api_user:
+    name: dummy-user
+    first_name: dummy
+    last_name: user
   client:
     additional_redirect_uris:
       - https://dummy:8080
diff --git a/src/test/helm/api_password_secret_test.yaml b/src/test/helm/api_password_secret_test.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..d3848ff1ccfefa89cbc3e0ceae91306823ccff9f
--- /dev/null
+++ b/src/test/helm/api_password_secret_test.yaml
@@ -0,0 +1,81 @@
+#
+# Copyright (C) 2024 Das Land Schleswig-Holstein vertreten durch den
+# Ministerpräsidenten des Landes Schleswig-Holstein
+# Staatskanzlei
+# Abteilung Digitalisierung und zentrales IT-Management der Landesregierung
+#
+# Lizenziert unter der EUPL, Version 1.2 oder - sobald
+# diese von der Europäischen Kommission genehmigt wurden -
+# Folgeversionen der EUPL ("Lizenz");
+# Sie dürfen dieses Werk ausschließlich gemäß
+# dieser Lizenz nutzen.
+# Eine Kopie der Lizenz finden Sie hier:
+#
+# https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+#
+# Sofern nicht durch anwendbare Rechtsvorschriften
+# gefordert oder in schriftlicher Form vereinbart, wird
+# die unter der Lizenz verbreitete Software "so wie sie
+# ist", OHNE JEGLICHE GEWÄHRLEISTUNG ODER BEDINGUNGEN -
+# ausdrücklich oder stillschweigend - verbreitet.
+# Die sprachspezifischen Genehmigungen und Beschränkungen
+# unter der Lizenz sind dem Lizenztext zu entnehmen.
+#
+
+suite: test api password secret
+release:
+  name: administration
+  namespace: sh-helm-test
+templates:
+  - templates/api_password_secret.yaml
+tests:
+  - it: should not create api_password_secret if api_user set
+    set:
+      sso:
+        api_user:
+          name: administrationApiUser
+    asserts:
+      - hasDocuments:
+          count: 0
+  - it: should fail if api_user not set and password not set
+    asserts:
+      - failedTemplate:
+          errorMessage: "ozgcloud.keycloak.api.password must be set"
+  - it: should create api_password_secret if api_user not set and password set
+    set:
+      ozgcloud:
+        keycloak:
+          api:
+            password: testPassword
+    asserts:
+      - hasDocuments:
+          count: 1
+  - it: test api secret kind
+    set:
+      ozgcloud:
+        keycloak:
+          api:
+            password: testPassword
+    asserts:
+      - isKind:
+          of: Secret
+      - isAPIVersion:
+          of: v1
+  - it: test api password
+    set:
+      ozgcloud:
+        keycloak:
+          api:
+            password: testPassword
+    asserts:
+      - equal:
+          path: stringData.password
+          value: testPassword
+  - it: not create api_password_secret if api_user set
+    set:
+      sso:
+        api_user:
+          name: administrationApiUser
+    asserts:
+      - hasDocuments:
+          count: 0
\ No newline at end of file
diff --git a/src/test/helm/deployment_env_test.yaml b/src/test/helm/deployment_env_test.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..1bf9144e8ce2a3a31e1dbc2330bb63c9fc0efb7f
--- /dev/null
+++ b/src/test/helm/deployment_env_test.yaml
@@ -0,0 +1,58 @@
+#
+# Copyright (C) 2022 Das Land Schleswig-Holstein vertreten durch den
+# Ministerpräsidenten des Landes Schleswig-Holstein
+# Staatskanzlei
+# Abteilung Digitalisierung und zentrales IT-Management der Landesregierung
+#
+# Lizenziert unter der EUPL, Version 1.2 oder - sobald
+# diese von der Europäischen Kommission genehmigt wurden -
+# Folgeversionen der EUPL ("Lizenz");
+# Sie dürfen dieses Werk ausschließlich gemäß
+# dieser Lizenz nutzen.
+# Eine Kopie der Lizenz finden Sie hier:
+#
+# https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+#
+# Sofern nicht durch anwendbare Rechtsvorschriften
+# gefordert oder in schriftlicher Form vereinbart, wird
+# die unter der Lizenz verbreitete Software "so wie sie
+# ist", OHNE JEGLICHE GEWÄHRLEISTUNG ODER BEDINGUNGEN -
+# ausdrücklich oder stillschweigend - verbreitet.
+# Die sprachspezifischen Genehmigungen und Beschränkungen
+# unter der Lizenz sind dem Lizenztext zu entnehmen.
+#
+
+suite: test environment variables
+release:
+  name: administration
+  namespace: by-helm-test
+templates:
+  - templates/deployment.yaml
+set:
+  ozgcloud:
+    bundesland: sh
+    bezeichner: helm
+  sso:
+    serverUrl: https://sso.company.local
+  imagePullSecret: image-pull-secret
+tests:
+  - it: should not contain organisationseinheiten sync cron by default
+    asserts:
+      - notContains:
+          path: spec.template.spec.containers[0].env
+          content:
+            name: ozgcloud_administration_sync_organisationseinheiten_cron
+  - it: should contain organisationseinheiten sync cron
+    set:
+      ozgcloud:
+        sync:
+          organisationseinheiten:
+            cron: "*/15 * * * *"
+    asserts:
+      - contains:
+          path: spec.template.spec.containers[0].env
+          content:
+            name: ozgcloud_administration_sync_organisationseinheiten_cron
+            value: "*/15 * * * *"
+
+
diff --git a/src/test/helm/deployment_keycloak_api_env_test.yaml b/src/test/helm/deployment_keycloak_api_env_test.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..2e0ad9b249cbf409bcad99ade931524e3f3f7766
--- /dev/null
+++ b/src/test/helm/deployment_keycloak_api_env_test.yaml
@@ -0,0 +1,91 @@
+#
+# Copyright (C) 2022 Das Land Schleswig-Holstein vertreten durch den
+# Ministerpräsidenten des Landes Schleswig-Holstein
+# Staatskanzlei
+# Abteilung Digitalisierung und zentrales IT-Management der Landesregierung
+#
+# Lizenziert unter der EUPL, Version 1.2 oder - sobald
+# diese von der Europäischen Kommission genehmigt wurden -
+# Folgeversionen der EUPL ("Lizenz");
+# Sie dürfen dieses Werk ausschließlich gemäß
+# dieser Lizenz nutzen.
+# Eine Kopie der Lizenz finden Sie hier:
+#
+# https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+#
+# Sofern nicht durch anwendbare Rechtsvorschriften
+# gefordert oder in schriftlicher Form vereinbart, wird
+# die unter der Lizenz verbreitete Software "so wie sie
+# ist", OHNE JEGLICHE GEWÄHRLEISTUNG ODER BEDINGUNGEN -
+# ausdrücklich oder stillschweigend - verbreitet.
+# Die sprachspezifischen Genehmigungen und Beschränkungen
+# unter der Lizenz sind dem Lizenztext zu entnehmen.
+#
+
+suite: test environment variables for username and password of keycloak api user
+release:
+  name: administration
+  namespace: sh-helm-test
+templates:
+  - templates/deployment.yaml
+set:
+  baseUrl: test.company.local
+  ozgcloud:
+    environment: test
+    bundesland: sh
+    bezeichner: helm
+  sso:
+    serverUrl: https://sso.company.local
+  imagePullSecret: image-pull-secret
+tests:
+  - it: should set user from values when api_user not set
+    asserts:
+      - contains:
+          path: spec.template.spec.containers[0].env
+          content:
+            name: ozgcloud_keycloak_api_user
+            value: administrationApiUser
+
+  - it: should set user from operator-generated secret when api_user is set
+    set:
+      sso:
+        api_user:
+          name: -administration-ApiUser
+    asserts:
+      - contains:
+          path: spec.template.spec.containers[0].env
+          content:
+            name: ozgcloud_keycloak_api_user
+            valueFrom:
+              secretKeyRef:
+                name: administrationapiuser-credentials
+                key: name
+                optional: false
+
+  - it: should set password from template-generated secret when api_user is not set
+    asserts:
+      - contains:
+          path: spec.template.spec.containers[0].env
+          content:
+            name: ozgcloud_keycloak_api_password
+            valueFrom:
+              secretKeyRef:
+                name: administration-api-password
+                key: password
+                optional: false
+
+  - it: should set password from operator-generated secret when api_user is set
+    set:
+      sso:
+        api_user:
+          name: -administration-ApiUser
+    asserts:
+      - contains:
+          path: spec.template.spec.containers[0].env
+          content:
+            name: ozgcloud_keycloak_api_password
+            valueFrom:
+              secretKeyRef:
+                name: administrationapiuser-credentials
+                key: password
+                optional: false
diff --git a/src/test/helm/keycloak_user_crd_test.yaml b/src/test/helm/keycloak_user_crd_test.yaml
index 29c901d0387015bdb782a9ad0b71a115ea16c2cc..850cb2c8f32d43704d8ed8a055ae40aa6d0b99d6 100644
--- a/src/test/helm/keycloak_user_crd_test.yaml
+++ b/src/test/helm/keycloak_user_crd_test.yaml
@@ -32,8 +32,8 @@ tests:
   - it: should contain header data
     set:
       sso:
-        api_users:
-          - name: testapiuser
+        api_user:
+          name: testapiuser
     asserts:
       - isAPIVersion:
           of: operator.ozgcloud.de/v1
@@ -42,8 +42,8 @@ tests:
   - it: should have metadata
     set:
       sso:
-        api_users:
-          - name: testapiuser
+        api_user:
+          name: testapiuser
     asserts:
       - equal:
           path: metadata.name
@@ -58,8 +58,8 @@ tests:
         bezeichner: helm
         environment: test
       sso:
-        api_users:
-          - name: testapiuser
+        api_user:
+          name: testapiuser
     asserts:
       - equal:
           path: spec.keep_after_delete
@@ -248,11 +248,11 @@ tests:
         environment: test
       baseUrl: "test.by.ozg-cloud.de"
       sso:
-        api_users:
-          - name: testapiuser
-            first_name: Api
-            last_name: User
-            email: testapiuser@ozg-sh.de
+        api_user:
+          name: testapiuser
+          first_name: Api
+          last_name: User
+          email: testapiuser@ozg-sh.de
     asserts:
       - equal:
           path: spec.keep_after_delete
@@ -284,14 +284,14 @@ tests:
         environment: test
       baseUrl: "test.by.ozg-cloud.de"
       sso:
-        api_users:
-          - name: testapiuser
-            first_name: Api
-            last_name: User
-            email: testapiuser@ozg-sh.de
-            client_roles:
-              - name: realm-management
-                role: view-users
+        api_user:
+          name: testapiuser
+          first_name: Api
+          last_name: User
+          email: testapiuser@ozg-sh.de
+          client_roles:
+            - name: realm-management
+              role: view-users
     asserts:
       - equal:
           path: spec.keep_after_delete
@@ -326,13 +326,13 @@ tests:
         environment: test
       baseUrl: "test.by.ozg-cloud.de"
       sso:
-        api_users:
-          - name: testapiuser
-            first_name: Api
-            last_name: User
-            email: testapiuser@ozg-sh.de
-            realm_roles:
-              - "offline_access"
+        api_user:
+          name: testapiuser
+          first_name: Api
+          last_name: User
+          email: testapiuser@ozg-sh.de
+          realm_roles:
+            - "offline_access"
     asserts:
       - equal:
           path: spec.keep_after_delete
@@ -366,13 +366,13 @@ tests:
         environment: test
       baseUrl: "test.by.ozg-cloud.de"
       sso:
-        api_users:
-          - name: testapiuser
-            first_name: Api
-            last_name: User
-            email: testapiuser@ozg-sh.de
-            groups:
-              - Bauamt
+        api_user:
+          name: testapiuser
+          first_name: Api
+          last_name: User
+          email: testapiuser@ozg-sh.de
+          groups:
+            - Bauamt
     asserts:
       - equal:
           path: spec.keep_after_delete
@@ -406,16 +406,16 @@ tests:
         environment: test
       baseUrl: "test.by.ozg-cloud.de"
       sso:
-        api_users:
-          - name: testapiuser
-            first_name: Api
-            last_name: User
-            email: testapiuser@ozg-sh.de
-            realm_roles:
-              - "offline_access"
-            client_roles:
-              - name: realm-management
-                role: view-users
+        api_user:
+          name: testapiuser
+          first_name: Api
+          last_name: User
+          email: testapiuser@ozg-sh.de
+          realm_roles:
+            - "offline_access"
+          client_roles:
+            - name: realm-management
+              role: view-users
         keycloak_users:
           - name: dorothea
             first_name: Dorothea
@@ -506,8 +506,8 @@ tests:
         bezeichner: helm
         environment: test
       sso:
-        api_users:
-          - name: testApiUser
+        api_user:
+          name: testApiUser
     asserts:
       - equal:
           path: spec.keycloak_user.username
@@ -546,9 +546,9 @@ tests:
   - it: should set updateUser
     set:
       sso:
-        api_users:
-          - name: testapiuser
-            update_user: true
+        api_user:
+          name: testapiuser
+          update_user: true
     asserts:
       - equal:
           path: spec.update_user
@@ -557,8 +557,8 @@ tests:
   - it: should set default updateUser to false
     set:
       sso:
-        api_users:
-          - name: testapiuser
+        api_user:
+          name: testapiuser
     asserts:
       - equal:
           path: spec.update_user
diff --git a/src/test/helm/ozgcloud_keycloak_operator_secrets_read_role_binding_test.yaml b/src/test/helm/ozgcloud_keycloak_operator_secrets_read_role_binding_test.yaml
index 6365584bef0d88e2cd172076609c1d918ae877ee..a1c34ab3ecaa3f81f5fb0ebefbb1910b28279e8d 100644
--- a/src/test/helm/ozgcloud_keycloak_operator_secrets_read_role_binding_test.yaml
+++ b/src/test/helm/ozgcloud_keycloak_operator_secrets_read_role_binding_test.yaml
@@ -62,11 +62,11 @@ tests:
     asserts:
       - hasDocuments:
           count: 0
-  - it: should have subjects values on api_users
+  - it: should have subjects values on api_user
     set:
       sso:
-        api_users:
-          - name: apiUser
+        api_user:
+          name: apiUser
         operatorNamespace: test-operator-namespace
     asserts:
       - contains:
@@ -80,8 +80,8 @@ tests:
     set:
       sso:
         disableOzgOperator: true
-        api_users:
-          - name: apiUser
+        api_user:
+          name: apiUser
     asserts:
       - hasDocuments:
           count: 0
diff --git a/src/test/helm/ozgcloud_keycloak_operator_secrets_read_role_test.yaml b/src/test/helm/ozgcloud_keycloak_operator_secrets_read_role_test.yaml
index f5e9ad5adcec2bd2d26ac27d602acbd2e1f54c7d..a35779d64276cad49fab52054cd68301cd60a0fb 100644
--- a/src/test/helm/ozgcloud_keycloak_operator_secrets_read_role_test.yaml
+++ b/src/test/helm/ozgcloud_keycloak_operator_secrets_read_role_test.yaml
@@ -55,11 +55,11 @@ tests:
     asserts:
       - hasDocuments:
           count: 0
-  - it: should have subjects values on api_users
+  - it: should have subjects values on api_user
     set:
       sso:
-        api_users:
-          - name: apiUser-
+        api_user:
+          name: apiUser
     asserts:
       - contains:
           path: rules
@@ -78,8 +78,8 @@ tests:
     set:
       sso:
         disableOzgOperator: true
-        api_users:
-          - name: apiUser
+        api_user:
+          name: apiUser
     asserts:
       - hasDocuments:
           count: 0
\ No newline at end of file
diff --git a/src/test/helm/ozgcloud_keycloak_operator_secrets_write_role_binding_test.yaml b/src/test/helm/ozgcloud_keycloak_operator_secrets_write_role_binding_test.yaml
index cbc773bf876afcb77f766dd68ead204453be20e4..3980873ea6ac6a58c8d00b5be397f27190dcaca3 100644
--- a/src/test/helm/ozgcloud_keycloak_operator_secrets_write_role_binding_test.yaml
+++ b/src/test/helm/ozgcloud_keycloak_operator_secrets_write_role_binding_test.yaml
@@ -62,11 +62,11 @@ tests:
     asserts:
       - hasDocuments:
           count: 0
-  - it: should have subjects values on api_users
+  - it: should have subjects values on api_user
     set:
       sso:
-        api_users:
-          - name: apiUsers
+        api_user:
+          name: apiUsers
         operatorNamespace: test-operator-namespace
     asserts:
       - contains:
@@ -80,8 +80,8 @@ tests:
     set:
       sso:
         disableOzgOperator: true
-        api_users:
-          - name: apiUser
+        api_user:
+          name: apiUser
     asserts:
       - hasDocuments:
           count: 0
diff --git a/src/test/helm/ozgcloud_keycloak_operator_secrets_write_role_test.yaml b/src/test/helm/ozgcloud_keycloak_operator_secrets_write_role_test.yaml
index a06970b1d97c3800f49474ce22173467547a0703..b9d68a70a47a42336d1ede35d294115b67f51a7d 100644
--- a/src/test/helm/ozgcloud_keycloak_operator_secrets_write_role_test.yaml
+++ b/src/test/helm/ozgcloud_keycloak_operator_secrets_write_role_test.yaml
@@ -47,11 +47,11 @@ tests:
     asserts:
       - hasDocuments:
           count: 0
-  - it: should have subjects values on api_users
+  - it: should have subjects values on api_user
     set:
       sso:
-        api_users:
-          - name: apiUser
+        api_user:
+          name: apiUser
     asserts:
       - contains:
           path: rules
@@ -67,8 +67,8 @@ tests:
     set:
       sso:
         disableOzgOperator: true
-        api_users:
-          - name: apiUser
+        api_user:
+          name: apiUser
     asserts:
       - hasDocuments:
           count: 0
\ No newline at end of file
diff --git a/src/test/java/de/ozgcloud/admin/AdministrationRepositoryRestConfigurerTest.java b/src/test/java/de/ozgcloud/admin/AdministrationRepositoryRestConfigurerTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..c07cece24c0f6f57e39c49a0e5b3273f123b4ebb
--- /dev/null
+++ b/src/test/java/de/ozgcloud/admin/AdministrationRepositoryRestConfigurerTest.java
@@ -0,0 +1,37 @@
+package de.ozgcloud.admin;
+
+import static org.assertj.core.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Captor;
+import org.mockito.InjectMocks;
+import org.springframework.data.rest.core.config.RepositoryRestConfiguration;
+import org.springframework.hateoas.server.LinkRelationProvider;
+import org.springframework.hateoas.server.core.DefaultLinkRelationProvider;
+
+class AdministrationRepositoryRestConfigurerTest {
+
+	@InjectMocks
+	private AdministrationRepositoryRestConfigurer configurer;
+
+	@Nested
+	class TestConfigureRepositoryRestConfiguration {
+
+		@Captor
+		private ArgumentCaptor<LinkRelationProvider> linkRelationProviderArgumentCaptor;
+
+		@Test
+		void shouldUseDefaultLinkRelationProvider() {
+			var configuration = mock(RepositoryRestConfiguration.class);
+
+			configurer.configureRepositoryRestConfiguration(configuration, null);
+
+			verify(configuration).setLinkRelationProvider(linkRelationProviderArgumentCaptor.capture());
+			assertThat(linkRelationProviderArgumentCaptor.getValue()).isInstanceOf(DefaultLinkRelationProvider.class);
+		}
+	}
+
+}
\ No newline at end of file
diff --git a/src/test/java/de/ozgcloud/admin/common/CollectionModelBuilderTest.java b/src/test/java/de/ozgcloud/admin/common/CollectionModelBuilderTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..bc4dd74e3f2b4121bf7fc681b8ab5de5cdc42fde
--- /dev/null
+++ b/src/test/java/de/ozgcloud/admin/common/CollectionModelBuilderTest.java
@@ -0,0 +1,92 @@
+package de.ozgcloud.admin.common;
+
+import static org.assertj.core.api.Assertions.*;
+
+import java.util.List;
+import java.util.stream.Stream;
+
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.springframework.hateoas.Link;
+
+import de.ozgcloud.admin.organisationseinheit.OrganisationsEinheit;
+import de.ozgcloud.admin.organisationseinheit.OrganisationsEinheitTestFactory;
+
+class CollectionModelBuilderTest {
+
+	private final String HREF = "http://test";
+	private final String REL = "rel";
+
+	@Nested
+	class TestBuildModel {
+
+		@Test
+		void shouldBuildModel() {
+			var vorgang = OrganisationsEinheitTestFactory.create();
+
+			var model = CollectionModelBuilder.fromEntities(List.of(vorgang)).buildModel();
+
+			assertThat(model.getContent()).hasSize(1).first().usingRecursiveComparison().isEqualTo(vorgang);
+		}
+	}
+
+	@Nested
+	class TestAddLink {
+
+		@Test
+		void shouldAddLink() {
+			var model = CollectionModelBuilder.fromEntities(List.of()).addLink(Link.of(HREF, REL)).buildModel();
+
+			assertThat(model.getLinks()).hasSize(1).first().extracting(Link::getHref, link -> link.getRel().value()).containsExactly(HREF, REL);
+		}
+	}
+
+	@Nested
+	class TestIfMatch {
+
+		@Nested
+		class TestWithBooleanSupplier {
+
+			@Test
+			void shouldAddLink() {
+				var model = CollectionModelBuilder.fromEntities(List.of()).ifMatch(() -> true).addLink(Link.of(HREF, REL)).buildModel();
+
+				assertThat(model.getLinks()).hasSize(1).first().extracting(Link::getHref, link -> link.getRel().value()).containsExactly(HREF, REL);
+			}
+
+			@Test
+			void shouldNotAddLink() {
+				var model = CollectionModelBuilder.fromEntities(List.of()).ifMatch(() -> false).addLink(Link.of(HREF, REL)).buildModel();
+
+				assertThat(model.getLinks()).isEmpty();
+			}
+		}
+
+		@Nested
+		class TestWithPredicate {
+
+			private final Stream<OrganisationsEinheit> wiedervorlageStream = Stream.of(OrganisationsEinheitTestFactory.create());
+
+			@Test
+			void shouldAddLink() {
+				var model = CollectionModelBuilder.fromEntities(wiedervorlageStream)
+						.ifMatch(wiedervorlagen -> true)
+						.addLink(Link.of(HREF, REL))
+						.buildModel();
+
+				assertThat(model.getLinks()).hasSize(1).first().extracting(Link::getHref, link -> link.getRel().value()).containsExactly(HREF, REL);
+			}
+
+			@Test
+			void shouldNotAddLink() {
+				var model = CollectionModelBuilder.fromEntities(wiedervorlageStream)
+						.ifMatch(wiedervorlagen -> false)
+						.addLink(Link.of(HREF, REL))
+						.buildModel();
+
+				assertThat(model.getLinks()).isEmpty();
+			}
+		}
+	}
+
+}
\ No newline at end of file
diff --git a/src/test/java/de/ozgcloud/admin/common/EntityModelTestFactory.java b/src/test/java/de/ozgcloud/admin/common/EntityModelTestFactory.java
new file mode 100644
index 0000000000000000000000000000000000000000..99617d582151e26fa46c12e4ef40e585b18fa0eb
--- /dev/null
+++ b/src/test/java/de/ozgcloud/admin/common/EntityModelTestFactory.java
@@ -0,0 +1,20 @@
+package de.ozgcloud.admin.common;
+
+import org.springframework.hateoas.EntityModel;
+
+import de.ozgcloud.admin.organisationseinheit.OrganisationsEinheit;
+import lombok.NoArgsConstructor;
+
+public class EntityModelTestFactory {
+
+	public static final NullableEntityModel NULLABLE = createNullable();
+
+	private static NullableEntityModel createNullable() {
+		return new NullableEntityModel();
+	}
+
+	@NoArgsConstructor
+	private static class NullableEntityModel extends EntityModel<OrganisationsEinheit> {
+
+	}
+}
diff --git a/src/test/java/de/ozgcloud/admin/common/IdBuilderTest.java b/src/test/java/de/ozgcloud/admin/common/IdBuilderTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..6a3d3a08d5b55434bc96d58632846d2da3936eab
--- /dev/null
+++ b/src/test/java/de/ozgcloud/admin/common/IdBuilderTest.java
@@ -0,0 +1,59 @@
+/*
+ * Copyright (C) 2022 Das Land Schleswig-Holstein vertreten durch den
+ * Ministerpräsidenten des Landes Schleswig-Holstein
+ * Staatskanzlei
+ * Abteilung Digitalisierung und zentrales IT-Management der Landesregierung
+ *
+ * Lizenziert unter der EUPL, Version 1.2 oder - sobald
+ * diese von der Europäischen Kommission genehmigt wurden -
+ * Folgeversionen der EUPL ("Lizenz");
+ * Sie dürfen dieses Werk ausschließlich gemäß
+ * dieser Lizenz nutzen.
+ * Eine Kopie der Lizenz finden Sie hier:
+ *
+ * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * Sofern nicht durch anwendbare Rechtsvorschriften
+ * gefordert oder in schriftlicher Form vereinbart, wird
+ * die unter der Lizenz verbreitete Software "so wie sie
+ * ist", OHNE JEGLICHE GEWÄHRLEISTUNG ODER BEDINGUNGEN -
+ * ausdrücklich oder stillschweigend - verbreitet.
+ * Die sprachspezifischen Genehmigungen und Beschränkungen
+ * unter der Lizenz sind dem Lizenztext zu entnehmen.
+ */
+package de.ozgcloud.admin.common;
+
+import static org.assertj.core.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import com.fasterxml.jackson.databind.BeanProperty;
+
+class IdBuilderTest {
+
+	@DisplayName("Test building ID when deserializing linked resources")
+	@Nested
+	class TestBuilingId {
+		private static final String ID = "id";
+
+		@Test
+		void shouldBuildId() {
+			IdBuilder idBuilder = new IdBuilder();
+
+			var idObject = idBuilder.build(ID);
+
+			assertThat(idObject).isInstanceOf(Object.class).asString().isEqualTo(ID);
+		}
+
+		@Test
+		void shouldCreateObjectBuilder() {
+			BeanProperty property = mock(BeanProperty.class);
+			ObjectBuilder<Object> idBuilder = new IdBuilder().constructContextAware(property);
+
+			assertThat(idBuilder).isNotNull();
+		}
+	}
+}
diff --git a/src/test/java/de/ozgcloud/admin/common/KeycloakInitializer.java b/src/test/java/de/ozgcloud/admin/common/KeycloakInitializer.java
new file mode 100644
index 0000000000000000000000000000000000000000..7fec947e3c250ecaa53b158bfa266c05e57acf9e
--- /dev/null
+++ b/src/test/java/de/ozgcloud/admin/common/KeycloakInitializer.java
@@ -0,0 +1,47 @@
+package de.ozgcloud.admin.common;
+
+import java.time.Duration;
+
+import org.springframework.boot.test.util.TestPropertyValues;
+import org.springframework.context.ApplicationContextInitializer;
+import org.springframework.context.ConfigurableApplicationContext;
+import org.testcontainers.containers.wait.strategy.Wait;
+
+import dasniko.testcontainers.keycloak.KeycloakContainer;
+import lombok.extern.log4j.Log4j2;
+
+@Log4j2
+public class KeycloakInitializer implements ApplicationContextInitializer<ConfigurableApplicationContext> {
+
+	private static final String AUTH_SERVER_URL = "ozgcloud.oauth2.auth-server-url";
+
+	private static KeycloakContainer keycloakContainer;
+
+	@Override
+	public void initialize(ConfigurableApplicationContext applicationContext) {
+		initContainer();
+		setProperties(applicationContext);
+	}
+
+	@SuppressWarnings("resource")
+	private void initContainer() {
+		if (keycloakContainer == null) {
+			LOG.info("Creating Keycloak-container...");
+			keycloakContainer = new KeycloakContainer().withRealmImportFile("keycloak/realm-export.json").withVerboseOutput();
+		}
+		if (!keycloakContainer.isRunning()) {
+			LOG.info("Starting Keycloak-container...");
+			keycloakContainer.setWaitStrategy(
+					Wait.forLogMessage(".*message\":\"started.*", 1).withStartupTimeout(Duration.ofMinutes(3)));
+			keycloakContainer.start();
+			LOG.info("Keycloak-container started");
+		}
+	}
+
+	private void setProperties(ConfigurableApplicationContext applicationContext) {
+		LOG.info("Keycloak URL: {}", keycloakContainer.getAuthServerUrl());
+
+		TestPropertyValues.of(AUTH_SERVER_URL + "=" + keycloakContainer.getAuthServerUrl()).applyTo(applicationContext);
+
+	}
+}
diff --git a/src/test/java/de/ozgcloud/admin/common/LinkedResourceDeserializerTest.java b/src/test/java/de/ozgcloud/admin/common/LinkedResourceDeserializerTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..85c2cffe20da1dfc5195087a66dc8ef069080f55
--- /dev/null
+++ b/src/test/java/de/ozgcloud/admin/common/LinkedResourceDeserializerTest.java
@@ -0,0 +1,56 @@
+/*
+ * Copyright (C) 2022 Das Land Schleswig-Holstein vertreten durch den
+ * Ministerpräsidenten des Landes Schleswig-Holstein
+ * Staatskanzlei
+ * Abteilung Digitalisierung und zentrales IT-Management der Landesregierung
+ *
+ * Lizenziert unter der EUPL, Version 1.2 oder - sobald
+ * diese von der Europäischen Kommission genehmigt wurden -
+ * Folgeversionen der EUPL ("Lizenz");
+ * Sie dürfen dieses Werk ausschließlich gemäß
+ * dieser Lizenz nutzen.
+ * Eine Kopie der Lizenz finden Sie hier:
+ *
+ * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * Sofern nicht durch anwendbare Rechtsvorschriften
+ * gefordert oder in schriftlicher Form vereinbart, wird
+ * die unter der Lizenz verbreitete Software "so wie sie
+ * ist", OHNE JEGLICHE GEWÄHRLEISTUNG ODER BEDINGUNGEN -
+ * ausdrücklich oder stillschweigend - verbreitet.
+ * Die sprachspezifischen Genehmigungen und Beschränkungen
+ * unter der Lizenz sind dem Lizenztext zu entnehmen.
+ */
+package de.ozgcloud.admin.common;
+
+import static org.assertj.core.api.Assertions.*;
+
+import java.io.IOException;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import com.fasterxml.jackson.core.exc.StreamReadException;
+import com.fasterxml.jackson.databind.DatabindException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import de.ozgcloud.admin.common.user.TestId;
+import de.ozgcloud.admin.organisationseinheit.OrganisationsEinheitTestFactory;
+
+class LinkedResourceDeserializerTest {
+	private static final String TEST_JSON = "{\"id\":\"/api/vorgangs/" + OrganisationsEinheitTestFactory.ID + "\"}";
+
+	@DisplayName("Test the deserilization of linked resource json")
+	@Nested
+	class TestDeserialization {
+		@Test
+		void shouldDeserialize() throws IOException {
+			LinkedResourceTestObject res = new ObjectMapper().readValue(TEST_JSON.getBytes(), LinkedResourceTestObject.class);
+
+			assertThat(res).hasFieldOrPropertyWithValue("id", TestId.from(OrganisationsEinheitTestFactory.ID));
+		}
+
+	}
+
+}
diff --git a/src/test/java/de/ozgcloud/admin/common/LinkedResourceSerializerTest.java b/src/test/java/de/ozgcloud/admin/common/LinkedResourceSerializerTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..dacf8bcedacb365629ea84c8e5366ed8f540b994
--- /dev/null
+++ b/src/test/java/de/ozgcloud/admin/common/LinkedResourceSerializerTest.java
@@ -0,0 +1,53 @@
+/*
+ * Copyright (C) 2022 Das Land Schleswig-Holstein vertreten durch den
+ * Ministerpräsidenten des Landes Schleswig-Holstein
+ * Staatskanzlei
+ * Abteilung Digitalisierung und zentrales IT-Management der Landesregierung
+ *
+ * Lizenziert unter der EUPL, Version 1.2 oder - sobald
+ * diese von der Europäischen Kommission genehmigt wurden -
+ * Folgeversionen der EUPL ("Lizenz");
+ * Sie dürfen dieses Werk ausschließlich gemäß
+ * dieser Lizenz nutzen.
+ * Eine Kopie der Lizenz finden Sie hier:
+ *
+ * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * Sofern nicht durch anwendbare Rechtsvorschriften
+ * gefordert oder in schriftlicher Form vereinbart, wird
+ * die unter der Lizenz verbreitete Software "so wie sie
+ * ist", OHNE JEGLICHE GEWÄHRLEISTUNG ODER BEDINGUNGEN -
+ * ausdrücklich oder stillschweigend - verbreitet.
+ * Die sprachspezifischen Genehmigungen und Beschränkungen
+ * unter der Lizenz sind dem Lizenztext zu entnehmen.
+ */
+package de.ozgcloud.admin.common;
+
+import static org.assertj.core.api.Assertions.*;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import de.ozgcloud.admin.common.user.TestId;
+import de.ozgcloud.admin.organisationseinheit.OrganisationsEinheitTestFactory;
+
+class LinkedResourceSerializerTest {
+
+	@DisplayName("Test the json serialization of linked resource annotations")
+	@Nested
+	class TestSerialization {
+		@Test
+		void shouldSerialize() throws JsonProcessingException {
+			var testObj = new LinkedResourceTestObject(TestId.from(OrganisationsEinheitTestFactory.ID));
+
+			String serialized = new ObjectMapper().writeValueAsString(testObj);
+
+			assertThat(serialized).isEqualTo("{\"id\":\"/api/organisationseinheits/" + OrganisationsEinheitTestFactory.ID + "\"}");
+		}
+
+	}
+}
diff --git a/src/test/java/de/ozgcloud/admin/common/LinkedResourceTestObject.java b/src/test/java/de/ozgcloud/admin/common/LinkedResourceTestObject.java
new file mode 100644
index 0000000000000000000000000000000000000000..f387e57475d5355f758eb1d2941d77fc51f4a74c
--- /dev/null
+++ b/src/test/java/de/ozgcloud/admin/common/LinkedResourceTestObject.java
@@ -0,0 +1,38 @@
+/*
+ * Copyright (C) 2022 Das Land Schleswig-Holstein vertreten durch den
+ * Ministerpräsidenten des Landes Schleswig-Holstein
+ * Staatskanzlei
+ * Abteilung Digitalisierung und zentrales IT-Management der Landesregierung
+ *
+ * Lizenziert unter der EUPL, Version 1.2 oder - sobald
+ * diese von der Europäischen Kommission genehmigt wurden -
+ * Folgeversionen der EUPL ("Lizenz");
+ * Sie dürfen dieses Werk ausschließlich gemäß
+ * dieser Lizenz nutzen.
+ * Eine Kopie der Lizenz finden Sie hier:
+ *
+ * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * Sofern nicht durch anwendbare Rechtsvorschriften
+ * gefordert oder in schriftlicher Form vereinbart, wird
+ * die unter der Lizenz verbreitete Software "so wie sie
+ * ist", OHNE JEGLICHE GEWÄHRLEISTUNG ODER BEDINGUNGEN -
+ * ausdrücklich oder stillschweigend - verbreitet.
+ * Die sprachspezifischen Genehmigungen und Beschränkungen
+ * unter der Lizenz sind dem Lizenztext zu entnehmen.
+ */
+package de.ozgcloud.admin.common;
+
+import de.ozgcloud.admin.common.user.TestId;
+import de.ozgcloud.admin.organisationseinheit.OrganisationsEinheitController;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Getter
+@AllArgsConstructor
+@NoArgsConstructor
+class LinkedResourceTestObject {
+	@LinkedResource(controllerClass = OrganisationsEinheitController.class)
+	private TestId id;
+}
diff --git a/src/test/java/de/ozgcloud/admin/common/ModelBuilderTest.java b/src/test/java/de/ozgcloud/admin/common/ModelBuilderTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..600e54392d19e7bf6b107ae6741f5b80488c926b
--- /dev/null
+++ b/src/test/java/de/ozgcloud/admin/common/ModelBuilderTest.java
@@ -0,0 +1,105 @@
+/*
+ * Copyright (C) 2022 Das Land Schleswig-Holstein vertreten durch den
+ * Ministerpräsidenten des Landes Schleswig-Holstein
+ * Staatskanzlei
+ * Abteilung Digitalisierung und zentrales IT-Management der Landesregierung
+ *
+ * Lizenziert unter der EUPL, Version 1.2 oder - sobald
+ * diese von der Europäischen Kommission genehmigt wurden -
+ * Folgeversionen der EUPL ("Lizenz");
+ * Sie dürfen dieses Werk ausschließlich gemäß
+ * dieser Lizenz nutzen.
+ * Eine Kopie der Lizenz finden Sie hier:
+ *
+ * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * Sofern nicht durch anwendbare Rechtsvorschriften
+ * gefordert oder in schriftlicher Form vereinbart, wird
+ * die unter der Lizenz verbreitete Software "so wie sie
+ * ist", OHNE JEGLICHE GEWÄHRLEISTUNG ODER BEDINGUNGEN -
+ * ausdrücklich oder stillschweigend - verbreitet.
+ * Die sprachspezifischen Genehmigungen und Beschränkungen
+ * unter der Lizenz sind dem Lizenztext zu entnehmen.
+ */
+package de.ozgcloud.admin.common;
+
+import static org.assertj.core.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+import java.util.UUID;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.NullAndEmptySource;
+import org.mockito.Mock;
+import org.springframework.context.ApplicationContext;
+import org.springframework.core.env.Environment;
+import org.springframework.web.bind.annotation.RequestMapping;
+
+import lombok.Builder;
+
+class ModelBuilderTest {
+
+	@DisplayName("Add link by annotation if missing")
+	@Nested
+	class TestAddLinkByAnnotationIfMissing {
+
+		private static final String USER_MANAGER_URL = "http://localhost";
+		private static final String USER_MANAGER_PROFILE_TEMPLATE = "/api/profile/%s";
+
+		@Mock
+		private ApplicationContext context;
+
+		private TestEntity entity = TestEntityTestFactory.create();
+
+		@Test
+		void shouldHaveAddLinkByLinkedResource() {
+			var model = ModelBuilder.fromEntity(entity).buildModel();
+
+			assertThat(model.getLink(TestController.FILE_REL).get().getHref()).isEqualTo(TestController.PATH + "/" + TestEntityTestFactory.FILE);
+		}
+
+		@ParameterizedTest
+		@NullAndEmptySource
+		void shouldNotAddLinkByLinkedRessourceIfFieldValueIsNotSet(String fileId) {
+			var model = ModelBuilder.fromEntity(TestEntityTestFactory.createBuilder().file(fileId).build()).buildModel();
+
+			assertThat(model.getLink(TestController.FILE_REL)).isEmpty();
+		}
+	}
+}
+
+@Builder
+class TestEntity {
+
+	@LinkedResource(controllerClass = TestController.class)
+	private String file;
+}
+
+@RequestMapping(TestController.PATH)
+class TestController {
+
+	static final String PATH = "/api/test";
+
+	static final String USER_REL = "user";
+	static final String FILE_REL = "file";
+
+}
+
+class TestEntityTestFactory {
+
+	static final String USER = UUID.randomUUID().toString();
+	static final String FILE = UUID.randomUUID().toString();
+
+	public static TestEntity create() {
+		return createBuilder().build();
+	}
+
+	public static TestEntity.TestEntityBuilder createBuilder() {
+		return TestEntity.builder()
+				.file(FILE);
+	}
+}
diff --git a/src/test/java/de/ozgcloud/admin/common/user/TestId.java b/src/test/java/de/ozgcloud/admin/common/user/TestId.java
new file mode 100644
index 0000000000000000000000000000000000000000..062ac78d9fed39e852260dec5f4ed938f3209346
--- /dev/null
+++ b/src/test/java/de/ozgcloud/admin/common/user/TestId.java
@@ -0,0 +1,57 @@
+/*
+ * Copyright (C) 2022 Das Land Schleswig-Holstein vertreten durch den
+ * Ministerpräsidenten des Landes Schleswig-Holstein
+ * Staatskanzlei
+ * Abteilung Digitalisierung und zentrales IT-Management der Landesregierung
+ *
+ * Lizenziert unter der EUPL, Version 1.2 oder - sobald
+ * diese von der Europäischen Kommission genehmigt wurden -
+ * Folgeversionen der EUPL ("Lizenz");
+ * Sie dürfen dieses Werk ausschließlich gemäß
+ * dieser Lizenz nutzen.
+ * Eine Kopie der Lizenz finden Sie hier:
+ *
+ * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
+ *
+ * Sofern nicht durch anwendbare Rechtsvorschriften
+ * gefordert oder in schriftlicher Form vereinbart, wird
+ * die unter der Lizenz verbreitete Software "so wie sie
+ * ist", OHNE JEGLICHE GEWÄHRLEISTUNG ODER BEDINGUNGEN -
+ * ausdrücklich oder stillschweigend - verbreitet.
+ * Die sprachspezifischen Genehmigungen und Beschränkungen
+ * unter der Lizenz sind dem Lizenztext zu entnehmen.
+ */
+package de.ozgcloud.admin.common.user;
+
+import java.util.UUID;
+
+import org.apache.commons.lang3.StringUtils;
+
+import de.ozgcloud.common.datatype.StringBasedValue;
+import lombok.AccessLevel;
+import lombok.EqualsAndHashCode;
+import lombok.NoArgsConstructor;
+
+@NoArgsConstructor(access = AccessLevel.PACKAGE)
+@EqualsAndHashCode(callSuper = true)
+public class TestId extends StringBasedValue {
+
+	private static final long serialVersionUID = 1L;
+
+	TestId(String userId) {
+		super(userId);
+	}
+
+	public static TestId from(UUID userId) {
+		return TestId.from(userId.toString());
+	}
+
+	public static TestId from(String userId) {
+		return new TestId(userId);
+	}
+
+	public static TestId empty() {
+		return new TestId(StringUtils.EMPTY);
+	}
+
+}
diff --git a/src/test/java/de/ozgcloud/admin/keycloak/AddGroupDataTestFactory.java b/src/test/java/de/ozgcloud/admin/keycloak/AddGroupDataTestFactory.java
new file mode 100644
index 0000000000000000000000000000000000000000..cea95944ffe15491d5a60ead097db8a8a29c4989
--- /dev/null
+++ b/src/test/java/de/ozgcloud/admin/keycloak/AddGroupDataTestFactory.java
@@ -0,0 +1,17 @@
+package de.ozgcloud.admin.keycloak;
+
+public class AddGroupDataTestFactory {
+
+	public static final String NAME = GroupRepresentationTestFactory.NAME;
+	public static final String ORGANISATIONS_EINHEIT_ID = GroupRepresentationTestFactory.ORGANISATIONS_EINHEIT_ID;
+
+	public static AddGroupData create() {
+		return createBuilder().build();
+	}
+
+	public static AddGroupData.AddGroupDataBuilder createBuilder() {
+		return new AddGroupData.AddGroupDataBuilder()
+				.name(NAME)
+				.organisationsEinheitId(ORGANISATIONS_EINHEIT_ID);
+	}
+}
diff --git a/src/test/java/de/ozgcloud/admin/keycloak/GroupMapperTest.java b/src/test/java/de/ozgcloud/admin/keycloak/GroupMapperTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..82b47bca310517e47c6ac5b812c4abc9aef66db9
--- /dev/null
+++ b/src/test/java/de/ozgcloud/admin/keycloak/GroupMapperTest.java
@@ -0,0 +1,318 @@
+package de.ozgcloud.admin.keycloak;
+
+import static de.ozgcloud.admin.keycloak.GroupRepresentationTestFactory.*;
+import static org.assertj.core.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.NullAndEmptySource;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.keycloak.representations.idm.GroupRepresentation;
+import org.mapstruct.factory.Mappers;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Captor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.Spy;
+
+class GroupMapperTest {
+
+	@Mock
+	private KeycloakApiProperties properties;
+	@Spy
+	@InjectMocks
+	private GroupMapper mapper = Mappers.getMapper(GroupMapper.class);
+
+	@Nested
+	class TestFromGroupRepresentations {
+
+		private final GroupRepresentation groupRepresentation = GroupRepresentationTestFactory.create();
+
+		@Captor
+		private ArgumentCaptor<List<Group>> groupsCaptor;
+
+		@Test
+		void shouldMapFromGroupRepresentation() {
+			givenMappedGroupWithOrganisationsEinheitId();
+
+			callMapper();
+
+			verify(mapper).fromGroupRepresentation(groupRepresentation);
+		}
+
+		@Test
+		void shouldReturnListWithMappedGroupRepresentation() {
+			var group = givenMappedGroupWithOrganisationsEinheitId();
+
+			var groups = callMapper();
+
+			assertThat(groups).containsExactly(group);
+		}
+
+		@ParameterizedTest
+		@NullAndEmptySource
+		void shouldReturnEmptyList(String organisationsEinheitId) {
+			givenMappedGroupWithOrganisationsEinheitId(organisationsEinheitId);
+
+			var groups = callMapper();
+
+			assertThat(groups).isEmpty();
+		}
+
+		@Test
+		void shouldDeleteGroupsWithoutOrganisationsEinheitId() {
+			var group = givenMappedGroupWithOrganisationsEinheitId();
+
+			callMapper();
+
+			verify(mapper).deleteGroupsWithoutOrganisationsEinheitId(groupsCaptor.capture());
+			assertThat(groupsCaptor.getValue()).containsExactly(group);
+		}
+
+		private Group givenMappedGroupWithOrganisationsEinheitId() {
+			var group = GroupTestFactory.create();
+			doReturn(group).when(mapper).fromGroupRepresentation(groupRepresentation);
+			return group;
+		}
+
+		private void givenMappedGroupWithOrganisationsEinheitId(String id) {
+			var group = GroupTestFactory.createBuilder().organisationsEinheitId(id).build();
+			doReturn(group).when(mapper).fromGroupRepresentation(groupRepresentation);
+		}
+
+		private List<Group> callMapper() {
+			return mapper.fromGroupRepresentations(List.of(groupRepresentation));
+		}
+	}
+
+	@Nested
+	class TestFromGroupRepresentation {
+
+		private final GroupRepresentation groupRepresentation = GroupRepresentationTestFactory.create();
+
+		@BeforeEach
+		void init() {
+			doReturn(GroupRepresentationTestFactory.ORGANISATIONS_EINHEIT_ID).when(mapper)
+					.getOrganisationsEinheitId(ATTRIBUTES);
+			doReturn(GroupRepresentationTestFactory.SUB_GROUP_ORGANISATIONS_EINHEIT_ID).when(mapper)
+					.getOrganisationsEinheitId(SUB_GROUP_ATTRIBUTES);
+		}
+
+		@Test
+		void shouldGetOrganisationsEinheitId() {
+			callMapper();
+
+			verify(mapper).getOrganisationsEinheitId(groupRepresentation.getAttributes());
+		}
+
+		@Test
+		void shouldSetId() {
+			var group = callMapper();
+
+			assertThat(group.getId()).isEqualTo(groupRepresentation.getId());
+		}
+
+		@Test
+		void shouldSetOrganisationsEinheitId() {
+			var group = callMapper();
+
+			assertThat(group.getOrganisationsEinheitId()).isEqualTo(GroupRepresentationTestFactory.ORGANISATIONS_EINHEIT_ID);
+		}
+
+		@Test
+		void shouldSetName() {
+			var group = callMapper();
+
+			assertThat(group.getName()).isEqualTo(GroupRepresentationTestFactory.NAME);
+		}
+
+		@Test
+		void shouldSetSubGroups() {
+			var group = callMapper();
+
+			assertThat(group.getSubGroups()).usingRecursiveFieldByFieldElementComparator()
+					.containsExactlyElementsOf(GroupTestFactory.create().getSubGroups());
+		}
+
+		private Group callMapper() {
+			return mapper.fromGroupRepresentation(groupRepresentation);
+		}
+	}
+
+	@Nested
+	class TestGetOrganisationsEinheitId {
+
+		@Test
+		void shouldReturnNullIfAttributesAreNull() {
+			var result = mapper.getOrganisationsEinheitId(null);
+
+			assertThat(result).isNull();
+		}
+
+		@Test
+		void shouldReturnNullIfAttributeIsAbsent() {
+			givenOrganisationsEinheitIdProperty();
+
+			var result = mapper.getOrganisationsEinheitId(Map.of("dummy-attribute", List.of("123")));
+
+			assertThat(result).isNull();
+		}
+
+		@Test
+		void shouldReturnOrganisationsEinheitId() {
+			givenOrganisationsEinheitIdProperty();
+			var value = GroupRepresentationTestFactory.ORGANISATIONS_EINHEIT_ID;
+			var result = mapper.getOrganisationsEinheitId(Map.of(ORGANIZATIONS_EINHEIT_ID_ATTRIBUTE, List.of(value)));
+
+			assertThat(result).isEqualTo(value);
+		}
+
+		@Test
+		void shouldThrowExceptionIfMultipleValuesAreAvailable() {
+			givenOrganisationsEinheitIdProperty();
+			var value = GroupRepresentationTestFactory.ORGANISATIONS_EINHEIT_ID;
+			var value2 = UUID.randomUUID().toString();
+
+			assertThatExceptionOfType(GroupRepresentationMappingException.class)
+					.isThrownBy(() ->  mapper.getOrganisationsEinheitId(Map.of(ORGANIZATIONS_EINHEIT_ID_ATTRIBUTE, List.of(value, value2))))
+					.withMessage("Group contains multiple values for organisationsEinheitId: %s", List.of(value, value2));
+		}
+	}
+
+	@Nested
+	class TestDeleteGroupsWithoutOrganisationsEinheitId {
+
+		private final Group group = GroupTestFactory.create();
+		private final List<Group> groups = new ArrayList<>();
+
+		@BeforeEach
+		void init() {
+			groups.add(group);
+		}
+
+		@Test
+		void shouldCheckIfGroupHasOrganisationsEinheitId() {
+			mapper.deleteGroupsWithoutOrganisationsEinheitId(groups);
+
+			verify(mapper).isMissingOrganisationsEinheitId(group);
+		}
+
+		@Test
+		void shouldKeepGroupsWithOrganisationsEinheitId() {
+			doReturn(false).when(mapper).isMissingOrganisationsEinheitId(group);
+
+			mapper.deleteGroupsWithoutOrganisationsEinheitId(groups);
+
+			assertThat(groups).containsExactly(group);
+		}
+
+		@Test
+		void shouldRemoveGroupsWithoutOrganisationsEinheitId() {
+			doReturn(true).when(mapper).isMissingOrganisationsEinheitId(group);
+
+			mapper.deleteGroupsWithoutOrganisationsEinheitId(groups);
+
+			assertThat(groups).isEmpty();
+		}
+	}
+
+	@Nested
+	class TestIsMissingOrganisationsEinheitId {
+
+		@ParameterizedTest
+		@ValueSource(strings = " ")
+		@NullAndEmptySource
+		void shouldReturnTrueIfIdIsBlank(String organisationsEinheitId) {
+			var group = GroupTestFactory.createBuilder().organisationsEinheitId(organisationsEinheitId).build();
+
+			var isMissing = mapper.isMissingOrganisationsEinheitId(group);
+
+			assertThat(isMissing).isTrue();
+		}
+
+		@Test
+		void shouldReturnFalseIfIdIsSet() {
+			var isMissing = mapper.isMissingOrganisationsEinheitId(GroupTestFactory.create());
+
+			assertThat(isMissing).isFalse();
+		}
+	}
+
+	@Nested
+	class TestToGroupRepresentation {
+
+		private final AddGroupData addGroupData = AddGroupDataTestFactory.create();
+
+		@BeforeEach
+		void init() {
+			doReturn(ATTRIBUTES).when(mapper).buildGroupAttributes(addGroupData);
+		}
+
+		@Test
+		void shouldSetName() {
+			var groupRepresentation = callMapper();
+
+			assertThat(groupRepresentation.getName()).isEqualTo(addGroupData.getName());
+		}
+
+		@Test
+		void shouldNotSetSubGroups() {
+			var groupRepresentation = callMapper();
+
+			assertThat(groupRepresentation.getSubGroups()).isEmpty();
+		}
+
+		@Test
+		void shouldBuildGroupAttributes() {
+			callMapper();
+
+			verify(mapper).buildGroupAttributes(addGroupData);
+		}
+
+		@Test
+		void shouldSetAttributeOrganisationsEinheitId() {
+			var groupRepresentation = callMapper();
+
+			assertThat(groupRepresentation.getAttributes()).isNotNull().containsKey(ORGANIZATIONS_EINHEIT_ID_ATTRIBUTE);
+		}
+
+		private GroupRepresentation callMapper() {
+			return mapper.toGroupRepresentation(addGroupData);
+		}
+	}
+
+	@Nested
+	class TestBuildGroupAttributes {
+
+		@ParameterizedTest
+		@NullAndEmptySource
+		void shouldReturnEmptyMap(String organisationsEinheitId) {
+			var addGroupData = AddGroupDataTestFactory.createBuilder().organisationsEinheitId(organisationsEinheitId).build();
+
+			var attributes = mapper.buildGroupAttributes(addGroupData);
+
+			assertThat(attributes).isEmpty();
+		}
+
+		@Test
+		void shouldAddOrganisationsEinheitIdToAttributes() {
+			givenOrganisationsEinheitIdProperty();
+
+			var attributes = mapper.buildGroupAttributes(AddGroupDataTestFactory.create());
+
+			assertThat(attributes).hasSize(1).containsEntry(ORGANIZATIONS_EINHEIT_ID_ATTRIBUTE, List.of(GroupTestFactory.ORGANISATIONS_EINHEIT_ID));
+		}
+	}
+
+	private void givenOrganisationsEinheitIdProperty() {
+		when(properties.getOrganisationsEinheitIdKey()).thenReturn(ORGANIZATIONS_EINHEIT_ID_ATTRIBUTE);
+	}
+}
diff --git a/src/test/java/de/ozgcloud/admin/keycloak/GroupRepresentationTestFactory.java b/src/test/java/de/ozgcloud/admin/keycloak/GroupRepresentationTestFactory.java
new file mode 100644
index 0000000000000000000000000000000000000000..034f962513dc0ffefe78f26ab9d7755281d763a0
--- /dev/null
+++ b/src/test/java/de/ozgcloud/admin/keycloak/GroupRepresentationTestFactory.java
@@ -0,0 +1,64 @@
+package de.ozgcloud.admin.keycloak;
+
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+
+import org.keycloak.representations.idm.GroupRepresentation;
+
+import com.thedeanda.lorem.LoremIpsum;
+
+class GroupRepresentationTestFactory {
+
+	public static final String ORGANIZATIONS_EINHEIT_ID_ATTRIBUTE = "organisationseinheitId";
+
+	public static final String ID = UUID.randomUUID().toString();
+	public static final String PARENT_ID = UUID.randomUUID().toString();
+	public static final String NAME = LoremIpsum.getInstance().getName();
+	public static final String ORGANISATIONS_EINHEIT_ID = UUID.randomUUID().toString();
+	public static final Map<String, List<String>> ATTRIBUTES = Map.of(ORGANIZATIONS_EINHEIT_ID_ATTRIBUTE, List.of(ORGANISATIONS_EINHEIT_ID));
+
+	public static final String SUB_GROUP_ID = UUID.randomUUID().toString();
+	public static final String SUB_GROUP_NAME = LoremIpsum.getInstance().getName();
+	public static final String SUB_GROUP_ORGANISATIONS_EINHEIT_ID = UUID.randomUUID().toString();
+	public static final Map<String, List<String>> SUB_GROUP_ATTRIBUTES = Map.of(ORGANIZATIONS_EINHEIT_ID_ATTRIBUTE, List.of(SUB_GROUP_ORGANISATIONS_EINHEIT_ID));
+
+	public static GroupRepresentation create() {
+		return create(true);
+	}
+
+	public static GroupRepresentation create(String name) {
+		var groupRepresentation = create();
+		groupRepresentation.setName(name);
+		return groupRepresentation;
+	}
+
+	public static GroupRepresentation createWithoutId(String name) {
+		var groupRepresentation =  create(false);
+		groupRepresentation.setName(name);
+		return groupRepresentation;
+	}
+
+	public static GroupRepresentation create(boolean withId) {
+		var groupRepresentation = new GroupRepresentation();
+		if (withId) {
+			groupRepresentation.setId(ID);
+		}
+		groupRepresentation.setParentId(PARENT_ID);
+		groupRepresentation.setName(NAME);
+		groupRepresentation.setAttributes(ATTRIBUTES);
+		groupRepresentation.setSubGroups(List.of(createSubGroup(withId)));
+		return groupRepresentation;
+	}
+
+	private static GroupRepresentation createSubGroup(boolean withId) {
+		var groupRepresentation = new GroupRepresentation();
+		if (withId) {
+			groupRepresentation.setId(SUB_GROUP_ID);
+		}
+		groupRepresentation.setParentId(ID);
+		groupRepresentation.setName(SUB_GROUP_NAME);
+		groupRepresentation.setAttributes(SUB_GROUP_ATTRIBUTES);
+		return groupRepresentation;
+	}
+}
diff --git a/src/test/java/de/ozgcloud/admin/keycloak/GroupTestFactory.java b/src/test/java/de/ozgcloud/admin/keycloak/GroupTestFactory.java
new file mode 100644
index 0000000000000000000000000000000000000000..ed248fdde42e8e6c245a1879aa1373df3e8e17aa
--- /dev/null
+++ b/src/test/java/de/ozgcloud/admin/keycloak/GroupTestFactory.java
@@ -0,0 +1,27 @@
+package de.ozgcloud.admin.keycloak;
+
+import java.util.List;
+
+public class GroupTestFactory {
+
+	public static final String ID = GroupRepresentationTestFactory.ID;
+	public static final String NAME = GroupRepresentationTestFactory.NAME;
+	public static final String ORGANISATIONS_EINHEIT_ID = GroupRepresentationTestFactory.ORGANISATIONS_EINHEIT_ID;
+
+	public static final String SUB_GROUP_ID = GroupRepresentationTestFactory.SUB_GROUP_ID;
+	public static final String SUB_GROUP_NAME = GroupRepresentationTestFactory.SUB_GROUP_NAME;
+	public static final String SUB_GROUP_ORGANISATIONS_EINHEIT_ID = GroupRepresentationTestFactory.SUB_GROUP_ORGANISATIONS_EINHEIT_ID;
+
+	public static Group create() {
+		return createBuilder().build();
+	}
+
+	public static Group.GroupBuilder createBuilder() {
+		return new Group.GroupBuilder()
+				.id(ID)
+				.name(NAME)
+				.organisationsEinheitId(ORGANISATIONS_EINHEIT_ID)
+				.subGroups(List.of(Group.builder().id(SUB_GROUP_ID).name(SUB_GROUP_NAME).organisationsEinheitId(SUB_GROUP_ORGANISATIONS_EINHEIT_ID)
+						.build()));
+	}
+}
diff --git a/src/test/java/de/ozgcloud/admin/keycloak/KeycloakApiServiceITCase.java b/src/test/java/de/ozgcloud/admin/keycloak/KeycloakApiServiceITCase.java
new file mode 100644
index 0000000000000000000000000000000000000000..f2c7dd6c83ee9651cca09c17ad7eda8927ac94eb
--- /dev/null
+++ b/src/test/java/de/ozgcloud/admin/keycloak/KeycloakApiServiceITCase.java
@@ -0,0 +1,172 @@
+package de.ozgcloud.admin.keycloak;
+
+import static org.assertj.core.api.Assertions.*;
+import static org.assertj.core.groups.Tuple.tuple;
+
+import java.util.List;
+import java.util.Optional;
+
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.keycloak.representations.idm.GroupRepresentation;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.test.context.ContextConfiguration;
+
+import com.thedeanda.lorem.LoremIpsum;
+
+import de.ozgcloud.admin.common.KeycloakInitializer;
+import de.ozgcloud.common.test.ITCase;
+
+@ITCase
+@ContextConfiguration(initializers = KeycloakInitializer.class)
+class KeycloakApiServiceITCase {
+
+	@Autowired
+	private KeycloakApiService service;
+	@Autowired
+	private KeycloakApiProperties properties;
+
+	@Nested
+	class TestGetAllGroups {
+
+		@Test
+		void shouldReturnAllTopLevelGroups() {
+			var groups = service.getAllGroups();
+
+			assertThat(groups).extracting(GroupRepresentation::getName)
+					.contains("GroupWithChild", "GroupWithoutOid", "GroupWithoutOidWithChild");
+		}
+
+		@Test
+		void shouldTopLevelGroupsHaveAttributes() {
+			var groups = service.getAllGroups();
+
+			assertThat(groups).extracting(GroupRepresentation::getName, this::getOrganisationsEinheitId)
+					.contains(tuple("GroupWithChild", "GroupWithChild-oid"), tuple("GroupWithoutOid", null),
+							tuple("GroupWithoutOidWithChild", null));
+		}
+
+		@Test
+		void shouldGroupWithChildHaveLevel1Children() {
+			var groups = service.getAllGroups();
+			var groupWithChild = getTopLevelGroup(groups, "GroupWithChild");
+
+			assertThat(groupWithChild.getSubGroups()).hasSize(2).extracting(GroupRepresentation::getName)
+					.containsExactlyInAnyOrder("ChildLevel1WithChild", "ChildLevel1WithoutOid");
+		}
+
+		@Test
+		void shouldLevel1ChildrenOfGroupWithChildHaveAttributes() {
+			var groups = service.getAllGroups();
+			var groupWithChild = getTopLevelGroup(groups, "GroupWithChild");
+
+			assertThat(groupWithChild.getSubGroups()).extracting(GroupRepresentation::getName, this::getOrganisationsEinheitId)
+					.containsExactlyInAnyOrder(tuple("ChildLevel1WithChild", "ChildLevel1WithChild-oid"), tuple("ChildLevel1WithoutOid", null));
+		}
+
+		@Test
+		void shouldLevel1ChildHaveLevel2Children() {
+			var groups = service.getAllGroups();
+			var level1Children = getTopLevelGroup(groups, "GroupWithChild").getSubGroups();
+			var level1Child = getTopLevelGroup(level1Children, "ChildLevel1WithChild");
+
+			assertThat(level1Child.getSubGroups()).hasSize(1).extracting(GroupRepresentation::getName).contains("ChildLevel2");
+		}
+
+		@Test
+		void shouldGroupWithoutOidHaveLevel1Children() {
+			var groups = service.getAllGroups();
+			var groupWithoutOid = getTopLevelGroup(groups, "GroupWithoutOidWithChild");
+
+			assertThat(groupWithoutOid.getSubGroups()).hasSize(1).extracting(GroupRepresentation::getName)
+					.containsExactlyInAnyOrder("ChildLevel1");
+		}
+
+		@Test
+		void shouldLevel1ChildrenOfGroupWithoutOidHaveAttributes() {
+			var groups = service.getAllGroups();
+			var groupWithoutOid = getTopLevelGroup(groups, "GroupWithoutOidWithChild");
+
+			assertThat(groupWithoutOid.getSubGroups()).extracting(GroupRepresentation::getName, this::getOrganisationsEinheitId)
+					.containsExactlyInAnyOrder(tuple("ChildLevel1", "ChildLevel1-oid"));
+		}
+
+		private GroupRepresentation getTopLevelGroup(List<GroupRepresentation> groups, String groupName) {
+			var foundGroup = groups.stream().filter(group -> group.getName().equals(groupName)).findFirst();
+			assertThat(foundGroup).isPresent();
+			return foundGroup.get();
+		}
+
+		private String getOrganisationsEinheitId(GroupRepresentation group) {
+			var attributes = group.getAttributes();
+			return attributes.containsKey(properties.getOrganisationsEinheitIdKey()) ?
+					attributes.get(properties.getOrganisationsEinheitIdKey()).getFirst() :
+					null;
+		}
+	}
+
+	@Nested
+	class TestAddGroup {
+
+		@Test
+		void shouldReturnId() {
+			var groupToAdd = createUniqueGroupRepresentation("shouldReturnId");
+
+			var groupId = service.addGroup(groupToAdd);
+
+			assertThat(groupId).isNotBlank();
+		}
+
+		@Test
+		void shouldAddGroup() {
+			var groupToAdd = createUniqueGroupRepresentation("shouldAddGroup");
+
+			var groupId = service.addGroup(groupToAdd);
+
+			assertThat(findGroupInKeycloak(groupId)).isPresent().get().extracting(GroupRepresentation::getName, GroupRepresentation::getAttributes)
+					.containsExactly(groupToAdd.getName(), groupToAdd.getAttributes());
+		}
+
+		@Test
+		void shouldThrowExceptionWhenAddingGroupWithId() {
+			var groupToAdd = GroupRepresentationTestFactory.create();
+
+			assertThatExceptionOfType(ResourceCreationException.class).isThrownBy(() -> service.addGroup(groupToAdd))
+					.withMessageContaining("404");
+		}
+
+		@Test
+		void shouldThrowExceptionWhenNameIsNotUnique() {
+			var groupToAdd = createUniqueGroupRepresentation("notUniqueName");
+
+			service.addGroup(groupToAdd);
+
+			assertThatExceptionOfType(ResourceCreationException.class).isThrownBy(() -> service.addGroup(groupToAdd))
+					.withMessageContaining("409");
+		}
+
+		@Test
+		void shouldNotAddSubgroupsOfAddedGroup() {
+			var groupToAdd = createUniqueGroupRepresentation("shouldNotAddSubgroups");
+
+			var groupId = service.addGroup(groupToAdd);
+
+			assertThat(findGroupInKeycloak(groupId)).isPresent().get().extracting(GroupRepresentation::getSubGroups)
+					.asList().isEmpty();
+		}
+
+		private GroupRepresentation createUniqueGroupRepresentation(String nameSuffix) {
+			// LoremIpsum does not guarantee unique results when called repeatedly
+			var groupName = "%s (%s)".formatted(LoremIpsum.getInstance().getName(), nameSuffix);
+			var group = GroupRepresentationTestFactory.createWithoutId(groupName);
+			group.setParentId(null);
+			return group;
+		}
+
+		private Optional<GroupRepresentation> findGroupInKeycloak(String groupId) {
+			return service.getAllGroups().stream()
+					.filter(groupRepresentation -> groupId.equals(groupRepresentation.getId()))
+					.findFirst();
+		}
+	}
+}
diff --git a/src/test/java/de/ozgcloud/admin/keycloak/KeycloakApiServiceTest.java b/src/test/java/de/ozgcloud/admin/keycloak/KeycloakApiServiceTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..ce9ed1256d94d81e5e04e820fe2436bfdc34b168
--- /dev/null
+++ b/src/test/java/de/ozgcloud/admin/keycloak/KeycloakApiServiceTest.java
@@ -0,0 +1,151 @@
+package de.ozgcloud.admin.keycloak;
+
+import static org.assertj.core.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+import java.util.List;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.keycloak.admin.client.resource.GroupsResource;
+import org.keycloak.representations.idm.GroupRepresentation;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.Spy;
+
+import com.thedeanda.lorem.LoremIpsum;
+
+import jakarta.ws.rs.core.Response;
+
+class KeycloakApiServiceTest {
+
+	@Spy
+	@InjectMocks
+	private KeycloakApiService service;
+	@Mock
+	private GroupsResource groupsResource;
+	@Mock
+	private Response response;
+
+	@Nested
+	class TestGetAllGroups {
+
+		@Test
+		void shouldCallGroupsResource() {
+			service.getAllGroups();
+
+			verify(groupsResource).groups("", 0, Integer.MAX_VALUE, false);
+		}
+
+		@Test
+		void shouldReturnGroupRepresentations() {
+			var groupRepresentation = GroupRepresentationTestFactory.create();
+			when(groupsResource.groups("", 0, Integer.MAX_VALUE, false)).thenReturn(List.of(groupRepresentation));
+
+			var gotGroupRepresentations = service.getAllGroups();
+
+			assertThat(gotGroupRepresentations).containsExactly(groupRepresentation);
+		}
+	}
+
+	@Nested
+	class TestAddGroup {
+
+		private final GroupRepresentation groupRepresentation = GroupRepresentationTestFactory.create();
+		private final String resourceId = GroupRepresentationTestFactory.create().getId();
+
+		@BeforeEach
+		void init() {
+			when(groupsResource.add(groupRepresentation)).thenReturn(response);
+			doReturn(resourceId).when(service).getCreatedResourceIdFromResponse(response);
+		}
+
+		@Test
+		void shouldCallGroupsResource() {
+			callService();
+
+			verify(groupsResource).add(groupRepresentation);
+		}
+
+		@Test
+		void shouldGetAddedResourceIdFromResponse() {
+			callService();
+
+			verify(service).getCreatedResourceIdFromResponse(response);
+		}
+
+		@Test
+		void shouldReturnAddedResourceId() {
+			var id = callService();
+
+			assertThat(id).isEqualTo(resourceId);
+		}
+
+		private String callService() {
+			return service.addGroup(groupRepresentation);
+		}
+	}
+
+	@Nested
+	class TestGetCreatedResourceIdFromResponse {
+
+		private final String resourceId = GroupRepresentationTestFactory.create().getId();
+		private final String location = LoremIpsum.getInstance().getUrl();
+
+		@Test
+		void shouldExtractResourceIdFromLocationHeader() {
+			givenResponseCreated();
+
+			callService();
+
+			verify(service).extractResourceIdFromLocationHeader(location);
+		}
+
+		@Test
+		void shouldReturnResourceId() {
+			givenResponseCreated();
+
+			var id = callService();
+
+			assertThat(id).isEqualTo(resourceId);
+		}
+
+		@Test
+		void shouldThrowExceptionIsStatusOtherThenCreated() {
+			givenResponseUnauthorized();
+
+			assertThatExceptionOfType(ResourceCreationException.class).isThrownBy(this::callService)
+					.withMessageStartingWith("Failed to add group - got response with status 403 (Unauthorized)");
+		}
+
+		private void givenResponseCreated() {
+			when(response.getStatus()).thenReturn(201);
+			when(response.getHeaderString("Location")).thenReturn(location);
+			doReturn(resourceId).when(service).extractResourceIdFromLocationHeader(location);
+		}
+
+		private void givenResponseUnauthorized() {
+			when(response.getStatus()).thenReturn(403);
+			when(response.getStatusInfo()).thenReturn(Response.Status.UNAUTHORIZED);
+		}
+
+		private String callService() {
+			return service.getCreatedResourceIdFromResponse(response);
+		}
+	}
+
+	@Nested
+	class TestExtractResourceIdFromLocationHeader {
+
+		private final String resourceId = GroupRepresentationTestFactory.create().getId();
+		private final String location = "https://keycloak-url.test/admin/realms/test/groups/" + resourceId;
+
+		@Test
+		void shouldReturnId() {
+			var id = service.extractResourceIdFromLocationHeader(location);
+
+			assertThat(id).isEqualTo(resourceId);
+		}
+	}
+}
diff --git a/src/test/java/de/ozgcloud/admin/keycloak/KeycloakRemoteServiceTest.java b/src/test/java/de/ozgcloud/admin/keycloak/KeycloakRemoteServiceTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..928122d6599d86a99b19b270e173827d27c3c269
--- /dev/null
+++ b/src/test/java/de/ozgcloud/admin/keycloak/KeycloakRemoteServiceTest.java
@@ -0,0 +1,104 @@
+package de.ozgcloud.admin.keycloak;
+
+import static org.assertj.core.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+import java.util.List;
+import java.util.UUID;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.keycloak.representations.idm.GroupRepresentation;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.Spy;
+
+class KeycloakRemoteServiceTest {
+
+	@Mock
+	private KeycloakApiService apiService;
+	@Mock
+	private GroupMapper mapper;
+	@Spy
+	@InjectMocks
+	private KeycloakRemoteService service;
+
+	@Nested
+	class TestGetGroupsWithOrganisationsEinheitId {
+
+		private final List<GroupRepresentation> groupRepresentations = List.of(
+			GroupRepresentationTestFactory.create("A"), GroupRepresentationTestFactory.create("B")
+		);
+		private final List<Group> mappedGroups = List.of(
+			GroupTestFactory.createBuilder().name("A").build(), GroupTestFactory.createBuilder().name("B").build()
+		);
+
+		@BeforeEach
+		void init() {
+			when(apiService.getAllGroups()).thenReturn(groupRepresentations);
+			when(mapper.fromGroupRepresentations(groupRepresentations)).thenReturn(mappedGroups);
+		}
+
+		@Test
+		void shouldGetAllGroups() {
+			service.getGroupsWithOrganisationsEinheitId();
+
+			verify(apiService).getAllGroups();
+		}
+
+		@Test
+		void shouldMapGroups() {
+			service.getGroupsWithOrganisationsEinheitId();
+
+			verify(mapper).fromGroupRepresentations(groupRepresentations);
+		}
+
+		@Test
+		void shouldReturnMappedGroups() {
+			var groups = service.getGroupsWithOrganisationsEinheitId();
+
+			assertThat(groups).containsExactlyElementsOf(mappedGroups);
+		}
+	}
+
+	@Nested
+	class TestAddGroup {
+
+		private static final String ADDED_GROUP_ID = UUID.randomUUID().toString();
+
+		private final AddGroupData addGroupData = AddGroupDataTestFactory.create();
+		private final GroupRepresentation groupRepresentation = GroupRepresentationTestFactory.create();
+
+		@BeforeEach
+		void init() {
+			when(mapper.toGroupRepresentation(addGroupData)).thenReturn(groupRepresentation);
+			when(apiService.addGroup(groupRepresentation)).thenReturn(ADDED_GROUP_ID);
+		}
+
+		@Test
+		void shouldMapToGroupRepresentation() {
+			callService();
+
+			verify(mapper).toGroupRepresentation(addGroupData);
+		}
+
+		@Test
+		void shouldAddGroupInKeycloak() {
+			callService();
+
+			verify(apiService).addGroup(groupRepresentation);
+		}
+
+		@Test
+		void shouldReturnIdOfAddedGroup() {
+			var id = callService();
+
+			assertThat(id).isEqualTo(ADDED_GROUP_ID);
+		}
+
+		private String callService() {
+			 return service.addGroup(addGroupData);
+		}
+	}
+}
diff --git a/src/test/java/de/ozgcloud/admin/organisationseinheit/GrpcGetByOrganisationsEinheitIdRequestTestFactory.java b/src/test/java/de/ozgcloud/admin/organisationseinheit/GrpcGetByOrganisationsEinheitIdRequestTestFactory.java
new file mode 100644
index 0000000000000000000000000000000000000000..8e3c82212d12ec8115fb413e2ec4c12dadb9202b
--- /dev/null
+++ b/src/test/java/de/ozgcloud/admin/organisationseinheit/GrpcGetByOrganisationsEinheitIdRequestTestFactory.java
@@ -0,0 +1,18 @@
+package de.ozgcloud.admin.organisationseinheit;
+
+import de.ozgcloud.zufi.grpc.organisationseinheit.GrpcGetByOrganisationsEinheitIdRequest;
+
+public class GrpcGetByOrganisationsEinheitIdRequestTestFactory {
+
+	public static final String ORGANISATIONS_EINHEIT_ID = OrganisationsEinheitTestFactory.ORGANISATIONS_EINHEIT_ID;
+
+	public static GrpcGetByOrganisationsEinheitIdRequest create() {
+		return createBuilder().build();
+	}
+
+	public static GrpcGetByOrganisationsEinheitIdRequest.Builder createBuilder() {
+		return GrpcGetByOrganisationsEinheitIdRequest.newBuilder()
+				.setOrganisationsEinheitId(ORGANISATIONS_EINHEIT_ID);
+	}
+
+}
diff --git a/src/test/java/de/ozgcloud/admin/organisationseinheit/GrpcGetByOrganisationsEinheitIdResponseTestFactory.java b/src/test/java/de/ozgcloud/admin/organisationseinheit/GrpcGetByOrganisationsEinheitIdResponseTestFactory.java
new file mode 100644
index 0000000000000000000000000000000000000000..e71492d4a93a233c1c7fa6afd31d9b61302a3699
--- /dev/null
+++ b/src/test/java/de/ozgcloud/admin/organisationseinheit/GrpcGetByOrganisationsEinheitIdResponseTestFactory.java
@@ -0,0 +1,18 @@
+package de.ozgcloud.admin.organisationseinheit;
+
+import de.ozgcloud.zufi.grpc.organisationseinheit.GrpcGetByOrganisationsEinheitIdResponse;
+import de.ozgcloud.zufi.grpc.organisationseinheit.GrpcOrganisationsEinheit;
+
+public class GrpcGetByOrganisationsEinheitIdResponseTestFactory {
+
+	public static final GrpcOrganisationsEinheit GRPC_ORGANISATIONS_EINHEIT = GrpcOrganisationsEinheitTestFactory.create();
+
+	public static GrpcGetByOrganisationsEinheitIdResponse create() {
+		return createBuilder().build();
+	}
+
+	public static GrpcGetByOrganisationsEinheitIdResponse.Builder createBuilder() {
+		return GrpcGetByOrganisationsEinheitIdResponse.newBuilder()
+				.addOrganisationsEinheiten(GRPC_ORGANISATIONS_EINHEIT);
+	}
+}
diff --git a/src/test/java/de/ozgcloud/admin/organisationseinheit/GrpcOrganisationsEinheitTestFactory.java b/src/test/java/de/ozgcloud/admin/organisationseinheit/GrpcOrganisationsEinheitTestFactory.java
new file mode 100644
index 0000000000000000000000000000000000000000..ed0a83acf32871168ee11c498e991be74354b7a9
--- /dev/null
+++ b/src/test/java/de/ozgcloud/admin/organisationseinheit/GrpcOrganisationsEinheitTestFactory.java
@@ -0,0 +1,23 @@
+package de.ozgcloud.admin.organisationseinheit;
+
+import de.ozgcloud.zufi.grpc.organisationseinheit.GrpcOrganisationsEinheit;
+import de.ozgcloud.zufi.grpc.organisationseinheit.GrpcXzufiId;
+
+public class GrpcOrganisationsEinheitTestFactory {
+
+	public static final String ID = OrganisationsEinheitTestFactory.ZUFI_ID;
+	public static final String NAME = OrganisationsEinheitTestFactory.NAME;
+	public static final GrpcXzufiId XZUFI_ID = GrpcXzufiIdTestFactory.create();
+
+	public static GrpcOrganisationsEinheit create() {
+		return createBuilder().build();
+	}
+
+	public static GrpcOrganisationsEinheit.Builder createBuilder() {
+		return GrpcOrganisationsEinheit.newBuilder()
+				.setId(ID)
+				.setName(NAME)
+				.setXzufiId(XZUFI_ID);
+	}
+
+}
diff --git a/src/test/java/de/ozgcloud/admin/organisationseinheit/GrpcXzufiIdTestFactory.java b/src/test/java/de/ozgcloud/admin/organisationseinheit/GrpcXzufiIdTestFactory.java
new file mode 100644
index 0000000000000000000000000000000000000000..7c7b71e25f7312cb86560ae7134d9d1bda28cba2
--- /dev/null
+++ b/src/test/java/de/ozgcloud/admin/organisationseinheit/GrpcXzufiIdTestFactory.java
@@ -0,0 +1,21 @@
+package de.ozgcloud.admin.organisationseinheit;
+
+import java.util.UUID;
+
+import de.ozgcloud.zufi.grpc.organisationseinheit.GrpcXzufiId;
+
+public class GrpcXzufiIdTestFactory {
+
+	public static final String ORGANISATIONS_EINHEIT_ID = OrganisationsEinheitTestFactory.ORGANISATIONS_EINHEIT_ID;
+	public static final String SCHEME_AGENCY_ID = UUID.randomUUID().toString();
+
+	public static GrpcXzufiId create() {
+		return createBuilder().build();
+	}
+
+	public static GrpcXzufiId.Builder createBuilder() {
+		return GrpcXzufiId.newBuilder()
+				.setId(ORGANISATIONS_EINHEIT_ID)
+				.setSchemeAgencyId(SCHEME_AGENCY_ID);
+	}
+}
diff --git a/src/test/java/de/ozgcloud/admin/organisationseinheit/OrganisationsEinheitControllerTest.java b/src/test/java/de/ozgcloud/admin/organisationseinheit/OrganisationsEinheitControllerTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..a913eb55dcfaeeb4c89da5a8085b04a3bef75e22
--- /dev/null
+++ b/src/test/java/de/ozgcloud/admin/organisationseinheit/OrganisationsEinheitControllerTest.java
@@ -0,0 +1,179 @@
+package de.ozgcloud.admin.organisationseinheit;
+
+import static org.assertj.core.api.Assertions.*;
+import static org.mockito.Mockito.*;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
+
+import java.util.List;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.springframework.hateoas.CollectionModel;
+import org.springframework.hateoas.EntityModel;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.ResultActions;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+
+import lombok.SneakyThrows;
+
+class OrganisationsEinheitControllerTest {
+
+	@InjectMocks
+	private OrganisationsEinheitController controller;
+
+	@Mock
+	private OrganisationsEinheitService organisationsEinheitService;
+
+	@Mock
+	private OrganisationsEinheitModelAssembler assembler;
+
+	private MockMvc mockMvc;
+
+	@BeforeEach
+	void setUp() {
+		mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
+	}
+
+	@Nested
+	class TestGetAll {
+
+		private final OrganisationsEinheit organisationsEinheit = OrganisationsEinheitTestFactory.create();
+		private final List<OrganisationsEinheit> organisationsEinheiten = List.of(organisationsEinheit);
+		private final CollectionModel<EntityModel<OrganisationsEinheit>> collectionModel = CollectionModel.of(
+				List.of(EntityModel.of(organisationsEinheit)));
+
+		@BeforeEach
+		void setUp() {
+			when(organisationsEinheitService.getOrganisationsEinheiten()).thenReturn(organisationsEinheiten);
+			when(assembler.toCollectionModel(organisationsEinheiten)).thenReturn(collectionModel);
+		}
+
+		@Test
+		void shouldCallService() {
+			doRequest();
+
+			verify(organisationsEinheitService).getOrganisationsEinheiten();
+		}
+
+		@Test
+		void shouldCallAssembler() {
+			doRequest();
+
+			verify(assembler).toCollectionModel(organisationsEinheiten);
+		}
+
+		@Test
+		void shouldReturnCollectionModel() {
+			var response = controller.getAll();
+
+			assertThat(response).isEqualTo(collectionModel);
+		}
+
+		@SneakyThrows
+		@Test
+		void shouldReturnHttpOk() {
+			doRequest().andExpect(status().isOk());
+		}
+
+		@SneakyThrows
+		private ResultActions doRequest() {
+			return mockMvc.perform(get(OrganisationsEinheitController.PATH));
+		}
+	}
+
+	@Nested
+	class TestGetById {
+
+		private final OrganisationsEinheit organisationsEinheit = OrganisationsEinheitTestFactory.create();
+		private final EntityModel<OrganisationsEinheit> entityModel = EntityModel.of(organisationsEinheit);
+
+		@BeforeEach
+		void setUp() {
+			when(organisationsEinheitService.getOrganisationsEinheitById(OrganisationsEinheitTestFactory.ID)).thenReturn(organisationsEinheit);
+			when(assembler.toModel(organisationsEinheit)).thenReturn(entityModel);
+		}
+
+		@Test
+		void shouldCallService() {
+			doRequest();
+
+			verify(organisationsEinheitService).getOrganisationsEinheitById(OrganisationsEinheitTestFactory.ID);
+		}
+
+		@Test
+		void shouldCallAssembler() {
+			doRequest();
+
+			verify(assembler).toModel(organisationsEinheit);
+		}
+
+		@SneakyThrows
+		@Test
+		void shouldReturnHttpOk() {
+			doRequest().andExpect(status().isOk());
+		}
+
+		@Test
+		void shouldReturnEntityModel() {
+			var response = controller.getById(OrganisationsEinheitTestFactory.ID);
+
+			assertThat(response).isEqualTo(entityModel);
+		}
+
+		@SneakyThrows
+		private ResultActions doRequest() {
+			return mockMvc.perform(get(OrganisationsEinheitController.PATH + "/" + OrganisationsEinheitTestFactory.ID));
+		}
+	}
+
+	@Nested
+	class TestGetChildren {
+
+		private final OrganisationsEinheit organisationsEinheit = OrganisationsEinheitTestFactory.create();
+		private final List<OrganisationsEinheit> children = List.of(organisationsEinheit);
+		private final CollectionModel<EntityModel<OrganisationsEinheit>> collectionModel = CollectionModel.of(
+				List.of(EntityModel.of(organisationsEinheit)));
+
+		@BeforeEach
+		void setUp() {
+			when(organisationsEinheitService.getChildren(OrganisationsEinheitTestFactory.ID)).thenReturn(children);
+			when(assembler.toChildrenCollectionModel(OrganisationsEinheitTestFactory.ID, children)).thenReturn(collectionModel);
+		}
+
+		@Test
+		void shouldCallService() {
+			doRequest();
+
+			verify(organisationsEinheitService).getChildren(OrganisationsEinheitTestFactory.ID);
+		}
+
+		@SneakyThrows
+		@Test
+		void shouldReturnHttpOk() {
+			doRequest().andExpect(status().isOk());
+		}
+
+		@Test
+		void shouldCallAssember() {
+			doRequest();
+
+			verify(assembler).toChildrenCollectionModel(OrganisationsEinheitTestFactory.ID, children);
+		}
+
+		@Test
+		void shouldReturnCollectionModel() {
+			var response = controller.getChildren(OrganisationsEinheitTestFactory.ID);
+
+			assertThat(response).isEqualTo(collectionModel);
+		}
+
+		@SneakyThrows
+		private ResultActions doRequest() {
+			return mockMvc.perform(get(OrganisationsEinheitController.PATH + "/" + OrganisationsEinheitTestFactory.ID + "/children"));
+		}
+	}
+}
\ No newline at end of file
diff --git a/src/test/java/de/ozgcloud/admin/organisationseinheit/OrganisationsEinheitITCase.java b/src/test/java/de/ozgcloud/admin/organisationseinheit/OrganisationsEinheitITCase.java
new file mode 100644
index 0000000000000000000000000000000000000000..43103841feb481d6b69ead078fe02e1b003dde32
--- /dev/null
+++ b/src/test/java/de/ozgcloud/admin/organisationseinheit/OrganisationsEinheitITCase.java
@@ -0,0 +1,108 @@
+package de.ozgcloud.admin.organisationseinheit;
+
+import static org.mockito.Mockito.*;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
+import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.*;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
+
+import java.util.List;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.security.oauth2.jwt.JwtDecoder;
+import org.springframework.security.test.context.support.WithMockUser;
+import org.springframework.test.web.servlet.MockMvc;
+
+import de.ozgcloud.common.test.ITCase;
+import lombok.SneakyThrows;
+
+@ITCase
+@WithMockUser
+@AutoConfigureMockMvc
+@SpringBootTest(properties = {
+		"ozgcloud.keycloak.api.url:test",
+		"ozgcloud.keycloak.api.user:test",
+		"ozgcloud.keycloak.api.password:test",
+		"ozgcloud.keycloak.api.realm:test",
+		"ozgcloud.keycloak.api.client:test",
+		"ozgcloud.keycloak.api.organisationsEinheitIdKey:test" })
+class OrganisationsEinheitITCase {
+
+	private final String PATH = OrganisationsEinheitController.PATH;
+
+	@MockBean
+	private OrganisationsEinheitService service;
+	@MockBean
+	private JwtDecoder decoder;
+
+	@Autowired
+	private MockMvc mockMvc;
+
+	@DisplayName("Get all")
+	@WithMockUser
+	@Nested
+	class TestGetAll {
+
+		@BeforeEach
+		void init() {
+			when(service.getOrganisationsEinheiten()).thenReturn(List.of(OrganisationsEinheitTestFactory.create()));
+		}
+
+		@SneakyThrows
+		@Test
+		void shouldContainList() {
+			var response = mockMvc.perform(get(PATH)).andExpect(status().isOk());
+
+			response.andDo(print()).andExpect(jsonPath("$._embedded.organisationsEinheitList").isNotEmpty());
+		}
+
+		@SneakyThrows
+		@Test
+		void shouldContainOrganisationsEinheit() {
+			mockMvc.perform(get(PATH))
+					.andExpect(jsonPath("$._embedded.organisationsEinheitList[0].name").value(OrganisationsEinheitTestFactory.NAME))
+					.andExpect(jsonPath("$._embedded.organisationsEinheitList[0].organisationsEinheitId").value(
+							OrganisationsEinheitTestFactory.ORGANISATIONS_EINHEIT_ID))
+					.andExpect(
+							jsonPath("$._embedded.organisationsEinheitList[0].syncResult").value(OrganisationsEinheitTestFactory.SYNC_RESULT.name()))
+					.andExpect(jsonPath("$._embedded.organisationsEinheitList[0].zufiId").doesNotExist())
+					.andExpect(jsonPath("$._embedded.organisationsEinheitList[0].parentId").doesNotExist())
+					.andExpect(jsonPath("$._embedded.organisationsEinheitList[0].keycloakId").doesNotExist())
+					.andExpect(jsonPath("$._embedded.organisationsEinheitList[0].id").doesNotExist());
+		}
+	}
+
+	@DisplayName("Get by id")
+	@WithMockUser
+	@Nested
+	class TestGetById {
+
+		@BeforeEach
+		void init() {
+			when(service.getOrganisationsEinheitById(OrganisationsEinheitTestFactory.ID)).thenReturn(OrganisationsEinheitTestFactory.create());
+		}
+
+		@SneakyThrows
+		@Test
+		void shouldReturnOrganisationsEinheit() {
+			var response = mockMvc.perform(get(PATH + "/" + OrganisationsEinheitTestFactory.ID)).andExpect(status().isOk());
+
+			response
+					.andExpect(jsonPath("$.name").value(OrganisationsEinheitTestFactory.NAME))
+					.andExpect(jsonPath("$.organisationsEinheitId").value(
+							OrganisationsEinheitTestFactory.ORGANISATIONS_EINHEIT_ID))
+					.andExpect(jsonPath("$.syncResult").value(OrganisationsEinheitTestFactory.SYNC_RESULT.name()))
+					.andExpect(jsonPath("$.zufiId").doesNotExist())
+					.andExpect(jsonPath("$.parentId").doesNotExist())
+					.andExpect(jsonPath("$.keycloakId").doesNotExist())
+					.andExpect(jsonPath("$.id").doesNotExist());
+		}
+	}
+
+}
\ No newline at end of file
diff --git a/src/test/java/de/ozgcloud/admin/organisationseinheit/OrganisationsEinheitMapperTest.java b/src/test/java/de/ozgcloud/admin/organisationseinheit/OrganisationsEinheitMapperTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..53ec27998c68c86ce3dcdb7b91a1a8ba77fb8c20
--- /dev/null
+++ b/src/test/java/de/ozgcloud/admin/organisationseinheit/OrganisationsEinheitMapperTest.java
@@ -0,0 +1,61 @@
+package de.ozgcloud.admin.organisationseinheit;
+
+import static org.assertj.core.api.Assertions.*;
+
+import java.util.Collections;
+import java.util.Objects;
+
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.mapstruct.factory.Mappers;
+
+import de.ozgcloud.admin.keycloak.AddGroupData;
+
+class OrganisationsEinheitMapperTest {
+
+	private final OrganisationsEinheitMapper mapper = Mappers.getMapper(OrganisationsEinheitMapper.class);
+
+	@Nested
+	class TestFromGrpc {
+
+		@Test
+		void shouldMap() {
+			var mapped = mapper.fromGrpc(GrpcOrganisationsEinheitTestFactory.create());
+
+			assertThat(mapped)
+					.matches(organisationsEinheit -> Objects.isNull(organisationsEinheit.getSettings().getSignatur()))
+					.extracting(
+							OrganisationsEinheit::getName,
+							OrganisationsEinheit::getOrganisationsEinheitId,
+							OrganisationsEinheit::getZufiId,
+							OrganisationsEinheit::getParentId,
+							OrganisationsEinheit::getSyncResult,
+							OrganisationsEinheit::getChildren
+					).containsExactly(
+							OrganisationsEinheitTestFactory.NAME,
+							OrganisationsEinheitTestFactory.ORGANISATIONS_EINHEIT_ID,
+							OrganisationsEinheitTestFactory.ZUFI_ID,
+							null,
+							null,
+							Collections.emptyList()
+					);
+		}
+	}
+
+	@Nested
+	class TestToAddGroupData {
+
+		@Test
+		void shouldMap() {
+			var mapped = mapper.toAddGroupData(OrganisationsEinheitTestFactory.create());
+
+			assertThat(mapped).extracting(
+					AddGroupData::getName,
+					AddGroupData::getOrganisationsEinheitId
+			).containsExactly(
+					OrganisationsEinheitTestFactory.NAME,
+					OrganisationsEinheitTestFactory.ORGANISATIONS_EINHEIT_ID
+			);
+		}
+	}
+}
\ No newline at end of file
diff --git a/src/test/java/de/ozgcloud/admin/organisationseinheit/OrganisationsEinheitModelAssemblerTest.java b/src/test/java/de/ozgcloud/admin/organisationseinheit/OrganisationsEinheitModelAssemblerTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..c726c6b5219bdadedb46e336c2baa9fed5edee5f
--- /dev/null
+++ b/src/test/java/de/ozgcloud/admin/organisationseinheit/OrganisationsEinheitModelAssemblerTest.java
@@ -0,0 +1,194 @@
+package de.ozgcloud.admin.organisationseinheit;
+
+import static de.ozgcloud.admin.organisationseinheit.OrganisationsEinheitModelAssembler.*;
+import static org.assertj.core.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.UUID;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Captor;
+import org.mockito.InjectMocks;
+import org.mockito.Spy;
+import org.springframework.hateoas.CollectionModel;
+import org.springframework.hateoas.EntityModel;
+import org.springframework.hateoas.IanaLinkRelations;
+import org.springframework.hateoas.Link;
+import org.springframework.hateoas.LinkRelation;
+import org.springframework.hateoas.mediatype.hal.HalModelBuilder;
+import org.springframework.web.util.UriTemplate;
+
+class OrganisationsEinheitModelAssemblerTest {
+
+	@Spy
+	@InjectMocks
+	private OrganisationsEinheitModelAssembler assembler;
+
+	@Nested
+	class TestToModel {
+
+		private final OrganisationsEinheit organisationsEinheit = OrganisationsEinheitTestFactory.createBuilder().parentId(null).build();
+
+		@BeforeEach
+		void setUp() {
+			doNothing().when(assembler).embedChildOrganisationsEinheiten(eq(organisationsEinheit), any());
+		}
+
+		@Test
+		void shouldAddSelfLink() {
+			var model = assembler.toModel(organisationsEinheit);
+
+			assertThat(model.getLink(IanaLinkRelations.SELF))
+					.isNotEmpty()
+					.get()
+					.extracting(Link::getHref)
+					.isEqualTo(new UriTemplate(OrganisationsEinheitController.PATH + "/{id}").expand(OrganisationsEinheitTestFactory.ID).toString());
+		}
+
+		@Test
+		void shouldReturnModel() {
+			var model = assembler.toModel(organisationsEinheit);
+
+			assertThat(model.getContent()).isEqualTo(organisationsEinheit);
+		}
+
+		@Test
+		void shouldEmbedChildOrganisationsEinheiten() {
+			assembler.toModel(organisationsEinheit);
+
+			verify(assembler).embedChildOrganisationsEinheiten(eq(organisationsEinheit), any());
+		}
+	}
+
+	@Nested
+	class TestEmbedChildOrganisationsEinheiten {
+
+		private OrganisationsEinheit parentOrganisationsEinheit;
+		private OrganisationsEinheit childOrganisationsEinheit;
+		private CollectionModel<EntityModel<OrganisationsEinheit>> childrenCollectionModel;
+		@Spy
+		private final HalModelBuilder halModelBuilder = HalModelBuilder.emptyHalModel();
+		@Captor
+		private ArgumentCaptor<LinkRelation> linkRelationArgumentCaptor;
+		@Captor
+		private ArgumentCaptor<Collection<EntityModel<OrganisationsEinheit>>> collectionModelContentArgumentCaptor;
+
+		@BeforeEach
+		void setUp() {
+			parentOrganisationsEinheit = OrganisationsEinheitTestFactory.createBuilder().parentId(null).build();
+			childOrganisationsEinheit = OrganisationsEinheitTestFactory.createBuilder().id(UUID.randomUUID().toString())
+					.parentId(parentOrganisationsEinheit.getId()).build();
+			parentOrganisationsEinheit = parentOrganisationsEinheit.toBuilder().child(childOrganisationsEinheit).build();
+			childrenCollectionModel = CollectionModel.of(List.of(EntityModel.of(childOrganisationsEinheit)));
+			doReturn(childrenCollectionModel).when(assembler)
+					.toChildrenCollectionModel(parentOrganisationsEinheit.getId(), parentOrganisationsEinheit.getChildren());
+		}
+
+		@Test
+		void shouldBuildChildrenCollectionModel() {
+			assembler.embedChildOrganisationsEinheiten(parentOrganisationsEinheit, halModelBuilder);
+
+			verify(assembler).toChildrenCollectionModel(parentOrganisationsEinheit.getId(), parentOrganisationsEinheit.getChildren());
+		}
+
+		@Test
+		void shouldEmbedChildrenModels() {
+			assembler.embedChildOrganisationsEinheiten(parentOrganisationsEinheit, halModelBuilder);
+
+			verify(halModelBuilder).embed(collectionModelContentArgumentCaptor.capture(), linkRelationArgumentCaptor.capture());
+			assertThat(collectionModelContentArgumentCaptor.getValue()).usingRecursiveComparison().isEqualTo(childrenCollectionModel.getContent());
+			assertThat(linkRelationArgumentCaptor.getValue().toString()).isEqualTo(REL_CHILD_ORGANISATIONS_EINHEITEN);
+		}
+
+		@Test
+		void shouldHaveChildListLinkRelation() {
+			assembler.embedChildOrganisationsEinheiten(parentOrganisationsEinheit, halModelBuilder);
+
+			assertThat(halModelBuilder.build().getLink(REL_CHILD_ORGANISATIONS_EINHEITEN))
+					.isNotEmpty()
+					.get()
+					.extracting(Link::getHref)
+					.isEqualTo(OrganisationsEinheitController.PATH + "/" + parentOrganisationsEinheit.getId() + "/children");
+		}
+	}
+
+	@Nested
+	class TestToCollectionModel {
+
+		private final OrganisationsEinheit organisationsEinheit = OrganisationsEinheitTestFactory.create();
+
+		@BeforeEach
+		void setUp() {
+			doReturn(EntityModel.of(organisationsEinheit)).when(assembler).toModel(organisationsEinheit);
+		}
+
+		@Test
+		void shouldAddSelfLink() {
+			var collectionModel = assembler.toCollectionModel(List.of(organisationsEinheit));
+
+			assertThat(collectionModel.getLink(IanaLinkRelations.SELF))
+					.isNotEmpty()
+					.get()
+					.extracting(Link::getHref)
+					.isEqualTo(OrganisationsEinheitController.PATH);
+		}
+
+		@Test
+		void shouldBuildModels() {
+			assembler.toCollectionModel(List.of(organisationsEinheit));
+
+			verify(assembler).toModel(organisationsEinheit);
+		}
+
+		@Test
+		void shouldHaveModels() {
+			var collectionModel = assembler.toCollectionModel(List.of(organisationsEinheit));
+
+			assertThat(collectionModel.getContent()).extracting(EntityModel::getContent).containsExactly(organisationsEinheit);
+		}
+	}
+
+	@Nested
+	class ToChildrenCollectionModel {
+
+		private final OrganisationsEinheit parentOrganisationsEinheit = OrganisationsEinheitTestFactory.createBuilder().parentId(null).build();
+		private final OrganisationsEinheit childOrganisationsEinheit = OrganisationsEinheitTestFactory.createBuilder()
+				.id(UUID.randomUUID().toString()).build();
+
+		@BeforeEach
+		void setUp() {
+			doReturn(EntityModel.of(childOrganisationsEinheit)).when(assembler).toModel(childOrganisationsEinheit);
+		}
+
+		@Test
+		void shouldAddSelfLink() {
+			var collectionModel = assembler.toChildrenCollectionModel(parentOrganisationsEinheit.getId(), List.of(childOrganisationsEinheit));
+
+			assertThat(collectionModel.getLink(IanaLinkRelations.SELF))
+					.isNotEmpty()
+					.get()
+					.extracting(Link::getHref)
+					.isEqualTo(OrganisationsEinheitController.PATH + "/" + parentOrganisationsEinheit.getId() + "/children");
+		}
+
+		@Test
+		void shouldBuildModels() {
+			assembler.toChildrenCollectionModel(parentOrganisationsEinheit.getId(), List.of(childOrganisationsEinheit));
+
+			verify(assembler).toModel(childOrganisationsEinheit);
+		}
+
+		@Test
+		void shouldHaveModels() {
+			var collectionModel = assembler.toChildrenCollectionModel(parentOrganisationsEinheit.getId(), List.of(childOrganisationsEinheit));
+
+			assertThat(collectionModel.getContent()).extracting(EntityModel::getContent).containsExactly(childOrganisationsEinheit);
+		}
+
+	}
+}
\ No newline at end of file
diff --git a/src/test/java/de/ozgcloud/admin/organisationseinheit/OrganisationsEinheitRemoteServiceTest.java b/src/test/java/de/ozgcloud/admin/organisationseinheit/OrganisationsEinheitRemoteServiceTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..fde08bcea821a504734046bb2d16024dbd1bb056
--- /dev/null
+++ b/src/test/java/de/ozgcloud/admin/organisationseinheit/OrganisationsEinheitRemoteServiceTest.java
@@ -0,0 +1,84 @@
+package de.ozgcloud.admin.organisationseinheit;
+
+import static org.assertj.core.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.Spy;
+
+import de.ozgcloud.zufi.grpc.organisationseinheit.GrpcGetByOrganisationsEinheitIdRequest;
+import de.ozgcloud.zufi.grpc.organisationseinheit.GrpcGetByOrganisationsEinheitIdResponse;
+import de.ozgcloud.zufi.grpc.organisationseinheit.OrganisationsEinheitServiceGrpc.OrganisationsEinheitServiceBlockingStub;
+
+class OrganisationsEinheitRemoteServiceTest {
+
+	@Spy
+	@InjectMocks
+	private OrganisationsEinheitRemoteService service;
+
+	@Mock
+	private OrganisationsEinheitServiceBlockingStub serviceStub;
+
+	@Mock
+	private OrganisationsEinheitMapper mapper;
+
+	@Nested
+	class TestGetByOrganisationsEinheitId {
+
+		private final GrpcGetByOrganisationsEinheitIdRequest request = GrpcGetByOrganisationsEinheitIdRequestTestFactory.create();
+		private final GrpcGetByOrganisationsEinheitIdResponse response = GrpcGetByOrganisationsEinheitIdResponseTestFactory.create();
+		private final OrganisationsEinheit organisationsEinheit = OrganisationsEinheitTestFactory.create();
+
+		@BeforeEach
+		void setUp() {
+			doReturn(request).when(service).buildGetByOrganisationsEinheitIdRequest(OrganisationsEinheitTestFactory.ORGANISATIONS_EINHEIT_ID);
+			when(serviceStub.getByOrganisationsEinheitId(request)).thenReturn(response);
+			when(mapper.fromGrpc(GrpcGetByOrganisationsEinheitIdResponseTestFactory.GRPC_ORGANISATIONS_EINHEIT)).thenReturn(organisationsEinheit);
+		}
+
+		@Test
+		void shouldBuildRequest() {
+			service.getByOrganisationsEinheitId(OrganisationsEinheitTestFactory.ORGANISATIONS_EINHEIT_ID);
+
+			verify(service).buildGetByOrganisationsEinheitIdRequest(OrganisationsEinheitTestFactory.ORGANISATIONS_EINHEIT_ID);
+		}
+
+		@Test
+		void shouldCallServiceStub() {
+			service.getByOrganisationsEinheitId(OrganisationsEinheitTestFactory.ORGANISATIONS_EINHEIT_ID);
+
+			verify(serviceStub).getByOrganisationsEinheitId(request);
+		}
+
+		@Test
+		void shouldMapResponse() {
+			service.getByOrganisationsEinheitId(OrganisationsEinheitTestFactory.ORGANISATIONS_EINHEIT_ID);
+
+			verify(mapper).fromGrpc(GrpcGetByOrganisationsEinheitIdResponseTestFactory.GRPC_ORGANISATIONS_EINHEIT);
+		}
+
+		@Test
+		void shouldReturnOrganisationsEinheiten() {
+			var organisationsEinheiten = service.getByOrganisationsEinheitId(OrganisationsEinheitTestFactory.ORGANISATIONS_EINHEIT_ID);
+
+			assertThat(organisationsEinheiten).containsExactly(organisationsEinheit);
+
+		}
+	}
+
+	@Nested
+	class TestBuildGetByOrganisationsEinheitIdRequest {
+
+		@Test
+		void shouldBuild() {
+			var request = service.buildGetByOrganisationsEinheitIdRequest(OrganisationsEinheitTestFactory.ID);
+
+			assertThat(request.getOrganisationsEinheitId()).isEqualTo(OrganisationsEinheitTestFactory.ID);
+		}
+	}
+
+}
\ No newline at end of file
diff --git a/src/test/java/de/ozgcloud/admin/organisationseinheit/OrganisationsEinheitRepositoryITCase.java b/src/test/java/de/ozgcloud/admin/organisationseinheit/OrganisationsEinheitRepositoryITCase.java
new file mode 100644
index 0000000000000000000000000000000000000000..dcfeebde1d6aa5bfb83944ec11c110f72dd833a8
--- /dev/null
+++ b/src/test/java/de/ozgcloud/admin/organisationseinheit/OrganisationsEinheitRepositoryITCase.java
@@ -0,0 +1,290 @@
+package de.ozgcloud.admin.organisationseinheit;
+
+import static org.assertj.core.api.Assertions.*;
+
+import java.time.Instant;
+import java.util.UUID;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
+import org.springframework.boot.test.autoconfigure.data.mongo.DataMongoTest;
+import org.springframework.data.mongodb.core.MongoOperations;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.context.ContextConfiguration;
+
+import de.ozgcloud.common.test.DbInitializer;
+
+@DataMongoTest
+@ContextConfiguration(initializers = { DbInitializer.class }, classes = { OrganisationsEinheitRepository.class, MongoOperations.class })
+@ActiveProfiles({ "itcase", "with_db" })
+@EnableAutoConfiguration
+class OrganisationsEinheitRepositoryITCase {
+
+	@Autowired
+	private OrganisationsEinheitRepository repository;
+
+	@Autowired
+	private MongoOperations operations;
+
+	@BeforeEach
+	void clearDatabase() {
+		operations.dropCollection(OrganisationsEinheit.class);
+	}
+
+	@Nested
+	class TestFindAllNotDeleted {
+
+		private static final String DELETED_ORGANISATIONS_EINHEIT_ID_1 = "1";
+		private static final String DELETED_ORGANISATIONS_EINHEIT_ID_2 = "2";
+		private static final String SYNCED_ORGANISATIONS_EINHEIT_ID_1 = "3";
+		private static final String SYNCED_ORGANISATIONS_EINHEIT_ID_2 = "4";
+		private static final String ADDED_FROM_ZUFI_ORGANISATIONS_EINHEIT_ID_1 = "5";
+
+		private final OrganisationsEinheit deleted1 = OrganisationsEinheitTestFactory.createBuilder()
+				.id(null)
+				.organisationsEinheitId(DELETED_ORGANISATIONS_EINHEIT_ID_1)
+				.syncResult(SyncResult.DELETED)
+				.build();
+		private final OrganisationsEinheit deleted2 = OrganisationsEinheitTestFactory.createBuilder()
+				.id(null)
+				.organisationsEinheitId(DELETED_ORGANISATIONS_EINHEIT_ID_2)
+				.syncResult(SyncResult.DELETED)
+				.build();
+		private final OrganisationsEinheit synced1 = OrganisationsEinheitTestFactory.createBuilder()
+				.id(null)
+				.organisationsEinheitId(SYNCED_ORGANISATIONS_EINHEIT_ID_1)
+				.build();
+		private final OrganisationsEinheit synced2 = OrganisationsEinheitTestFactory.createBuilder()
+				.id(null)
+				.organisationsEinheitId(SYNCED_ORGANISATIONS_EINHEIT_ID_2)
+				.build();
+		private final OrganisationsEinheit addedFromZufi = OrganisationsEinheitTestFactory.createBuilder()
+				.id(null)
+				.syncResult(null)
+				.organisationsEinheitId(ADDED_FROM_ZUFI_ORGANISATIONS_EINHEIT_ID_1)
+				.build();
+
+		@BeforeEach
+		void setUp() {
+			operations.save(deleted1);
+			operations.save(deleted2);
+			operations.save(synced1);
+			operations.save(synced2);
+			operations.save(addedFromZufi);
+		}
+
+		@Test
+		void shouldReturnNotDeleted() {
+			var allNotDeleted = repository.findAllNotDeleted();
+
+			assertThat(allNotDeleted).extracting(
+					OrganisationsEinheit::getOrganisationsEinheitId,
+					OrganisationsEinheit::getSyncResult
+			).containsExactly(
+					tuple(SYNCED_ORGANISATIONS_EINHEIT_ID_1, OrganisationsEinheitTestFactory.SYNC_RESULT),
+					tuple(SYNCED_ORGANISATIONS_EINHEIT_ID_2, OrganisationsEinheitTestFactory.SYNC_RESULT),
+					tuple(ADDED_FROM_ZUFI_ORGANISATIONS_EINHEIT_ID_1, null)
+			);
+		}
+	}
+
+	@Nested
+	class TestFindSyncedByKeycloakId {
+
+		@Test
+		void shouldReturnEmptyOnEmptyDatabase() {
+			var organisationsEinheit = repository.findSyncedByKeycloakId(OrganisationsEinheitTestFactory.KEYCLOAK_ID);
+
+			assertThat(organisationsEinheit).isEmpty();
+		}
+
+		@Test
+		void shouldReturnEmptyOnNotFoundKeycloakId() {
+			operations.save(OrganisationsEinheitTestFactory.createBuilder().id(null).build());
+
+			var organisationsEinheit = repository.findSyncedByKeycloakId("not_found");
+
+			assertThat(organisationsEinheit).isEmpty();
+		}
+
+		@Test
+		void shouldReturnEmptyOnSyncResultNull() {
+			operations.save(OrganisationsEinheitTestFactory.createBuilder().id(null).syncResult(null).build());
+
+			var organisationsEinheit = repository.findSyncedByKeycloakId(OrganisationsEinheitTestFactory.KEYCLOAK_ID);
+
+			assertThat(organisationsEinheit).isEmpty();
+		}
+
+		@Test
+		void shouldFind() {
+			operations.save(OrganisationsEinheitTestFactory.createBuilder().id(null).build());
+			operations.save(OrganisationsEinheitTestFactory.createBuilder().id(null).keycloakId(UUID.randomUUID().toString()).build());
+
+			var organisationsEinheit = repository.findSyncedByKeycloakId(OrganisationsEinheitTestFactory.KEYCLOAK_ID);
+
+			assertThat(organisationsEinheit)
+					.isNotEmpty()
+					.get()
+					.extracting(OrganisationsEinheit::getKeycloakId)
+					.isEqualTo(OrganisationsEinheitTestFactory.KEYCLOAK_ID);
+		}
+	}
+
+	@Nested
+	class TestSetUnsyncedAsDeleted {
+
+		private static final String DELETED_ORGANISATIONS_EINHEIT_ID_1 = "1";
+		private static final String DELETED_ORGANISATIONS_EINHEIT_ID_2 = "2";
+		private static final String SYNCED_ORGANISATIONS_EINHEIT_ID_1 = "3";
+		private static final String SYNCED_ORGANISATIONS_EINHEIT_ID_2 = "4";
+
+		private final OrganisationsEinheit deleted1 = OrganisationsEinheitTestFactory.createBuilder()
+				.id(null)
+				.organisationsEinheitId(DELETED_ORGANISATIONS_EINHEIT_ID_1)
+				.lastSyncTimestamp(OrganisationsEinheitTestFactory.LAST_SYNC_UPDATE - 1)
+				.build();
+		private final OrganisationsEinheit deleted2 = OrganisationsEinheitTestFactory.createBuilder()
+				.id(null)
+				.organisationsEinheitId(DELETED_ORGANISATIONS_EINHEIT_ID_2)
+				.lastSyncTimestamp(OrganisationsEinheitTestFactory.LAST_SYNC_UPDATE - 2)
+				.build();
+		private final OrganisationsEinheit synced1 = OrganisationsEinheitTestFactory.createBuilder()
+				.id(null)
+				.organisationsEinheitId(SYNCED_ORGANISATIONS_EINHEIT_ID_1)
+				.build();
+		private final OrganisationsEinheit synced2 = OrganisationsEinheitTestFactory.createBuilder()
+				.id(null)
+				.organisationsEinheitId(SYNCED_ORGANISATIONS_EINHEIT_ID_2)
+				.build();
+
+		@BeforeEach
+		void setUp() {
+			operations.save(deleted1);
+			operations.save(deleted2);
+			operations.save(synced1);
+			operations.save(synced2);
+		}
+
+		@Test
+		void shouldSetUnsyncedAsDeleted() {
+			repository.setUnsyncedAsDeleted(OrganisationsEinheitTestFactory.LAST_SYNC_UPDATE);
+
+			var all = repository.findAll();
+
+			assertThat(all).extracting(
+					OrganisationsEinheit::getOrganisationsEinheitId,
+					OrganisationsEinheit::getSyncResult
+			).containsExactly(
+					tuple(DELETED_ORGANISATIONS_EINHEIT_ID_1, SyncResult.DELETED),
+					tuple(DELETED_ORGANISATIONS_EINHEIT_ID_2, SyncResult.DELETED),
+					tuple(SYNCED_ORGANISATIONS_EINHEIT_ID_1, OrganisationsEinheitTestFactory.SYNC_RESULT),
+					tuple(SYNCED_ORGANISATIONS_EINHEIT_ID_2, OrganisationsEinheitTestFactory.SYNC_RESULT)
+			);
+		}
+
+		@Test
+		void shouldSetAllAsDeleted() {
+			repository.setUnsyncedAsDeleted(Instant.now().toEpochMilli());
+
+			var all = repository.findAll();
+
+			assertThat(all).extracting(
+					OrganisationsEinheit::getOrganisationsEinheitId,
+					OrganisationsEinheit::getSyncResult
+			).containsExactly(
+					tuple(DELETED_ORGANISATIONS_EINHEIT_ID_1, SyncResult.DELETED),
+					tuple(DELETED_ORGANISATIONS_EINHEIT_ID_2, SyncResult.DELETED),
+					tuple(SYNCED_ORGANISATIONS_EINHEIT_ID_1, SyncResult.DELETED),
+					tuple(SYNCED_ORGANISATIONS_EINHEIT_ID_2, SyncResult.DELETED)
+			);
+		}
+	}
+
+	@Nested
+	class TestFindAllWithoutSyncResult {
+
+		private static final String SYNCED_ID_1 = "1";
+		private static final String WITHOUT_SYNC_RESULT_ID_1 = "2";
+		private static final String WITHOUT_SYNC_RESULT_ID_2 = "3";
+
+		private final OrganisationsEinheit synced1 = OrganisationsEinheitTestFactory.createBuilder()
+				.id(SYNCED_ID_1)
+				.build();
+		private final OrganisationsEinheit withoutSyncResult1 = OrganisationsEinheitTestFactory.createBuilder()
+				.id(WITHOUT_SYNC_RESULT_ID_1)
+				.syncResult(null)
+				.build();
+		private final OrganisationsEinheit withoutSyncResult2 = OrganisationsEinheitTestFactory.createBuilder()
+				.id(WITHOUT_SYNC_RESULT_ID_2)
+				.parentId(SYNCED_ID_1)
+				.syncResult(null)
+				.build();
+
+		@BeforeEach
+		void setUp() {
+			operations.save(synced1);
+			operations.save(withoutSyncResult1);
+			operations.save(withoutSyncResult2);
+		}
+
+		@Test
+		void shouldFindAllWithoutSyncResult() {
+			var allWithoutSyncResult = repository.findAllWithoutSyncResult();
+
+			assertThat(allWithoutSyncResult).extracting(OrganisationsEinheit::getId)
+					.containsExactlyInAnyOrder(WITHOUT_SYNC_RESULT_ID_1, WITHOUT_SYNC_RESULT_ID_2);
+		}
+	}
+
+	@Nested
+	class TestFindChildren {
+
+		private final OrganisationsEinheit root1 = OrganisationsEinheitTestFactory.createBuilder().parentId(null).build();
+		private final OrganisationsEinheit root2 = OrganisationsEinheitTestFactory.createBuilder()
+				.id(UUID.randomUUID().toString())
+				.parentId(null)
+				.build();
+		private final OrganisationsEinheit child1 = OrganisationsEinheitTestFactory.createBuilder()
+				.id(UUID.randomUUID().toString())
+				.parentId(root1.getId())
+				.build();
+		private final OrganisationsEinheit child2 = OrganisationsEinheitTestFactory.createBuilder()
+				.id(UUID.randomUUID().toString())
+				.parentId(root1.getId())
+				.build();
+
+		private final OrganisationsEinheit child3 = OrganisationsEinheitTestFactory.createBuilder()
+				.id(UUID.randomUUID().toString())
+				.parentId(root2.getId())
+				.build();
+
+		@BeforeEach
+		void setUp() {
+			operations.save(root1);
+			operations.save(root2);
+			operations.save(child1);
+			operations.save(child2);
+			operations.save(child3);
+		}
+
+		@Test
+		void shouldFind() {
+			var found = repository.findChildren(root1.getId());
+
+			assertThat(found).extracting(OrganisationsEinheit::getId).containsOnly(child1.getId(), child2.getId());
+		}
+
+		@Test
+		void shouldNotFind() {
+			var found = repository.findChildren(UUID.randomUUID().toString());
+
+			assertThat(found).isEmpty();
+
+		}
+	}
+
+}
\ No newline at end of file
diff --git a/src/test/java/de/ozgcloud/admin/organisationseinheit/OrganisationsEinheitRootProcessorTest.java b/src/test/java/de/ozgcloud/admin/organisationseinheit/OrganisationsEinheitRootProcessorTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..85e0308c11706d45e11369ae26a7ea634b8fc87a
--- /dev/null
+++ b/src/test/java/de/ozgcloud/admin/organisationseinheit/OrganisationsEinheitRootProcessorTest.java
@@ -0,0 +1,44 @@
+package de.ozgcloud.admin.organisationseinheit;
+
+import static org.assertj.core.api.Assertions.*;
+
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.mockito.InjectMocks;
+import org.springframework.hateoas.EntityModel;
+import org.springframework.hateoas.Link;
+
+import de.ozgcloud.admin.RootTestFactory;
+
+class OrganisationsEinheitRootProcessorTest {
+
+	@InjectMocks
+	private OrganisationsEinheitRootProcessor organisationsEinheitRootProcessor;
+
+	@Nested
+	class TestProcess {
+
+		@Nested
+		class OrganisationsEinheitenLinkRelation {
+
+			@Test
+			void shouldExists() {
+				var model = organisationsEinheitRootProcessor.process(EntityModel.of(RootTestFactory.create()));
+
+				assertThat(model.getLink(OrganisationsEinheitRootProcessor.REL_ORGANISATIONS_EINHEITEN)).isNotEmpty();
+			}
+
+			@Test
+			void shouldHaveHref() {
+				var model = organisationsEinheitRootProcessor.process(EntityModel.of(RootTestFactory.create()));
+
+				assertThat(model.getLink(OrganisationsEinheitRootProcessor.REL_ORGANISATIONS_EINHEITEN))
+						.get()
+						.extracting(Link::getHref)
+						.isEqualTo(OrganisationsEinheitController.PATH);
+			}
+		}
+
+	}
+
+}
\ No newline at end of file
diff --git a/src/test/java/de/ozgcloud/admin/organisationseinheit/OrganisationsEinheitServiceTest.java b/src/test/java/de/ozgcloud/admin/organisationseinheit/OrganisationsEinheitServiceTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..e151636ee95ef52d2dab645c0e19ad5b134f1114
--- /dev/null
+++ b/src/test/java/de/ozgcloud/admin/organisationseinheit/OrganisationsEinheitServiceTest.java
@@ -0,0 +1,161 @@
+package de.ozgcloud.admin.organisationseinheit;
+
+import static org.assertj.core.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.Spy;
+import org.springframework.data.rest.webmvc.ResourceNotFoundException;
+
+class OrganisationsEinheitServiceTest {
+
+	@Spy
+	@InjectMocks
+	private OrganisationsEinheitService service;
+
+	@Mock
+	private OrganisationsEinheitRepository repository;
+
+	@Nested
+	class TestSaveOrganisationsEinheit {
+
+		private final OrganisationsEinheit organisationsEinheit = OrganisationsEinheitTestFactory.create();
+
+		@BeforeEach
+		void setUp() {
+			when(repository.save(organisationsEinheit)).thenReturn(organisationsEinheit);
+		}
+
+		@Test
+		void shouldCallRepository() {
+			service.saveOrganisationsEinheit(organisationsEinheit);
+
+			verify(repository).save(organisationsEinheit);
+		}
+
+		@Test
+		void shouldReturnSavedOrganisationsEinheit() {
+			var saved = service.saveOrganisationsEinheit(organisationsEinheit);
+
+			assertThat(saved).isEqualTo(organisationsEinheit);
+		}
+	}
+
+	@Nested
+	class TestGetOrganisationsEinheiten {
+
+		private final OrganisationsEinheit rootOrganisationsEinheit = OrganisationsEinheitTestFactory.createBuilder().parentId(null).build();
+		private final OrganisationsEinheit childOrganisationsEinheit = OrganisationsEinheitTestFactory.createBuilder()
+				.id(UUID.randomUUID().toString()).parentId(rootOrganisationsEinheit.getId()).build();
+		private final List<OrganisationsEinheit> organisationsEinheiten = List.of(rootOrganisationsEinheit, childOrganisationsEinheit);
+
+		@BeforeEach
+		void setUp() {
+			when(repository.findAllNotDeleted()).thenReturn(organisationsEinheiten);
+			doReturn(rootOrganisationsEinheit).when(service).addChildren(rootOrganisationsEinheit, organisationsEinheiten);
+		}
+
+		@Test
+		void shouldCallRepository() {
+			service.getOrganisationsEinheiten();
+
+			verify(repository).findAllNotDeleted();
+		}
+
+		@Test
+		void shouldAddChildren() {
+			service.getOrganisationsEinheiten();
+
+			verify(service).addChildren(rootOrganisationsEinheit, organisationsEinheiten);
+		}
+
+		@Test
+		void shouldReturnAll() {
+			var gotOrganisationsEinheiten = service.getOrganisationsEinheiten();
+
+			assertThat(gotOrganisationsEinheiten).containsExactly(rootOrganisationsEinheit);
+		}
+	}
+
+	@Nested
+	class TestGetOrganisationsEinheitById {
+
+		private final OrganisationsEinheit organisationsEinheit = OrganisationsEinheitTestFactory.create();
+
+		@Test
+		void shouldCallRepository() {
+			when(repository.findById(OrganisationsEinheitTestFactory.ID)).thenReturn(Optional.of(organisationsEinheit));
+
+			service.getOrganisationsEinheitById(OrganisationsEinheitTestFactory.ID);
+
+			verify(repository).findById(OrganisationsEinheitTestFactory.ID);
+		}
+
+		@Test
+		void shouldReturnOrganisationsEinheit() {
+			when(repository.findById(OrganisationsEinheitTestFactory.ID)).thenReturn(Optional.of(organisationsEinheit));
+
+			var gotOrganisationsEinheit = service.getOrganisationsEinheitById(OrganisationsEinheitTestFactory.ID);
+
+			assertThat(gotOrganisationsEinheit).isEqualTo(organisationsEinheit);
+		}
+
+		@Test
+		void shouldThrowResourceNotFoundException() {
+			when(repository.findById("not_exists")).thenReturn(Optional.empty());
+
+			assertThatThrownBy(() -> service.getOrganisationsEinheitById("not_exists"))
+					.isInstanceOf(ResourceNotFoundException.class)
+					.hasMessage("Organisationseinheit with id not_exists not found");
+		}
+	}
+
+	@Nested
+	class TestAddChildren {
+
+		private final OrganisationsEinheit rootOrganisationsEinheit = OrganisationsEinheitTestFactory.create();
+		private final OrganisationsEinheit childOrganisationsEinheit = OrganisationsEinheitTestFactory.createBuilder()
+				.id(UUID.randomUUID().toString()).parentId(rootOrganisationsEinheit.getId()).build();
+		private final List<OrganisationsEinheit> organisationsEinheiten = List.of(rootOrganisationsEinheit, childOrganisationsEinheit);
+
+		@Test
+		void shouldAddChildren() {
+			var organisationsEinheit = service.addChildren(rootOrganisationsEinheit, organisationsEinheiten);
+
+			assertThat(organisationsEinheit.getChildren()).containsExactly(childOrganisationsEinheit);
+		}
+	}
+
+	@Nested
+	class TestGetChildren {
+
+		private final OrganisationsEinheit organisationsEinheit = OrganisationsEinheitTestFactory.create();
+
+		@BeforeEach
+		void setUp() {
+			when(repository.findChildren(OrganisationsEinheitTestFactory.ID)).thenReturn(List.of(organisationsEinheit));
+		}
+
+		@Test
+		void shouldCallRepository() {
+			service.getChildren(OrganisationsEinheitTestFactory.ID);
+
+			verify(repository).findChildren(OrganisationsEinheitTestFactory.ID);
+		}
+
+		@Test
+		void shouldReturnChildren() {
+			var children = service.getChildren(OrganisationsEinheitTestFactory.ID);
+
+			assertThat(children).containsExactly(organisationsEinheit);
+		}
+	}
+}
\ No newline at end of file
diff --git a/src/test/java/de/ozgcloud/admin/organisationseinheit/OrganisationsEinheitSettingsTestFactory.java b/src/test/java/de/ozgcloud/admin/organisationseinheit/OrganisationsEinheitSettingsTestFactory.java
new file mode 100644
index 0000000000000000000000000000000000000000..c5028fb0e25bb8b6127e623a4e09b75794545d61
--- /dev/null
+++ b/src/test/java/de/ozgcloud/admin/organisationseinheit/OrganisationsEinheitSettingsTestFactory.java
@@ -0,0 +1,23 @@
+package de.ozgcloud.admin.organisationseinheit;
+
+import com.thedeanda.lorem.LoremIpsum;
+
+public class OrganisationsEinheitSettingsTestFactory {
+
+	public static final String SIGNATUR = LoremIpsum.getInstance().getWords(4);
+
+	public static OrganisationsEinheitSettings create() {
+		return createBuilder().build();
+	}
+
+	public static OrganisationsEinheitSettings.OrganisationsEinheitSettingsBuilder createBuilder() {
+		return OrganisationsEinheitSettings.builder()
+				.signatur(SIGNATUR);
+	}
+
+	public static OrganisationsEinheitSettings.OrganisationsEinheitSettingsBuilder createNewBuilder() {
+		return OrganisationsEinheitSettings.builder()
+				.signatur(LoremIpsum.getInstance().getWords(3));
+	}
+
+}
diff --git a/src/test/java/de/ozgcloud/admin/organisationseinheit/OrganisationsEinheitTestFactory.java b/src/test/java/de/ozgcloud/admin/organisationseinheit/OrganisationsEinheitTestFactory.java
new file mode 100644
index 0000000000000000000000000000000000000000..756ec38fc6a01660b352924cebbd24176c4a9939
--- /dev/null
+++ b/src/test/java/de/ozgcloud/admin/organisationseinheit/OrganisationsEinheitTestFactory.java
@@ -0,0 +1,49 @@
+package de.ozgcloud.admin.organisationseinheit;
+
+import java.time.Instant;
+import java.util.UUID;
+
+import com.thedeanda.lorem.LoremIpsum;
+
+public class OrganisationsEinheitTestFactory {
+
+	public static final String ID = UUID.randomUUID().toString();
+	public static final String KEYCLOAK_ID = UUID.randomUUID().toString();
+	public static final String PARENT_ID = UUID.randomUUID().toString();
+	public static final String NAME = LoremIpsum.getInstance().getName();
+	public static final String ORGANISATIONS_EINHEIT_ID = UUID.randomUUID().toString();
+	public static final String ZUFI_ID = UUID.randomUUID().toString();
+	public static final SyncResult SYNC_RESULT = SyncResult.OK;
+	public static final long LAST_SYNC_UPDATE = Instant.now().toEpochMilli();
+	public static final OrganisationsEinheitSettings SETTINGS = OrganisationsEinheitSettingsTestFactory.create();
+
+	public static OrganisationsEinheit create() {
+		return createBuilder().build();
+	}
+
+	public static OrganisationsEinheit.OrganisationsEinheitBuilder createBuilder() {
+		return OrganisationsEinheit.builder()
+				.id(ID)
+				.keycloakId(KEYCLOAK_ID)
+				.name(NAME)
+				.organisationsEinheitId(ORGANISATIONS_EINHEIT_ID)
+				.parentId(PARENT_ID)
+				.zufiId(ZUFI_ID)
+				.syncResult(SYNC_RESULT)
+				.settings(SETTINGS)
+				.lastSyncTimestamp(LAST_SYNC_UPDATE);
+	}
+
+	public static OrganisationsEinheit.OrganisationsEinheitBuilder createNewBuilder() {
+		return OrganisationsEinheit.builder()
+				.id(UUID.randomUUID().toString())
+				.keycloakId(UUID.randomUUID().toString())
+				.name(LoremIpsum.getInstance().getName())
+				.organisationsEinheitId(UUID.randomUUID().toString())
+				.parentId(UUID.randomUUID().toString())
+				.zufiId(UUID.randomUUID().toString())
+				.syncResult(SyncResult.DELETED)
+				.settings(OrganisationsEinheitSettingsTestFactory.createNewBuilder().build())
+				.lastSyncTimestamp(LAST_SYNC_UPDATE - 10000);
+	}
+}
diff --git a/src/test/java/de/ozgcloud/admin/organisationseinheit/SyncAddedOrganisationsEinheitenITCase.java b/src/test/java/de/ozgcloud/admin/organisationseinheit/SyncAddedOrganisationsEinheitenITCase.java
new file mode 100644
index 0000000000000000000000000000000000000000..1af0cf1617c485ebb64936aba8e653cdafa83ed1
--- /dev/null
+++ b/src/test/java/de/ozgcloud/admin/organisationseinheit/SyncAddedOrganisationsEinheitenITCase.java
@@ -0,0 +1,62 @@
+package de.ozgcloud.admin.organisationseinheit;
+
+import static org.assertj.core.api.Assertions.*;
+import static org.assertj.core.groups.Tuple.tuple;
+
+import java.time.Instant;
+import java.util.List;
+import java.util.UUID;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.mock.mockito.SpyBean;
+import org.springframework.data.mongodb.core.MongoOperations;
+import org.springframework.test.context.ContextConfiguration;
+
+import de.ozgcloud.admin.common.KeycloakInitializer;
+import de.ozgcloud.admin.keycloak.Group;
+import de.ozgcloud.admin.keycloak.KeycloakRemoteService;
+import de.ozgcloud.common.test.DbInitializer;
+import de.ozgcloud.common.test.ITCase;
+
+@ITCase
+@ContextConfiguration(initializers = { DbInitializer.class, KeycloakInitializer.class })
+class SyncAddedOrganisationsEinheitenITCase {
+
+	@Autowired
+	private SyncService service;
+	@SpyBean
+	private KeycloakRemoteService keycloakRemoteService;
+	@Autowired
+	private MongoOperations operations;
+
+	private final long syncTimestamp = Instant.now().toEpochMilli();
+
+	@BeforeEach
+	void clearDatabase() {
+		operations.dropCollection(OrganisationsEinheit.class);
+	}
+
+	@Test
+	void shouldSynchronizeAddedTopLevelGroup() {
+		var topLevel = topLevel("shouldSynchronizeAddedTopLevelGroup");
+		operations.save(topLevel);
+
+		service.syncAddedOrganisationsEinheiten(syncTimestamp);
+
+		assertThat(keycloakRemoteService.getGroupsWithOrganisationsEinheitId())
+				.extracting(Group::getName, Group::getOrganisationsEinheitId, Group::getSubGroups)
+				.contains(tuple(topLevel.getName(), topLevel.getOrganisationsEinheitId(), List.of()));
+	}
+
+	private static OrganisationsEinheit topLevel(String nameSuffix) {
+		return OrganisationsEinheitTestFactory.createBuilder()
+				.id(UUID.randomUUID().toString())
+				.parentId(null)
+				.keycloakId(null)
+				.syncResult(null)
+				.name("topLevel (%s)".formatted(nameSuffix))
+				.build();
+	}
+}
diff --git a/src/test/java/de/ozgcloud/admin/organisationseinheit/SyncSchedulerTest.java b/src/test/java/de/ozgcloud/admin/organisationseinheit/SyncSchedulerTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..c78510c4c8c77dabfe5bf774382775c6959af0fa
--- /dev/null
+++ b/src/test/java/de/ozgcloud/admin/organisationseinheit/SyncSchedulerTest.java
@@ -0,0 +1,55 @@
+package de.ozgcloud.admin.organisationseinheit;
+
+import static org.assertj.core.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+import java.time.Instant;
+
+import org.assertj.core.data.*;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Captor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+
+class SyncSchedulerTest {
+
+	@InjectMocks
+	private SyncScheduler scheduler;
+
+	@Mock
+	private SyncService syncService;
+
+	@Nested
+	class TestSyncOrganisationsEinheitenWithKeycloak {
+
+		@Captor
+		private ArgumentCaptor<Long> syncTimestampArgumentCaptor;
+
+		@Test
+		void shouldSyncOrganisationsEinheitenFromKeycloak() {
+			callService();
+
+			verify(syncService).syncOrganisationsEinheitenFromKeycloak(syncTimestampArgumentCaptor.capture());
+			assertThatSyncTimestampIsCloseToNow();
+		}
+
+		@Test
+		void shouldSyncAddedOrganisationsEinheiten() {
+			callService();
+
+			verify(syncService).syncAddedOrganisationsEinheiten(syncTimestampArgumentCaptor.capture());
+			assertThatSyncTimestampIsCloseToNow();
+		}
+
+		private void assertThatSyncTimestampIsCloseToNow() {
+			assertThat(syncTimestampArgumentCaptor.getValue()).isCloseTo(Instant.now().toEpochMilli(), Offset.offset(5000L));
+		}
+
+		private void callService() {
+			scheduler.syncOrganisationsEinheitenWithKeycloak();
+		}
+	}
+
+}
\ No newline at end of file
diff --git a/src/test/java/de/ozgcloud/admin/organisationseinheit/SyncServiceITCase.java b/src/test/java/de/ozgcloud/admin/organisationseinheit/SyncServiceITCase.java
new file mode 100644
index 0000000000000000000000000000000000000000..c681785b71ea74380ac446ca736527d131579284
--- /dev/null
+++ b/src/test/java/de/ozgcloud/admin/organisationseinheit/SyncServiceITCase.java
@@ -0,0 +1,630 @@
+package de.ozgcloud.admin.organisationseinheit;
+
+import static org.assertj.core.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+import java.time.Instant;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+import java.util.UUID;
+import java.util.stream.Stream;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.data.mongodb.core.MongoOperations;
+
+import com.thedeanda.lorem.LoremIpsum;
+
+import de.ozgcloud.admin.keycloak.GroupTestFactory;
+import de.ozgcloud.admin.keycloak.KeycloakRemoteService;
+import de.ozgcloud.common.test.DataITCase;
+
+@DataITCase
+@EnableAutoConfiguration
+class SyncServiceITCase {
+
+	@Autowired
+	private MongoOperations operations;
+
+	@Autowired
+	private SyncService service;
+
+	@Autowired
+	private OrganisationsEinheitRepository repository;
+
+	@MockBean
+	private KeycloakRemoteService keycloakRemoteService;
+
+	@MockBean
+	private OrganisationsEinheitRemoteService organisationsEinheitRemoteService;
+
+	@BeforeEach
+	void clearDatabase() {
+		operations.dropCollection(OrganisationsEinheit.class);
+	}
+
+	@DisplayName("Organisationseinheit not found in PVOG")
+	@Test
+	void shouldSyncNotFoundInPvog() {
+		var syncTimestamp = Instant.now().toEpochMilli();
+		var group = GroupTestFactory.createBuilder().clearSubGroups().build();
+		when(keycloakRemoteService.getGroupsWithOrganisationsEinheitId()).thenReturn(Stream.of(group));
+		when(organisationsEinheitRemoteService.getByOrganisationsEinheitId(GroupTestFactory.ORGANISATIONS_EINHEIT_ID)).thenReturn(List.of());
+
+		service.syncOrganisationsEinheitenFromKeycloak(syncTimestamp);
+
+		var synced = repository.findAll();
+
+		assertThat(synced).
+				hasSize(1)
+				.first()
+				.matches(organisationsEinheit -> Objects.nonNull(organisationsEinheit.getId()))
+				.matches(organisationsEinheit -> Objects.isNull(organisationsEinheit.getSettings().getSignatur()))
+				.extracting(
+						OrganisationsEinheit::getKeycloakId,
+						OrganisationsEinheit::getName,
+						OrganisationsEinheit::getOrganisationsEinheitId,
+						OrganisationsEinheit::getSyncResult,
+						OrganisationsEinheit::getZufiId,
+						OrganisationsEinheit::getParentId,
+						OrganisationsEinheit::getChildren,
+						OrganisationsEinheit::getLastSyncTimestamp
+				).containsExactly(
+						group.getId(),
+						group.getName(),
+						group.getOrganisationsEinheitId(),
+						SyncResult.NOT_FOUND_IN_PVOG,
+						null,
+						null,
+						Collections.emptyList(),
+						syncTimestamp);
+	}
+
+	@DisplayName("Organisationseinheit found in PVOG but group name is different")
+	@Test
+	void shouldSyncNameMismatch() {
+		var syncTimestamp = Instant.now().toEpochMilli();
+		var group = GroupTestFactory.createBuilder().clearSubGroups().build();
+		var pvogOranigastionsEinheit = OrganisationsEinheitTestFactory.createBuilder().organisationsEinheitId(group.getOrganisationsEinheitId())
+				.build();
+		when(keycloakRemoteService.getGroupsWithOrganisationsEinheitId()).thenReturn(Stream.of(group));
+		when(organisationsEinheitRemoteService.getByOrganisationsEinheitId(GroupTestFactory.ORGANISATIONS_EINHEIT_ID)).thenReturn(
+				List.of(pvogOranigastionsEinheit));
+
+		service.syncOrganisationsEinheitenFromKeycloak(syncTimestamp);
+
+		var synced = repository.findAll();
+
+		assertThat(synced).
+				hasSize(1)
+				.first()
+				.matches(organisationsEinheit -> Objects.nonNull(organisationsEinheit.getId()))
+				.matches(organisationsEinheit -> Objects.isNull(organisationsEinheit.getSettings().getSignatur()))
+				.extracting(
+						OrganisationsEinheit::getKeycloakId,
+						OrganisationsEinheit::getName,
+						OrganisationsEinheit::getOrganisationsEinheitId,
+						OrganisationsEinheit::getSyncResult,
+						OrganisationsEinheit::getZufiId,
+						OrganisationsEinheit::getParentId,
+						OrganisationsEinheit::getChildren,
+						OrganisationsEinheit::getLastSyncTimestamp
+				).containsExactly(
+						group.getId(),
+						pvogOranigastionsEinheit.getName(),
+						group.getOrganisationsEinheitId(),
+						SyncResult.NAME_MISMATCH,
+						pvogOranigastionsEinheit.getId(),
+						null,
+						Collections.emptyList(),
+						syncTimestamp);
+	}
+
+	@DisplayName("Multiple Organisationseinheiten for same group found in PVOG")
+	@Test
+	void shouldSyncOrganisationsEinheitNotUnique() {
+		var syncTimestamp = Instant.now().toEpochMilli();
+		var group = GroupTestFactory.createBuilder().clearSubGroups().build();
+		var pvogOranigastionsEinheit1 = OrganisationsEinheitTestFactory.createBuilder().organisationsEinheitId(group.getOrganisationsEinheitId())
+				.build();
+		var pvogOranigastionsEinheit2 = OrganisationsEinheitTestFactory.createBuilder().organisationsEinheitId(group.getOrganisationsEinheitId())
+				.build();
+		when(keycloakRemoteService.getGroupsWithOrganisationsEinheitId()).thenReturn(Stream.of(group));
+		when(organisationsEinheitRemoteService.getByOrganisationsEinheitId(GroupTestFactory.ORGANISATIONS_EINHEIT_ID)).thenReturn(
+				List.of(pvogOranigastionsEinheit1, pvogOranigastionsEinheit2));
+
+		service.syncOrganisationsEinheitenFromKeycloak(syncTimestamp);
+
+		var synced = repository.findAll();
+
+		assertThat(synced).
+				hasSize(1)
+				.first()
+				.matches(organisationsEinheit -> Objects.nonNull(organisationsEinheit.getId()))
+				.matches(organisationsEinheit -> Objects.isNull(organisationsEinheit.getSettings().getSignatur()))
+				.extracting(
+						OrganisationsEinheit::getKeycloakId,
+						OrganisationsEinheit::getName,
+						OrganisationsEinheit::getOrganisationsEinheitId,
+						OrganisationsEinheit::getSyncResult,
+						OrganisationsEinheit::getZufiId,
+						OrganisationsEinheit::getParentId,
+						OrganisationsEinheit::getChildren,
+						OrganisationsEinheit::getLastSyncTimestamp
+				).containsExactly(
+						group.getId(),
+						group.getName(),
+						group.getOrganisationsEinheitId(),
+						SyncResult.ORGANISATIONSEINHEIT_ID_NOT_UNIQUE,
+						null,
+						null,
+						Collections.emptyList(),
+						syncTimestamp);
+	}
+
+	@DisplayName("Organisationseinheit found in PVOG and have same data")
+	@Test
+	void shouldSyncOk() {
+		var syncTimestamp = Instant.now().toEpochMilli();
+		var group = GroupTestFactory.createBuilder().clearSubGroups().build();
+		var pvogOranigastionsEinheit = OrganisationsEinheitTestFactory.createBuilder().organisationsEinheitId(group.getOrganisationsEinheitId())
+				.name(group.getName()).build();
+		when(keycloakRemoteService.getGroupsWithOrganisationsEinheitId()).thenReturn(Stream.of(group));
+		when(organisationsEinheitRemoteService.getByOrganisationsEinheitId(GroupTestFactory.ORGANISATIONS_EINHEIT_ID)).thenReturn(
+				List.of(pvogOranigastionsEinheit));
+
+		service.syncOrganisationsEinheitenFromKeycloak(syncTimestamp);
+
+		var synced = repository.findAll();
+
+		assertThat(synced).
+				hasSize(1)
+				.first()
+				.matches(organisationsEinheit -> Objects.nonNull(organisationsEinheit.getId()))
+				.matches(organisationsEinheit -> Objects.isNull(organisationsEinheit.getSettings().getSignatur()))
+				.extracting(
+						OrganisationsEinheit::getKeycloakId,
+						OrganisationsEinheit::getName,
+						OrganisationsEinheit::getOrganisationsEinheitId,
+						OrganisationsEinheit::getSyncResult,
+						OrganisationsEinheit::getZufiId,
+						OrganisationsEinheit::getParentId,
+						OrganisationsEinheit::getChildren,
+						OrganisationsEinheit::getLastSyncTimestamp
+				).containsExactly(
+						group.getId(),
+						group.getName(),
+						group.getOrganisationsEinheitId(),
+						SyncResult.OK,
+						pvogOranigastionsEinheit.getId(),
+						null,
+						Collections.emptyList(),
+						syncTimestamp);
+	}
+
+	@DisplayName("OrganisationseinheitId attribute of group changed")
+	@Test
+	void shouldSyncUpdateAttribute() {
+		var syncTimestamp = Instant.now().toEpochMilli();
+		var group = GroupTestFactory.createBuilder().clearSubGroups().build();
+		var pvogOranigastionsEinheit = OrganisationsEinheitTestFactory.createBuilder().organisationsEinheitId(group.getOrganisationsEinheitId())
+				.name(group.getName()).build();
+		var newOrganisationsEinheitId = UUID.randomUUID().toString();
+		when(keycloakRemoteService.getGroupsWithOrganisationsEinheitId()).thenReturn(Stream.of(group));
+		when(organisationsEinheitRemoteService.getByOrganisationsEinheitId(GroupTestFactory.ORGANISATIONS_EINHEIT_ID)).thenReturn(
+				List.of(pvogOranigastionsEinheit));
+
+		service.syncOrganisationsEinheitenFromKeycloak(syncTimestamp);
+		when(keycloakRemoteService.getGroupsWithOrganisationsEinheitId()).thenReturn(
+				Stream.of(group.toBuilder().organisationsEinheitId(newOrganisationsEinheitId).build()));
+		service.syncOrganisationsEinheitenFromKeycloak(syncTimestamp + 1000);
+
+		var synced = repository.findAll();
+
+		assertThat(synced).
+				hasSize(1)
+				.first()
+				.matches(organisationsEinheit -> Objects.nonNull(organisationsEinheit.getId()))
+				.matches(organisationsEinheit -> Objects.isNull(organisationsEinheit.getSettings().getSignatur()))
+				.extracting(
+						OrganisationsEinheit::getKeycloakId,
+						OrganisationsEinheit::getName,
+						OrganisationsEinheit::getOrganisationsEinheitId,
+						OrganisationsEinheit::getSyncResult,
+						OrganisationsEinheit::getZufiId,
+						OrganisationsEinheit::getParentId,
+						OrganisationsEinheit::getChildren,
+						OrganisationsEinheit::getLastSyncTimestamp
+				).containsExactly(
+						group.getId(),
+						group.getName(),
+						newOrganisationsEinheitId,
+						SyncResult.NOT_FOUND_IN_PVOG,
+						null,
+						null,
+						Collections.emptyList(),
+						syncTimestamp + 1000);
+	}
+
+	@DisplayName("Group deleted in Keycloak")
+	@Test
+	void shouldSyncDeleted() {
+		var syncTimestamp = Instant.now().toEpochMilli();
+		var group = GroupTestFactory.createBuilder().clearSubGroups().build();
+		var pvogOranigastionsEinheit = OrganisationsEinheitTestFactory.createBuilder().organisationsEinheitId(group.getOrganisationsEinheitId())
+				.name(group.getName()).build();
+		when(keycloakRemoteService.getGroupsWithOrganisationsEinheitId()).thenReturn(Stream.of(group));
+		when(organisationsEinheitRemoteService.getByOrganisationsEinheitId(GroupTestFactory.ORGANISATIONS_EINHEIT_ID)).thenReturn(
+				List.of(pvogOranigastionsEinheit));
+
+		service.syncOrganisationsEinheitenFromKeycloak(syncTimestamp);
+		when(keycloakRemoteService.getGroupsWithOrganisationsEinheitId()).thenReturn(Stream.of());
+		service.syncOrganisationsEinheitenFromKeycloak(syncTimestamp + 1000);
+
+		var synced = repository.findAll();
+
+		assertThat(synced).
+				hasSize(1)
+				.first()
+				.matches(organisationsEinheit -> Objects.nonNull(organisationsEinheit.getId()))
+				.matches(organisationsEinheit -> Objects.isNull(organisationsEinheit.getSettings().getSignatur()))
+				.extracting(
+						OrganisationsEinheit::getKeycloakId,
+						OrganisationsEinheit::getName,
+						OrganisationsEinheit::getOrganisationsEinheitId,
+						OrganisationsEinheit::getSyncResult,
+						OrganisationsEinheit::getZufiId,
+						OrganisationsEinheit::getParentId,
+						OrganisationsEinheit::getChildren,
+						OrganisationsEinheit::getLastSyncTimestamp
+				).containsExactly(
+						group.getId(),
+						group.getName(),
+						group.getOrganisationsEinheitId(),
+						SyncResult.DELETED,
+						pvogOranigastionsEinheit.getId(),
+						null,
+						Collections.emptyList(),
+						syncTimestamp);
+	}
+
+	@DisplayName("Organisationseinheit for parent group and sub group not found in PVOG")
+	@Test
+	void shouldSyncParentNotFoundInPvogSubGroupNotFoundInPvog() {
+		var syncTimestamp = Instant.now().toEpochMilli();
+		var group = GroupTestFactory.createBuilder().build();
+		var subGroup = group.getSubGroups().getFirst();
+		when(keycloakRemoteService.getGroupsWithOrganisationsEinheitId()).thenReturn(Stream.of(group));
+		when(organisationsEinheitRemoteService.getByOrganisationsEinheitId(GroupTestFactory.ORGANISATIONS_EINHEIT_ID)).thenReturn(List.of());
+
+		service.syncOrganisationsEinheitenFromKeycloak(syncTimestamp);
+
+		var parentSynced = repository.findSyncedByKeycloakId(group.getId()).get();
+		var childSynced = repository.findSyncedByKeycloakId(subGroup.getId()).get();
+
+		assertThat(parentSynced)
+				.matches(organisationsEinheit -> Objects.nonNull(organisationsEinheit.getId()))
+				.matches(organisationsEinheit -> Objects.isNull(organisationsEinheit.getSettings().getSignatur()))
+				.extracting(
+						OrganisationsEinheit::getKeycloakId,
+						OrganisationsEinheit::getName,
+						OrganisationsEinheit::getOrganisationsEinheitId,
+						OrganisationsEinheit::getSyncResult,
+						OrganisationsEinheit::getZufiId,
+						OrganisationsEinheit::getParentId,
+						OrganisationsEinheit::getChildren,
+						OrganisationsEinheit::getLastSyncTimestamp
+				).containsExactly(
+						group.getId(),
+						group.getName(),
+						group.getOrganisationsEinheitId(),
+						SyncResult.NOT_FOUND_IN_PVOG,
+						null,
+						null,
+						Collections.emptyList(),
+						syncTimestamp);
+		assertThat(childSynced)
+				.matches(organisationsEinheit -> Objects.nonNull(organisationsEinheit.getId()))
+				.matches(organisationsEinheit -> Objects.isNull(organisationsEinheit.getSettings().getSignatur()))
+				.extracting(
+						OrganisationsEinheit::getKeycloakId,
+						OrganisationsEinheit::getName,
+						OrganisationsEinheit::getOrganisationsEinheitId,
+						OrganisationsEinheit::getSyncResult,
+						OrganisationsEinheit::getZufiId,
+						OrganisationsEinheit::getParentId,
+						OrganisationsEinheit::getChildren,
+						OrganisationsEinheit::getLastSyncTimestamp
+				).containsExactly(
+						subGroup.getId(),
+						subGroup.getName(),
+						subGroup.getOrganisationsEinheitId(),
+						SyncResult.NOT_FOUND_IN_PVOG,
+						null,
+						parentSynced.getId(),
+						Collections.emptyList(),
+						syncTimestamp);
+	}
+
+	@DisplayName("Organisationseinheit for parent group and sub group found in PVOG but have different names")
+	@Test
+	void shouldSyncParentNameMismatchSubGroupNameMismatch() {
+		var syncTimestamp = Instant.now().toEpochMilli();
+		var group = GroupTestFactory.createBuilder().build();
+		var subGroup = group.getSubGroups().getFirst();
+		var pvogOranigastionsEinheit = OrganisationsEinheitTestFactory.createBuilder().organisationsEinheitId(group.getOrganisationsEinheitId())
+				.build();
+		var pvogChildOranigastionsEinheit = OrganisationsEinheitTestFactory.createBuilder()
+				.organisationsEinheitId(subGroup.getOrganisationsEinheitId()).name(
+						LoremIpsum.getInstance().getWords(2))
+				.build();
+		when(keycloakRemoteService.getGroupsWithOrganisationsEinheitId()).thenReturn(Stream.of(group));
+		when(organisationsEinheitRemoteService.getByOrganisationsEinheitId(GroupTestFactory.ORGANISATIONS_EINHEIT_ID)).thenReturn(
+				List.of(pvogOranigastionsEinheit));
+		when(organisationsEinheitRemoteService.getByOrganisationsEinheitId(GroupTestFactory.SUB_GROUP_ORGANISATIONS_EINHEIT_ID)).thenReturn(
+				List.of(pvogChildOranigastionsEinheit));
+
+		service.syncOrganisationsEinheitenFromKeycloak(syncTimestamp);
+
+		var parentSynced = repository.findSyncedByKeycloakId(group.getId()).get();
+		var childSynced = repository.findSyncedByKeycloakId(subGroup.getId()).get();
+
+		assertThat(parentSynced)
+				.matches(organisationsEinheit -> Objects.nonNull(organisationsEinheit.getId()))
+				.matches(organisationsEinheit -> Objects.isNull(organisationsEinheit.getSettings().getSignatur()))
+				.extracting(
+						OrganisationsEinheit::getKeycloakId,
+						OrganisationsEinheit::getName,
+						OrganisationsEinheit::getOrganisationsEinheitId,
+						OrganisationsEinheit::getSyncResult,
+						OrganisationsEinheit::getZufiId,
+						OrganisationsEinheit::getParentId,
+						OrganisationsEinheit::getChildren,
+						OrganisationsEinheit::getLastSyncTimestamp
+				).containsExactly(
+						group.getId(),
+						pvogOranigastionsEinheit.getName(),
+						group.getOrganisationsEinheitId(),
+						SyncResult.NAME_MISMATCH,
+						pvogOranigastionsEinheit.getId(),
+						null,
+						Collections.emptyList(),
+						syncTimestamp);
+		assertThat(childSynced)
+				.matches(organisationsEinheit -> Objects.nonNull(organisationsEinheit.getId()))
+				.matches(organisationsEinheit -> Objects.isNull(organisationsEinheit.getSettings().getSignatur()))
+				.extracting(
+						OrganisationsEinheit::getKeycloakId,
+						OrganisationsEinheit::getName,
+						OrganisationsEinheit::getOrganisationsEinheitId,
+						OrganisationsEinheit::getSyncResult,
+						OrganisationsEinheit::getZufiId,
+						OrganisationsEinheit::getParentId,
+						OrganisationsEinheit::getChildren,
+						OrganisationsEinheit::getLastSyncTimestamp
+				).containsExactly(
+						subGroup.getId(),
+						pvogChildOranigastionsEinheit.getName(),
+						subGroup.getOrganisationsEinheitId(),
+						SyncResult.NAME_MISMATCH,
+						pvogChildOranigastionsEinheit.getId(),
+						parentSynced.getId(),
+						Collections.emptyList(),
+						syncTimestamp);
+	}
+
+	@DisplayName("Multiple Organisationseinheiten for parent group and sub group found in PVOG")
+	@Test
+	void shouldSyncParentAndChildOrganisationsEinheitNotUnique() {
+		var syncTimestamp = Instant.now().toEpochMilli();
+		var group = GroupTestFactory.createBuilder().build();
+		var subGroup = group.getSubGroups().getFirst();
+		var pvogOranigastionsEinheit1 = OrganisationsEinheitTestFactory.createBuilder().organisationsEinheitId(group.getOrganisationsEinheitId())
+				.build();
+		var pvogOranigastionsEinheit2 = OrganisationsEinheitTestFactory.createBuilder().organisationsEinheitId(group.getOrganisationsEinheitId())
+				.build();
+		var pvogChildOranigastionsEinheit1 = OrganisationsEinheitTestFactory.createBuilder()
+				.organisationsEinheitId(subGroup.getOrganisationsEinheitId()).name(
+						LoremIpsum.getInstance().getWords(2))
+				.build();
+		var pvogChildOranigastionsEinheit2 = OrganisationsEinheitTestFactory.createBuilder()
+				.organisationsEinheitId(subGroup.getOrganisationsEinheitId()).name(
+						LoremIpsum.getInstance().getWords(2))
+				.build();
+		when(keycloakRemoteService.getGroupsWithOrganisationsEinheitId()).thenReturn(Stream.of(group));
+		when(organisationsEinheitRemoteService.getByOrganisationsEinheitId(GroupTestFactory.ORGANISATIONS_EINHEIT_ID)).thenReturn(
+				List.of(pvogOranigastionsEinheit1, pvogOranigastionsEinheit2));
+		when(organisationsEinheitRemoteService.getByOrganisationsEinheitId(GroupTestFactory.SUB_GROUP_ORGANISATIONS_EINHEIT_ID)).thenReturn(
+				List.of(pvogChildOranigastionsEinheit1, pvogChildOranigastionsEinheit2));
+
+		service.syncOrganisationsEinheitenFromKeycloak(syncTimestamp);
+
+		var parentSynced = repository.findSyncedByKeycloakId(group.getId()).get();
+		var childSynced = repository.findSyncedByKeycloakId(subGroup.getId()).get();
+
+		assertThat(parentSynced)
+				.matches(organisationsEinheit -> Objects.nonNull(organisationsEinheit.getId()))
+				.matches(organisationsEinheit -> Objects.isNull(organisationsEinheit.getSettings().getSignatur()))
+				.extracting(
+						OrganisationsEinheit::getKeycloakId,
+						OrganisationsEinheit::getName,
+						OrganisationsEinheit::getOrganisationsEinheitId,
+						OrganisationsEinheit::getSyncResult,
+						OrganisationsEinheit::getZufiId,
+						OrganisationsEinheit::getParentId,
+						OrganisationsEinheit::getChildren,
+						OrganisationsEinheit::getLastSyncTimestamp
+				).containsExactly(
+						group.getId(),
+						group.getName(),
+						group.getOrganisationsEinheitId(),
+						SyncResult.ORGANISATIONSEINHEIT_ID_NOT_UNIQUE,
+						null,
+						null,
+						Collections.emptyList(),
+						syncTimestamp);
+		assertThat(childSynced)
+				.matches(organisationsEinheit -> Objects.nonNull(organisationsEinheit.getId()))
+				.matches(organisationsEinheit -> Objects.isNull(organisationsEinheit.getSettings().getSignatur()))
+				.extracting(
+						OrganisationsEinheit::getKeycloakId,
+						OrganisationsEinheit::getName,
+						OrganisationsEinheit::getOrganisationsEinheitId,
+						OrganisationsEinheit::getSyncResult,
+						OrganisationsEinheit::getZufiId,
+						OrganisationsEinheit::getParentId,
+						OrganisationsEinheit::getChildren,
+						OrganisationsEinheit::getLastSyncTimestamp
+				).containsExactly(
+						subGroup.getId(),
+						subGroup.getName(),
+						subGroup.getOrganisationsEinheitId(),
+						SyncResult.ORGANISATIONSEINHEIT_ID_NOT_UNIQUE,
+						null,
+						parentSynced.getId(),
+						Collections.emptyList(),
+						syncTimestamp);
+	}
+
+	@DisplayName("Organisationseinheit and child Organisationseinheit found in PVOG and have same data")
+	@Test
+	void shouldSyncOkGroupWithSubGroup() {
+		var syncTimestamp = Instant.now().toEpochMilli();
+		var group = GroupTestFactory.createBuilder().build();
+		var subGroup = group.getSubGroups().getFirst();
+		var pvogOranigastionsEinheit = OrganisationsEinheitTestFactory.createBuilder().organisationsEinheitId(group.getOrganisationsEinheitId())
+				.name(group.getName()).build();
+		var pvogChildOranigastionsEinheit = OrganisationsEinheitTestFactory.createBuilder()
+				.organisationsEinheitId(subGroup.getOrganisationsEinheitId()).name(
+						subGroup.getName())
+				.build();
+		when(keycloakRemoteService.getGroupsWithOrganisationsEinheitId()).thenReturn(Stream.of(group));
+		when(organisationsEinheitRemoteService.getByOrganisationsEinheitId(GroupTestFactory.ORGANISATIONS_EINHEIT_ID)).thenReturn(
+				List.of(pvogOranigastionsEinheit));
+		when(organisationsEinheitRemoteService.getByOrganisationsEinheitId(GroupTestFactory.SUB_GROUP_ORGANISATIONS_EINHEIT_ID)).thenReturn(
+				List.of(pvogChildOranigastionsEinheit));
+
+		service.syncOrganisationsEinheitenFromKeycloak(syncTimestamp);
+
+		var parentSynced = repository.findSyncedByKeycloakId(group.getId()).get();
+		var childSynced = repository.findSyncedByKeycloakId(subGroup.getId()).get();
+
+		assertThat(parentSynced)
+				.matches(organisationsEinheit -> Objects.nonNull(organisationsEinheit.getId()))
+				.matches(organisationsEinheit -> Objects.isNull(organisationsEinheit.getSettings().getSignatur()))
+				.extracting(
+						OrganisationsEinheit::getKeycloakId,
+						OrganisationsEinheit::getName,
+						OrganisationsEinheit::getOrganisationsEinheitId,
+						OrganisationsEinheit::getSyncResult,
+						OrganisationsEinheit::getZufiId,
+						OrganisationsEinheit::getParentId,
+						OrganisationsEinheit::getChildren,
+						OrganisationsEinheit::getLastSyncTimestamp
+				).containsExactly(
+						group.getId(),
+						group.getName(),
+						group.getOrganisationsEinheitId(),
+						SyncResult.OK,
+						pvogOranigastionsEinheit.getId(),
+						null,
+						Collections.emptyList(),
+						syncTimestamp);
+		assertThat(childSynced)
+				.matches(organisationsEinheit -> Objects.nonNull(organisationsEinheit.getId()))
+				.matches(organisationsEinheit -> Objects.isNull(organisationsEinheit.getSettings().getSignatur()))
+				.extracting(
+						OrganisationsEinheit::getKeycloakId,
+						OrganisationsEinheit::getName,
+						OrganisationsEinheit::getOrganisationsEinheitId,
+						OrganisationsEinheit::getSyncResult,
+						OrganisationsEinheit::getZufiId,
+						OrganisationsEinheit::getParentId,
+						OrganisationsEinheit::getChildren,
+						OrganisationsEinheit::getLastSyncTimestamp
+				).containsExactly(
+						subGroup.getId(),
+						subGroup.getName(),
+						subGroup.getOrganisationsEinheitId(),
+						SyncResult.OK,
+						pvogChildOranigastionsEinheit.getId(),
+						parentSynced.getId(),
+						Collections.emptyList(),
+						syncTimestamp);
+	}
+
+	@DisplayName("Group with sub group deleted in Keycloak")
+	@Test
+	void shouldSyncGroupAndSubGroupDeleted() {
+		var syncTimestamp = Instant.now().toEpochMilli();
+		var group = GroupTestFactory.createBuilder().build();
+		var subGroup = group.getSubGroups().getFirst();
+		var pvogOranigastionsEinheit = OrganisationsEinheitTestFactory.createBuilder().organisationsEinheitId(group.getOrganisationsEinheitId())
+				.name(group.getName()).build();
+		var pvogChildOranigastionsEinheit = OrganisationsEinheitTestFactory.createBuilder()
+				.organisationsEinheitId(subGroup.getOrganisationsEinheitId()).name(
+						subGroup.getName())
+				.build();
+		when(keycloakRemoteService.getGroupsWithOrganisationsEinheitId()).thenReturn(Stream.of(group));
+		when(organisationsEinheitRemoteService.getByOrganisationsEinheitId(GroupTestFactory.ORGANISATIONS_EINHEIT_ID)).thenReturn(
+				List.of(pvogOranigastionsEinheit));
+		when(organisationsEinheitRemoteService.getByOrganisationsEinheitId(GroupTestFactory.SUB_GROUP_ORGANISATIONS_EINHEIT_ID)).thenReturn(
+				List.of(pvogChildOranigastionsEinheit));
+
+		service.syncOrganisationsEinheitenFromKeycloak(syncTimestamp);
+		when(keycloakRemoteService.getGroupsWithOrganisationsEinheitId()).thenReturn(Stream.of());
+		service.syncOrganisationsEinheitenFromKeycloak(syncTimestamp + 1000);
+
+		var parentSynced = repository.findSyncedByKeycloakId(group.getId()).get();
+		var childSynced = repository.findSyncedByKeycloakId(subGroup.getId()).get();
+
+		assertThat(parentSynced)
+				.matches(organisationsEinheit -> Objects.nonNull(organisationsEinheit.getId()))
+				.matches(organisationsEinheit -> Objects.isNull(organisationsEinheit.getSettings().getSignatur()))
+				.extracting(
+						OrganisationsEinheit::getKeycloakId,
+						OrganisationsEinheit::getName,
+						OrganisationsEinheit::getOrganisationsEinheitId,
+						OrganisationsEinheit::getSyncResult,
+						OrganisationsEinheit::getZufiId,
+						OrganisationsEinheit::getParentId,
+						OrganisationsEinheit::getChildren,
+						OrganisationsEinheit::getLastSyncTimestamp
+				).containsExactly(
+						group.getId(),
+						group.getName(),
+						group.getOrganisationsEinheitId(),
+						SyncResult.DELETED,
+						pvogOranigastionsEinheit.getId(),
+						null,
+						Collections.emptyList(),
+						syncTimestamp);
+		assertThat(childSynced)
+				.matches(organisationsEinheit -> Objects.nonNull(organisationsEinheit.getId()))
+				.matches(organisationsEinheit -> Objects.isNull(organisationsEinheit.getSettings().getSignatur()))
+				.extracting(
+						OrganisationsEinheit::getKeycloakId,
+						OrganisationsEinheit::getName,
+						OrganisationsEinheit::getOrganisationsEinheitId,
+						OrganisationsEinheit::getSyncResult,
+						OrganisationsEinheit::getZufiId,
+						OrganisationsEinheit::getParentId,
+						OrganisationsEinheit::getChildren,
+						OrganisationsEinheit::getLastSyncTimestamp
+				).containsExactly(
+						subGroup.getId(),
+						subGroup.getName(),
+						subGroup.getOrganisationsEinheitId(),
+						SyncResult.DELETED,
+						pvogChildOranigastionsEinheit.getId(),
+						parentSynced.getId(),
+						Collections.emptyList(),
+						syncTimestamp);
+	}
+
+}
\ No newline at end of file
diff --git a/src/test/java/de/ozgcloud/admin/organisationseinheit/SyncServiceTest.java b/src/test/java/de/ozgcloud/admin/organisationseinheit/SyncServiceTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..f9b3a0011fc4767b653a49ea3d1c3b85f65034e8
--- /dev/null
+++ b/src/test/java/de/ozgcloud/admin/organisationseinheit/SyncServiceTest.java
@@ -0,0 +1,650 @@
+package de.ozgcloud.admin.organisationseinheit;
+
+import static org.assertj.core.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+import java.time.Instant;
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+import java.util.stream.Stream;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Captor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.Spy;
+
+import com.thedeanda.lorem.LoremIpsum;
+
+import de.ozgcloud.admin.keycloak.AddGroupData;
+import de.ozgcloud.admin.keycloak.AddGroupDataTestFactory;
+import de.ozgcloud.admin.keycloak.Group;
+import de.ozgcloud.admin.keycloak.GroupTestFactory;
+import de.ozgcloud.admin.keycloak.KeycloakRemoteService;
+import de.ozgcloud.admin.keycloak.ResourceCreationException;
+
+class SyncServiceTest {
+
+	@Spy
+	@InjectMocks
+	private SyncService service;
+
+	@Mock
+	private OrganisationsEinheitRepository repository;
+
+	@Mock
+	private KeycloakRemoteService keycloakRemoteService;
+
+	@Mock
+	private OrganisationsEinheitRemoteService organisationsEinheitRemoteService;
+
+	@Mock
+	private OrganisationsEinheitMapper organisationsEinheitMapper;
+
+	@Nested
+	class TestSyncOrganisationsEinheitenFromKeycloak {
+
+		private final long syncTimestamp = Instant.now().toEpochMilli();
+		private final Group group = GroupTestFactory.create();
+
+		@BeforeEach
+		void setUp() {
+			when(keycloakRemoteService.getGroupsWithOrganisationsEinheitId()).thenReturn(Stream.of(group));
+			doNothing().when(service).syncGroupsWithSubGroups(any(), any(), anyLong());
+		}
+
+		@Test
+		void shouldGetGroupsFromKeycloak() {
+			callService();
+
+			verify(keycloakRemoteService).getGroupsWithOrganisationsEinheitId();
+		}
+
+		@Test
+		void shouldSyncGroupsFromKeycloak() {
+			callService();
+
+			verify(service).syncGroupsWithSubGroups(group, null, syncTimestamp);
+		}
+
+		@Test
+		void shouldSetUnsyncedAsDeleted() {
+			callService();
+
+			verify(repository).setUnsyncedAsDeleted(syncTimestamp);
+		}
+
+		private void callService() {
+			service.syncOrganisationsEinheitenFromKeycloak(syncTimestamp);
+		}
+
+	}
+
+	@Nested
+	class TestSyncGroupsWithSubGroups {
+
+		private final long syncTimestamp = Instant.now().toEpochMilli();
+		private final Group group = GroupTestFactory.create();
+		private final OrganisationsEinheit syncedOrganisationsEinheitGroup = OrganisationsEinheitTestFactory.create();
+		private final OrganisationsEinheit savedOrganisationsEinheitGroup = OrganisationsEinheitTestFactory.createBuilder()
+				.id(UUID.randomUUID().toString()).build();
+
+		@BeforeEach
+		void setUp() {
+			doReturn(syncedOrganisationsEinheitGroup).when(service).syncGroup(group, null, syncTimestamp);
+			doReturn(savedOrganisationsEinheitGroup).when(service).saveSyncedOrganisationsEinheit(syncedOrganisationsEinheitGroup);
+		}
+
+		@Test
+		void shouldSyncGroup() {
+			service.syncGroupsWithSubGroups(group, null, syncTimestamp);
+
+			verify(service).syncGroup(group, null, syncTimestamp);
+		}
+
+		@Test
+		void shouldSaveSyncedOrganisationsEinheit() {
+			service.syncGroupsWithSubGroups(group, null, syncTimestamp);
+
+			verify(service).saveSyncedOrganisationsEinheit(syncedOrganisationsEinheitGroup);
+		}
+
+		@Test
+		void shouldSyncSubGroupsWithSyncedOrganisationsEinheitAsParent() {
+			service.syncGroupsWithSubGroups(group, null, syncTimestamp);
+
+			verify(service).syncGroupsWithSubGroups(group.getSubGroups().getFirst(), savedOrganisationsEinheitGroup, syncTimestamp);
+		}
+	}
+
+	@Nested
+	class TestSyncGroup {
+
+		private final long syncTimestamp = Instant.now().toEpochMilli();
+		private final Group group = GroupTestFactory.create();
+		private final OrganisationsEinheit parent = OrganisationsEinheitTestFactory.createBuilder().id(UUID.randomUUID().toString()).build();
+		private final OrganisationsEinheit pvogOrganisationsEinheit = OrganisationsEinheitTestFactory.createBuilder().zufiId(null).settings(null)
+				.parentId(null).syncResult(null).build();
+		private final String syncedName = LoremIpsum.getInstance().getWords(3);
+		private final SyncResult syncedSyncResult = SyncResult.OK;
+		private final String syncedZufiId = UUID.randomUUID().toString();
+
+		@Nested
+		class ParentGroup {
+
+			@BeforeEach
+			void setUp() {
+				doReturn(syncedName).when(service).syncName(List.of(pvogOrganisationsEinheit), group);
+				doReturn(syncedSyncResult).when(service).evaluateSyncResult(List.of(pvogOrganisationsEinheit), group);
+				doReturn(syncedZufiId).when(service).syncZufiId(List.of(pvogOrganisationsEinheit));
+				when(organisationsEinheitRemoteService.getByOrganisationsEinheitId(GroupTestFactory.ORGANISATIONS_EINHEIT_ID)).thenReturn(
+						List.of(pvogOrganisationsEinheit));
+			}
+
+			@Test
+			void shouldSyncKeycloakId() {
+				var synced = service.syncGroup(group, null, syncTimestamp);
+
+				assertThat(synced.getKeycloakId()).isEqualTo(GroupTestFactory.ID);
+			}
+
+			@Test
+			void shouldSyncName() {
+				var synced = service.syncGroup(group, null, syncTimestamp);
+
+				verify(service).syncName(List.of(pvogOrganisationsEinheit), group);
+				assertThat(synced.getName()).isEqualTo(syncedName);
+			}
+
+			@Test
+			void shouldEvaluateSyncResult() {
+				var synced = service.syncGroup(group, null, syncTimestamp);
+
+				verify(service).evaluateSyncResult(List.of(pvogOrganisationsEinheit), group);
+				assertThat(synced.getSyncResult()).isEqualTo(syncedSyncResult);
+			}
+
+			@Test
+			void shouldGetOrganisationsEinheit() {
+				service.syncGroup(group, null, syncTimestamp);
+
+				verify(organisationsEinheitRemoteService).getByOrganisationsEinheitId(GroupTestFactory.ORGANISATIONS_EINHEIT_ID);
+			}
+
+			@Test
+			void shouldSetOrganisationsEinheitId() {
+				var synced = service.syncGroup(group, null, syncTimestamp);
+
+				assertThat(synced.getOrganisationsEinheitId()).isEqualTo(group.getOrganisationsEinheitId());
+			}
+
+			@Test
+			void shouldSyncZufiId() {
+				var synced = service.syncGroup(group, null, syncTimestamp);
+
+				verify(service).syncZufiId(List.of(pvogOrganisationsEinheit));
+				assertThat(synced.getZufiId()).isEqualTo(syncedZufiId);
+			}
+
+			@Test
+			void shouldSetLastSyncTimestamp() {
+				var synced = service.syncGroup(group, null, syncTimestamp);
+
+				assertThat(synced.getLastSyncTimestamp()).isEqualTo(syncTimestamp);
+			}
+		}
+
+		@Nested
+		class SubGroup {
+
+			@BeforeEach
+			void setUp() {
+				doReturn(syncedName).when(service).syncName(List.of(pvogOrganisationsEinheit), group.getSubGroups().getFirst());
+				doReturn(syncedSyncResult).when(service).evaluateSyncResult(List.of(pvogOrganisationsEinheit), group.getSubGroups().getFirst());
+				doReturn(syncedZufiId).when(service).syncZufiId(List.of(pvogOrganisationsEinheit));
+				when(organisationsEinheitRemoteService.getByOrganisationsEinheitId(GroupTestFactory.SUB_GROUP_ORGANISATIONS_EINHEIT_ID)).thenReturn(
+						List.of(pvogOrganisationsEinheit));
+			}
+
+			@Test
+			void shouldSyncKeycloakId() {
+				var synced = service.syncGroup(group.getSubGroups().getFirst(), parent, syncTimestamp);
+
+				assertThat(synced.getKeycloakId()).isEqualTo(GroupTestFactory.SUB_GROUP_ID);
+			}
+
+			@Test
+			void shouldSyncName() {
+				var synced = service.syncGroup(group.getSubGroups().getFirst(), parent, syncTimestamp);
+
+				verify(service).syncName(List.of(pvogOrganisationsEinheit), group.getSubGroups().getFirst());
+				assertThat(synced.getName()).isEqualTo(syncedName);
+			}
+
+			@Test
+			void shouldEvaluateSyncResult() {
+				var synced = service.syncGroup(group.getSubGroups().getFirst(), parent, syncTimestamp);
+
+				verify(service).evaluateSyncResult(List.of(pvogOrganisationsEinheit), group.getSubGroups().getFirst());
+				assertThat(synced.getSyncResult()).isEqualTo(syncedSyncResult);
+			}
+
+			@Test
+			void shouldGetOrganisationsEinheit() {
+				var synced = service.syncGroup(group.getSubGroups().getFirst(), parent, syncTimestamp);
+
+				verify(organisationsEinheitRemoteService).getByOrganisationsEinheitId(GroupTestFactory.SUB_GROUP_ORGANISATIONS_EINHEIT_ID);
+				assertThat(synced.getOrganisationsEinheitId()).isEqualTo(group.getSubGroups().getFirst().getOrganisationsEinheitId());
+			}
+
+			@Test
+			void shouldSetParent() {
+				var synced = service.syncGroup(group.getSubGroups().getFirst(), parent, syncTimestamp);
+
+				assertThat(synced.getParentId()).contains(parent.getId());
+			}
+
+			@Test
+			void shouldSyncZufiId() {
+				var synced = service.syncGroup(group.getSubGroups().getFirst(), parent, syncTimestamp);
+
+				verify(service).syncZufiId(List.of(pvogOrganisationsEinheit));
+				assertThat(synced.getZufiId()).isEqualTo(syncedZufiId);
+			}
+
+			@Test
+			void shouldSetLastSyncTimestamp() {
+				var synced = service.syncGroup(group.getSubGroups().getFirst(), parent, syncTimestamp);
+
+				assertThat(synced.getLastSyncTimestamp()).isEqualTo(syncTimestamp);
+			}
+		}
+	}
+
+	@Nested
+	class TestSyncName {
+
+		@Test
+		void shouldReturnPvogName() {
+			var name = service.syncName(List.of(OrganisationsEinheitTestFactory.create()), GroupTestFactory.create());
+
+			assertThat(name).isEqualTo(OrganisationsEinheitTestFactory.NAME);
+
+		}
+
+		@Test
+		void shouldReturnGroupNameIfOrganisatonsEinheitNotFoundInPvog() {
+			var name = service.syncName(Collections.emptyList(), GroupTestFactory.create());
+
+			assertThat(name).isEqualTo(GroupTestFactory.NAME);
+		}
+
+		@Test
+		void shouldReturnGroupNameIfMultipleOrganisatonsEinheitenFoundInPvog() {
+			var name = service.syncName(List.of(OrganisationsEinheitTestFactory.create(), OrganisationsEinheitTestFactory.create()),
+					GroupTestFactory.create());
+
+			assertThat(name).isEqualTo(GroupTestFactory.NAME);
+		}
+	}
+
+	@Nested
+	class TestEvaluateSyncResult {
+
+		@Test
+		void shouldReturnOk() {
+			var syncResult = service.evaluateSyncResult(List.of(OrganisationsEinheitTestFactory.createBuilder().name(GroupTestFactory.NAME).build()),
+					GroupTestFactory.create());
+
+			assertThat(syncResult).isEqualTo(SyncResult.OK);
+		}
+
+		@Test
+		void shouldReturnNotFoundInPvog() {
+			var syncResult = service.evaluateSyncResult(Collections.emptyList(), GroupTestFactory.create());
+
+			assertThat(syncResult).isEqualTo(SyncResult.NOT_FOUND_IN_PVOG);
+		}
+
+		@Test
+		void shouldReturnNameMismatch() {
+			var syncResult = service.evaluateSyncResult(List.of(OrganisationsEinheitTestFactory.create()), GroupTestFactory.create());
+
+			assertThat(syncResult).isEqualTo(SyncResult.NAME_MISMATCH);
+		}
+
+		@Test
+		void shouldReturnOrganisationseinheitIdNotUnique() {
+			var syncResult = service.evaluateSyncResult(List.of(OrganisationsEinheitTestFactory.create(), OrganisationsEinheitTestFactory.create()),
+					GroupTestFactory.create());
+
+			assertThat(syncResult).isEqualTo(SyncResult.ORGANISATIONSEINHEIT_ID_NOT_UNIQUE);
+		}
+
+	}
+
+	@Nested
+	class TestSyncZufiId {
+
+		@Test
+		void shouldReturnZufiId() {
+			var zufiId = service.syncZufiId(List.of(OrganisationsEinheitTestFactory.create()));
+
+			assertThat(zufiId).isEqualTo(OrganisationsEinheitTestFactory.ID);
+		}
+
+		@Test
+		void shouldReturnNullIfOrganisationsEinheitNotInPvog() {
+			var zufiId = service.syncZufiId(Collections.emptyList());
+
+			assertThat(zufiId).isNull();
+		}
+
+		@Test
+		void shouldReturnNullIfOrganisationsEinheitNotUnique() {
+			var zufiId = service.syncZufiId(List.of(OrganisationsEinheitTestFactory.create(), OrganisationsEinheitTestFactory.create()));
+
+			assertThat(zufiId).isNull();
+		}
+	}
+
+	@Nested
+	class TestSaveSyncedOrganisationsEinheit {
+
+		private final OrganisationsEinheit syncedOrganisationsEinheit = OrganisationsEinheitTestFactory.createBuilder().id(null).settings(null)
+				.build();
+		private final OrganisationsEinheit savedOrganisationsEinheit = OrganisationsEinheitTestFactory.createBuilder().settings(null)
+				.build();
+
+		@Test
+		void shouldFindSyncedOrganisationsEinheitInDatabase() {
+			service.saveSyncedOrganisationsEinheit(syncedOrganisationsEinheit);
+
+			verify(repository).findSyncedByKeycloakId(syncedOrganisationsEinheit.getKeycloakId());
+		}
+
+		@Test
+		void shouldReturnSaved() {
+			when(repository.save(syncedOrganisationsEinheit)).thenReturn(savedOrganisationsEinheit);
+
+			var saved = service.saveSyncedOrganisationsEinheit(syncedOrganisationsEinheit);
+
+			assertThat(saved).isEqualTo(savedOrganisationsEinheit);
+		}
+
+		@Nested
+		class SyncedOrganisationsEinheitDoesNotExist {
+
+			@BeforeEach
+			void setUp() {
+				when(repository.findSyncedByKeycloakId(
+						syncedOrganisationsEinheit.getKeycloakId())).thenReturn(Optional.empty());
+			}
+
+			@Test
+			void shouldSave() {
+				service.saveSyncedOrganisationsEinheit(syncedOrganisationsEinheit);
+
+				verify(repository).save(syncedOrganisationsEinheit);
+			}
+		}
+
+		@Nested
+		class SyncedOrganisationsEinheitExists {
+
+			private final OrganisationsEinheit existingOrganisationsEinheit = OrganisationsEinheitTestFactory.createNewBuilder().build();
+
+			@Captor
+			private ArgumentCaptor<OrganisationsEinheit> savedOrganisationsEinheitArgumentCaptor;
+
+			@BeforeEach
+			void setUp() {
+				when(repository.findSyncedByKeycloakId(
+						syncedOrganisationsEinheit.getKeycloakId())).thenReturn(Optional.of(existingOrganisationsEinheit));
+			}
+
+			@Test
+			void shouldSaveSyncedOrganisationsEinheitWithSettings() {
+				service.saveSyncedOrganisationsEinheit(syncedOrganisationsEinheit);
+
+				verify(repository).save(savedOrganisationsEinheitArgumentCaptor.capture());
+				assertThat(savedOrganisationsEinheitArgumentCaptor.getValue())
+						.extracting(
+								OrganisationsEinheit::getId,
+								OrganisationsEinheit::getOrganisationsEinheitId,
+								OrganisationsEinheit::getSettings,
+								OrganisationsEinheit::getName,
+								OrganisationsEinheit::getSyncResult,
+								OrganisationsEinheit::getParentId,
+								OrganisationsEinheit::getZufiId)
+						.containsExactly(
+								existingOrganisationsEinheit.getId(),
+								syncedOrganisationsEinheit.getOrganisationsEinheitId(),
+								existingOrganisationsEinheit.getSettings(),
+								syncedOrganisationsEinheit.getName(),
+								syncedOrganisationsEinheit.getSyncResult(),
+								syncedOrganisationsEinheit.getParentId(),
+								syncedOrganisationsEinheit.getZufiId()
+						);
+			}
+		}
+	}
+
+	@Nested
+	class TestSyncAddedOrganisationsEinheiten {
+
+		private final long syncTimestamp = Instant.now().toEpochMilli();
+		private final OrganisationsEinheit withoutSyncResult1 = OrganisationsEinheitTestFactory.createBuilder().id("A").build();
+		private final OrganisationsEinheit withoutSyncResult2 = OrganisationsEinheitTestFactory.createBuilder().id("B").build();
+		private final OrganisationsEinheit[] organisationsEinheiten = new OrganisationsEinheit[] { withoutSyncResult2, withoutSyncResult1 };
+
+		@Captor
+		private ArgumentCaptor<Stream<OrganisationsEinheit>> streamArgumentCaptor;
+
+		@BeforeEach
+		void setUp() {
+			when(repository.findAllWithoutSyncResult()).thenReturn(Stream.of(organisationsEinheiten));
+			doNothing().when(service).syncAddedOrganisationsEinheit(any(), anyLong());
+		}
+
+		@Test
+		void shouldFindAllWithoutSyncResult() {
+			callService();
+
+			verify(repository).findAllWithoutSyncResult();
+		}
+
+		@Test
+		void shouldSynchronizeFirstOrganisationsEinheit() {
+			callService();
+
+			verify(service).syncAddedOrganisationsEinheit(withoutSyncResult1, syncTimestamp);
+		}
+
+		@Test
+		void shouldSynchronizeSecondOrganisationsEinheit() {
+			callService();
+
+			verify(service).syncAddedOrganisationsEinheit(withoutSyncResult2, syncTimestamp);
+		}
+
+		private void callService() {
+			service.syncAddedOrganisationsEinheiten(syncTimestamp);
+		}
+	}
+
+	@Nested
+	class TestSyncAddedOrganisationsEinheit {
+
+		private final long syncTimestamp = Instant.now().toEpochMilli();
+		private final String addedGroupId = UUID.randomUUID().toString();
+		private final OrganisationsEinheit organisationsEinheit = OrganisationsEinheitTestFactory.create();
+
+		@Test
+		void shouldAddAsGroupInKeycloak() {
+			doReturn(Optional.empty()).when(service).addAsGroupInKeycloak(organisationsEinheit);
+
+			callService();
+
+			verify(service).addAsGroupInKeycloak(organisationsEinheit);
+		}
+
+		@Test
+		void shouldUpdateAfterSuccessfulGroupCreation() {
+			doReturn(Optional.of(addedGroupId)).when(service).addAsGroupInKeycloak(organisationsEinheit);
+			doNothing().when(service).updateAfterSuccessfulGroupCreation(organisationsEinheit, addedGroupId, syncTimestamp);
+
+			callService();
+
+			verify(service).updateAfterSuccessfulGroupCreation(organisationsEinheit, addedGroupId, syncTimestamp);
+		}
+
+		@Test
+		void shouldNotUpdate() {
+			doReturn(Optional.empty()).when(service).addAsGroupInKeycloak(organisationsEinheit);
+
+			callService();
+
+			verify(service, never()).updateAfterSuccessfulGroupCreation(any(), any(), anyLong());
+		}
+
+		private void callService() {
+			service.syncAddedOrganisationsEinheit(organisationsEinheit, syncTimestamp);
+		}
+	}
+
+	@Nested
+	class TestAddAsGroupInKeycloak {
+
+		@Nested
+		class OnParentIdIsNotSet {
+
+			private final OrganisationsEinheit organisationsEinheit = OrganisationsEinheitTestFactory.createBuilder().parentId(null).build();
+			private final AddGroupData addGroupData = AddGroupDataTestFactory.create();
+			private final String addedGroupId = UUID.randomUUID().toString();
+
+			@BeforeEach
+			void init() {
+				when(organisationsEinheitMapper.toAddGroupData(organisationsEinheit)).thenReturn(addGroupData);
+				doReturn(Optional.of(addedGroupId)).when(service).addGroupInKeycloak(addGroupData);
+			}
+
+			@Test
+			void shouldCreateAddGroupData() {
+				callService();
+
+				verify(organisationsEinheitMapper).toAddGroupData(organisationsEinheit);
+			}
+
+			@Test
+			void shouldAddGroupInKeycloak() {
+				callService();
+
+				verify(service).addGroupInKeycloak(addGroupData);
+			}
+
+			@Test
+			void shouldReturnAddedGroupId() {
+				var returnedGroupId = callService();
+
+				assertThat(returnedGroupId).get().isEqualTo(addedGroupId);
+			}
+
+			private Optional<String> callService() {
+				return service.addAsGroupInKeycloak(organisationsEinheit);
+			}
+		}
+
+		@Nested
+		class OnParentIdIsSet {
+
+			@Test
+			void shouldThrowException() {
+				var withParentId = OrganisationsEinheitTestFactory.create();
+				assertThatExceptionOfType(OrganisationsEinheitSynchronizationException.class)
+						.isThrownBy(() -> service.addAsGroupInKeycloak(withParentId))
+						.withMessage("Organisationseinheit " + withParentId.getOrganisationsEinheitId() + " has parent");
+			}
+		}
+	}
+
+	@Nested
+	class TestUpdateAfterSuccessfulGroupCreation {
+
+		private final OrganisationsEinheit organisationsEinheit = OrganisationsEinheitTestFactory.createBuilder()
+				.keycloakId(null)
+				.syncResult(null)
+				.lastSyncTimestamp(null)
+				.build();
+		private final long syncTimestamp = Instant.now().toEpochMilli();
+		private final String keycloakId = UUID.randomUUID().toString();
+
+		@Captor
+		private ArgumentCaptor<OrganisationsEinheit> organisationsEinheitArgumentCaptor;
+
+		@Test
+		void shouldSaveUpdatedOrganisationsEinheit() {
+			service.updateAfterSuccessfulGroupCreation(organisationsEinheit, keycloakId, syncTimestamp);
+
+			verify(repository).save(organisationsEinheitArgumentCaptor.capture());
+			assertThat(organisationsEinheitArgumentCaptor.getValue())
+					.extracting(OrganisationsEinheit::getKeycloakId, OrganisationsEinheit::getSyncResult, OrganisationsEinheit::getLastSyncTimestamp)
+					.containsExactly(keycloakId, SyncResult.OK, syncTimestamp);
+		}
+	}
+
+	@Nested
+	class TestAddGroupInKeycloak {
+
+		private static final String FAILURE_MESSAGE = LoremIpsum.getInstance().getWords(5);
+		private final Throwable resourceCreationException = new ResourceCreationException(FAILURE_MESSAGE);
+
+		private final String keycloakId = GroupTestFactory.ID;
+		private final AddGroupData addGroupData = AddGroupDataTestFactory.create();
+
+		@Test
+		void shouldAddGroup() {
+			givenAddGroupSuccessful();
+
+			callService();
+
+			verify(keycloakRemoteService).addGroup(addGroupData);
+		}
+
+		@Test
+		void shouldReturnKeycloakId() {
+			givenAddGroupSuccessful();
+
+			var keycloakId = callService();
+
+			assertThat(keycloakId).isPresent().get().isEqualTo(this.keycloakId);
+		}
+
+		@Test
+		void shouldThrowExceptionInCaseOfResourceCreationException() {
+			givenAddGroupFailed();
+
+			assertThatExceptionOfType(OrganisationsEinheitSynchronizationException.class).isThrownBy(this::callService)
+					.withMessage("Error creating group %s in Keycloak", addGroupData.toString()).withCause(resourceCreationException);
+		}
+
+		private void givenAddGroupSuccessful() {
+			when(keycloakRemoteService.addGroup(addGroupData)).thenReturn(keycloakId);
+		}
+
+		private void givenAddGroupFailed() {
+			when(keycloakRemoteService.addGroup(addGroupData)).thenThrow(resourceCreationException);
+		}
+
+		private Optional<String> callService() {
+			return service.addGroupInKeycloak(addGroupData);
+		}
+	}
+
+}
\ No newline at end of file
diff --git a/src/test/resources/application-itcase.yaml b/src/test/resources/application-itcase.yaml
index 3082babc0c50e52484ef75f5a650980975eea15d..1217c868aaa994671a26171005505ce110d401a5 100644
--- a/src/test/resources/application-itcase.yaml
+++ b/src/test/resources/application-itcase.yaml
@@ -1,3 +1,8 @@
+spring:
+  data:
+    mongodb:
+      database: config-db
+
 mongock:
   enabled: false
 
@@ -6,3 +11,7 @@ ozgcloud:
     auth-server-url: https://sso.it-case.de
     realm: by-kiel-dev
     resource: admin
+  keycloak:
+    api:
+      user: administrationApiUser
+      password: administrationApiUser
diff --git a/src/test/resources/keycloak/realm-export.json b/src/test/resources/keycloak/realm-export.json
index 1a35e536dee166f56fe2a5b52dd5d6cb3452e21c..9bb03f072583dec19005dc96e8af90465d6ff7e5 100755
--- a/src/test/resources/keycloak/realm-export.json
+++ b/src/test/resources/keycloak/realm-export.json
@@ -44,6 +44,94 @@
   "quickLoginCheckMilliSeconds": 1000,
   "maxDeltaTimeSeconds": 43200,
   "failureFactor": 30,
+  "groups": [
+    {
+      "id": "GroupWithChild-id",
+      "name": "GroupWithChild",
+      "path": "/GroupWithChild",
+      "subGroups": [
+        {
+          "id": "ChildLevel1WithChild-id",
+          "name": "ChildLevel1WithChild",
+          "path": "/GroupWithChild/ChildLevel1WithChild",
+          "parentId": "GroupWithChild-id",
+          "subGroups": [
+            {
+              "id": "ChildLevel2-id",
+              "name": "ChildLevel2",
+              "path": "/GroupWithChild/ChildLevel1WithChild/ChildLevel2",
+              "parentId": "ChildLevel1WithChild-id",
+              "subGroups": [],
+              "attributes": {
+                "organisationseinheitId": [
+                  "ChildLevel2-oid"
+                ]
+              },
+              "realmRoles": [],
+              "clientRoles": {}
+            }
+          ],
+          "attributes": {
+            "organisationseinheitId": [
+              "ChildLevel1WithChild-oid"
+            ]
+          },
+          "realmRoles": [],
+          "clientRoles": {}
+        },
+        {
+          "id": "ChildLevel1WithoutOid-id",
+          "name": "ChildLevel1WithoutOid",
+          "path": "/GroupWithChild/ChildLevel1WithoutOid",
+          "parentId": "GroupWithChild-id",
+          "subGroups": [],
+          "attributes": {},
+          "realmRoles": [],
+          "clientRoles": {}
+        }
+      ],
+      "attributes": {
+        "organisationseinheitId": [
+          "GroupWithChild-oid"
+        ]
+      },
+      "realmRoles": [],
+      "clientRoles": {}
+    },
+    {
+      "id": "GroupWithoutOid-id",
+      "name": "GroupWithoutOid",
+      "path": "/GroupWithoutOid",
+      "subGroups": [],
+      "attributes": {},
+      "realmRoles": [],
+      "clientRoles": {}
+    },
+    {
+      "id": "GroupWithoutOidWithChild-id",
+      "name": "GroupWithoutOidWithChild",
+      "path": "/GroupWithoutOidWithChild",
+      "subGroups": [
+        {
+          "id": "6683b1ee-3f27-4f42-aced-35b0d271f4df",
+          "name": "ChildLevel1",
+          "path": "/GroupWithoutOidWithChild/ChildLevel1",
+          "parentId": "GroupWithoutOidWithChild-id",
+          "subGroups": [],
+          "attributes": {
+            "organisationseinheitId": [
+              "ChildLevel1-oid"
+            ]
+          },
+          "realmRoles": [],
+          "clientRoles": {}
+        }
+      ],
+      "attributes": {},
+      "realmRoles": [],
+      "clientRoles": {}
+    }
+  ],
   "defaultRole": {
     "id": "dd27b699-836d-4ed8-9111-ee34acbaf5ce",
     "name": "default-roles-by-kiel-dev",
@@ -2037,16 +2125,20 @@
   },
   "users": [
     {
-      "username": "admin-test",
+      "id": "b46def26-a599-4940-a32f-e070c478750d",
+      "username": "administrationApiUser",
       "firstName": "Vorname",
       "lastName": "Nachname",
       "enabled": true,
       "credentials": [
         {
           "type": "password",
-          "value": "Password"
+          "value": "administrationApiUser"
         }
-      ]
+      ],
+      "clientRoles" : {
+        "realm-management" : [ "view-users", "manage-users" ]
+      }
     }
   ]
 }
\ No newline at end of file