From 956ee581c6c6c77b562d378f006554f775fe642a Mon Sep 17 00:00:00 2001
From: Michael Hoennig <michael@hoennig.de>
Date: Mon, 26 Sep 2022 10:57:22 +0200
Subject: [PATCH] implements table hs_office_relationship to
 HsOfficeRelationshipController

---
 .../net/hostsharing/hsadminng/Mapper.java     |   3 +
 .../net/hostsharing/hsadminng/Stringify.java  |  63 +++
 .../hostsharing/hsadminng/Stringifyable.java  |   6 +
 .../office/contact/HsOfficeContactEntity.java |  22 +-
 .../office/person/HsOfficePersonEntity.java   |  30 +-
 .../HsOfficeRelationshipController.java       | 151 ++++++
 .../HsOfficeRelationshipEntity.java           |  60 +++
 .../HsOfficeRelationshipEntityPatcher.java    |  34 ++
 .../HsOfficeRelationshipRepository.java       |  37 ++
 .../HsOfficeRelationshipType.java             |   8 +
 .../hs-office/api-mappings.yaml               |   2 +
 .../hs-office/hs-office-person-schemas.yaml   |   6 -
 .../hs-office-relationship-schemas.yaml       |  55 ++
 .../hs-office-relationships-with-uuid.yaml    |  83 +++
 .../hs-office/hs-office-relationships.yaml    |  63 +++
 .../api-definition/hs-office/hs-office.yaml   |  10 +
 .../resources/db/changelog/050-rbac-base.sql  |  26 +-
 .../resources/db/changelog/055-rbac-views.sql |   2 +-
 .../db/changelog/057-rbac-role-builder.sql    |  10 +-
 .../db/changelog/058-rbac-generators.sql      |   1 +
 .../208-hs-office-contact-test-data.sql       |   3 +-
 .../218-hs-office-person-test-data.sql        |   4 +-
 .../changelog/230-hs-office-relationship.sql  |  19 +
 .../233-hs-office-relationship-rbac.sql       | 192 +++++++
 .../238-hs-office-relationship-test-data.sql  |  81 +++
 .../db/changelog/db.changelog-master.yaml     |   6 +
 .../hostsharing/hsadminng/MapperUnitTest.java |  57 ++
 .../hsadminng/StringifyUnitTest.java          |  99 ++++
 ...OfficeContactControllerAcceptanceTest.java |   5 +-
 .../HsOfficeContactEntityUnitTest.java        |  21 +
 ...OfficePartnerControllerAcceptanceTest.java |   4 +-
 .../HsOfficePartnerEntityPatcherUnitTest.java |   1 +
 ...fficePartnerRepositoryIntegrationTest.java |   3 -
 ...sOfficePersonControllerAcceptanceTest.java |  70 ++-
 .../person/HsOfficePersonEntityUnitTest.java  |  53 ++
 ...OfficePersonRepositoryIntegrationTest.java |   2 +-
 ...eRelationshipControllerAcceptanceTest.java | 510 ++++++++++++++++++
 ...ficeRelationshipEntityPatcherUnitTest.java |  90 ++++
 ...RelationshipRepositoryIntegrationTest.java | 421 +++++++++++++++
 .../rbac/rbacgrant/RawRbacGrantEntity.java    |   2 +-
 src/test/java/net/hostsharing/test/Array.java |  11 +-
 .../java/net/hostsharing/test/JpaAttempt.java |   2 +-
 tools/generate                                |  66 ++-
 43 files changed, 2292 insertions(+), 102 deletions(-)
 create mode 100644 src/main/java/net/hostsharing/hsadminng/Stringify.java
 create mode 100644 src/main/java/net/hostsharing/hsadminng/Stringifyable.java
 create mode 100644 src/main/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipController.java
 create mode 100644 src/main/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipEntity.java
 create mode 100644 src/main/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipEntityPatcher.java
 create mode 100644 src/main/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipRepository.java
 create mode 100644 src/main/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipType.java
 create mode 100644 src/main/resources/api-definition/hs-office/hs-office-relationship-schemas.yaml
 create mode 100644 src/main/resources/api-definition/hs-office/hs-office-relationships-with-uuid.yaml
 create mode 100644 src/main/resources/api-definition/hs-office/hs-office-relationships.yaml
 create mode 100644 src/main/resources/db/changelog/230-hs-office-relationship.sql
 create mode 100644 src/main/resources/db/changelog/233-hs-office-relationship-rbac.sql
 create mode 100644 src/main/resources/db/changelog/238-hs-office-relationship-test-data.sql
 create mode 100644 src/test/java/net/hostsharing/hsadminng/MapperUnitTest.java
 create mode 100644 src/test/java/net/hostsharing/hsadminng/StringifyUnitTest.java
 create mode 100644 src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactEntityUnitTest.java
 create mode 100644 src/test/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipControllerAcceptanceTest.java
 create mode 100644 src/test/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipEntityPatcherUnitTest.java
 create mode 100644 src/test/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipRepositoryIntegrationTest.java

diff --git a/src/main/java/net/hostsharing/hsadminng/Mapper.java b/src/main/java/net/hostsharing/hsadminng/Mapper.java
index 8976f4b2..4d82cf32 100644
--- a/src/main/java/net/hostsharing/hsadminng/Mapper.java
+++ b/src/main/java/net/hostsharing/hsadminng/Mapper.java
@@ -35,6 +35,9 @@ public abstract class Mapper {
     }
 
     public static <S, T> T map(final S source, final Class<T> targetClass, final BiConsumer<S, T> postMapper) {
+        if (source == null ) {
+            return null;
+        }
         final var target = modelMapper.map(source, targetClass);
         if (postMapper != null) {
             postMapper.accept(source, target);
diff --git a/src/main/java/net/hostsharing/hsadminng/Stringify.java b/src/main/java/net/hostsharing/hsadminng/Stringify.java
new file mode 100644
index 00000000..76078329
--- /dev/null
+++ b/src/main/java/net/hostsharing/hsadminng/Stringify.java
@@ -0,0 +1,63 @@
+package net.hostsharing.hsadminng;
+
+import javax.validation.constraints.NotNull;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+// TODO.refa: use this instead of toDisplayName everywhere and add JavaDoc
+public class Stringify<B> {
+
+    private final Class<B> clazz;
+    private final String name;
+    private final List<Property<B>> props = new ArrayList<>();
+
+    public static <B> Stringify<B> stringify(final Class<B> clazz, final String name) {
+        return new Stringify<B>(clazz, name);
+    }
+
+    public static <B> Stringify<B> stringify(final Class<B> clazz) {
+        return new Stringify<B>(clazz, null);
+    }
+
+    private Stringify(final Class<B> clazz, final String name) {
+        this.clazz = clazz;
+        this.name = name;
+    }
+
+    public Stringify<B> withProp(final String propName, final Function<B, ?> getter) {
+        props.add(new Property<B>(propName, getter));
+        return this;
+    }
+
+    public String apply(@NotNull B object) {
+        final var propValues = props.stream()
+                .map(prop -> PropertyValue.of(prop, prop.getter.apply(object)))
+                .filter(Objects::nonNull)
+                .map(propVal -> {
+                    if (propVal.rawValue instanceof Stringifyable stringifyable) {
+                        return new PropertyValue<>(propVal.prop, propVal.rawValue, stringifyable.toShortString());
+                    }
+                    return propVal;
+                })
+                .map(propVal -> propVal.prop.name + "=" + optionallyQuoted(propVal))
+                .collect(Collectors.joining(", "));
+        return (name != null ? name : object.getClass().getSimpleName()) + "(" + propValues + ")";
+    }
+
+    private String optionallyQuoted(final PropertyValue<B> propVal) {
+        return (propVal.rawValue instanceof Number) || (propVal.rawValue instanceof Boolean)
+                ? propVal.value
+                : "'" + propVal.value + "'";
+    }
+
+    private record Property<B>(String name, Function<B, ?> getter) {}
+
+    private record PropertyValue<B>(Property<B> prop, Object rawValue, String value) {
+        static <B> PropertyValue<B> of(Property<B> prop, Object rawValue) {
+            return rawValue != null ? new PropertyValue<>(prop, rawValue, rawValue.toString()) : null;
+        }
+    }
+}
diff --git a/src/main/java/net/hostsharing/hsadminng/Stringifyable.java b/src/main/java/net/hostsharing/hsadminng/Stringifyable.java
new file mode 100644
index 00000000..2e4cec93
--- /dev/null
+++ b/src/main/java/net/hostsharing/hsadminng/Stringifyable.java
@@ -0,0 +1,6 @@
+package net.hostsharing.hsadminng;
+
+public interface Stringifyable {
+
+    String toShortString();
+}
diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactEntity.java b/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactEntity.java
index cf41c0f2..23ee3f6e 100644
--- a/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactEntity.java
+++ b/src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactEntity.java
@@ -1,6 +1,9 @@
 package net.hostsharing.hsadminng.hs.office.contact;
 
 import lombok.*;
+import lombok.experimental.FieldNameConstants;
+import net.hostsharing.hsadminng.Stringify;
+import net.hostsharing.hsadminng.Stringifyable;
 
 import javax.persistence.Column;
 import javax.persistence.Entity;
@@ -8,6 +11,8 @@ import javax.persistence.Id;
 import javax.persistence.Table;
 import java.util.UUID;
 
+import static net.hostsharing.hsadminng.Stringify.stringify;
+
 @Entity
 @Table(name = "hs_office_contact_rv")
 @Getter
@@ -15,7 +20,12 @@ import java.util.UUID;
 @Builder
 @NoArgsConstructor
 @AllArgsConstructor
-public class HsOfficeContactEntity {
+@FieldNameConstants
+public class HsOfficeContactEntity implements Stringifyable {
+
+    private static Stringify<HsOfficeContactEntity> toString = stringify(HsOfficeContactEntity.class, "contact")
+            .withProp(Fields.label, HsOfficeContactEntity::getLabel)
+            .withProp(Fields.emailAddresses, HsOfficeContactEntity::getEmailAddresses);
 
     private @Id UUID uuid;
     private String label;
@@ -28,4 +38,14 @@ public class HsOfficeContactEntity {
 
     @Column(name = "phonenumbers", columnDefinition = "json")
     private String phoneNumbers;
+
+    @Override
+    public String toString() {
+        return toString.apply(this);
+    }
+
+    @Override
+    public String toShortString() {
+        return label;
+    }
 }
diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonEntity.java b/src/main/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonEntity.java
index 79ad1a52..cadcd4ab 100644
--- a/src/main/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonEntity.java
+++ b/src/main/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonEntity.java
@@ -2,6 +2,9 @@ package net.hostsharing.hsadminng.hs.office.person;
 
 import com.vladmihalcea.hibernate.type.basic.PostgreSQLEnumType;
 import lombok.*;
+import lombok.experimental.FieldNameConstants;
+import net.hostsharing.hsadminng.Stringify;
+import net.hostsharing.hsadminng.Stringifyable;
 import org.apache.commons.lang3.StringUtils;
 import org.hibernate.annotations.Type;
 import org.hibernate.annotations.TypeDef;
@@ -9,6 +12,8 @@ import org.hibernate.annotations.TypeDef;
 import javax.persistence.*;
 import java.util.UUID;
 
+import static net.hostsharing.hsadminng.Stringify.stringify;
+
 @Entity
 @Table(name = "hs_office_person_rv")
 @TypeDef(
@@ -20,7 +25,14 @@ import java.util.UUID;
 @Builder
 @NoArgsConstructor
 @AllArgsConstructor
-public class HsOfficePersonEntity {
+@FieldNameConstants
+public class HsOfficePersonEntity implements Stringifyable {
+
+    private static Stringify<HsOfficePersonEntity> toString = stringify(HsOfficePersonEntity.class, "person")
+            .withProp(Fields.personType, HsOfficePersonEntity::getPersonType)
+            .withProp(Fields.tradeName, HsOfficePersonEntity::getTradeName)
+            .withProp(Fields.familyName, HsOfficePersonEntity::getFamilyName)
+            .withProp(Fields.givenName, HsOfficePersonEntity::getGivenName);
 
     private @Id UUID uuid;
 
@@ -32,13 +44,23 @@ public class HsOfficePersonEntity {
     @Column(name = "tradename")
     private String tradeName;
 
-    @Column(name = "givenname")
-    private String givenName;
-
     @Column(name = "familyname")
     private String familyName;
 
+    @Column(name = "givenname")
+    private String givenName;
+
     public String getDisplayName() {
+        return toShortString();
+    }
+
+    @Override
+    public String toString() {
+        return toString.apply(this);
+    }
+
+    @Override
+    public String toShortString() {
         return !StringUtils.isEmpty(tradeName) ? tradeName : (familyName + ", " + givenName);
     }
 }
diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipController.java b/src/main/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipController.java
new file mode 100644
index 00000000..e30a0b31
--- /dev/null
+++ b/src/main/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipController.java
@@ -0,0 +1,151 @@
+package net.hostsharing.hsadminng.hs.office.relationship;
+
+import net.hostsharing.hsadminng.Mapper;
+import net.hostsharing.hsadminng.context.Context;
+import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRepository;
+import net.hostsharing.hsadminng.hs.office.generated.api.v1.api.HsOfficeRelationshipsApi;
+import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.*;
+import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRepository;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.ResponseEntity;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.servlet.mvc.method.annotation.MvcUriComponentsBuilder;
+
+import javax.persistence.EntityManager;
+import java.util.List;
+import java.util.NoSuchElementException;
+import java.util.UUID;
+import java.util.function.BiConsumer;
+
+import static net.hostsharing.hsadminng.Mapper.map;
+import static net.hostsharing.hsadminng.Mapper.mapList;
+
+@RestController
+
+public class HsOfficeRelationshipController implements HsOfficeRelationshipsApi {
+
+    @Autowired
+    private Context context;
+
+    @Autowired
+    private HsOfficeRelationshipRepository relationshipRepo;
+
+    @Autowired
+    private HsOfficePersonRepository relHolderRepo;
+
+    @Autowired
+    private HsOfficeContactRepository contactRepo;
+
+    @Autowired
+    private EntityManager em;
+
+    @Override
+    @Transactional(readOnly = true)
+    public ResponseEntity<List<HsOfficeRelationshipResource>> listRelationships(
+            final String currentUser,
+            final String assumedRoles,
+            final UUID personUuid,
+            final HsOfficeRelationshipTypeResource relationshipType) {
+        context.define(currentUser, assumedRoles);
+
+        final var entities = relationshipRepo.findRelationshipRelatedToPersonUuid(personUuid,
+                map(relationshipType, HsOfficeRelationshipType.class));
+
+        final var resources = mapList(entities, HsOfficeRelationshipResource.class,
+                RELATIONSHIP_ENTITY_TO_RESOURCE_POSTMAPPER);
+        return ResponseEntity.ok(resources);
+    }
+
+    @Override
+    @Transactional
+    public ResponseEntity<HsOfficeRelationshipResource> addRelationship(
+            final String currentUser,
+            final String assumedRoles,
+            final HsOfficeRelationshipInsertResource body) {
+
+        context.define(currentUser, assumedRoles);
+
+        final var entityToSave = new HsOfficeRelationshipEntity();
+        entityToSave.setRelType(HsOfficeRelationshipType.valueOf(body.getRelType()));
+        entityToSave.setUuid(UUID.randomUUID());
+        entityToSave.setRelAnchor(relHolderRepo.findByUuid(body.getRelAnchorUuid()).orElseThrow(
+                () -> new NoSuchElementException("cannot find relAnchorUuid " + body.getRelAnchorUuid())
+        ));
+        entityToSave.setRelHolder(relHolderRepo.findByUuid(body.getRelHolderUuid()).orElseThrow(
+                () -> new NoSuchElementException("cannot find relHolderUuid " + body.getRelHolderUuid())
+        ));
+        entityToSave.setContact(contactRepo.findByUuid(body.getContactUuid()).orElseThrow(
+                () -> new NoSuchElementException("cannot find contactUuid " + body.getContactUuid())
+        ));
+
+        final var saved = relationshipRepo.save(entityToSave);
+
+        final var uri =
+                MvcUriComponentsBuilder.fromController(getClass())
+                        .path("/api/hs/office/relationships/{id}")
+                        .buildAndExpand(entityToSave.getUuid())
+                        .toUri();
+        final var mapped = map(saved, HsOfficeRelationshipResource.class,
+                RELATIONSHIP_ENTITY_TO_RESOURCE_POSTMAPPER);
+        return ResponseEntity.created(uri).body(mapped);
+    }
+
+    @Override
+    @Transactional(readOnly = true)
+    public ResponseEntity<HsOfficeRelationshipResource> getRelationshipByUuid(
+            final String currentUser,
+            final String assumedRoles,
+            final UUID relationshipUuid) {
+
+        context.define(currentUser, assumedRoles);
+
+        final var result = relationshipRepo.findByUuid(relationshipUuid);
+        if (result.isEmpty()) {
+            return ResponseEntity.notFound().build();
+        }
+        return ResponseEntity.ok(map(result.get(), HsOfficeRelationshipResource.class, RELATIONSHIP_ENTITY_TO_RESOURCE_POSTMAPPER));
+    }
+
+    @Override
+    @Transactional
+    public ResponseEntity<Void> deleteRelationshipByUuid(
+            final String currentUser,
+            final String assumedRoles,
+            final UUID relationshipUuid) {
+        context.define(currentUser, assumedRoles);
+
+        final var result = relationshipRepo.deleteByUuid(relationshipUuid);
+        if (result == 0) {
+            return ResponseEntity.notFound().build();
+        }
+
+        return ResponseEntity.noContent().build();
+    }
+
+    @Override
+    @Transactional
+    public ResponseEntity<HsOfficeRelationshipResource> patchRelationship(
+            final String currentUser,
+            final String assumedRoles,
+            final UUID relationshipUuid,
+            final HsOfficeRelationshipPatchResource body) {
+
+        context.define(currentUser, assumedRoles);
+
+        final var current = relationshipRepo.findByUuid(relationshipUuid).orElseThrow();
+
+        new HsOfficeRelationshipEntityPatcher(em, current).apply(body);
+
+        final var saved = relationshipRepo.save(current);
+        final var mapped = map(saved, HsOfficeRelationshipResource.class);
+        return ResponseEntity.ok(mapped);
+    }
+
+
+    final BiConsumer<HsOfficeRelationshipEntity, HsOfficeRelationshipResource> RELATIONSHIP_ENTITY_TO_RESOURCE_POSTMAPPER = (entity, resource) -> {
+        resource.setRelAnchor(map(entity.getRelAnchor(), HsOfficePersonResource.class));
+        resource.setRelHolder(map(entity.getRelHolder(), HsOfficePersonResource.class));
+        resource.setContact(map(entity.getContact(), HsOfficeContactResource.class));
+    };
+}
diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipEntity.java b/src/main/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipEntity.java
new file mode 100644
index 00000000..5513ceee
--- /dev/null
+++ b/src/main/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipEntity.java
@@ -0,0 +1,60 @@
+package net.hostsharing.hsadminng.hs.office.relationship;
+
+import com.vladmihalcea.hibernate.type.basic.PostgreSQLEnumType;
+import lombok.*;
+import lombok.experimental.FieldNameConstants;
+import net.hostsharing.hsadminng.Stringify;
+import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactEntity;
+import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonEntity;
+import org.hibernate.annotations.Type;
+import org.hibernate.annotations.TypeDef;
+
+import javax.persistence.*;
+import java.util.UUID;
+
+import static net.hostsharing.hsadminng.Stringify.stringify;
+
+@Entity
+@Table(name = "hs_office_relationship_rv")
+@TypeDef(
+        name = "pgsql_enum",
+        typeClass = PostgreSQLEnumType.class
+)
+@Getter
+@Setter
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@FieldNameConstants
+public class HsOfficeRelationshipEntity {
+
+    private static Stringify<HsOfficeRelationshipEntity> toString = stringify(HsOfficeRelationshipEntity.class, "rel")
+            .withProp(Fields.relAnchor, HsOfficeRelationshipEntity::getRelAnchor)
+            .withProp(Fields.relType, HsOfficeRelationshipEntity::getRelType)
+            .withProp(Fields.relHolder, HsOfficeRelationshipEntity::getRelHolder)
+            .withProp(Fields.contact, HsOfficeRelationshipEntity::getContact);
+
+    private @Id UUID uuid;
+
+    @ManyToOne
+    @JoinColumn(name = "relanchoruuid")
+    private HsOfficePersonEntity relAnchor;
+
+    @ManyToOne
+    @JoinColumn(name = "relholderuuid")
+    private HsOfficePersonEntity relHolder;
+
+    @ManyToOne
+    @JoinColumn(name = "contactuuid")
+    private HsOfficeContactEntity contact;
+
+    @Column(name = "reltype")
+    @Enumerated(EnumType.STRING)
+    @Type( type = "pgsql_enum" )
+    private HsOfficeRelationshipType relType;
+
+    @Override
+    public String toString() {
+        return toString.apply(this);
+    }
+}
diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipEntityPatcher.java b/src/main/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipEntityPatcher.java
new file mode 100644
index 00000000..c01a3467
--- /dev/null
+++ b/src/main/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipEntityPatcher.java
@@ -0,0 +1,34 @@
+package net.hostsharing.hsadminng.hs.office.relationship;
+
+import net.hostsharing.hsadminng.EntityPatcher;
+import net.hostsharing.hsadminng.OptionalFromJson;
+import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactEntity;
+import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeRelationshipPatchResource;
+
+import javax.persistence.EntityManager;
+import java.util.UUID;
+
+class HsOfficeRelationshipEntityPatcher implements EntityPatcher<HsOfficeRelationshipPatchResource> {
+
+    private final EntityManager em;
+    private final HsOfficeRelationshipEntity entity;
+
+    HsOfficeRelationshipEntityPatcher(final EntityManager em, final HsOfficeRelationshipEntity entity) {
+        this.em = em;
+        this.entity = entity;
+    }
+
+    @Override
+    public void apply(final HsOfficeRelationshipPatchResource resource) {
+        OptionalFromJson.of(resource.getContactUuid()).ifPresent(newValue -> {
+            verifyNotNull(newValue, "contact");
+            entity.setContact(em.getReference(HsOfficeContactEntity.class, newValue));
+        });
+    }
+
+    private void verifyNotNull(final UUID newValue, final String propertyName) {
+        if (newValue == null) {
+            throw new IllegalArgumentException("property '" + propertyName + "' must not be null");
+        }
+    }
+}
diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipRepository.java b/src/main/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipRepository.java
new file mode 100644
index 00000000..6412e590
--- /dev/null
+++ b/src/main/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipRepository.java
@@ -0,0 +1,37 @@
+package net.hostsharing.hsadminng.hs.office.relationship;
+
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.Repository;
+
+import javax.validation.constraints.NotNull;
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+
+public interface HsOfficeRelationshipRepository extends Repository<HsOfficeRelationshipEntity, UUID> {
+
+    Optional<HsOfficeRelationshipEntity> findByUuid(UUID id);
+
+    default List<HsOfficeRelationshipEntity> findRelationshipRelatedToPersonUuid(@NotNull UUID personUuid, HsOfficeRelationshipType relationshipType) {
+        return findRelationshipRelatedToPersonUuid(personUuid, relationshipType.toString());
+    }
+
+    @Query(value = """
+            SELECT p.* FROM hs_office_relationship_rv AS p
+                WHERE p.relAnchorUuid = :personUuid OR p.relHolderUuid = :personUuid
+               """, nativeQuery = true)
+    List<HsOfficeRelationshipEntity> findRelationshipRelatedToPersonUuid(@NotNull UUID personUuid);
+
+    @Query(value = """
+            SELECT p.* FROM hs_office_relationship_rv AS p
+                WHERE (:relationshipType IS NULL OR p.relType = cast(:relationshipType AS HsOfficeRelationshipType))
+                    AND ( p.relAnchorUuid = :personUuid OR p.relHolderUuid = :personUuid)
+               """, nativeQuery = true)
+    List<HsOfficeRelationshipEntity> findRelationshipRelatedToPersonUuid(@NotNull UUID personUuid, String relationshipType);
+
+    HsOfficeRelationshipEntity save(final HsOfficeRelationshipEntity entity);
+
+    long count();
+
+    int deleteByUuid(UUID uuid);
+}
diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipType.java b/src/main/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipType.java
new file mode 100644
index 00000000..7a5097a3
--- /dev/null
+++ b/src/main/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipType.java
@@ -0,0 +1,8 @@
+package net.hostsharing.hsadminng.hs.office.relationship;
+
+public enum HsOfficeRelationshipType {
+    SOLE_AGENT,
+    JOINT_AGENT,
+    ACCOUNTING_CONTACT,
+    TECHNICAL_CONTACT
+}
diff --git a/src/main/resources/api-definition/hs-office/api-mappings.yaml b/src/main/resources/api-definition/hs-office/api-mappings.yaml
index d3ade71b..c77f5c86 100644
--- a/src/main/resources/api-definition/hs-office/api-mappings.yaml
+++ b/src/main/resources/api-definition/hs-office/api-mappings.yaml
@@ -18,3 +18,5 @@ map:
             null: org.openapitools.jackson.nullable.JsonNullable
         /api/hs/office/persons/{personUUID}:
             null: org.openapitools.jackson.nullable.JsonNullable
+        /api/hs/office/relationships/{relationshipUUID}:
+            null: org.openapitools.jackson.nullable.JsonNullable
diff --git a/src/main/resources/api-definition/hs-office/hs-office-person-schemas.yaml b/src/main/resources/api-definition/hs-office/hs-office-person-schemas.yaml
index 5a3d4596..34636034 100644
--- a/src/main/resources/api-definition/hs-office/hs-office-person-schemas.yaml
+++ b/src/main/resources/api-definition/hs-office/hs-office-person-schemas.yaml
@@ -3,12 +3,6 @@ components:
 
     schemas:
 
-        HsOfficePersonTypeValues:
-            - NATURAL               # a human
-            - LEGAL                 # e.g. Corp., Inc., AG, GmbH, eG
-            - SOLE_REPRESENTATION   # e.g. OHG, GbR
-            - JOINT_REPRESENTATION  # e.g. community of heirs
-
         HsOfficePersonType:
             type: string
             enum:
diff --git a/src/main/resources/api-definition/hs-office/hs-office-relationship-schemas.yaml b/src/main/resources/api-definition/hs-office/hs-office-relationship-schemas.yaml
new file mode 100644
index 00000000..23af0ff0
--- /dev/null
+++ b/src/main/resources/api-definition/hs-office/hs-office-relationship-schemas.yaml
@@ -0,0 +1,55 @@
+
+components:
+
+    schemas:
+
+        HsOfficeRelationshipType:
+            type: string
+            enum:
+                - SOLE_AGENT        # e.g. CEO
+                - JOINT_AGENT       # e.g. heir
+                - ACCOUNTING_CONTACT
+                - TECHNICAL_CONTACT
+
+        HsOfficeRelationship:
+            type: object
+            properties:
+                uuid:
+                    type: string
+                    format: uuid
+                relAnchor:
+                    $ref: './hs-office-person-schemas.yaml#/components/schemas/HsOfficePerson'
+                relHolder:
+                    $ref: './hs-office-person-schemas.yaml#/components/schemas/HsOfficePerson'
+                relType:
+                    type: string
+                contact:
+                    $ref: './hs-office-contact-schemas.yaml#/components/schemas/HsOfficeContact'
+
+        HsOfficeRelationshipPatch:
+            type: object
+            properties:
+                contactUuid:
+                    type: string
+                    format: uuid
+                    nullable: true
+
+        HsOfficeRelationshipInsert:
+            type: object
+            properties:
+                relAnchorUuid:
+                    type: string
+                    format: uuid
+                relHolderUuid:
+                    type: string
+                    format: uuid
+                relType:
+                    type: string
+                    nullable: true
+                contactUuid:
+                    type: string
+                    format: uuid
+            required:
+              - relAnchorUuid
+              - relHolderUuid
+              - relType
diff --git a/src/main/resources/api-definition/hs-office/hs-office-relationships-with-uuid.yaml b/src/main/resources/api-definition/hs-office/hs-office-relationships-with-uuid.yaml
new file mode 100644
index 00000000..d3b9605e
--- /dev/null
+++ b/src/main/resources/api-definition/hs-office/hs-office-relationships-with-uuid.yaml
@@ -0,0 +1,83 @@
+get:
+    tags:
+        - hs-office-relationships
+    description: 'Fetch a single person relationship by its uuid, if visible for the current subject.'
+    operationId: getRelationshipByUuid
+    parameters:
+        - $ref: './auth.yaml#/components/parameters/currentUser'
+        - $ref: './auth.yaml#/components/parameters/assumedRoles'
+        - name: relationshipUUID
+          in: path
+          required: true
+          schema:
+              type: string
+              format: uuid
+          description: UUID of the relationship to fetch.
+    responses:
+        "200":
+            description: OK
+            content:
+                'application/json':
+                    schema:
+                        $ref: './hs-office-relationship-schemas.yaml#/components/schemas/HsOfficeRelationship'
+
+        "401":
+            $ref: './error-responses.yaml#/components/responses/Unauthorized'
+        "403":
+            $ref: './error-responses.yaml#/components/responses/Forbidden'
+
+patch:
+    tags:
+        - hs-office-relationships
+    description: 'Updates a single person relationship by its uuid, if permitted for the current subject.'
+    operationId: patchRelationship
+    parameters:
+        -   $ref: './auth.yaml#/components/parameters/currentUser'
+        -   $ref: './auth.yaml#/components/parameters/assumedRoles'
+        -   name: relationshipUUID
+            in: path
+            required: true
+            schema:
+                type: string
+                format: uuid
+    requestBody:
+        content:
+            'application/json':
+                schema:
+                    $ref: './hs-office-relationship-schemas.yaml#/components/schemas/HsOfficeRelationshipPatch'
+    responses:
+        "200":
+            description: OK
+            content:
+                'application/json':
+                    schema:
+                        $ref: './hs-office-relationship-schemas.yaml#/components/schemas/HsOfficeRelationship'
+        "401":
+            $ref: './error-responses.yaml#/components/responses/Unauthorized'
+        "403":
+            $ref: './error-responses.yaml#/components/responses/Forbidden'
+
+delete:
+    tags:
+        - hs-office-relationships
+    description: 'Delete a single person relationship by its uuid, if permitted for the current subject.'
+    operationId: deleteRelationshipByUuid
+    parameters:
+        - $ref: './auth.yaml#/components/parameters/currentUser'
+        - $ref: './auth.yaml#/components/parameters/assumedRoles'
+        - name: relationshipUUID
+          in: path
+          required: true
+          schema:
+              type: string
+              format: uuid
+          description: UUID of the relationship to delete.
+    responses:
+        "204":
+            description: No Content
+        "401":
+            $ref: './error-responses.yaml#/components/responses/Unauthorized'
+        "403":
+            $ref: './error-responses.yaml#/components/responses/Forbidden'
+        "404":
+            $ref: './error-responses.yaml#/components/responses/NotFound'
diff --git a/src/main/resources/api-definition/hs-office/hs-office-relationships.yaml b/src/main/resources/api-definition/hs-office/hs-office-relationships.yaml
new file mode 100644
index 00000000..2d7ed2fd
--- /dev/null
+++ b/src/main/resources/api-definition/hs-office/hs-office-relationships.yaml
@@ -0,0 +1,63 @@
+get:
+    summary: Returns a list of (optionally filtered) person relationships for a given person.
+    description: Returns the list of (optionally filtered) person relationships of a given person and which are visible to the current user or any of it's assumed roles.
+    tags:
+        - hs-office-relationships
+    operationId: listRelationships
+    parameters:
+        - $ref: './auth.yaml#/components/parameters/currentUser'
+        - $ref: './auth.yaml#/components/parameters/assumedRoles'
+        - name: personUuid
+          in: query
+          required: true
+          schema:
+              type: string
+              format: uuid
+          description: Prefix of name properties from relHolder or contact to filter the results.
+        - name: relationshipType
+          in: query
+          required: false
+          schema:
+              $ref: './hs-office-relationship-schemas.yaml#/components/schemas/HsOfficeRelationshipType'
+          description: Prefix of name properties from relHolder or contact to filter the results.
+    responses:
+        "200":
+            description: OK
+            content:
+                'application/json':
+                    schema:
+                        type: array
+                        items:
+                            $ref: './hs-office-relationship-schemas.yaml#/components/schemas/HsOfficeRelationship'
+        "401":
+            $ref: './error-responses.yaml#/components/responses/Unauthorized'
+        "403":
+            $ref: './error-responses.yaml#/components/responses/Forbidden'
+
+post:
+    summary: Adds a new person relationship.
+    tags:
+        - hs-office-relationships
+    operationId: addRelationship
+    parameters:
+        - $ref: './auth.yaml#/components/parameters/currentUser'
+        - $ref: './auth.yaml#/components/parameters/assumedRoles'
+    requestBody:
+        content:
+            'application/json':
+                schema:
+                    $ref: './hs-office-relationship-schemas.yaml#/components/schemas/HsOfficeRelationshipInsert'
+        required: true
+    responses:
+        "201":
+            description: Created
+            content:
+                'application/json':
+                    schema:
+                        $ref: './hs-office-relationship-schemas.yaml#/components/schemas/HsOfficeRelationship'
+        "401":
+            $ref: './error-responses.yaml#/components/responses/Unauthorized'
+        "403":
+            $ref: './error-responses.yaml#/components/responses/Forbidden'
+        "409":
+            $ref: './error-responses.yaml#/components/responses/Conflict'
diff --git a/src/main/resources/api-definition/hs-office/hs-office.yaml b/src/main/resources/api-definition/hs-office/hs-office.yaml
index 125ae698..856209ab 100644
--- a/src/main/resources/api-definition/hs-office/hs-office.yaml
+++ b/src/main/resources/api-definition/hs-office/hs-office.yaml
@@ -34,3 +34,13 @@ paths:
   /api/hs/office/persons/{personUUID}:
     $ref: "./hs-office-persons-with-uuid.yaml"
 
+
+
+  # Relationships
+
+  /api/hs/office/relationships:
+    $ref: "./hs-office-relationships.yaml"
+
+  /api/hs/office/relationships/{relationshipUUID}:
+    $ref: "./hs-office-relationships-with-uuid.yaml"
+
diff --git a/src/main/resources/db/changelog/050-rbac-base.sql b/src/main/resources/db/changelog/050-rbac-base.sql
index 8b98808f..ac09e7b8 100644
--- a/src/main/resources/db/changelog/050-rbac-base.sql
+++ b/src/main/resources/db/changelog/050-rbac-base.sql
@@ -20,6 +20,10 @@ create or replace function assertReferenceType(argument varchar, referenceId uui
 declare
     actualType ReferenceType;
 begin
+    if referenceId is null then
+        raise exception '% must be a % and not null', argument, expectedType;
+    end if;
+
     actualType = (select type from RbacReference where uuid = referenceId);
     if (actualType <> expectedType) then
         raise exception '% must reference a %, but got a %', argument, expectedType, actualType;
@@ -608,21 +612,29 @@ begin
         into RbacGrants (ascendantuuid, descendantUuid, assumed)
         values (superRoleId, subRoleId, doAssume)
     on conflict do nothing; -- allow granting multiple times
-    delete from RbacGrants where ascendantUuid = superRoleId and descendantUuid = subRoleId;
-    insert
-        into RbacGrants (ascendantuuid, descendantUuid, assumed)
-        values (superRoleId, subRoleId, doAssume); -- allow granting multiple times
 end; $$;
 
-create or replace procedure revokeRoleFromRole(subRoleId uuid, superRoleId uuid)
+create or replace procedure grantRoleToRoleIfNotNull(subRole RbacRoleDescriptor, superRole RbacRoleDescriptor, doAssume bool = true)
     language plpgsql as $$
+declare
+    superRoleId uuid;
+    subRoleId uuid;
 begin
+    superRoleId := findRoleId(superRole);
+    if ( subRoleId is null ) then return; end if;
+    subRoleId := findRoleId(subRole);
+
     perform assertReferenceType('superRoleId (ascendant)', superRoleId, 'RbacRole');
     perform assertReferenceType('subRoleId (descendant)', subRoleId, 'RbacRole');
 
-    if (isGranted(superRoleId, subRoleId)) then
-        delete from RbacGrants where ascendantUuid = superRoleId and descendantUuid = subRoleId;
+    if isGranted(subRoleId, superRoleId) then
+        raise exception '[400] Cyclic role grant detected between % and %', subRoleId, superRoleId;
     end if;
+
+    insert
+        into RbacGrants (ascendantuuid, descendantUuid, assumed)
+        values (superRoleId, subRoleId, doAssume)
+    on conflict do nothing; -- allow granting multiple times
 end; $$;
 
 create or replace procedure revokeRoleFromRole(subRole RbacRoleDescriptor, superRole RbacRoleDescriptor)
diff --git a/src/main/resources/db/changelog/055-rbac-views.sql b/src/main/resources/db/changelog/055-rbac-views.sql
index f4337162..68ea11b5 100644
--- a/src/main/resources/db/changelog/055-rbac-views.sql
+++ b/src/main/resources/db/changelog/055-rbac-views.sql
@@ -61,7 +61,7 @@ create or replace view rbacgrants_ev as
            x.descendingIdName as descendantIdName,
            x.grantedByRoleUuid,
            x.ascendantUuid as ascendantUuid,
-           x.descendantUuid as descenantUuid,
+           x.descendantUuid as descendantUuid,
            x.assumed
         from (
              select g.uuid as grantUuid,
diff --git a/src/main/resources/db/changelog/057-rbac-role-builder.sql b/src/main/resources/db/changelog/057-rbac-role-builder.sql
index f9f83ea8..32e740ab 100644
--- a/src/main/resources/db/changelog/057-rbac-role-builder.sql
+++ b/src/main/resources/db/changelog/057-rbac-role-builder.sql
@@ -51,7 +51,9 @@ declare
 begin
     foreach superRoleDescriptor in array roleDescriptors
         loop
-            superRoleUuids := superRoleUuids || getRoleId(superRoleDescriptor, 'fail');
+            if superRoleDescriptor is not null then
+                superRoleUuids := superRoleUuids || getRoleId(superRoleDescriptor, 'fail');
+            end if;
         end loop;
 
     return row (superRoleUuids)::RbacSuperRoles;
@@ -96,7 +98,6 @@ create type RbacSubRoles as
     roleUuids uuid[]
 );
 
--- drop FUNCTION beingItselfA(roleUuid uuid)
 create or replace function beingItselfA(roleUuid uuid)
     returns RbacSubRoles
     language plpgsql
@@ -105,7 +106,6 @@ begin
     return row (array [roleUuid]::uuid[])::RbacSubRoles;
 end; $$;
 
--- drop FUNCTION beingItselfA(roleDescriptor RbacRoleDescriptor)
 create or replace function beingItselfA(roleDescriptor RbacRoleDescriptor)
     returns RbacSubRoles
     language plpgsql
@@ -124,7 +124,9 @@ declare
 begin
     foreach subRoleDescriptor in array roleDescriptors
         loop
-            subRoleUuids := subRoleUuids || getRoleId(subRoleDescriptor, 'fail');
+            if subRoleDescriptor is not null then
+                subRoleUuids := subRoleUuids || getRoleId(subRoleDescriptor, 'fail');
+            end if;
         end loop;
 
     return row (subRoleUuids)::RbacSubRoles;
diff --git a/src/main/resources/db/changelog/058-rbac-generators.sql b/src/main/resources/db/changelog/058-rbac-generators.sql
index 6c897eaf..934005d7 100644
--- a/src/main/resources/db/changelog/058-rbac-generators.sql
+++ b/src/main/resources/db/changelog/058-rbac-generators.sql
@@ -127,6 +127,7 @@ begin
     /*
         Creates a restricted view based on the 'view' permission of the current subject.
     */
+    -- TODO.refa: hoist `select queryAccessibleObjectUuidsOfSubjectIds(...)` into WITH CTE  for performance
     sql := format($sql$
         set session session authorization default;
         create view %1$s_rv as
diff --git a/src/main/resources/db/changelog/208-hs-office-contact-test-data.sql b/src/main/resources/db/changelog/208-hs-office-contact-test-data.sql
index 2d843611..1d651a69 100644
--- a/src/main/resources/db/changelog/208-hs-office-contact-test-data.sql
+++ b/src/main/resources/db/changelog/208-hs-office-contact-test-data.sql
@@ -17,7 +17,7 @@ begin
     currentTask = 'creating RBAC test contact ' || contLabel;
     execute format('set local hsadminng.currentTask to %L', currentTask);
 
-    emailAddr = 'customer-admin@' || cleanIdentifier(contLabel) || '.example.com';
+    emailAddr = 'contact-admin@' || cleanIdentifier(contLabel) || '.example.com';
     call defineContext(currentTask);
     perform createRbacUser(emailAddr);
     call defineContext(currentTask, null, emailAddr);
@@ -64,6 +64,7 @@ do language plpgsql $$
         call createHsOfficeContactTestData('forth contact');
         call createHsOfficeContactTestData('fifth contact');
         call createHsOfficeContactTestData('sixth contact');
+        call createHsOfficeContactTestData('seventh contact');
         call createHsOfficeContactTestData('eighth contact');
         call createHsOfficeContactTestData('ninth contact');
         call createHsOfficeContactTestData('tenth contact');
diff --git a/src/main/resources/db/changelog/218-hs-office-person-test-data.sql b/src/main/resources/db/changelog/218-hs-office-person-test-data.sql
index d7ac1169..382c5f0e 100644
--- a/src/main/resources/db/changelog/218-hs-office-person-test-data.sql
+++ b/src/main/resources/db/changelog/218-hs-office-person-test-data.sql
@@ -60,10 +60,12 @@ end; $$;
 do language plpgsql $$
     begin
         call createHsOfficePersonTestData('LEGAL', 'First Impressions GmbH');
-        call createHsOfficePersonTestData('NATURAL', null, 'Peter', 'Smith');
+        call createHsOfficePersonTestData('NATURAL', null, 'Smith', 'Peter');
         call createHsOfficePersonTestData('LEGAL', 'Rockshop e.K.', 'Sandra', 'Miller');
         call createHsOfficePersonTestData('SOLE_REPRESENTATION', 'Ostfriesische Kuhhandel OHG');
         call createHsOfficePersonTestData('JOINT_REPRESENTATION', 'Erben Bessler', 'Mel', 'Bessler');
+        call createHsOfficePersonTestData('NATURAL', null, 'Bessler', 'Anita');
+        call createHsOfficePersonTestData('NATURAL', null, 'Winkler', 'Paul');
     end;
 $$;
 --//
diff --git a/src/main/resources/db/changelog/230-hs-office-relationship.sql b/src/main/resources/db/changelog/230-hs-office-relationship.sql
new file mode 100644
index 00000000..2f17e88e
--- /dev/null
+++ b/src/main/resources/db/changelog/230-hs-office-relationship.sql
@@ -0,0 +1,19 @@
+--liquibase formatted sql
+
+-- ============================================================================
+--changeset hs-office-relationship-MAIN-TABLE:1 endDelimiter:--//
+-- ----------------------------------------------------------------------------
+
+CREATE TYPE HsOfficeRelationshipType AS ENUM ('SOLE_AGENT', 'JOINT_AGENT', 'CO_OWNER', 'ACCOUNTING_CONTACT', 'TECHNICAL_CONTACT');
+
+CREATE CAST (character varying as HsOfficeRelationshipType) WITH INOUT AS IMPLICIT;
+
+create table if not exists hs_office_relationship
+(
+    uuid                uuid unique references RbacObject (uuid) initially deferred, -- on delete cascade
+    relAnchorUuid       uuid not null references hs_office_person(uuid),
+    relHolderUuid       uuid not null references hs_office_person(uuid),
+    contactUuid         uuid references hs_office_contact(uuid),
+    relType             HsOfficeRelationshipType not null
+);
+--//
diff --git a/src/main/resources/db/changelog/233-hs-office-relationship-rbac.sql b/src/main/resources/db/changelog/233-hs-office-relationship-rbac.sql
new file mode 100644
index 00000000..8c494bbe
--- /dev/null
+++ b/src/main/resources/db/changelog/233-hs-office-relationship-rbac.sql
@@ -0,0 +1,192 @@
+--liquibase formatted sql
+
+-- ============================================================================
+--changeset hs-office-relationship-rbac-OBJECT:1 endDelimiter:--//
+-- ----------------------------------------------------------------------------
+call generateRelatedRbacObject('hs_office_relationship');
+--//
+
+
+-- ============================================================================
+--changeset hs-office-relationship-rbac-ROLE-DESCRIPTORS:1 endDelimiter:--//
+-- ----------------------------------------------------------------------------
+call generateRbacRoleDescriptors('hsOfficeRelationship', 'hs_office_relationship');
+--//
+
+
+-- ============================================================================
+--changeset hs-office-relationship-rbac-ROLES-CREATION:1 endDelimiter:--//
+-- ----------------------------------------------------------------------------
+
+/*
+    Creates and updates the roles and their assignments for relationship entities.
+ */
+
+create or replace function hsOfficeRelationshipRbacRolesTrigger()
+    returns trigger
+    language plpgsql
+    strict as $$
+declare
+    hsOfficeRelationshipTenant  RbacRoleDescriptor;
+    ownerRole                   uuid;
+    adminRole                   uuid;
+    newRelAnchor                hs_office_person;
+    newRelHolder                hs_office_person;
+    oldContact                  hs_office_contact;
+    newContact                  hs_office_contact;
+begin
+
+    hsOfficeRelationshipTenant := hsOfficeRelationshipTenant(NEW);
+
+    select * from hs_office_person as p where p.uuid = NEW.relAnchorUuid into newRelAnchor;
+    select * from hs_office_person as p where p.uuid = NEW.relHolderUuid into newRelHolder;
+    select * from hs_office_contact as c where c.uuid = NEW.contactUuid into newContact;
+
+    if TG_OP = 'INSERT' then
+
+        -- the owner role with full access for admins of the relAnchor global admins
+        ownerRole = createRole(
+                hsOfficeRelationshipOwner(NEW),
+                grantingPermissions(forObjectUuid => NEW.uuid, permitOps => array ['*']),
+                beneathRoles(array[
+                    globalAdmin(),
+                    hsOfficePersonAdmin(newRelAnchor)])
+            );
+
+        -- the admin role with full access for the owner
+        adminRole = createRole(
+                hsOfficeRelationshipAdmin(NEW),
+                grantingPermissions(forObjectUuid => NEW.uuid, permitOps => array ['edit']),
+                beneathRole(ownerRole)
+            );
+
+        -- the tenant role for those related users who can view the data
+        perform createRole(
+                hsOfficeRelationshipTenant,
+                grantingPermissions(forObjectUuid => NEW.uuid, permitOps => array ['view']),
+                beneathRoles(array[
+                    hsOfficePersonAdmin(newRelAnchor),
+                    hsOfficePersonAdmin(newRelHolder),
+                    hsOfficeContactAdmin(newContact)]),
+                withSubRoles(array[
+                    hsOfficePersonTenant(newRelAnchor),
+                    hsOfficePersonTenant(newRelHolder),
+                    hsOfficeContactTenant(newContact)])
+            );
+
+        -- anchor and holder admin roles need each others tenant role
+        -- to be able to see the joined relationship
+        call grantRoleToRole(hsOfficePersonTenant(newRelAnchor), hsOfficePersonAdmin(newRelHolder));
+        call grantRoleToRole(hsOfficePersonTenant(newRelHolder), hsOfficePersonAdmin(newRelAnchor));
+        call grantRoleToRoleIfNotNull(hsOfficePersonTenant(newRelHolder), hsOfficeContactAdmin(newContact));
+
+    elsif TG_OP = 'UPDATE' then
+
+        if OLD.contactUuid <> NEW.contactUuid then
+            -- nothing but the contact can be updated,
+            -- in other cases, a new relationship needs to be created and the old updated
+
+            select * from hs_office_contact as c where c.uuid = OLD.contactUuid into oldContact;
+
+            call revokeRoleFromRole( hsOfficeRelationshipTenant, hsOfficeContactAdmin(oldContact) );
+            call grantRoleToRole( hsOfficeRelationshipTenant, hsOfficeContactAdmin(newContact) );
+
+            call revokeRoleFromRole( hsOfficeContactTenant(oldContact), hsOfficeRelationshipTenant );
+            call grantRoleToRole( hsOfficeContactTenant(newContact), hsOfficeRelationshipTenant );
+        end if;
+    else
+        raise exception 'invalid usage of TRIGGER';
+    end if;
+
+    return NEW;
+end; $$;
+
+/*
+    An AFTER INSERT TRIGGER which creates the role structure for a new customer.
+ */
+create trigger createRbacRolesForHsOfficeRelationship_Trigger
+    after insert
+    on hs_office_relationship
+    for each row
+execute procedure hsOfficeRelationshipRbacRolesTrigger();
+
+/*
+    An AFTER UPDATE TRIGGER which updates the role structure of a customer.
+ */
+create trigger updateRbacRolesForHsOfficeRelationship_Trigger
+    after update
+    on hs_office_relationship
+    for each row
+execute procedure hsOfficeRelationshipRbacRolesTrigger();
+--//
+
+
+-- ============================================================================
+--changeset hs-office-relationship-rbac-IDENTITY-VIEW:1 endDelimiter:--//
+-- ----------------------------------------------------------------------------
+call generateRbacIdentityView('hs_office_relationship', $idName$
+    (select idName from hs_office_person_iv p where p.uuid = target.relAnchorUuid)
+    || '-with-' || target.relType || '-' ||
+    (select idName from hs_office_person_iv p where p.uuid = target.relHolderUuid)
+    $idName$);
+--//
+
+
+-- ============================================================================
+--changeset hs-office-relationship-rbac-RESTRICTED-VIEW:1 endDelimiter:--//
+-- ----------------------------------------------------------------------------
+call generateRbacRestrictedView('hs_office_relationship',
+    '(select idName from hs_office_person_iv p where p.uuid = target.relHolderUuid)',
+    $updates$
+        contactUuid = new.contactUuid
+    $updates$);
+--//
+
+-- TODO: exception if one tries to amend any other column
+
+
+-- ============================================================================
+--changeset hs-office-relationship-rbac-NEW-RELATHIONSHIP:1 endDelimiter:--//
+-- ----------------------------------------------------------------------------
+/*
+    Creates a global permission for new-relationship and assigns it to the hostsharing admins role.
+ */
+do language plpgsql $$
+    declare
+        addCustomerPermissions uuid[];
+        globalObjectUuid       uuid;
+        globalAdminRoleUuid    uuid ;
+    begin
+        call defineContext('granting global new-relationship permission to global admin role', null, null, null);
+
+        globalAdminRoleUuid := findRoleId(globalAdmin());
+        globalObjectUuid := (select uuid from global);
+        addCustomerPermissions := createPermissions(globalObjectUuid, array ['new-relationship']);
+        call grantPermissionsToRole(globalAdminRoleUuid, addCustomerPermissions);
+    end;
+$$;
+
+/**
+    Used by the trigger to prevent the add-customer to current user respectively assumed roles.
+ */
+create or replace function addHsOfficeRelationshipNotAllowedForCurrentSubjects()
+    returns trigger
+    language PLPGSQL
+as $$
+begin
+    raise exception '[403] new-relationship not permitted for %',
+        array_to_string(currentSubjects(), ';', 'null');
+end; $$;
+
+/**
+    Checks if the user or assumed roles are allowed to create a new customer.
+ */
+create trigger hs_office_relationship_insert_trigger
+    before insert
+    on hs_office_relationship
+    for each row
+    -- TODO.spec: who is allowed to create new relationships
+    when ( not hasAssumedRole() )
+execute procedure addHsOfficeRelationshipNotAllowedForCurrentSubjects();
+--//
+
diff --git a/src/main/resources/db/changelog/238-hs-office-relationship-test-data.sql b/src/main/resources/db/changelog/238-hs-office-relationship-test-data.sql
new file mode 100644
index 00000000..a2b71ccc
--- /dev/null
+++ b/src/main/resources/db/changelog/238-hs-office-relationship-test-data.sql
@@ -0,0 +1,81 @@
+--liquibase formatted sql
+
+
+-- ============================================================================
+--changeset hs-office-relationship-TEST-DATA-GENERATOR:1 endDelimiter:--//
+-- ----------------------------------------------------------------------------
+
+/*
+    Creates a single relationship test record.
+ */
+create or replace procedure createHsOfficeRelationshipTestData(
+        anchorPersonTradeName varchar,
+        holderPersonFamilyName varchar,
+        relationshipType HsOfficeRelationshipType,
+        contactLabel varchar)
+    language plpgsql as $$
+declare
+    currentTask     varchar;
+    idName          varchar;
+    anchorPerson    hs_office_person;
+    holderPerson    hs_office_person;
+    contact         hs_office_contact;
+
+begin
+    idName := cleanIdentifier( anchorPersonTradeName|| '-' || holderPersonFamilyName);
+    currentTask := 'creating RBAC test relationship ' || idName;
+    call defineContext(currentTask, null, 'superuser-alex@hostsharing.net', 'global#global.admin');
+    execute format('set local hsadminng.currentTask to %L', currentTask);
+
+    select p.* from hs_office_person p where p.tradeName = anchorPersonTradeName into anchorPerson;
+    select p.* from hs_office_person p where p.familyName = holderPersonFamilyName into holderPerson;
+    select c.* from hs_office_contact c where c.label = contactLabel into contact;
+
+    raise notice 'creating test relationship: %', idName;
+    raise notice '- using anchor person (%): %', anchorPerson.uuid, anchorPerson;
+    raise notice '- using holder person (%): %', holderPerson.uuid, holderPerson;
+    raise notice '- using contact (%): %', contact.uuid, contact;
+    insert
+        into hs_office_relationship (uuid, relanchoruuid, relholderuuid, reltype, contactUuid)
+        values (uuid_generate_v4(), anchorPerson.uuid, holderPerson.uuid, relationshipType, contact.uuid);
+end; $$;
+--//
+
+/*
+    Creates a range of test relationship for mass data generation.
+ */
+create or replace procedure createHsOfficeRelationshipTestData(
+    startCount integer,  -- count of auto generated rows before the run
+    endCount integer     -- count of auto generated rows after the run
+)
+    language plpgsql as $$
+declare
+    person hs_office_person;
+    contact hs_office_contact;
+begin
+    for t in startCount..endCount
+        loop
+            select p.* from hs_office_person p where tradeName = intToVarChar(t, 4) into person;
+            select c.* from hs_office_contact c where c.label = intToVarChar(t, 4) || '#' || t into contact;
+
+            call createHsOfficeRelationshipTestData(person.uuid, contact.uuid, 'SOLE_AGENT');
+            commit;
+        end loop;
+end; $$;
+--//
+
+
+-- ============================================================================
+--changeset hs-office-relationship-TEST-DATA-GENERATION:1 –context=dev,tc endDelimiter:--//
+-- ----------------------------------------------------------------------------
+
+do language plpgsql $$
+    begin
+        call createHsOfficeRelationshipTestData('First Impressions GmbH', 'Smith', 'SOLE_AGENT', 'first contact');
+
+        call createHsOfficeRelationshipTestData('Rockshop e.K.', 'Smith', 'SOLE_AGENT', 'second contact');
+
+        call createHsOfficeRelationshipTestData('Ostfriesische Kuhhandel OHG', 'Smith', 'SOLE_AGENT', 'third contact');
+    end;
+$$;
+--//
diff --git a/src/main/resources/db/changelog/db.changelog-master.yaml b/src/main/resources/db/changelog/db.changelog-master.yaml
index c62f182e..1ed2aa8c 100644
--- a/src/main/resources/db/changelog/db.changelog-master.yaml
+++ b/src/main/resources/db/changelog/db.changelog-master.yaml
@@ -65,3 +65,9 @@ databaseChangeLog:
         file: db/changelog/223-hs-office-partner-rbac.sql
     - include:
         file: db/changelog/228-hs-office-partner-test-data.sql
+    - include:
+        file: db/changelog/230-hs-office-relationship.sql
+    - include:
+        file: db/changelog/233-hs-office-relationship-rbac.sql
+    - include:
+        file: db/changelog/238-hs-office-relationship-test-data.sql
diff --git a/src/test/java/net/hostsharing/hsadminng/MapperUnitTest.java b/src/test/java/net/hostsharing/hsadminng/MapperUnitTest.java
new file mode 100644
index 00000000..b23e6c3c
--- /dev/null
+++ b/src/test/java/net/hostsharing/hsadminng/MapperUnitTest.java
@@ -0,0 +1,57 @@
+package net.hostsharing.hsadminng;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.*;
+
+class MapperUnitTest {
+
+    @Getter
+    @Setter
+    @NoArgsConstructor
+    @AllArgsConstructor
+    public static class SourceBean {
+        private String a;
+        private String b;
+    }
+
+    @Getter
+    @Setter
+    @NoArgsConstructor
+    @AllArgsConstructor
+    public static class TargetBean {
+        private String a;
+        private String b;
+        private String c;
+    }
+
+    @Test
+    void mapsNullBeanToNull() {
+        final SourceBean givenSource = null;
+        final var result = Mapper.map(givenSource, TargetBean.class, (s, t) -> { fail("should not have been called"); });
+        assertThat(result).isNull();
+    }
+
+    @Test
+    void mapsBean() {
+        final SourceBean givenSource = new SourceBean("1234", "Text");
+        final var result = Mapper.map(givenSource, TargetBean.class, null);
+        assertThat(result).usingRecursiveComparison().isEqualTo(
+                new TargetBean("1234", "Text", null)
+        );
+    }
+
+    @Test
+    void mapsBeanWithPostmapper() {
+        final SourceBean givenSource = new SourceBean("1234", "Text");
+        final var result = Mapper.map(givenSource, TargetBean.class, (s, t) -> { t.setC("Extra"); });
+        assertThat(result).usingRecursiveComparison().isEqualTo(
+                new TargetBean("1234", "Text", "Extra")
+        );
+    }
+}
diff --git a/src/test/java/net/hostsharing/hsadminng/StringifyUnitTest.java b/src/test/java/net/hostsharing/hsadminng/StringifyUnitTest.java
new file mode 100644
index 00000000..e7e22e6b
--- /dev/null
+++ b/src/test/java/net/hostsharing/hsadminng/StringifyUnitTest.java
@@ -0,0 +1,99 @@
+package net.hostsharing.hsadminng;
+
+import lombok.*;
+import lombok.experimental.FieldNameConstants;
+import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactEntity;
+import org.junit.jupiter.api.Test;
+
+import javax.persistence.Column;
+import javax.persistence.Entity;
+import javax.persistence.Id;
+import javax.persistence.Table;
+import java.util.UUID;
+
+import static net.hostsharing.hsadminng.Stringify.stringify;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.*;
+
+class StringifyUnitTest {
+
+    @Getter
+    @Setter
+    @Builder
+    @NoArgsConstructor
+    @AllArgsConstructor
+    @FieldNameConstants
+    public static class TestBean implements Stringifyable {
+
+        private static Stringify<TestBean> toString = stringify(TestBean.class, "bean")
+                .withProp(TestBean.Fields.label, TestBean::getLabel)
+                .withProp(TestBean.Fields.content, TestBean::getContent)
+                .withProp(TestBean.Fields.active, TestBean::isActive);
+
+        private UUID uuid;
+
+        private String label;
+
+        private SubBean content;
+
+        private boolean active;
+
+        @Override
+        public String toString() {
+            return toString.apply(this);
+        }
+
+        @Override
+        public String toShortString() {
+            return label;
+        }
+    }
+
+    @Getter
+    @Setter
+    @Builder
+    @NoArgsConstructor
+    @AllArgsConstructor
+    @FieldNameConstants
+    public static class SubBean implements Stringifyable {
+
+        private static Stringify<SubBean> toString = stringify(SubBean.class)
+                .withProp(SubBean.Fields.key, SubBean::getKey)
+                .withProp(Fields.value, SubBean::getValue);
+
+        private String key;
+        private Integer value;
+
+        @Override
+        public String toString() {
+            return toString.apply(this);
+        }
+
+        @Override
+        public String toShortString() {
+            return key + ":" + value;
+        }
+    }
+
+    @Test
+    void stringifyWhenAllPropsHaveValues() {
+        final var given = new TestBean(UUID.randomUUID(), "some label",
+                new SubBean("some content", 1234), false);
+        final var result = given.toString();
+        assertThat(result).isEqualTo("bean(label='some label', content='some content:1234', active=false)");
+    }
+
+    @Test
+    void stringifyWhenAllNullablePropsHaveNulValues() {
+        final var given = new TestBean();
+        final var result = given.toString();
+        assertThat(result).isEqualTo("bean(active=false)");
+    }
+
+    @Test
+    void stringifyWithoutExplicitNameUsesSimpleClassName() {
+        final var given = new SubBean("some key", 1234);
+        final var result = given.toString();
+        assertThat(result).isEqualTo("SubBean(key='some key', value=1234)");
+    }
+}
diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactControllerAcceptanceTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactControllerAcceptanceTest.java
index 8bf0c71b..b018b9fb 100644
--- a/src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactControllerAcceptanceTest.java
+++ b/src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactControllerAcceptanceTest.java
@@ -74,6 +74,7 @@ class HsOfficeContactControllerAcceptanceTest {
                             { "label": "forth contact" },
                             { "label": "fifth contact" },
                             { "label": "sixth contact" },
+                            { "label": "seventh contact" },
                             { "label": "eighth contact" },
                             { "label": "ninth contact" },
                             { "label": "tenth contact" },
@@ -173,7 +174,7 @@ class HsOfficeContactControllerAcceptanceTest {
 
             RestAssured // @formatter:off
                 .given()
-                    .header("current-user", "customer-admin@firstcontact.example.com")
+                    .header("current-user", "contact-admin@firstcontact.example.com")
                     .port(port)
                 .when()
                     .get("http://localhost/api/hs/office/contacts/" + givenContactUuid)
@@ -183,7 +184,7 @@ class HsOfficeContactControllerAcceptanceTest {
                     .body("", lenientlyEquals("""
                     {
                          "label": "first contact",
-                         "emailAddresses": "customer-admin@firstcontact.example.com",
+                         "emailAddresses": "contact-admin@firstcontact.example.com",
                          "phoneNumbers": "+49 123 1234567"
                      }
                     """)); // @formatter:on
diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactEntityUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactEntityUnitTest.java
new file mode 100644
index 00000000..8f779b5b
--- /dev/null
+++ b/src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactEntityUnitTest.java
@@ -0,0 +1,21 @@
+package net.hostsharing.hsadminng.hs.office.contact;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class HsOfficeContactEntityUnitTest {
+
+    @Test
+    void toStringReturnsNullForNullContact() {
+        final HsOfficeContactEntity givenContact = null;
+      assertThat("" + givenContact).isEqualTo("null");
+    }
+
+    @Test
+    void toStringReturnsLabel() {
+        final var givenContact = HsOfficeContactEntity.builder().label("given label").build();
+        assertThat("" + givenContact).isEqualTo("contact(label='given label')");
+    }
+
+}
diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerControllerAcceptanceTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerControllerAcceptanceTest.java
index f600dfe9..73a6d0c5 100644
--- a/src/test/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerControllerAcceptanceTest.java
+++ b/src/test/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerControllerAcceptanceTest.java
@@ -244,7 +244,7 @@ class HsOfficePartnerControllerAcceptanceTest {
 
             RestAssured // @formatter:off
                 .given()
-                    .header("current-user", "customer-admin@firstcontact.example.com")
+                    .header("current-user", "contact-admin@firstcontact.example.com")
                     .port(port)
                 .when()
                     .get("http://localhost/api/hs/office/partners/" + givenPartnerUuid)
@@ -390,7 +390,7 @@ class HsOfficePartnerControllerAcceptanceTest {
 
             RestAssured // @formatter:off
                 .given()
-                    .header("current-user", "customer-admin@forthcontact.example.com")
+                    .header("current-user", "contact-admin@forthcontact.example.com")
                     .port(port)
                 .when()
                     .delete("http://localhost/api/hs/office/partners/" + givenPartner.getUuid())
diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerEntityPatcherUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerEntityPatcherUnitTest.java
index 5d80f623..46f51001 100644
--- a/src/test/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerEntityPatcherUnitTest.java
+++ b/src/test/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerEntityPatcherUnitTest.java
@@ -56,6 +56,7 @@ class HsOfficePartnerEntityPatcherUnitTest extends PatchUnitTestBase<
         lenient().when(em.getReference(eq(HsOfficePersonEntity.class), any())).thenAnswer(invocation ->
                 HsOfficePersonEntity.builder().uuid(invocation.getArgument(1)).build());
     }
+
     @Override
     protected HsOfficePartnerEntity newInitialEntity() {
         final var entity = new HsOfficePartnerEntity();
diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerRepositoryIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerRepositoryIntegrationTest.java
index ba63488a..9917e9d5 100644
--- a/src/test/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerRepositoryIntegrationTest.java
+++ b/src/test/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerRepositoryIntegrationTest.java
@@ -393,9 +393,6 @@ class HsOfficePartnerRepositoryIntegrationTest extends ContextBasedTest {
         context("superuser-alex@hostsharing.net", null);
         tempPartners.forEach(tempPartner -> {
             System.out.println("DELETING temporary partner: " + tempPartner.getDisplayName());
-            if ( tempPartner.getContact().getLabel().equals("sixth contact")) {
-                toString();
-            }
             partnerRepo.deleteByUuid(tempPartner.getUuid());
         });
     }
diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonControllerAcceptanceTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonControllerAcceptanceTest.java
index ef9e00da..4c56dde4 100644
--- a/src/test/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonControllerAcceptanceTest.java
+++ b/src/test/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonControllerAcceptanceTest.java
@@ -70,35 +70,47 @@ class HsOfficePersonControllerAcceptanceTest {
                     .body("", lenientlyEquals("""
                         [
                              {
-                                 "personType": "JOINT_REPRESENTATION",
-                                 "tradeName": "Erben Bessler",
-                                 "givenName": "Bessler",
-                                 "familyName": "Mel"
-                             },
-                             {
-                                 "personType": "LEGAL",
-                                 "tradeName": "First Impressions GmbH",
-                                 "givenName": null,
-                                 "familyName": null
-                             },
-                             {
-                                 "personType": "SOLE_REPRESENTATION",
-                                 "tradeName": "Ostfriesische Kuhhandel OHG",
-                                 "givenName": null,
-                                 "familyName": null
-                             },
-                             {
-                                 "personType": "NATURAL",
-                                 "tradeName": null,
-                                 "givenName": "Smith",
-                                 "familyName": "Peter"
-                             },
-                             {
-                                 "personType": "LEGAL",
-                                 "tradeName": "Rockshop e.K.",
-                                 "givenName": "Miller",
-                                 "familyName": "Sandra"
-                             }
+                                  "personType": "NATURAL",
+                                  "tradeName": null,
+                                  "givenName": "Anita",
+                                  "familyName": "Bessler"
+                              },
+                              {
+                                  "personType": "JOINT_REPRESENTATION",
+                                  "tradeName": "Erben Bessler",
+                                  "givenName": "Bessler",
+                                  "familyName": "Mel"
+                              },
+                              {
+                                  "personType": "LEGAL",
+                                  "tradeName": "First Impressions GmbH",
+                                  "givenName": null,
+                                  "familyName": null
+                              },
+                              {
+                                  "personType": "SOLE_REPRESENTATION",
+                                  "tradeName": "Ostfriesische Kuhhandel OHG",
+                                  "givenName": null,
+                                  "familyName": null
+                              },
+                              {
+                                  "personType": "LEGAL",
+                                  "tradeName": "Rockshop e.K.",
+                                  "givenName": "Miller",
+                                  "familyName": "Sandra"
+                              },
+                              {
+                                  "personType": "NATURAL",
+                                  "tradeName": null,
+                                  "givenName": "Peter",
+                                  "familyName": "Smith"
+                              },
+                              {
+                                  "personType": "NATURAL",
+                                  "tradeName": null,
+                                  "givenName": "Paul",
+                                  "familyName": "Winkler"
+                              }
                          ]
                         """
                             ));
diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonEntityUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonEntityUnitTest.java
index fe3f5074..fc7392b6 100644
--- a/src/test/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonEntityUnitTest.java
+++ b/src/test/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonEntityUnitTest.java
@@ -2,6 +2,8 @@ package net.hostsharing.hsadminng.hs.office.person;
 
 import org.junit.jupiter.api.Test;
 
+import java.util.UUID;
+
 import static org.assertj.core.api.Assertions.assertThat;
 
 class HsOfficePersonEntityUnitTest {
@@ -29,4 +31,55 @@ class HsOfficePersonEntityUnitTest {
         assertThat(actualDisplay).isEqualTo("some family name, some given name");
     }
 
+    @Test
+    void toShortStringWithTradeNameReturnsTradeName() {
+        final var givenPersonEntity = HsOfficePersonEntity.builder()
+                .tradeName("some trade name")
+                .familyName("some family name")
+                .givenName("some given name")
+                .build();
+
+        final var actualDisplay = givenPersonEntity.toShortString();
+
+        assertThat(actualDisplay).isEqualTo("some trade name");
+    }
+
+    @Test
+    void toShortStringWithoutTradeNameReturnsFamilyAndGivenName() {
+        final var givenPersonEntity = HsOfficePersonEntity.builder()
+                .familyName("some family name")
+                .givenName("some given name")
+                .build();
+
+        final var actualDisplay = givenPersonEntity.toShortString();
+
+        assertThat(actualDisplay).isEqualTo("some family name, some given name");
+    }
+
+    @Test
+    void toStringWithAllFieldsReturnsAllButUuid() {
+        final var givenPersonEntity = HsOfficePersonEntity.builder()
+                .uuid(UUID.randomUUID())
+                .personType(HsOfficePersonType.NATURAL)
+                .tradeName("some trade name")
+                .familyName("some family name")
+                .givenName("some given name")
+                .build();
+
+        final var actualDisplay = givenPersonEntity.toString();
+
+        assertThat(actualDisplay).isEqualTo("person(personType='NATURAL', tradeName='some trade name', familyName='some family name', givenName='some given name')");
+    }
+
+    @Test
+    void toStringSkipsNullFields() {
+        final var givenPersonEntity = HsOfficePersonEntity.builder()
+                .familyName("some family name")
+                .givenName("some given name")
+                .build();
+
+        final var actualDisplay = givenPersonEntity.toString();
+
+        assertThat(actualDisplay).isEqualTo("person(familyName='some family name', givenName='some given name')");
+    }
 }
diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonRepositoryIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonRepositoryIntegrationTest.java
index 733b0ca9..1280f595 100644
--- a/src/test/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonRepositoryIntegrationTest.java
+++ b/src/test/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonRepositoryIntegrationTest.java
@@ -143,7 +143,7 @@ class HsOfficePersonRepositoryIntegrationTest extends ContextBasedTest {
             // then
             allThesePersonsAreReturned(
                     result,
-                    "Peter, Smith",
+                    "Smith, Peter",
                     "Rockshop e.K.",
                     "Ostfriesische Kuhhandel OHG",
                     "Erben Bessler");
diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipControllerAcceptanceTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipControllerAcceptanceTest.java
new file mode 100644
index 00000000..79ccbc71
--- /dev/null
+++ b/src/test/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipControllerAcceptanceTest.java
@@ -0,0 +1,510 @@
+package net.hostsharing.hsadminng.hs.office.relationship;
+
+import io.restassured.RestAssured;
+import io.restassured.http.ContentType;
+import net.hostsharing.hsadminng.Accepts;
+import net.hostsharing.hsadminng.HsadminNgApplication;
+import net.hostsharing.hsadminng.context.Context;
+import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRepository;
+import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeRelationshipTypeResource;
+import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRepository;
+import net.hostsharing.test.JpaAttempt;
+import org.json.JSONException;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.web.server.LocalServerPort;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.HashSet;
+import java.util.Set;
+import java.util.UUID;
+
+import static net.hostsharing.test.IsValidUuidMatcher.isUuidValid;
+import static net.hostsharing.test.JsonMatcher.lenientlyEquals;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assumptions.assumeThat;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.startsWith;
+
+@SpringBootTest(
+        webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
+        classes = { HsadminNgApplication.class, JpaAttempt.class }
+)
+@Transactional
+class HsOfficeRelationshipControllerAcceptanceTest {
+
+    @LocalServerPort
+    private Integer port;
+
+    @Autowired
+    Context context;
+
+    @Autowired
+    Context contextMock;
+
+    @Autowired
+    HsOfficeRelationshipRepository relationshipRepo;
+
+    @Autowired
+    HsOfficePersonRepository personRepo;
+
+    @Autowired
+    HsOfficeContactRepository contactRepo;
+
+    @Autowired
+    JpaAttempt jpaAttempt;
+
+    Set<UUID> tempRelationshipUuids = new HashSet<>();
+
+    @Nested
+    @Accepts({ "Relationship:F(Find)" })
+    class ListRelationships {
+
+        @Test
+        void globalAdmin_withoutAssumedRoles_canViewAllRelationshipsOfGivenPersonAndType_ifNoCriteriaGiven() throws JSONException {
+
+            // given
+            context.define("superuser-alex@hostsharing.net");
+            final var givenPerson = personRepo.findPersonByOptionalNameLike("Smith").get(0);
+
+            RestAssured // @formatter:off
+                .given()
+                    .header("current-user", "superuser-alex@hostsharing.net")
+                    .port(port)
+                .when()
+                    .get("http://localhost/api/hs/office/relationships?personUuid=%s&relationshipType=%s"
+                            .formatted(givenPerson.getUuid(), HsOfficeRelationshipTypeResource.SOLE_AGENT))
+                .then().log().all().assertThat()
+                    .statusCode(200)
+                    .contentType("application/json")
+                    .body("", lenientlyEquals("""
+                    [
+                         {
+                             "relAnchor": {
+                                 "personType": "SOLE_REPRESENTATION",
+                                 "tradeName": "Ostfriesische Kuhhandel OHG"
+                             },
+                             "relHolder": {
+                                 "personType": "NATURAL",
+                                 "givenName": "Peter",
+                                 "familyName": "Smith"
+                             },
+                             "relType": "SOLE_AGENT",
+                             "contact": { "label": "third contact" }
+                         },
+                         {
+                             "relAnchor": {
+                                 "personType": "LEGAL",
+                                 "tradeName": "Rockshop e.K.",
+                                 "givenName": "Miller",
+                                 "familyName": "Sandra"
+                             },
+                             "relHolder": {
+                                 "personType": "NATURAL",
+                                 "givenName": "Peter",
+                                 "familyName": "Smith"
+                             },
+                             "relType": "SOLE_AGENT",
+                             "contact": { "label": "second contact" }
+                         },
+                         {
+                             "relAnchor": {
+                                 "personType": "LEGAL",
+                                 "tradeName": "First Impressions GmbH"
+                             },
+                             "relHolder": {
+                                 "personType": "NATURAL",
+                                 "tradeName": null,
+                                 "givenName": "Peter",
+                                 "familyName": "Smith"
+                             },
+                             "relType": "SOLE_AGENT",
+                             "contact": { "label": "first contact" }
+                         }
+                     ]
+                    """));
+                // @formatter:on
+        }
+    }
+
+    @Nested
+    @Accepts({ "Relationship:C(Create)" })
+    class AddRelationship {
+
+        @Test
+        void globalAdmin_withoutAssumedRole_canAddRelationship() {
+
+            context.define("superuser-alex@hostsharing.net");
+            final var givenAnchorPerson = personRepo.findPersonByOptionalNameLike("Ostfriesische").get(0);
+            final var givenHolderPerson = personRepo.findPersonByOptionalNameLike("Paul").get(0);
+            final var givenContact = contactRepo.findContactByOptionalLabelLike("forth").get(0);
+
+            final var location = RestAssured // @formatter:off
+                    .given()
+                        .header("current-user", "superuser-alex@hostsharing.net")
+                        .contentType(ContentType.JSON)
+                        .body("""
+                               {
+                                   "relType": "%s",
+                                   "relAnchorUuid": "%s",
+                                   "relHolderUuid": "%s",
+                                   "contactUuid": "%s"
+                                 }
+                            """.formatted(
+                                HsOfficeRelationshipTypeResource.ACCOUNTING_CONTACT,
+                                givenAnchorPerson.getUuid(),
+                                givenHolderPerson.getUuid(),
+                                givenContact.getUuid()))
+                        .port(port)
+                    .when()
+                        .post("http://localhost/api/hs/office/relationships")
+                    .then().log().all().assertThat()
+                        .statusCode(201)
+                        .contentType(ContentType.JSON)
+                        .body("uuid", isUuidValid())
+                        .body("relType", is("ACCOUNTING_CONTACT"))
+                        .body("relAnchor.tradeName", is("Ostfriesische Kuhhandel OHG"))
+                        .body("relHolder.givenName", is("Paul"))
+                        .body("contact.label", is("forth contact"))
+                        .header("Location", startsWith("http://localhost"))
+                    .extract().header("Location");  // @formatter:on
+
+            // finally, the new relationship can be accessed under the generated UUID
+            final var newUserUuid = toCleanup(UUID.fromString(
+                    location.substring(location.lastIndexOf('/') + 1)));
+            assertThat(newUserUuid).isNotNull();
+        }
+
+        @Test
+        void globalAdmin_canNotAddRelationship_ifAnchorPersonDoesNotExist() {
+
+            context.define("superuser-alex@hostsharing.net");
+            final var givenAnchorPersonUuid = UUID.fromString("3fa85f64-5717-4562-b3fc-2c963f66afa6");
+            final var givenHolderPerson = personRepo.findPersonByOptionalNameLike("Smith").get(0);
+            final var givenContact = contactRepo.findContactByOptionalLabelLike("forth").get(0);
+
+            final var location = RestAssured // @formatter:off
+                .given()
+                    .header("current-user", "superuser-alex@hostsharing.net")
+                    .contentType(ContentType.JSON)
+                    .body("""
+                               {
+                                   "relType": "%s",
+                                   "relAnchorUuid": "%s",
+                                   "relHolderUuid": "%s",
+                                   "contactUuid": "%s"
+                                 }
+                            """.formatted(
+                            HsOfficeRelationshipTypeResource.ACCOUNTING_CONTACT,
+                            givenAnchorPersonUuid,
+                            givenHolderPerson.getUuid(),
+                            givenContact.getUuid()))
+                    .port(port)
+                .when()
+                    .post("http://localhost/api/hs/office/relationships")
+                .then().log().all().assertThat()
+                    .statusCode(404)
+                    .body("message", is("cannot find relAnchorUuid 3fa85f64-5717-4562-b3fc-2c963f66afa6"));
+            // @formatter:on
+        }
+
+        @Test
+        void globalAdmin_canNotAddRelationship_ifHolderPersonDoesNotExist() {
+
+            context.define("superuser-alex@hostsharing.net");
+            final var givenAnchorPerson = personRepo.findPersonByOptionalNameLike("Ostfriesische").get(0);
+            final var givenHolderPersonUuid = UUID.fromString("3fa85f64-5717-4562-b3fc-2c963f66afa6");
+            final var givenContact = contactRepo.findContactByOptionalLabelLike("forth").get(0);
+
+            final var location = RestAssured // @formatter:off
+                .given()
+                    .header("current-user", "superuser-alex@hostsharing.net")
+                    .contentType(ContentType.JSON)
+                    .body("""
+                               {
+                                   "relType": "%s",
+                                   "relAnchorUuid": "%s",
+                                   "relHolderUuid": "%s",
+                                   "contactUuid": "%s"
+                                 }
+                            """.formatted(
+                            HsOfficeRelationshipTypeResource.ACCOUNTING_CONTACT,
+                            givenAnchorPerson.getUuid(),
+                            givenHolderPersonUuid,
+                            givenContact.getUuid()))
+                    .port(port)
+                .when()
+                    .post("http://localhost/api/hs/office/relationships")
+                .then().log().all().assertThat()
+                    .statusCode(404)
+                    .body("message", is("cannot find relHolderUuid 3fa85f64-5717-4562-b3fc-2c963f66afa6"));
+            // @formatter:on
+        }
+
+        @Test
+        void globalAdmin_canNotAddRelationship_ifContactDoesNotExist() {
+
+            context.define("superuser-alex@hostsharing.net");
+            final var givenAnchorPerson = personRepo.findPersonByOptionalNameLike("Ostfriesische").get(0);
+            final var givenHolderPerson = personRepo.findPersonByOptionalNameLike("Paul").get(0);
+            final var givenContactUuid = UUID.fromString("3fa85f64-5717-4562-b3fc-2c963f66afa6");
+
+            final var location = RestAssured // @formatter:off
+                .given()
+                    .header("current-user", "superuser-alex@hostsharing.net")
+                    .contentType(ContentType.JSON)
+                    .body("""
+                           {
+                               "relType": "%s",
+                               "relAnchorUuid": "%s",
+                               "relHolderUuid": "%s",
+                               "contactUuid": "%s"
+                             }
+                            """.formatted(
+                                    HsOfficeRelationshipTypeResource.ACCOUNTING_CONTACT,
+                                    givenAnchorPerson.getUuid(),
+                                    givenHolderPerson.getUuid(),
+                                    givenContactUuid))
+                    .port(port)
+                .when()
+                    .post("http://localhost/api/hs/office/relationships")
+                .then().log().all().assertThat()
+                    .statusCode(404)
+                    .body("message", is("cannot find contactUuid 3fa85f64-5717-4562-b3fc-2c963f66afa6"));
+            // @formatter:on
+        }
+    }
+
+    @Nested
+    @Accepts({ "Relationship:R(Read)" })
+    class GetRelationship {
+
+        @Test
+        void globalAdmin_withoutAssumedRole_canGetArbitraryRelationship() {
+            context.define("superuser-alex@hostsharing.net");
+            final UUID givenRelationshipUuid = findRelationship("First", "Smith").getUuid();
+
+            RestAssured // @formatter:off
+                .given()
+                    .header("current-user", "superuser-alex@hostsharing.net")
+                    .port(port)
+                .when()
+                    .get("http://localhost/api/hs/office/relationships/" + givenRelationshipUuid)
+                .then().log().body().assertThat()
+                    .statusCode(200)
+                    .contentType("application/json")
+                    .body("", lenientlyEquals("""
+                    {
+                        "relAnchor": { "tradeName": "First Impressions GmbH" },
+                        "relHolder": { "familyName": "Smith" },
+                        "contact": { "label": "first contact" }
+                    }
+                    """)); // @formatter:on
+        }
+
+        @Test
+        @Accepts({ "Relationship:X(Access Control)" })
+        void normalUser_canNotGetUnrelatedRelationship() {
+            context.define("superuser-alex@hostsharing.net");
+            final UUID givenRelationshipUuid = findRelationship("First", "Smith").getUuid();
+
+            RestAssured // @formatter:off
+                .given()
+                    .header("current-user", "selfregistered-user-drew@hostsharing.org")
+                    .port(port)
+                .when()
+                    .get("http://localhost/api/hs/office/relationships/" + givenRelationshipUuid)
+                .then().log().body().assertThat()
+                    .statusCode(404); // @formatter:on
+        }
+
+        @Test
+        @Accepts({ "Relationship:X(Access Control)" })
+        void contactAdminUser_canGetRelatedRelationship() {
+            context.define("superuser-alex@hostsharing.net");
+            final var givenRelationship = findRelationship("First", "Smith");
+            assertThat(givenRelationship.getContact().getLabel()).isEqualTo("first contact");
+
+            RestAssured // @formatter:off
+                .given()
+                    .header("current-user", "contact-admin@firstcontact.example.com")
+                    .port(port)
+                .when()
+                    .get("http://localhost/api/hs/office/relationships/" + givenRelationship.getUuid())
+                .then().log().body().assertThat()
+                    .statusCode(200)
+                    .contentType("application/json")
+                    .body("", lenientlyEquals("""
+                    {
+                        "relAnchor": { "tradeName": "First Impressions GmbH" },
+                        "relHolder": { "familyName": "Smith" },
+                        "contact": { "label": "first contact" }
+                    }
+                    """)); // @formatter:on
+        }
+    }
+
+    private HsOfficeRelationshipEntity findRelationship(
+            final String anchorPersonName,
+            final String holderPersoneName) {
+        final var anchorPersonUuid = personRepo.findPersonByOptionalNameLike(anchorPersonName).get(0).getUuid();
+        final var holderPersonUuid = personRepo.findPersonByOptionalNameLike(holderPersoneName).get(0).getUuid();
+        final var givenRelationship = relationshipRepo
+                .findRelationshipRelatedToPersonUuid(anchorPersonUuid)
+                .stream()
+                .filter(r -> r.getRelHolder().getUuid().equals(holderPersonUuid))
+                .findFirst().orElseThrow();
+        return givenRelationship;
+    }
+
+    @Nested
+    @Accepts({ "Relationship:U(Update)" })
+    class PatchRelationship {
+
+        @Test
+        void globalAdmin_withoutAssumedRole_canPatchContactOfArbitraryRelationship() {
+
+            context.define("superuser-alex@hostsharing.net");
+            final var givenRelationship = givenSomeTemporaryRelationshipBessler();
+            assertThat(givenRelationship.getContact().getLabel()).isEqualTo("seventh contact");
+            final var givenContact = contactRepo.findContactByOptionalLabelLike("forth").get(0);
+
+            final var location = RestAssured // @formatter:off
+                .given()
+                    .header("current-user", "superuser-alex@hostsharing.net")
+                    .contentType(ContentType.JSON)
+                    .body("""
+                           {
+                              "contactUuid": "%s"
+                           }
+                            """.formatted(givenContact.getUuid()))
+                    .port(port)
+                .when()
+                    .patch("http://localhost/api/hs/office/relationships/" + givenRelationship.getUuid())
+                .then().log().all().assertThat()
+                    .statusCode(200)
+                    .contentType(ContentType.JSON)
+                    .body("uuid", isUuidValid())
+                    .body("relType", is("JOINT_AGENT"))
+                    .body("relAnchor.tradeName", is("Erben Bessler"))
+                    .body("relHolder.familyName", is("Winkler"))
+                    .body("contact.label", is("forth contact"));
+                // @formatter:on
+
+            // finally, the relationship is actually updated
+            context.define("superuser-alex@hostsharing.net");
+            assertThat(relationshipRepo.findByUuid(givenRelationship.getUuid())).isPresent().get()
+                    .matches(rel -> {
+                        assertThat(rel.getRelAnchor().getTradeName()).contains("Bessler");
+                        assertThat(rel.getRelHolder().getFamilyName()).contains("Winkler");
+                        assertThat(rel.getContact().getLabel()).isEqualTo("forth contact");
+                        assertThat(rel.getRelType()).isEqualTo(HsOfficeRelationshipType.JOINT_AGENT);
+                        return true;
+                    });
+        }
+    }
+
+    @Nested
+    @Accepts({ "Relationship:D(Delete)" })
+    class DeleteRelationship {
+
+        @Test
+        void globalAdmin_withoutAssumedRole_canDeleteArbitraryRelationship() {
+            context.define("superuser-alex@hostsharing.net");
+            final var givenRelationship = givenSomeTemporaryRelationshipBessler();
+
+            RestAssured // @formatter:off
+                .given()
+                    .header("current-user", "superuser-alex@hostsharing.net")
+                    .port(port)
+                .when()
+                    .delete("http://localhost/api/hs/office/relationships/" + givenRelationship.getUuid())
+                .then().log().body().assertThat()
+                    .statusCode(204); // @formatter:on
+
+            // then the given relationship is gone
+            assertThat(relationshipRepo.findByUuid(givenRelationship.getUuid())).isEmpty();
+        }
+
+        @Test
+        @Accepts({ "Relationship:X(Access Control)" })
+        void contactAdminUser_canNotDeleteRelatedRelationship() {
+            context.define("superuser-alex@hostsharing.net");
+            final var givenRelationship = givenSomeTemporaryRelationshipBessler();
+            assertThat(givenRelationship.getContact().getLabel()).isEqualTo("seventh contact");
+
+            RestAssured // @formatter:off
+                .given()
+                    .header("current-user", "contact-admin@seventhcontact.example.com")
+                    .port(port)
+                .when()
+                    .delete("http://localhost/api/hs/office/relationships/" + givenRelationship.getUuid())
+                .then().log().body().assertThat()
+                    .statusCode(403); // @formatter:on
+
+            // then the given relationship is still there
+            assertThat(relationshipRepo.findByUuid(givenRelationship.getUuid())).isNotEmpty();
+        }
+
+        @Test
+        @Accepts({ "Relationship:X(Access Control)" })
+        void normalUser_canNotDeleteUnrelatedRelationship() {
+            context.define("superuser-alex@hostsharing.net");
+            final var givenRelationship = givenSomeTemporaryRelationshipBessler();
+            assertThat(givenRelationship.getContact().getLabel()).isEqualTo("seventh contact");
+
+            RestAssured // @formatter:off
+                .given()
+                    .header("current-user", "selfregistered-user-drew@hostsharing.org")
+                    .port(port)
+                .when()
+                    .delete("http://localhost/api/hs/office/relationships/" + givenRelationship.getUuid())
+                .then().log().body().assertThat()
+                    .statusCode(404); // @formatter:on
+
+            // then the given relationship is still there
+            assertThat(relationshipRepo.findByUuid(givenRelationship.getUuid())).isNotEmpty();
+        }
+    }
+
+    private HsOfficeRelationshipEntity givenSomeTemporaryRelationshipBessler() {
+        return jpaAttempt.transacted(() -> {
+            context.define("superuser-alex@hostsharing.net");
+            final var givenAnchorPerson = personRepo.findPersonByOptionalNameLike("Erben Bessler").get(0);
+            final var givenHolderPerson = personRepo.findPersonByOptionalNameLike("Winkler").get(0);
+            final var givenContact = contactRepo.findContactByOptionalLabelLike("seventh contact").get(0);
+            final var newRelationship = HsOfficeRelationshipEntity.builder()
+                    .uuid(UUID.randomUUID())
+                    .relType(HsOfficeRelationshipType.JOINT_AGENT)
+                    .relAnchor(givenAnchorPerson)
+                    .relHolder(givenHolderPerson)
+                    .contact(givenContact)
+                    .build();
+
+            toCleanup(newRelationship.getUuid());
+
+            return relationshipRepo.save(newRelationship);
+        }).assertSuccessful().returnedValue();
+    }
+
+    private UUID toCleanup(final UUID tempRelationshipUuid) {
+        tempRelationshipUuids.add(tempRelationshipUuid);
+        return tempRelationshipUuid;
+    }
+
+    @AfterEach
+    void cleanup() {
+        tempRelationshipUuids.forEach(uuid -> {
+            jpaAttempt.transacted(() -> {
+                context.define("superuser-alex@hostsharing.net", null);
+                System.out.println("DELETING temporary relationship: " + uuid);
+                final var count = relationshipRepo.deleteByUuid(uuid);
+                System.out.println("DELETED temporary relationship: " + uuid + (count > 0 ? " successful" : " failed"));
+            });
+        });
+    }
+
+}
diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipEntityPatcherUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipEntityPatcherUnitTest.java
new file mode 100644
index 00000000..65ece28c
--- /dev/null
+++ b/src/test/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipEntityPatcherUnitTest.java
@@ -0,0 +1,90 @@
+package net.hostsharing.hsadminng.hs.office.relationship;
+
+import net.hostsharing.hsadminng.PatchUnitTestBase;
+import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactEntity;
+import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeRelationshipPatchResource;
+import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonEntity;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.TestInstance;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import javax.persistence.EntityManager;
+import java.util.UUID;
+import java.util.stream.Stream;
+
+import static org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.lenient;
+
+@TestInstance(PER_CLASS)
+@ExtendWith(MockitoExtension.class)
+class HsOfficeRelationshipEntityPatcherUnitTest extends PatchUnitTestBase<
+        HsOfficeRelationshipPatchResource,
+        HsOfficeRelationshipEntity
+        > {
+
+    static final UUID INITIAL_RELATIONSHIP_UUID = UUID.randomUUID();
+    static final UUID PATCHED_CONTACT_UUID = UUID.randomUUID();
+
+    @Mock
+    EntityManager em;
+
+    @BeforeEach
+    void initMocks() {
+        lenient().when(em.getReference(eq(HsOfficeContactEntity.class), any())).thenAnswer(invocation ->
+                HsOfficeContactEntity.builder().uuid(invocation.getArgument(1)).build());
+    }
+
+    final HsOfficePersonEntity givenInitialAnchorPerson = HsOfficePersonEntity.builder()
+            .uuid(UUID.randomUUID())
+            .build();
+    final HsOfficePersonEntity givenInitialHolderPerson = HsOfficePersonEntity.builder()
+            .uuid(UUID.randomUUID())
+            .build();
+    final HsOfficeContactEntity givenInitialContact = HsOfficeContactEntity.builder()
+            .uuid(UUID.randomUUID())
+            .build();
+
+    @Override
+    protected HsOfficeRelationshipEntity newInitialEntity() {
+        final var entity = new HsOfficeRelationshipEntity();
+        entity.setUuid(INITIAL_RELATIONSHIP_UUID);
+        entity.setRelType(HsOfficeRelationshipType.SOLE_AGENT);
+        entity.setRelAnchor(givenInitialAnchorPerson);
+        entity.setRelHolder(givenInitialHolderPerson);
+        entity.setContact(givenInitialContact);
+        return entity;
+    }
+
+    @Override
+    protected HsOfficeRelationshipPatchResource newPatchResource() {
+        return new HsOfficeRelationshipPatchResource();
+    }
+
+    @Override
+    protected HsOfficeRelationshipEntityPatcher createPatcher(final HsOfficeRelationshipEntity relationship) {
+        return new HsOfficeRelationshipEntityPatcher(em, relationship);
+    }
+
+    @Override
+    protected Stream<Property> propertyTestDescriptors() {
+        return Stream.of(
+                new JsonNullableProperty<>(
+                        "contact",
+                        HsOfficeRelationshipPatchResource::setContactUuid,
+                        PATCHED_CONTACT_UUID,
+                        HsOfficeRelationshipEntity::setContact,
+                        newContact(PATCHED_CONTACT_UUID))
+                        .notNullable()
+        );
+    }
+
+    static HsOfficeContactEntity newContact(final UUID uuid) {
+        final var newContact = new HsOfficeContactEntity();
+        newContact.setUuid(uuid);
+        return newContact;
+    }
+}
diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipRepositoryIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipRepositoryIntegrationTest.java
new file mode 100644
index 00000000..bfbfd4ff
--- /dev/null
+++ b/src/test/java/net/hostsharing/hsadminng/hs/office/relationship/HsOfficeRelationshipRepositoryIntegrationTest.java
@@ -0,0 +1,421 @@
+package net.hostsharing.hsadminng.hs.office.relationship;
+
+import net.hostsharing.hsadminng.context.Context;
+import net.hostsharing.hsadminng.context.ContextBasedTest;
+import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRepository;
+import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRepository;
+import net.hostsharing.hsadminng.rbac.rbacgrant.RawRbacGrantRepository;
+import net.hostsharing.hsadminng.rbac.rbacrole.RawRbacRoleRepository;
+import net.hostsharing.test.Array;
+import net.hostsharing.test.JpaAttempt;
+import org.junit.jupiter.api.AfterEach;
+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.orm.jpa.DataJpaTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.context.annotation.ComponentScan;
+import org.springframework.orm.jpa.JpaSystemException;
+import org.springframework.test.annotation.DirtiesContext;
+
+import javax.persistence.EntityManager;
+import javax.servlet.http.HttpServletRequest;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.UUID;
+
+import static net.hostsharing.hsadminng.rbac.rbacgrant.RawRbacGrantEntity.grantDisplaysOf;
+import static net.hostsharing.hsadminng.rbac.rbacrole.RawRbacRoleEntity.roleNamesOf;
+import static net.hostsharing.test.JpaAttempt.attempt;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assumptions.assumeThat;
+
+@DataJpaTest
+@ComponentScan(basePackageClasses = { HsOfficeRelationshipRepository.class, Context.class, JpaAttempt.class })
+@DirtiesContext
+class HsOfficeRelationshipRepositoryIntegrationTest extends ContextBasedTest {
+
+    @Autowired
+    HsOfficeRelationshipRepository relationshipRepo;
+
+    @Autowired
+    HsOfficePersonRepository personRepo;
+
+    @Autowired
+    HsOfficeContactRepository contactRepo;
+
+    @Autowired
+    RawRbacRoleRepository rawRoleRepo;
+
+    @Autowired
+    RawRbacGrantRepository rawGrantRepo;
+
+    @Autowired
+    EntityManager em;
+
+    @Autowired
+    JpaAttempt jpaAttempt;
+
+    @MockBean
+    HttpServletRequest request;
+
+    Set<HsOfficeRelationshipEntity> tempRelationships = new HashSet<>();
+
+    @Nested
+    class CreateRelationship {
+
+        @Test
+        public void testHostsharingAdmin_withoutAssumedRole_canCreateNewRelationship() {
+            // given
+            context("superuser-alex@hostsharing.net");
+            final var count = relationshipRepo.count();
+            final var givenAnchorPerson = personRepo.findPersonByOptionalNameLike("Bessler").get(0);
+            final var givenHolderPerson = personRepo.findPersonByOptionalNameLike("Anita").get(0);
+            final var givenContact = contactRepo.findContactByOptionalLabelLike("forth contact").get(0);
+
+            // when
+            final var result = attempt(em, () -> {
+                final var newRelationship = toCleanup(HsOfficeRelationshipEntity.builder()
+                        .uuid(UUID.randomUUID())
+                        .relAnchor(givenAnchorPerson)
+                        .relHolder(givenHolderPerson)
+                        .relType(HsOfficeRelationshipType.JOINT_AGENT)
+                        .contact(givenContact)
+                        .build());
+                return relationshipRepo.save(newRelationship);
+            });
+
+            // then
+            result.assertSuccessful();
+            assertThat(result.returnedValue()).isNotNull().extracting(HsOfficeRelationshipEntity::getUuid).isNotNull();
+            assertThatRelationshipIsPersisted(result.returnedValue());
+            assertThat(relationshipRepo.count()).isEqualTo(count + 1);
+        }
+
+        @Test
+        public void createsAndGrantsRoles() {
+            // given
+            context("superuser-alex@hostsharing.net");
+            final var initialRoleNames = roleNamesOf(rawRoleRepo.findAll());
+            final var initialGrantNames = grantDisplaysOf(rawGrantRepo.findAll());
+
+            // when
+            attempt(em, () -> {
+                final var givenAnchorPerson = personRepo.findPersonByOptionalNameLike("Bessler").get(0);
+                final var givenHolderPerson = personRepo.findPersonByOptionalNameLike("Anita").get(0);
+                final var givenContact = contactRepo.findContactByOptionalLabelLike("forth contact").get(0);
+                final var newRelationship = toCleanup(HsOfficeRelationshipEntity.builder()
+                        .uuid(UUID.randomUUID())
+                        .relAnchor(givenAnchorPerson)
+                        .relHolder(givenHolderPerson)
+                        .relType(HsOfficeRelationshipType.JOINT_AGENT)
+                        .contact(givenContact)
+                        .build());
+                return relationshipRepo.save(newRelationship);
+            });
+
+            // then
+            assertThat(roleNamesOf(rawRoleRepo.findAll())).containsExactlyInAnyOrder(Array.from(
+                    initialRoleNames,
+                    "hs_office_relationship#BesslerAnita-with-JOINT_AGENT-BesslerAnita.admin",
+                    "hs_office_relationship#BesslerAnita-with-JOINT_AGENT-BesslerAnita.owner",
+                    "hs_office_relationship#BesslerAnita-with-JOINT_AGENT-BesslerAnita.tenant"));
+            assertThat(grantDisplaysOf(rawGrantRepo.findAll())).containsExactlyInAnyOrder(Array.fromSkippingNull(
+                    initialGrantNames,
+
+                    "{ grant perm * on hs_office_relationship#BesslerAnita-with-JOINT_AGENT-BesslerAnita to role hs_office_relationship#BesslerAnita-with-JOINT_AGENT-BesslerAnita.owner by system and assume }",
+                    "{ grant role hs_office_relationship#BesslerAnita-with-JOINT_AGENT-BesslerAnita.owner to role global#global.admin by system and assume }",
+                    "{ grant role hs_office_relationship#BesslerAnita-with-JOINT_AGENT-BesslerAnita.owner to role hs_office_person#BesslerAnita.admin by system and assume }",
+
+                    "{ grant perm edit on hs_office_relationship#BesslerAnita-with-JOINT_AGENT-BesslerAnita to role hs_office_relationship#BesslerAnita-with-JOINT_AGENT-BesslerAnita.admin by system and assume }",
+                    "{ grant role hs_office_relationship#BesslerAnita-with-JOINT_AGENT-BesslerAnita.admin to role hs_office_relationship#BesslerAnita-with-JOINT_AGENT-BesslerAnita.owner by system and assume }",
+
+                    "{ grant perm view on hs_office_relationship#BesslerAnita-with-JOINT_AGENT-BesslerAnita to role hs_office_relationship#BesslerAnita-with-JOINT_AGENT-BesslerAnita.tenant by system and assume }",
+                    "{ grant role hs_office_relationship#BesslerAnita-with-JOINT_AGENT-BesslerAnita.tenant to role hs_office_contact#forthcontact.admin by system and assume }",
+                    "{ grant role hs_office_relationship#BesslerAnita-with-JOINT_AGENT-BesslerAnita.tenant to role hs_office_person#BesslerAnita.admin by system and assume }",
+
+                    "{ grant role hs_office_contact#forthcontact.tenant to role hs_office_relationship#BesslerAnita-with-JOINT_AGENT-BesslerAnita.tenant by system and assume }",
+                    "{ grant role hs_office_person#BesslerAnita.tenant to role hs_office_relationship#BesslerAnita-with-JOINT_AGENT-BesslerAnita.tenant by system and assume }",
+                    null)
+            );
+        }
+
+        private void assertThatRelationshipIsPersisted(final HsOfficeRelationshipEntity saved) {
+            final var found = relationshipRepo.findByUuid(saved.getUuid());
+            assertThat(found).isNotEmpty().get().usingRecursiveComparison().isEqualTo(saved);
+        }
+    }
+
+    @Nested
+    class FindAllRelationships {
+
+        @Test
+        public void globalAdmin_withoutAssumedRole_canViewAllRelationshipsOfArbitraryPerson() {
+            // given
+            context("superuser-alex@hostsharing.net");
+            final var person = personRepo.findPersonByOptionalNameLike("Smith").stream().findFirst().orElseThrow();
+
+            // when
+            final var result = relationshipRepo.findRelationshipRelatedToPersonUuid(person.getUuid());
+
+            // then
+            allTheseRelationshipsAreReturned(
+                    result,
+                    "rel(relAnchor='First Impressions GmbH', relType='SOLE_AGENT', relHolder='Smith, Peter', contact='first contact')",
+                    "rel(relAnchor='Ostfriesische Kuhhandel OHG', relType='SOLE_AGENT', relHolder='Smith, Peter', contact='third contact')",
+                    "rel(relAnchor='Rockshop e.K.', relType='SOLE_AGENT', relHolder='Smith, Peter', contact='second contact')");
+        }
+
+        @Test
+        public void normalUser_canViewRelationshipsOfOwnedPersons() {
+            // given:
+            context("person-FirstImpressionsGmbH@example.com");
+            final var person = personRepo.findPersonByOptionalNameLike("First").stream().findFirst().orElseThrow();
+
+            // when:
+            final var result = relationshipRepo.findRelationshipRelatedToPersonUuid(person.getUuid());
+
+            // then:
+            exactlyTheseRelationshipsAreReturned(
+                    result,
+                    "rel(relAnchor='First Impressions GmbH', relType='SOLE_AGENT', relHolder='Smith, Peter', contact='first contact')");
+        }
+    }
+
+    @Nested
+    class UpdateRelationship {
+
+        @Test
+        public void hostsharingAdmin_withoutAssumedRole_canUpdateContactOfArbitraryRelationship() {
+            // given
+            context("superuser-alex@hostsharing.net");
+            final var givenRelationship = givenSomeTemporaryRelationshipBessler(
+                    "Anita", "fifth contact");
+            assertThatRelationshipIsVisibleForUserWithRole(
+                    givenRelationship,
+                    "hs_office_person#ErbenBesslerMelBessler.admin");
+            assertThatRelationshipActuallyInDatabase(givenRelationship);
+            context("superuser-alex@hostsharing.net");
+            final var givenContact = contactRepo.findContactByOptionalLabelLike("sixth contact").get(0);
+
+            // when
+            final var result = jpaAttempt.transacted(() -> {
+                context("superuser-alex@hostsharing.net");
+                givenRelationship.setContact(givenContact);
+                return toCleanup(relationshipRepo.save(givenRelationship));
+            });
+
+            // then
+            result.assertSuccessful();
+            assertThat(result.returnedValue().getContact().getLabel()).isEqualTo("sixth contact");
+            assertThatRelationshipIsVisibleForUserWithRole(
+                    result.returnedValue(),
+                    "global#global.admin");
+            assertThatRelationshipIsVisibleForUserWithRole(
+                    result.returnedValue(),
+                    "hs_office_contact#sixthcontact.admin");
+
+            assertThatRelationshipIsNotVisibleForUserWithRole(
+                    result.returnedValue(),
+                    "hs_office_contact#fifthcontact.admin");
+
+            relationshipRepo.deleteByUuid(givenRelationship.getUuid());
+        }
+
+        @Test
+        public void relHolderAdmin_canNotUpdateRelatedRelationship() {
+            // given
+            context("superuser-alex@hostsharing.net");
+            final var givenRelationship = givenSomeTemporaryRelationshipBessler(
+                    "Anita", "eighth");
+            assertThatRelationshipIsVisibleForUserWithRole(
+                    givenRelationship,
+                    "hs_office_person#BesslerAnita.admin");
+            assertThatRelationshipActuallyInDatabase(givenRelationship);
+
+            // when
+            final var result = jpaAttempt.transacted(() -> {
+                context("superuser-alex@hostsharing.net", "hs_office_person#BesslerAnita.admin");
+                givenRelationship.setContact(null);
+                return relationshipRepo.save(givenRelationship);
+            });
+
+            // then
+            result.assertExceptionWithRootCauseMessage(JpaSystemException.class,
+                    "[403] Subject ", " is not allowed to update hs_office_relationship uuid");
+        }
+
+        @Test
+        public void contactAdmin_canNotUpdateRelatedRelationship() {
+            // given
+            context("superuser-alex@hostsharing.net");
+            final var givenRelationship = givenSomeTemporaryRelationshipBessler(
+                    "Anita", "ninth");
+            assertThatRelationshipIsVisibleForUserWithRole(
+                    givenRelationship,
+                    "hs_office_contact#ninthcontact.admin");
+            assertThatRelationshipActuallyInDatabase(givenRelationship);
+
+            // when
+            final var result = jpaAttempt.transacted(() -> {
+                context("superuser-alex@hostsharing.net", "hs_office_contact#ninthcontact.admin");
+                givenRelationship.setContact(null); // TODO
+                return relationshipRepo.save(givenRelationship);
+            });
+
+            // then
+            result.assertExceptionWithRootCauseMessage(JpaSystemException.class,
+                    "[403] Subject ", " is not allowed to update hs_office_relationship uuid");
+        }
+
+        private void assertThatRelationshipActuallyInDatabase(final HsOfficeRelationshipEntity saved) {
+            final var found = relationshipRepo.findByUuid(saved.getUuid());
+            assertThat(found).isNotEmpty().get().isNotSameAs(saved).usingRecursiveComparison().isEqualTo(saved);
+        }
+
+        private void assertThatRelationshipIsVisibleForUserWithRole(
+                final HsOfficeRelationshipEntity entity,
+                final String assumedRoles) {
+            jpaAttempt.transacted(() -> {
+                context("superuser-alex@hostsharing.net", assumedRoles);
+                assertThatRelationshipActuallyInDatabase(entity);
+            }).assertSuccessful();
+        }
+
+        private void assertThatRelationshipIsNotVisibleForUserWithRole(
+                final HsOfficeRelationshipEntity entity,
+                final String assumedRoles) {
+            jpaAttempt.transacted(() -> {
+                context("superuser-alex@hostsharing.net", assumedRoles);
+                final var found = relationshipRepo.findByUuid(entity.getUuid());
+                assertThat(found).isEmpty();
+            }).assertSuccessful();
+        }
+    }
+
+    @Nested
+    class DeleteByUuid {
+
+        @Test
+        public void globalAdmin_withoutAssumedRole_canDeleteAnyRelationship() {
+            // given
+            context("superuser-alex@hostsharing.net", null);
+            final var givenRelationship = givenSomeTemporaryRelationshipBessler(
+                    "Anita", "tenth");
+
+            // when
+            final var result = jpaAttempt.transacted(() -> {
+                context("superuser-alex@hostsharing.net");
+                relationshipRepo.deleteByUuid(givenRelationship.getUuid());
+            });
+
+            // then
+            result.assertSuccessful();
+            assertThat(jpaAttempt.transacted(() -> {
+                context("superuser-fran@hostsharing.net", null);
+                return relationshipRepo.findByUuid(givenRelationship.getUuid());
+            }).assertSuccessful().returnedValue()).isEmpty();
+        }
+
+        @Test
+        public void contactUser_canViewButNotDeleteTheirRelatedRelationship() {
+            // given
+            context("superuser-alex@hostsharing.net", null);
+            final var givenRelationship = givenSomeTemporaryRelationshipBessler(
+                    "Anita", "eleventh");
+
+            // when
+            final var result = jpaAttempt.transacted(() -> {
+                context("contact-admin@eleventhcontact.example.com");
+                assertThat(relationshipRepo.findByUuid(givenRelationship.getUuid())).isPresent();
+                relationshipRepo.deleteByUuid(givenRelationship.getUuid());
+            });
+
+            // then
+            result.assertExceptionWithRootCauseMessage(
+                    JpaSystemException.class,
+                    "[403] Subject ", " not allowed to delete hs_office_relationship");
+            assertThat(jpaAttempt.transacted(() -> {
+                context("superuser-alex@hostsharing.net");
+                return relationshipRepo.findByUuid(givenRelationship.getUuid());
+            }).assertSuccessful().returnedValue()).isPresent(); // still there
+        }
+
+        @Test
+        public void deletingARelationshipAlsoDeletesRelatedRolesAndGrants() {
+            // given
+            context("superuser-alex@hostsharing.net");
+            final var initialRoleNames = Array.from(roleNamesOf(rawRoleRepo.findAll()));
+            final var initialGrantNames = Array.from(grantDisplaysOf(rawGrantRepo.findAll()));
+            final var givenRelationship = givenSomeTemporaryRelationshipBessler(
+                    "Anita", "twelfth");
+            assertThat(rawRoleRepo.findAll().size()).as("unexpected number of roles created")
+                    .isEqualTo(initialRoleNames.length + 3);
+            assertThat(rawGrantRepo.findAll().size()).as("unexpected number of grants created")
+                    .isEqualTo(initialGrantNames.length + 12);
+
+            // when
+            final var result = jpaAttempt.transacted(() -> {
+                context("superuser-alex@hostsharing.net");
+                return relationshipRepo.deleteByUuid(givenRelationship.getUuid());
+            });
+
+            // then
+            result.assertSuccessful();
+            assertThat(result.returnedValue()).isEqualTo(1);
+            assertThat(roleNamesOf(rawRoleRepo.findAll())).containsExactlyInAnyOrder(initialRoleNames);
+            assertThat(grantDisplaysOf(rawGrantRepo.findAll())).containsExactlyInAnyOrder(initialGrantNames);
+        }
+    }
+
+    private HsOfficeRelationshipEntity givenSomeTemporaryRelationshipBessler(final String holderPerson, final String contact) {
+        return jpaAttempt.transacted(() -> {
+            context("superuser-alex@hostsharing.net");
+            final var givenAnchorPerson = personRepo.findPersonByOptionalNameLike("Erben Bessler").get(0);
+            final var givenHolderPerson = personRepo.findPersonByOptionalNameLike(holderPerson).get(0);
+            final var givenContact = contactRepo.findContactByOptionalLabelLike(contact).get(0);
+            final var newRelationship = HsOfficeRelationshipEntity.builder()
+                    .uuid(UUID.randomUUID())
+                    .relType(HsOfficeRelationshipType.JOINT_AGENT)
+                    .relAnchor(givenAnchorPerson)
+                    .relHolder(givenHolderPerson)
+                    .contact(givenContact)
+                    .build();
+
+            toCleanup(newRelationship);
+
+            return relationshipRepo.save(newRelationship);
+        }).assertSuccessful().returnedValue();
+    }
+
+    private HsOfficeRelationshipEntity toCleanup(final HsOfficeRelationshipEntity tempRelationship) {
+        tempRelationships.add(tempRelationship);
+        return tempRelationship;
+    }
+
+    @AfterEach
+    void cleanup() {
+        context("superuser-alex@hostsharing.net", null);
+        tempRelationships.forEach(tempRelationship -> {
+            System.out.println("DELETING temporary relationship: " + tempRelationship);
+            relationshipRepo.deleteByUuid(tempRelationship.getUuid());
+        });
+    }
+
+    void exactlyTheseRelationshipsAreReturned(
+            final List<HsOfficeRelationshipEntity> actualResult,
+            final String... relationshipNames) {
+        assertThat(actualResult)
+                .extracting(HsOfficeRelationshipEntity::toString)
+                .containsExactlyInAnyOrder(relationshipNames);
+    }
+
+    void allTheseRelationshipsAreReturned(
+            final List<HsOfficeRelationshipEntity> actualResult,
+            final String... relationshipNames) {
+        assertThat(actualResult)
+                .extracting(HsOfficeRelationshipEntity::toString)
+                .contains(relationshipNames);
+    }
+}
diff --git a/src/test/java/net/hostsharing/hsadminng/rbac/rbacgrant/RawRbacGrantEntity.java b/src/test/java/net/hostsharing/hsadminng/rbac/rbacgrant/RawRbacGrantEntity.java
index fa9fc7ad..ccdd332c 100644
--- a/src/test/java/net/hostsharing/hsadminng/rbac/rbacgrant/RawRbacGrantEntity.java
+++ b/src/test/java/net/hostsharing/hsadminng/rbac/rbacgrant/RawRbacGrantEntity.java
@@ -38,7 +38,7 @@ public class RawRbacGrantEntity {
     @Column(name = "descendantidname", updatable = false, insertable = false)
     private String descendantIdName;
 
-    @Column(name = "descenantuuid", updatable = false, insertable = false)
+    @Column(name = "descendantuuid", updatable = false, insertable = false)
     private UUID descendantUuid;
 
     @Column(name = "assumed", updatable = false, insertable = false)
diff --git a/src/test/java/net/hostsharing/test/Array.java b/src/test/java/net/hostsharing/test/Array.java
index 1c98ce57..321efafe 100644
--- a/src/test/java/net/hostsharing/test/Array.java
+++ b/src/test/java/net/hostsharing/test/Array.java
@@ -3,6 +3,7 @@ package net.hostsharing.test;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.List;
+import java.util.Objects;
 
 /**
  * Java has List.of(...), Set.of(...) and Map.of(...) all with varargs parameter,
@@ -17,13 +18,19 @@ public class Array {
 
     public static String[] from(final List<String> initialList, final String... additionalStrings) {
         final var resultList = new ArrayList<>(initialList);
-        resultList.addAll(Arrays.asList(additionalStrings));
+        resultList.addAll(Arrays.stream(additionalStrings).toList());
+        return resultList.toArray(String[]::new);
+    }
+
+    public static String[] fromSkippingNull(final List<String> initialList, final String... additionalStrings) {
+        final var resultList = new ArrayList<>(initialList);
+        resultList.addAll(Arrays.stream(additionalStrings).filter(Objects::nonNull).toList());
         return resultList.toArray(String[]::new);
     }
 
     public static String[] from(final String[] initialStrings, final String... additionalStrings) {
         final var resultList = Arrays.asList(initialStrings);
-        resultList.addAll(Arrays.asList(additionalStrings));
+        resultList.addAll(Arrays.stream(additionalStrings).toList());
         return resultList.toArray(String[]::new);
     }
 
diff --git a/src/test/java/net/hostsharing/test/JpaAttempt.java b/src/test/java/net/hostsharing/test/JpaAttempt.java
index 7ffb270d..6a71711c 100644
--- a/src/test/java/net/hostsharing/test/JpaAttempt.java
+++ b/src/test/java/net/hostsharing/test/JpaAttempt.java
@@ -124,7 +124,7 @@ public class JpaAttempt {
         public void assertExceptionWithRootCauseMessage(
                 final Class<? extends RuntimeException> expectedExceptionClass,
                 final String... expectedRootCauseMessages) {
-            assertThat(wasSuccessful()).isFalse();
+            assertThat(wasSuccessful()).as("wasSuccessful").isFalse();
             final String firstRootCauseMessageLine = firstRootCauseMessageLineOf(caughtException(expectedExceptionClass));
             for (String expectedRootCauseMessage : expectedRootCauseMessages) {
                 assertThat(firstRootCauseMessageLine).contains(expectedRootCauseMessage);
diff --git a/tools/generate b/tools/generate
index ea23a83a..93aa5c7c 100755
--- a/tools/generate
+++ b/tools/generate
@@ -1,47 +1,41 @@
 #!/bin/bash
 
-mkdir -p src/test/java/net/hostsharing/hsadminng/hs/office/partner
+sourceLower=partner
+targetLower=relationship
 
-sed -e 's/hs-admin-contact/hs-office-partner/g' \
-	-e 's/hs_admin_contact/hs_office_partner/g' \
-	-e 's/HsOfficeContact/HsOfficePartner/g' \
-	-e 's/HsOfficeContact/HsOfficePartner/g' \
-	-e 's/contact/partner/g' \
-<src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRepositoryIntegrationTest.java \
->src/test/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerRepositoryIntegrationTest.java
+sourceStudly=Partner
+targetStudly=Relationship
+
+## for source in `find src -iname ""*$sourceLower*"" -type f \( -iname \*.yaml -o -iname \*.sql -o -iname \*.java \)`; do
+for source in `find src -iname ""*$sourceLower*"" -type f \( -iname \*.yaml \)`; do
+  target=`echo $source | sed -e "s/$sourceStudly/$targetStudly/g" -e "s/$sourceLower/$targetLower/g"`
+  echo "Generating $target from $source:"
+
+  mkdir -p `dirname $target`
+
+  sed -e 's/hs-office-partner/hs-office-relationship/g' \
+    -e 's/hs_office_partner/hs_office_relationship/g' \
+    -e 's/HsOfficePartner/HsOfficeRelationship/g' \
+    -e 's/hsOfficePartner/hsOfficeRelationship/g' \
+    -e 's/partner/relationship/g' \
+    \
+    -e 's/addPartner/addRelationship/g' \
+    -e 's/listPartners/listRelationships/g' \
+    -e 's/getPartnerByUuid/getRelationshipByUuid/g' \
+    -e 's/patchPartner/patchRelationship/g' \
+    -e 's/person/relHolder/g' \
+    -e 's/registrationOffice/relType/g' \
+  <$source >$target
+
+done
 
 exit
 
-sed -e 's/hs-admin-contact/hs-office-partner/g' \
-	-e 's/hs_admin_contact/hs_office_partner/g' \
-	<src/main/resources/db/changelog/200-hs-admin-contact.sql >src/main/resources/db/changelog/220-hs-office-partner.sql
-
-sed -e 's/hs-admin-contact/hs-office-partner/g' \
-	-e 's/hs_admin_contact/hs_office_partner/g' \
-	-e 's/HsAdminCustomer/HsOfficePartner/g' \
-	-e 's/HsOfficeContact/HsOfficePartner/g' \
-	-e 's/contact/partner/g' \
-	<src/main/resources/db/changelog/203-hs-admin-contact-rbac.sql >src/main/resources/db/changelog/223-hs-office-partner-rbac.sql
-
-sed -e 's/hs-admin-contact/hs-office-partner/g' \
-	-e 's/hs_admin_contact/hs_office_partner/g' \
-	-e 's/HsOfficeContact/HsOfficePartner/g' \
-	-e 's/HsOfficeContact/HsOfficePartner/g' \
-	-e 's/contact/partner/g' \
-	<src/main/resources/db/changelog/208-hs-admin-contact-test-data.sql >src/main/resources/db/changelog/228-hs-office-partner-test-data.sql
-
-
-# mkdir -p src/main/java/net/hostsharing/hsadminng/hs/office/partner
-# 
-# sed -e 's/HsOfficeContactEntity/HsOfficePartnerEntity/g' \
-# sed -e 's/admin.contact/admin.partner/g' \
-# 	<src/main/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactEntity.java >src/main/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerEntity.java
-
 cat >>src/main/resources/db/changelog/db.changelog-master.yaml <<EOF
     - include:
-        file: db/changelog/220-hs-office-partner.sql
+        file: db/changelog/2X0-hs-office-$sourceLower.sql
     - include:
-        file: db/changelog/223-hs-office-partner-rbac.sql
+        file: db/changelog/2X3-hs-office-$sourceLower-rbac.sql
     - include:
-        file: db/changelog/228-hs-office-partner-test-data.sql
+        file: db/changelog/2X8-hs-office-$sourceLower-test-data.sql
 EOF