diff --git a/.aliases b/.aliases index 2e72272f..2d6b0146 100644 --- a/.aliases +++ b/.aliases @@ -74,8 +74,11 @@ function importLegacyData() { alias gw-importHostingAssets='importLegacyData importHostingAssets' function gradlewBootRun() { - echo gw bootRun --args="--spring.profiles.active=dev,fakeCasAuthenticator,complete,test-data --server.port=${1:-8080}" - ./gradlew bootRun --args="--spring.profiles.active=dev,fakeCasAuthenticator,complete,test-data --server.port=${1:-8080}" + local port=${1:-8080} + shift + local additional_args="$@" + echo gw bootRun --args="--spring.profiles.active=dev,fakeCasAuthenticator,complete,test-data --server.port=${port} ${additional_args}" + ./gradlew bootRun --args="--spring.profiles.active=dev,fakeCasAuthenticator,complete,test-data --server.port=${port} ${additional_args}" } alias gw-bootRun=gradlewBootRun @@ -155,4 +158,3 @@ source .environment alias scenario-reports-upload='./gradlew scenarioTest convertMarkdownToHtml && ssh hsh03-hsngdev@hsh03.hostsharing.net "rm -f doms/hsngdev.hs-example.de/htdocs-ssl/scenarios/office/*.html" && scp build/doc/scenarios/*.html hsh03-hsngdev@hsh03.hostsharing.net:doms/hsngdev.hs-example.de/htdocs-ssl/scenarios/office' alias scenario-reports-open='open https://hsngdev.hs-example.de/scenarios/office' - diff --git a/build.gradle.kts b/build.gradle.kts index 985d99de..6e8e7c69 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -669,3 +669,13 @@ tasks.register("compile") { description = "Compiles main and test Java sources." dependsOn("compileJava", "compileTestJava") } + + +tasks.named("bootRun") { + // Enable debug when the debug property is set + if (project.hasProperty("debug")) { + jvmArgs = listOf("-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005") + } + // Or always enable debug (remove the if condition) + // jvmArgs = listOf("-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005") +} diff --git a/src/main/java/net/hostsharing/hsadminng/context/Context.java b/src/main/java/net/hostsharing/hsadminng/context/Context.java index 1fb74f4a..23a86237 100644 --- a/src/main/java/net/hostsharing/hsadminng/context/Context.java +++ b/src/main/java/net/hostsharing/hsadminng/context/Context.java @@ -43,6 +43,11 @@ public class Context { define(currentSubject, null); } + @Transactional(propagation = MANDATORY) + public void define() { + define(SecurityContextHolder.getContext().getAuthentication().getName(), null); + } + @Transactional(propagation = MANDATORY) public void define(final String currentSubject, final String assumedRoles) { define(toTask(request), toCurl(request), currentSubject, assumedRoles); @@ -86,7 +91,7 @@ public class Context { return (UUID) em.createNativeQuery("select rbac.currentSubjectUuid()", UUID.class).getSingleResult(); } - public String[] fetchAssumedRoles() { + public String[] fetchAssumedRolesNames() { return (String[]) em.createNativeQuery("select base.assumedRoles() as roles", String[].class).getSingleResult(); } @@ -94,6 +99,10 @@ public class Context { return (UUID[]) em.createNativeQuery("select rbac.currentSubjectOrAssumedRolesUuids() as uuids", UUID[].class).getSingleResult(); } + public boolean isGlobalAdmin() { + return (boolean) em.createNativeQuery("select rbac.isGlobalAdmin()", boolean.class).getSingleResult(); + } + public static String getCallerMethodNameFromStackFrame(final int skipFrames) { final Optional caller = StackWalker.getInstance(StackWalker.Option.RETAIN_CLASS_REFERENCE) diff --git a/src/main/java/net/hostsharing/hsadminng/errors/RestResponseEntityExceptionHandler.java b/src/main/java/net/hostsharing/hsadminng/errors/RestResponseEntityExceptionHandler.java index 0a6c3eed..13a74701 100644 --- a/src/main/java/net/hostsharing/hsadminng/errors/RestResponseEntityExceptionHandler.java +++ b/src/main/java/net/hostsharing/hsadminng/errors/RestResponseEntityExceptionHandler.java @@ -34,7 +34,7 @@ import static net.hostsharing.hsadminng.errors.CustomErrorResponse.*; @ControllerAdvice @RequiredArgsConstructor -// HOWTO handle exceptions to produce specific http error codes and sensible error messages +// HOWTO error handler mapping exceptions to specific http error codes and sensible error messages public class RestResponseEntityExceptionHandler extends ResponseEntityExceptionHandler { diff --git a/src/main/java/net/hostsharing/hsadminng/hs/accounts/CredentialContextResourceToEntityMapper.java b/src/main/java/net/hostsharing/hsadminng/hs/accounts/CredentialContextResourceToEntityMapper.java index cd662752..225ee95a 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/accounts/CredentialContextResourceToEntityMapper.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/accounts/CredentialContextResourceToEntityMapper.java @@ -2,6 +2,7 @@ package net.hostsharing.hsadminng.hs.accounts; import jakarta.persistence.EntityManager; import jakarta.persistence.EntityNotFoundException; + import net.hostsharing.hsadminng.config.MessageTranslator; import net.hostsharing.hsadminng.accounts.generated.api.v1.model.ContextResource; import org.springframework.beans.factory.annotation.Autowired; @@ -54,8 +55,8 @@ public class CredentialContextResourceToEntityMapper { messageTranslator.translate("{0} \"{1}\" not found or not accessible", "credentials uuid", resource.getUuid())); } - if (!existingContextEntity.getType().equals(resource.getType()) && - !existingContextEntity.getQualifier().equals(resource.getQualifier())) { + if ((resource.getType() != null && !existingContextEntity.getType().equals(resource.getType())) || + (resource.getQualifier() != null && !existingContextEntity.getQualifier().equals(resource.getQualifier()))) { throw new EntityNotFoundException( messageTranslator.translate("existing {0} does not match given resource {1}", existingContextEntity, resource)); diff --git a/src/main/java/net/hostsharing/hsadminng/hs/accounts/HsCredentialsContext.java b/src/main/java/net/hostsharing/hsadminng/hs/accounts/HsCredentialsContext.java index 9716fa6a..d6cbcc23 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/accounts/HsCredentialsContext.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/accounts/HsCredentialsContext.java @@ -32,9 +32,9 @@ public abstract class HsCredentialsContext implements Stringifyable, BaseEntity< private static Stringify stringify = stringify(HsCredentialsContext.class, "loginContext") .withProp(HsCredentialsContext::getType) .withProp(HsCredentialsContext::getQualifier) - .withProp(HsCredentialsContext::isOnlyForNaturalPersons, + .withProp(HsCredentialsContext::isOnlyForNaturalPersons, value -> value ? symbol("NP-ONLY") : null) - .withProp(HsCredentialsContext::isPublicAccess, + .withProp(HsCredentialsContext::isPublicAccess, value -> value ? symbol("PUBLIC") : symbol("INTERNAL")) .quotedValues(false) .withSeparator(":"); diff --git a/src/main/java/net/hostsharing/hsadminng/hs/accounts/HsCredentialsController.java b/src/main/java/net/hostsharing/hsadminng/hs/accounts/HsCredentialsController.java index bcfcd8bf..64668ca1 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/accounts/HsCredentialsController.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/accounts/HsCredentialsController.java @@ -9,6 +9,8 @@ import java.util.function.BiConsumer; import io.micrometer.core.annotation.Timed; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import net.hostsharing.hsadminng.accounts.generated.api.v1.model.ContextResource; +import net.hostsharing.hsadminng.accounts.generated.api.v1.model.CurrentLoginUserResource; +import net.hostsharing.hsadminng.accounts.generated.api.v1.model.RbacSubjectResource; import net.hostsharing.hsadminng.config.MessageTranslator; import net.hostsharing.hsadminng.context.Context; import net.hostsharing.hsadminng.accounts.generated.api.v1.api.CredentialsApi; @@ -16,6 +18,7 @@ import net.hostsharing.hsadminng.accounts.generated.api.v1.model.CredentialsInse import net.hostsharing.hsadminng.accounts.generated.api.v1.model.CredentialsPatchResource; import net.hostsharing.hsadminng.accounts.generated.api.v1.model.CredentialsResource; import net.hostsharing.hsadminng.accounts.generated.api.v1.model.HsOfficePersonResource; +import net.hostsharing.hsadminng.hs.office.person.HsOfficePerson; import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRbacRepository; import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonType; import net.hostsharing.hsadminng.mapper.StrictMapper; @@ -29,6 +32,7 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.servlet.mvc.method.annotation.MvcUriComponentsBuilder; import jakarta.persistence.EntityNotFoundException; +import jakarta.validation.ValidationException; import static java.util.Optional.ofNullable; import static java.util.Optional.of; @@ -61,13 +65,15 @@ public class HsCredentialsController implements CredentialsApi { @Autowired private HsCredentialsRepository credentialsRepo; + @Autowired + private RbacSubjectRepository rbacSubjectRepo; + @Override @Transactional(readOnly = true) @Timed("app.credentials.credentials.getSingleCredentialsByUuid") - public ResponseEntity getSingleCredentialsByUuid( - final String assumedRoles, - final UUID credentialsUuid) { - context.assumeRoles(assumedRoles); + public ResponseEntity getSingleCredentialsByUuid(final UUID credentialsUuid) { + + context.define(); // without assumed roles, otherwise we cannot access the subject anymore final var credentialsEntity = credentialsRepo.findByUuid(credentialsUuid); if (credentialsEntity.isEmpty()) { @@ -99,10 +105,9 @@ public class HsCredentialsController implements CredentialsApi { @Transactional @Timed("app.credentials.credentials.postNewCredentials") public ResponseEntity postNewCredentials( - final String assumedRoles, final CredentialsInsertResource body ) { - context.assumeRoles(assumedRoles); + context.define(); // without assumed roles, otherwise we cannot access the subject anymore // first create and save the subject to get its UUID final var newlySavedSubject = createSubject(body.getNickname()); @@ -110,6 +115,7 @@ public class HsCredentialsController implements CredentialsApi { // afterward, create and save the credentials entity with the subject's UUID final var newCredentialsEntity = mapper.map( body, HsCredentialsEntity.class, RESOURCE_TO_ENTITY_POSTMAPPER); + validate(newCredentialsEntity); newCredentialsEntity.setSubject(newlySavedSubject); em.persist(newCredentialsEntity); // newCredentialsEntity.uuid == newlySavedSubject.uuid => do not use repository! @@ -127,10 +133,13 @@ public class HsCredentialsController implements CredentialsApi { @Override @Transactional @Timed("app.credentials.credentials.deleteCredentialsByUuid") - public ResponseEntity deleteCredentialsByUuid(final String assumedRoles, final UUID credentialsUuid) { - context.assumeRoles(assumedRoles); + public ResponseEntity deleteCredentialsByUuid(final UUID credentialsUuid) { + context.define(); // without assumed roles, otherwise we cannot access the subject anymore final var credentialsEntity = em.getReference(HsCredentialsEntity.class, credentialsUuid); + credentialsEntity.getLoginContexts().clear(); + em.flush(); em.remove(credentialsEntity); + em.remove(credentialsEntity.getSubject()); return ResponseEntity.noContent().build(); } @@ -138,11 +147,10 @@ public class HsCredentialsController implements CredentialsApi { @Transactional @Timed("app.credentials.credentials.patchCredentials") public ResponseEntity patchCredentials( - final String assumedRoles, final UUID credentialsUuid, final CredentialsPatchResource body ) { - context.assumeRoles(assumedRoles); + context.define(); // without assumed roles, otherwise we cannot access the subject anymore final var current = credentialsRepo.findByUuid(credentialsUuid).orElseThrow(); @@ -154,6 +162,25 @@ public class HsCredentialsController implements CredentialsApi { return ResponseEntity.ok(mapped); } + @Override + @Transactional + @Timed("app.credentials.credentials.getCurrentLoginUser") + public ResponseEntity getCurrentLoginUser() { + + // define a context without assumed roles, otherwise we cannot access the subject anymore + context.define(); + + // fetch the data + final var currentSubjectUuid = context.fetchCurrentSubjectUuid(); + final var currentSubject = rbacSubjectRepo.findByUuid(currentSubjectUuid); + final boolean isGlobalAdmin = context.isGlobalAdmin(); + final var person = credentialsRepo.findByUuid(currentSubjectUuid).orElseThrow().getPerson(); + + // finally, return the result + final var result = currentLoginUserResponse(currentSubject, person, isGlobalAdmin); + return ResponseEntity.ok(result); + } + @Override @Timed("app.credentials.credentials.credentialsUsed") public ResponseEntity credentialsUsed( @@ -171,12 +198,25 @@ public class HsCredentialsController implements CredentialsApi { return ResponseEntity.ok(mapped); } + private void validate(final HsCredentialsEntity newCredentialsEntity) { + // the referenced person must be represented by currently logged in person + final var personUuid = newCredentialsEntity.getPerson().getUuid(); + final var representedPersonUuids = rbacPersonRepo.findPersonsrepresentedByPersonWithUuid(personUuid) + .stream().map(HsOfficePerson::getUuid).toList(); + if ( !representedPersonUuids.contains(personUuid)) { + throw new ValidationException( + messageTranslator.translate( + "access-denied-personUuid-{0}-not-represented-by-currently-logged-in-person", + personUuid)); + } + } + private RbacSubjectEntity createSubject(final String nickname) { final var newRbacSubject = subjectRepo.create(new RbacSubjectEntity(null, nickname)); if(context.fetchCurrentSubject() == null) { - context.define("activate newly created self-servie subject", null, nickname, null); + context.define("activate newly created self-service subject", null, nickname, null); } - return subjectRepo.findByUuid(newRbacSubject.getUuid()); // attached to EM + return subjectRepo.findByUuid(newRbacSubject.getUuid()); // now attached to EM } private List findByPersonUuid(final UUID personUuid) { @@ -189,6 +229,18 @@ public class HsCredentialsController implements CredentialsApi { return credentialsRepo.findByPerson(person); } + + private CurrentLoginUserResource currentLoginUserResponse( + final RbacSubjectEntity currentSubject, + final HsOfficePerson person, + final boolean isGlobalAdmin) { + final var result = new CurrentLoginUserResource(); + result.setSubject(mapper.map(currentSubject, RbacSubjectResource.class)); + result.setPerson(mapper.map(person, HsOfficePersonResource.class)); + result.setGlobalAdmin(isGlobalAdmin); + return result; + } + final BiConsumer ENTITY_TO_RESOURCE_POSTMAPPER = (entity, resource) -> { ofNullable(entity.getLastUsed()).ifPresent( dt -> resource.setLastUsed(dt.atOffset(ZoneOffset.UTC))); @@ -213,8 +265,6 @@ public class HsCredentialsController implements CredentialsApi { } final BiConsumer RESOURCE_TO_ENTITY_POSTMAPPER = (resource, entity) -> { - - // TODO.impl: we need to make sure that the current subject is OWNER (or ADMIN?) of the person final var person = rbacPersonRepo.findByUuid(resource.getPersonUuid()).orElseThrow( () -> new EntityNotFoundException( messageTranslator.translate("{0} \"{1}\" not found or not accessible", "personUuid", resource.getPersonUuid()) diff --git a/src/main/java/net/hostsharing/hsadminng/hs/accounts/HsCredentialsEntity.java b/src/main/java/net/hostsharing/hsadminng/hs/accounts/HsCredentialsEntity.java index 178990d2..65655dc4 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/accounts/HsCredentialsEntity.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/accounts/HsCredentialsEntity.java @@ -40,7 +40,7 @@ public class HsCredentialsEntity implements BaseEntity, Str private UUID uuid; @MapsId - @OneToOne(optional = false, fetch = FetchType.LAZY) + @OneToOne(optional = false, fetch = FetchType.EAGER, cascade = CascadeType.ALL, orphanRemoval = true) @JoinColumn(name = "uuid", nullable = false, updatable = false, referencedColumnName = "uuid") private RbacSubjectEntity subject; @@ -78,7 +78,7 @@ public class HsCredentialsEntity implements BaseEntity, Str @Column private String smsNumber; - @OneToMany(fetch = FetchType.LAZY, cascade = { MERGE, REFRESH }, orphanRemoval = true) + @OneToMany(fetch = FetchType.EAGER, cascade = { MERGE, REFRESH }) @JoinTable( name = "context_mapping", schema = "hs_accounts", joinColumns = @JoinColumn(name = "credentials_uuid", referencedColumnName = "uuid"), diff --git a/src/main/java/net/hostsharing/hsadminng/hs/accounts/HsCredentialsRepository.java b/src/main/java/net/hostsharing/hsadminng/hs/accounts/HsCredentialsRepository.java index 8e013478..d24d5cd5 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/accounts/HsCredentialsRepository.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/accounts/HsCredentialsRepository.java @@ -19,30 +19,22 @@ public interface HsCredentialsRepository extends Repository findByCurrentSubject(); diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonController.java b/src/main/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonController.java index 68ab62a1..fac7bdff 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonController.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonController.java @@ -35,10 +35,13 @@ public class HsOfficePersonController implements HsOfficePersonsApi { @Timed("app.office.persons.api.getListOfPersons") public ResponseEntity> getListOfPersons( final String assumedRoles, - final String name) { + final String name, + final UUID representedByPersonUuid) { context.assumeRoles(assumedRoles); - final var entities = personRepo.findPersonByOptionalNameLike(name); + final var entities = representedByPersonUuid != null + ? personRepo.findPersonsrepresentedByPersonWithUuid(representedByPersonUuid) + : personRepo.findPersonByOptionalNameLike(name); final var resources = mapper.mapList(entities, HsOfficePersonResource.class); return ResponseEntity.ok(resources); diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonRbacRepository.java b/src/main/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonRbacRepository.java index a6b2a436..e171feef 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonRbacRepository.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonRbacRepository.java @@ -23,6 +23,22 @@ public interface HsOfficePersonRbacRepository extends Repository findPersonByOptionalNameLike(String name); + @Query(value = """ + WITH RECURSIVE + represented_persons AS ( + SELECT relation.anchorUuid person_uuid + FROM hs_office.relation relation + WHERE relation.type = 'REPRESENTATIVE' + AND relation.holderUuid = :personUuid + ) + SELECT person.* + FROM hs_office.person person + WHERE person.uuid IN (SELECT person_uuid FROM represented_persons) + OR person.uuid = :personUuid + """, nativeQuery = true) + @Timed("app.office.persons.repo.findRepresentedPersons.rbac") + List findPersonsrepresentedByPersonWithUuid(UUID personUuid); + @Timed("app.office.persons.repo.save.rbac") HsOfficePersonRbacEntity save(final HsOfficePersonRbacEntity entity); diff --git a/src/main/java/net/hostsharing/hsadminng/persistence/BaseEntity.java b/src/main/java/net/hostsharing/hsadminng/persistence/BaseEntity.java index aede0d7d..71bd8105 100644 --- a/src/main/java/net/hostsharing/hsadminng/persistence/BaseEntity.java +++ b/src/main/java/net/hostsharing/hsadminng/persistence/BaseEntity.java @@ -1,22 +1,13 @@ package net.hostsharing.hsadminng.persistence; -import org.hibernate.Hibernate; import jakarta.persistence.EntityManager; -import java.util.UUID; -public interface BaseEntity> { - UUID getUuid(); +public interface BaseEntity> extends ImmutableBaseEntity { int getVersion(); - default T load() { - Hibernate.initialize(this); - //noinspection unchecked - return (T) this; - }; - default T reload(final EntityManager em) { em.flush(); em.refresh(this); diff --git a/src/main/java/net/hostsharing/hsadminng/persistence/ImmutableBaseEntity.java b/src/main/java/net/hostsharing/hsadminng/persistence/ImmutableBaseEntity.java new file mode 100644 index 00000000..3e9fc4f2 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/persistence/ImmutableBaseEntity.java @@ -0,0 +1,16 @@ +package net.hostsharing.hsadminng.persistence; + + +import org.hibernate.Hibernate; + +import java.util.UUID; + +public interface ImmutableBaseEntity> { + UUID getUuid(); + + default T load() { + Hibernate.initialize(this); + //noinspection unchecked + return (T) this; + }; +} diff --git a/src/main/java/net/hostsharing/hsadminng/rbac/context/RbacContextController.java b/src/main/java/net/hostsharing/hsadminng/rbac/context/RbacContextController.java new file mode 100644 index 00000000..1bc1a348 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/rbac/context/RbacContextController.java @@ -0,0 +1,77 @@ +package net.hostsharing.hsadminng.rbac.context; + +import io.micrometer.core.annotation.Timed; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import net.hostsharing.hsadminng.context.Context; +import net.hostsharing.hsadminng.mapper.StrictMapper; +import net.hostsharing.hsadminng.rbac.generated.api.v1.api.RbacContextApi; +import net.hostsharing.hsadminng.rbac.generated.api.v1.model.RbacContextResource; +import net.hostsharing.hsadminng.rbac.generated.api.v1.model.RbacRoleResource; +import net.hostsharing.hsadminng.rbac.generated.api.v1.model.RbacSubjectResource; +import net.hostsharing.hsadminng.rbac.role.RbacRoleEntity; +import net.hostsharing.hsadminng.rbac.role.RbacRoleRepository; +import net.hostsharing.hsadminng.rbac.subject.RbacSubjectEntity; +import net.hostsharing.hsadminng.rbac.subject.RbacSubjectRepository; +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 java.util.List; +import java.util.UUID; + +@RestController +@SecurityRequirement(name = "casTicket") +public class RbacContextController implements RbacContextApi { + + @Autowired + private Context context; + + @Autowired + private StrictMapper mapper; + + @Autowired + private RbacSubjectRepository rbacSubjectRepo; + + @Autowired + private RbacRoleRepository roleRepo; + + @Override + @Transactional(readOnly = true) + @Timed("app.rbac.current.api.getContext") + public ResponseEntity getContext(final String roleNamesToAssume) { + + // fetch subject data before assuming any roles; otherwise we might have no SELECT permission anymore + context.define(); + final var currentSubjectUuid = context.fetchCurrentSubjectUuid(); + final var currentSubject = rbacSubjectRepo.findByUuid(currentSubjectUuid); + if (currentSubject == null) { + return ResponseEntity.notFound().build(); + } + final boolean isGlobalAdmin = context.isGlobalAdmin(); + + // now we can assume the roles + context.assumeRoles(roleNamesToAssume); + final var assumedRoles = roleRepo.fetchAssumedRoles(); + + // finally, return the result + final var result = rbacContextResponse(currentSubjectUuid, currentSubject, assumedRoles, isGlobalAdmin); + return ResponseEntity.ok(result); + } + + private RbacContextResource rbacContextResponse( + final UUID currentSubjectUuid, + final RbacSubjectEntity currentSubject, + final List assumedRoles, + final boolean isGlobalAdmin) { + final var result = new RbacContextResource(); + final var currentSubjectResource = new RbacSubjectResource(); + currentSubjectResource.setUuid(currentSubjectUuid); + currentSubjectResource.setName(currentSubject.getName()); + result.setSubject(currentSubjectResource); + result.setGlobalAdmin(isGlobalAdmin); + final var assumedRolesResource = mapper.mapList(assumedRoles, RbacRoleResource.class); + result.setAssumedRoles(assumedRolesResource); + return result; + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/rbac/role/RbacRoleRepository.java b/src/main/java/net/hostsharing/hsadminng/rbac/role/RbacRoleRepository.java index 25348cd6..aef8a910 100644 --- a/src/main/java/net/hostsharing/hsadminng/rbac/role/RbacRoleRepository.java +++ b/src/main/java/net/hostsharing/hsadminng/rbac/role/RbacRoleRepository.java @@ -1,6 +1,7 @@ package net.hostsharing.hsadminng.rbac.role; import io.micrometer.core.annotation.Timed; +import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.Repository; import java.util.List; @@ -22,4 +23,12 @@ public interface RbacRoleRepository extends Repository { @Timed("app.rbac.roles.repo.findByRoleName") RbacRoleEntity findByRoleName(String roleName); + + @Timed("app.rbac.roles.repo.fetchAssumedRoles") + @Query(value = """ + SELECT rev.*, rev.objectTable||'#'||rev.objectIdName||':'||rev.roleType AS roleName + FROM rbac.role_ev rev + WHERE rev.uuid = ANY(rbac.currentSubjectOrAssumedRolesUuids()) + """, nativeQuery = true) + List fetchAssumedRoles(); } diff --git a/src/main/java/net/hostsharing/hsadminng/rbac/subject/RbacSubjectEntity.java b/src/main/java/net/hostsharing/hsadminng/rbac/subject/RbacSubjectEntity.java index 460f2970..ce3033e8 100644 --- a/src/main/java/net/hostsharing/hsadminng/rbac/subject/RbacSubjectEntity.java +++ b/src/main/java/net/hostsharing/hsadminng/rbac/subject/RbacSubjectEntity.java @@ -1,6 +1,7 @@ package net.hostsharing.hsadminng.rbac.subject; import lombok.*; +import net.hostsharing.hsadminng.persistence.ImmutableBaseEntity; import org.springframework.data.annotation.Immutable; import jakarta.persistence.Entity; @@ -21,7 +22,7 @@ import java.util.UUID; @Immutable @NoArgsConstructor @AllArgsConstructor -public class RbacSubjectEntity { +public class RbacSubjectEntity implements ImmutableBaseEntity { private static final int MAX_VALIDITY_DAYS = 21; private static DateTimeFormatter DATE_FORMAT_WITH_FULLHOUR = DateTimeFormatter.ofPattern("MM-dd-yyyy HH"); diff --git a/src/main/resources/api-definition/accounts/api-paths.yaml b/src/main/resources/api-definition/accounts/api-paths.yaml index 81f5176d..b33c4512 100644 --- a/src/main/resources/api-definition/accounts/api-paths.yaml +++ b/src/main/resources/api-definition/accounts/api-paths.yaml @@ -8,6 +8,11 @@ servers: paths: + # current + + /api/hs/accounts/current: + $ref: "current.yaml" + # Contexts /api/hs/accounts/contexts: diff --git a/src/main/resources/api-definition/accounts/context-schemas.yaml b/src/main/resources/api-definition/accounts/context-schemas.yaml index 7ed5acd7..3dc85d60 100644 --- a/src/main/resources/api-definition/accounts/context-schemas.yaml +++ b/src/main/resources/api-definition/accounts/context-schemas.yaml @@ -21,7 +21,3 @@ components: type: boolean required: - uuid - - type - - qualifier - - onlyForNaturalPersons - - publicAccess diff --git a/src/main/resources/api-definition/accounts/credentials-schemas.yaml b/src/main/resources/api-definition/accounts/credentials-schemas.yaml index 508e9786..95b8e018 100644 --- a/src/main/resources/api-definition/accounts/credentials-schemas.yaml +++ b/src/main/resources/api-definition/accounts/credentials-schemas.yaml @@ -3,6 +3,16 @@ components: schemas: + CurrentLoginUser: + type: object + properties: + subject: + $ref: '../rbac/rbac-subject-schemas.yaml#/components/schemas/RbacSubject' + person: + $ref: '../hs-office/hs-office-person-schemas.yaml#/components/schemas/HsOfficePerson' + globalAdmin: + type: boolean + Credentials: type: object properties: @@ -101,7 +111,8 @@ components: items: $ref: 'context-schemas.yaml#/components/schemas/Context' required: - - uuid + - person.uuid + - nickname - active additionalProperties: false diff --git a/src/main/resources/api-definition/accounts/credentials-with-uuid.yaml b/src/main/resources/api-definition/accounts/credentials-with-uuid.yaml index ddc160f8..07590f0b 100644 --- a/src/main/resources/api-definition/accounts/credentials-with-uuid.yaml +++ b/src/main/resources/api-definition/accounts/credentials-with-uuid.yaml @@ -4,7 +4,6 @@ get: description: 'Fetch a single credentials its uuid, if visible for the current subject.' operationId: getSingleCredentialsByUuid parameters: - - $ref: 'auth.yaml#/components/parameters/assumedRoles' - name: credentialsUuid in: path required: true @@ -31,7 +30,6 @@ patch: description: 'Updates a single credentials identified by its uuid, if permitted for the current subject.' operationId: patchCredentials parameters: - - $ref: 'auth.yaml#/components/parameters/assumedRoles' - name: credentialsUuid in: path required: true @@ -61,8 +59,7 @@ delete: description: 'Delete a single credentials identified by its uuid, if permitted for the current subject.' operationId: deleteCredentialsByUuid parameters: - - $ref: 'auth.yaml#/components/parameters/assumedRoles' - - name: CredentialsUuid + - name: credentialsUuid in: path required: true schema: diff --git a/src/main/resources/api-definition/accounts/credentials.yaml b/src/main/resources/api-definition/accounts/credentials.yaml index 6e2b0d04..b7ad86a0 100644 --- a/src/main/resources/api-definition/accounts/credentials.yaml +++ b/src/main/resources/api-definition/accounts/credentials.yaml @@ -32,8 +32,6 @@ post: tags: - credentials operationId: postNewCredentials - parameters: - - $ref: 'auth.yaml#/components/parameters/assumedRoles' requestBody: description: A JSON object describing the new credential. required: true diff --git a/src/main/resources/api-definition/accounts/current.yaml b/src/main/resources/api-definition/accounts/current.yaml new file mode 100644 index 00000000..df63ec28 --- /dev/null +++ b/src/main/resources/api-definition/accounts/current.yaml @@ -0,0 +1,17 @@ +get: + summary: Currently logged in user data. + description: Returns information about the currently logged in user. + tags: + - credentials + operationId: getCurrentLoginUser + responses: + "200": + description: OK + content: + 'application/json': + schema: + $ref: 'credentials-schemas.yaml#/components/schemas/CurrentLoginUser' + "401": + $ref: 'error-responses.yaml#/components/responses/Unauthorized' + "403": + $ref: 'error-responses.yaml#/components/responses/Forbidden' diff --git a/src/main/resources/api-definition/accounts/error-responses.yaml b/src/main/resources/api-definition/accounts/error-responses.yaml index e295230e..e04d364b 100644 --- a/src/main/resources/api-definition/accounts/error-responses.yaml +++ b/src/main/resources/api-definition/accounts/error-responses.yaml @@ -1,3 +1,4 @@ + components: responses: diff --git a/src/main/resources/api-definition/hs-office/hs-office-persons.yaml b/src/main/resources/api-definition/hs-office/hs-office-persons.yaml index 63da3a29..e699de96 100644 --- a/src/main/resources/api-definition/hs-office/hs-office-persons.yaml +++ b/src/main/resources/api-definition/hs-office/hs-office-persons.yaml @@ -12,6 +12,30 @@ get: schema: type: string description: Prefix of caption to filter the results. + - name: representedByPersonUuid + in: query + required: false + schema: + type: string + format: uuid + description: if given, if only persons represented given person uuid should be returned + x-parameter-dependencies: + oneOf: + - properties: + name: + type: string + not: + required: [ representedByPersonUuid ] + - properties: + representedByPersonUuid: + type: string + format: uuid + not: + required: [ name ] + - not: + anyOf: + - required: [ name ] + - required: [ representedByPersonUuid ] responses: "200": description: OK diff --git a/src/main/resources/api-definition/rbac/rbac-context-schemas.yaml b/src/main/resources/api-definition/rbac/rbac-context-schemas.yaml new file mode 100644 index 00000000..8aefc1af --- /dev/null +++ b/src/main/resources/api-definition/rbac/rbac-context-schemas.yaml @@ -0,0 +1,16 @@ + +components: + + schemas: + + RbacContext: + type: object + properties: + subject: + $ref: 'rbac-subject-schemas.yaml#/components/schemas/RbacSubject' + assumedRoles: + type: array + items: + $ref: 'rbac-role-schemas.yaml#/components/schemas/RbacRole' + globalAdmin: + type: boolean diff --git a/src/main/resources/api-definition/rbac/rbac-context.yaml b/src/main/resources/api-definition/rbac/rbac-context.yaml new file mode 100644 index 00000000..fc770df7 --- /dev/null +++ b/src/main/resources/api-definition/rbac/rbac-context.yaml @@ -0,0 +1,18 @@ +get: + tags: + - rbac-context + description: Information about the current subject. + operationId: getContext + parameters: + - $ref: 'auth.yaml#/components/parameters/assumedRoles' + responses: + '200': + description: OK + content: + 'application/json': + schema: + $ref: 'rbac-context-schemas.yaml#/components/schemas/RbacContext' + '401': + $ref: 'error-responses.yaml#/components/responses/Unauthorized' + '403': + $ref: 'error-responses.yaml#/components/responses/Forbidden' diff --git a/src/main/resources/api-definition/rbac/rbac.yaml b/src/main/resources/api-definition/rbac/rbac.yaml index 463692d0..2984e752 100644 --- a/src/main/resources/api-definition/rbac/rbac.yaml +++ b/src/main/resources/api-definition/rbac/rbac.yaml @@ -8,6 +8,9 @@ servers: paths: + /api/rbac/context: + $ref: 'rbac-context.yaml' + /api/rbac/subjects: $ref: 'rbac-subjects.yaml' diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 7eb80e25..520d897f 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -72,6 +72,9 @@ metrics: logging: level: org.springframework.security: info + org.springframework.web: DEBUG + org.springframework.web.method.annotation: DEBUG + org.springframework.validation: DEBUG # HOWTO configure logging, e.g. logging to a separate file, see: # https://docs.spring.io/spring-boot/reference/features/logging.html diff --git a/src/main/resources/db/changelog/9-hs-global/950-accounts/9519-hs-accounts-test-data.sql b/src/main/resources/db/changelog/9-hs-global/950-accounts/9519-hs-accounts-test-data.sql index 53c15ed0..857a3f0b 100644 --- a/src/main/resources/db/changelog/9-hs-global/950-accounts/9519-hs-accounts-test-data.sql +++ b/src/main/resources/db/changelog/9-hs-global/950-accounts/9519-hs-accounts-test-data.sql @@ -12,6 +12,9 @@ declare personAlexUuid uuid; superuserFranSubjectUuid uuid; personFranUuid uuid; + userDrewSubjectUuid uuid; + personDrewUuid uuid; + context_HSADMIN_prod hs_accounts.context; context_SSH_internal hs_accounts.context; @@ -26,6 +29,8 @@ begin personAlexUuid = (SELECT uuid FROM hs_office.person WHERE givenName='Alex'); superuserFranSubjectUuid = (SELECT uuid FROM rbac.subject WHERE name='superuser-fran@hostsharing.net'); personFranUuid = (SELECT uuid FROM hs_office.person WHERE givenName='Fran'); + userDrewSubjectUuid = (SELECT uuid FROM rbac.subject WHERE name='selfregistered-user-drew@hostsharing.org'); + personDrewUuid = (SELECT uuid FROM hs_office.person WHERE givenName='Drew'); -- Add test contexts INSERT INTO hs_accounts.context (uuid, type, qualifier, only_for_natural_persons, public_access) VALUES @@ -65,14 +70,17 @@ begin -- Add test credentials (linking to assumed rbac.subject UUIDs) INSERT INTO hs_accounts.credentials (uuid, version, person_uuid, active, global_uid, global_gid, onboarding_token, totp_secrets, phone_password, email_address, sms_number) VALUES ( superuserAlexSubjectUuid, 0, personAlexUuid, true, 1001, 1001, 'token-abc', ARRAY['otp-secret-1a', 'otp-secret-1b'], 'phone-pw-1', 'alex@example.com', '111-222-3333'), - ( superuserFranSubjectUuid, 0, personFranUuid, true, 1002, 1002, 'token-def', ARRAY['otp-secret-2'], 'phone-pw-2', 'fran@example.com', '444-555-6666'); + ( superuserFranSubjectUuid, 0, personFranUuid, true, 1002, 1002, 'token-def', ARRAY['otp-secret-2'], 'phone-pw-2', 'fran@example.com', '444-555-6666'), + ( userDrewSubjectUuid, 0, personDrewUuid, true, 1003, 1003, 'token-def', ARRAY['otp-secret-3'], 'phone-pw-3', 'drew@example.org', '999-888-7777'); -- Map credentials to contexts INSERT INTO hs_accounts.context_mapping (credentials_uuid, context_uuid) VALUES (superuserAlexSubjectUuid, context_HSADMIN_prod.uuid), (superuserFranSubjectUuid, context_HSADMIN_prod.uuid), + (userDrewSubjectUuid, context_HSADMIN_prod.uuid), (superuserAlexSubjectUuid, context_SSH_internal.uuid), (superuserFranSubjectUuid, context_SSH_internal.uuid), + (userDrewSubjectUuid, context_SSH_external.uuid), (superuserAlexSubjectUuid, context_MATRIX_internal.uuid), (superuserFranSubjectUuid, context_MATRIX_internal.uuid); diff --git a/src/main/resources/i18n/messages_de.properties b/src/main/resources/i18n/messages_de.properties index 5494037e..c5e7bcf5 100644 --- a/src/main/resources/i18n/messages_de.properties +++ b/src/main/resources/i18n/messages_de.properties @@ -14,6 +14,7 @@ but\ is=ist aber # credentials validations existing\ {0}\ does\ not\ match\ given\ resource\ {1}=existierender Credentials-Context {0} passt nicht zum angegebenen {1} +access-denied-personUuid-{0}-not-represented-by-currently-logged-in-person=Zugriff verweigert: personUuid "{0}" wird von der eingeloggten Person nicht repräsentiert # office.coop-shares for\ transactionType\={0},\ shareCount\ must\ be\ positive\ but\ is\ {1}=für transactionType={0}, muss shareCount positiv sein, ist aber {1} diff --git a/src/main/resources/i18n/messages_en.properties b/src/main/resources/i18n/messages_en.properties index c7facd4e..e318f0be 100644 --- a/src/main/resources/i18n/messages_en.properties +++ b/src/main/resources/i18n/messages_en.properties @@ -5,4 +5,5 @@ # But in that case, you can NOT use a prefix - or the prefix would be shown to the user as well. # I'm not sure, though, if using the english default translations as keys is really a good idea. - +# credentials validations +access-denied-personUuid-{0}-not-represented-by-currently-logged-in-person=access denied: personUuid "{0}" not represented by currently logged in person diff --git a/src/test/java/net/hostsharing/hsadminng/arch/ArchitectureTest.java b/src/test/java/net/hostsharing/hsadminng/arch/ArchitectureTest.java index c6b73278..13cf9709 100644 --- a/src/test/java/net/hostsharing/hsadminng/arch/ArchitectureTest.java +++ b/src/test/java/net/hostsharing/hsadminng/arch/ArchitectureTest.java @@ -83,6 +83,7 @@ public class ArchitectureTest { "..mapper", "..ping", "..rbac", + "..rbac.context", "..rbac.generator", "..rbac.subject", "..rbac.grant", @@ -238,7 +239,8 @@ public class ArchitectureTest { "..hs.office.debitor..", "..hs.office.membership..", "..hs.migration..", - "..hs.hosting.asset.." + "..hs.hosting.asset..", + "..hs.accounts.." ); @ArchTest diff --git a/src/test/java/net/hostsharing/hsadminng/hs/accounts/HsCredentialsControllerRestTest.java b/src/test/java/net/hostsharing/hsadminng/hs/accounts/HsCredentialsControllerRestTest.java index 8f2ee944..0ef3f4a1 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/accounts/HsCredentialsControllerRestTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/accounts/HsCredentialsControllerRestTest.java @@ -3,6 +3,7 @@ package net.hostsharing.hsadminng.hs.accounts; import net.hostsharing.hsadminng.config.DisableSecurityConfig; import net.hostsharing.hsadminng.config.JsonObjectMapperConfiguration; import net.hostsharing.hsadminng.config.MessageTranslator; +import net.hostsharing.hsadminng.config.MessagesResourceConfig; import net.hostsharing.hsadminng.context.Context; import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRbacEntity; import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRbacRepository; @@ -26,20 +27,31 @@ import jakarta.persistence.EntityManagerFactory; import java.time.LocalDateTime; import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; +import java.util.List; import java.util.Optional; import java.util.Set; import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; import static net.hostsharing.hsadminng.hs.office.person.HsOfficePersonType.LEGAL_PERSON; +import static net.hostsharing.hsadminng.hs.office.person.HsOfficePersonType.NATURAL_PERSON; import static net.hostsharing.hsadminng.test.JsonMatcher.lenientlyEquals; +import static org.hamcrest.Matchers.containsString; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @WebMvcTest(HsCredentialsController.class) -@Import({ StrictMapper.class, JsonObjectMapperConfiguration.class, DisableSecurityConfig.class, MessageTranslator.class }) +@Import({ + StrictMapper.class, + JsonObjectMapperConfiguration.class, + DisableSecurityConfig.class, + // HOWTO: test i18n translations + MessagesResourceConfig.class, + MessageTranslator.class }) @ActiveProfiles("test") class HsCredentialsControllerRestTest { @@ -79,6 +91,45 @@ class HsCredentialsControllerRestTest { @MockitoBean CredentialContextResourceToEntityMapper contextMapper; + @Test + void shouldFetchCurrentLoginUser() throws Exception { + // given + final UUID currentSubjectUuid = UUID.randomUUID(); + given(contextMock.fetchCurrentSubjectUuid()).willReturn(currentSubjectUuid); + given(contextMock.isGlobalAdmin()).willReturn(true); + given(subjectRepo.findByUuid(currentSubjectUuid)).willReturn( + RbacSubjectEntity.builder().uuid(currentSubjectUuid).name("test-user").build() + ); + given(credentialsRepo.findByUuid(currentSubjectUuid)).willReturn( + Optional.of(HsCredentialsEntity.builder() + .uuid(currentSubjectUuid) + .person(HsOfficePersonRbacEntity.builder() + .uuid(PERSON_UUID) + .personType(NATURAL_PERSON) + .familyName("Miller") + .givenName("Steph") + .build()) + .subject(RbacSubjectEntity.builder().name("steph-miller").build()) + .build()) + ); + + // when + mockMvc.perform(MockMvcRequestBuilders + .get("/api/hs/accounts/current") + .header("Authorization", "Bearer test") + .accept(MediaType.APPLICATION_JSON)) + .andDo(print()) + + // then + .andExpect(status().isOk()) + .andExpect(jsonPath("$.subject.uuid").value(currentSubjectUuid.toString())) + .andExpect(jsonPath("$.subject.name").value("test-user")) + .andExpect(jsonPath("$.person.uuid").value(PERSON_UUID.toString())) + .andExpect(jsonPath("$.person.familyName").value("Miller")) + .andExpect(jsonPath("$.person.givenName").value("Steph")) + .andExpect(jsonPath("$.globalAdmin").value(true)); + } + @Test void shouldFilterInvalidContextsRegardingNonNaturalPerson() throws Exception { // given @@ -123,7 +174,95 @@ class HsCredentialsControllerRestTest { } @Test - void patchCredentialsUsed() throws Exception { + void shouldRejectCreatingCredentialsForUnrepresentedPerson() throws Exception { + // given + final var personUuid = UUID.randomUUID(); + + final AtomicReference createdSubject = new AtomicReference<>(); + given(subjectRepo.create(any())).willAnswer(invocation -> { + final var passedEntity = (RbacSubjectEntity) invocation.getArgument(0); + passedEntity.setUuid(UUID.randomUUID()); + createdSubject.set(passedEntity); // Capture the instance + return passedEntity; + }); + given(contextMock.fetchCurrentSubject()).willAnswer(invocation -> createdSubject.get().getName()); + given(subjectRepo.findByUuid(any())).willAnswer(invocation -> createdSubject.get()); + given(rbacPersonRepo.findByUuid(personUuid)).willReturn(Optional.of( + HsOfficePersonRbacEntity.builder().uuid(personUuid).personType(NATURAL_PERSON).build() + )); + given(rbacPersonRepo.findPersonsrepresentedByPersonWithUuid(personUuid)).willReturn(List.of( + // some persons, but not the one from the login-user itself + HsOfficePersonRbacEntity.builder().uuid(UUID.randomUUID()).personType(NATURAL_PERSON).build(), + HsOfficePersonRbacEntity.builder().uuid(UUID.randomUUID()).personType(LEGAL_PERSON).build() + )); + + final var givenCredentialsUuid = UUID.randomUUID(); + + final var contextForNP = HsCredentialsContextRealEntity.builder() + .uuid(UUID.randomUUID()) + .type("HSADMIN") + .qualifier("prod") + .onlyForNaturalPersons(true) + .build(); + final var contextForAll = HsCredentialsContextRealEntity.builder() + .uuid(UUID.randomUUID()) + .type("SSH") + .qualifier("prod") + .onlyForNaturalPersons(false) + .build(); + final var credentialsEntity = HsCredentialsEntity.builder() + .uuid(givenCredentialsUuid) + .person(HsOfficePersonRbacEntity.builder() + .uuid(PERSON_UUID) + .personType(LEGAL_PERSON) + .build()) + .subject(RbacSubjectEntity.builder().name("some-nickname").build()) + .loginContexts(Set.of(contextForNP, contextForAll)) + .build(); + when(credentialsRepo.findByUuid(givenCredentialsUuid)) + .thenReturn(Optional.of(credentialsEntity)); + + // when + mockMvc.perform(MockMvcRequestBuilders + .post("/api/hs/accounts/credentials") + .header("Authorization", "Bearer test") + // HOWTO: test i18n translations + .header("Accept-Language", "de") + .contentType(MediaType.APPLICATION_JSON) + .content( + """ + { + "person.uuid": "${personUuid}", + "nickname": "${nickname}", + "active": true, + "globalUid": 30001, + "globalGid": 40001, + "contexts": [ + { + "uuid" : "11111111-1111-1111-1111-111111111111", + "type" : "HSADMIN", + "qualifier" : "prod", + "onlyForNaturalPersons" : true, + "publicAccess" : true + } + ] + } + """ + .replace("${personUuid}", personUuid.toString()) + .replace("${nickname}", "new-user") + ) + .accept(MediaType.APPLICATION_JSON)) + .andDo(print()) + + // then + .andExpect(status().is4xxClientError()) + .andExpect(jsonPath("$.message", containsString( + "Zugriff verweigert: personUuid \"${personUuid}\" wird von der eingeloggten Person nicht repräsentiert" + .replace("${personUuid}", personUuid.toString())))); + } + + @Test + void markCredentialsAsUsed() throws Exception { // given final var givenCredentialsUuid = UUID.randomUUID(); diff --git a/src/test/java/net/hostsharing/hsadminng/hs/accounts/HsCredentialsRepositoryIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/hs/accounts/HsCredentialsRepositoryIntegrationTest.java index 55bfe649..afcc5126 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/accounts/HsCredentialsRepositoryIntegrationTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/accounts/HsCredentialsRepositoryIntegrationTest.java @@ -1,9 +1,14 @@ package net.hostsharing.hsadminng.hs.accounts; import net.hostsharing.hsadminng.context.Context; +import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRealEntity; import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRbacEntity; -import net.hostsharing.hsadminng.rbac.context.ContextBasedTest; +import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRealEntity; +import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRealRepository; +import net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationRealEntity; +import net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationType; import net.hostsharing.hsadminng.rbac.subject.RbacSubjectEntity; +import net.hostsharing.hsadminng.rbac.test.ContextBasedTestWithCleanup; import net.hostsharing.hsadminng.rbac.test.JpaAttempt; import org.hibernate.TransientObjectException; import org.junit.jupiter.api.BeforeEach; @@ -22,15 +27,18 @@ import java.time.ZonedDateTime; import java.util.HashSet; import java.util.Set; +import static net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationType.REPRESENTATIVE; +import static net.hostsharing.hsadminng.rbac.test.JpaAttempt.attempt; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.catchThrowable; @DataJpaTest @Tag("generalIntegrationTest") @Import({ Context.class, JpaAttempt.class }) -class HsCredentialsRepositoryIntegrationTest extends ContextBasedTest { +class HsCredentialsRepositoryIntegrationTest extends ContextBasedTestWithCleanup { private static final String SUPERUSER_ALEX_SUBJECT_NAME = "superuser-alex@hostsharing.net"; + private static final String SUPERUSER_FRAN_SUBJECT_NAME = "superuser-fran@hostsharing.net"; private static final String USER_DREW_SUBJECT_NAME = "selfregistered-user-drew@hostsharing.org"; private static final String TEST_USER_SUBJECT_NAME = "selfregistered-test-user@hostsharing.org"; @@ -41,6 +49,9 @@ class HsCredentialsRepositoryIntegrationTest extends ContextBasedTest { @MockitoBean HttpServletRequest request; + @Autowired + private HsOfficePersonRealRepository personRepo; + @Autowired private HsCredentialsRepository credentialsRepository; @@ -74,7 +85,9 @@ class HsCredentialsRepositoryIntegrationTest extends ContextBasedTest { final var rowsBefore = query.getResultList(); // then - assertThat(rowsBefore).as("hs_accounts.credentials_hv only contain no rows for a timestamp before test data creation").hasSize(0); + assertThat(rowsBefore) + .as("hs_accounts.credentials_hv only contain no rows for a timestamp before test data creation") + .hasSize(0); // and when historicalContext(Timestamp.from(ZonedDateTime.now().toInstant())); @@ -82,7 +95,53 @@ class HsCredentialsRepositoryIntegrationTest extends ContextBasedTest { final var rowsAfter = query.getResultList(); // then - assertThat(rowsAfter).as("hs_accounts.credentials_hv should now contain the test-data rows for the current timestamp").hasSize(2); + assertThat(rowsAfter) + .as("hs_accounts.credentials_hv should now contain the test-data rows for the current timestamp") + .hasSize(3); + } + + @Test + void representativeShouldFindOwnAndRepresentedCredentialsByCurrentSubject() { + // given + final var firstGmbHPerson = givenPerson("First GmbH"); + givenRelation(REPRESENTATIVE) + .withAnchorPersonLike(firstGmbHPerson) + .withHolder(drewPerson) + .withContact("some test contact"); + givenCredentials() + .forSubject("first-gmbh") + .forPerson(firstGmbHPerson) + .withEMailAddress("first-gmbh@example.com"); + + // when + final var foundCredentials = attempt( + em, () -> { + context(USER_DREW_SUBJECT_NAME); + return credentialsRepository.findByCurrentSubject(); + }) + .assertNotNull().returnedValue(); + + // then + assertThat(foundCredentials).hasSize(2) + .map(HsCredentialsEntity::getEmailAddress) + .containsExactlyInAnyOrder("drew@example.org", "first-gmbh@example.com"); + } + + @Test + void globalAdminShouldFindOnlyOwnCredentialsByCurrentSubject() { + + // when + final var foundCredentials = attempt( + em, () -> { + context(SUPERUSER_FRAN_SUBJECT_NAME); + return credentialsRepository.findByCurrentSubject(); + }) + .assertNotNull().returnedValue(); + + // then + assertThat(foundCredentials).hasSize(1) + .map(HsCredentialsEntity::getEmailAddress) + .containsExactlyInAnyOrder("fran@example.com"); } @Test @@ -101,28 +160,28 @@ class HsCredentialsRepositoryIntegrationTest extends ContextBasedTest { final var existingContext = loginContextRealRepo.findByTypeAndQualifier("HSADMIN", "prod") .orElseThrow(); final var newCredentials = HsCredentialsEntity.builder() - .subject(drewSubject) - .person(drewPerson) + .subject(testUserSubject) + .person(testUserPerson) .active(true) - .emailAddress("drew.new@example.com") - .globalUid(2001) - .globalGid(2001) + .emailAddress("test-user@example.com") + .globalUid(2011) + .globalGid(2011) .loginContexts(mutableSetOf(existingContext)) .build(); // when - credentialsRepository.save(newCredentials); + toCleanup(credentialsRepository.save(newCredentials)); em.flush(); em.clear(); // then - final var foundEntityOptional = credentialsRepository.findByUuid(drewSubject.getUuid()); + final var foundEntityOptional = credentialsRepository.findByUuid(testUserSubject.getUuid()); assertThat(foundEntityOptional).isPresent(); final var foundEntity = foundEntityOptional.get(); - assertThat(foundEntity.getEmailAddress()).isEqualTo("drew.new@example.com"); + assertThat(foundEntity.getEmailAddress()).isEqualTo("test-user@example.com"); assertThat(foundEntity.isActive()).isTrue(); assertThat(foundEntity.getVersion()).isEqualTo(0); // Initial version - assertThat(foundEntity.getGlobalUid()).isEqualTo(2001); + assertThat(foundEntity.getGlobalUid()).isEqualTo(2011); assertThat(foundEntity.getLoginContexts()).hasSize(1) .map(HsCredentialsContextRealEntity::toString).contains("loginContext(HSADMIN:prod:NP-ONLY:PUBLIC)"); @@ -240,4 +299,88 @@ class HsCredentialsRepositoryIntegrationTest extends ContextBasedTest { private Set mutableSetOf(final T... elements) { return new HashSet(Set.of(elements)); } + + private HsOfficePersonRealEntity givenPerson(String personName) { + return personRepo.findPersonByOptionalNameLike(personName).getFirst(); + } + + private RelationBuilder givenRelation(HsOfficeRelationType relationType) { + return new RelationBuilder(relationType); + } + + private CredentialsBuilder givenCredentials() { + return new CredentialsBuilder(); + } + + private class RelationBuilder { + private final HsOfficeRelationType relationType; + private HsOfficePersonRealEntity anchorPerson; + private HsOfficePersonRbacEntity holderPerson; + private HsOfficeContactRealEntity contact; + + public RelationBuilder(HsOfficeRelationType relationType) { + this.relationType = relationType; + } + + public RelationBuilder withAnchorPersonLike(HsOfficePersonRealEntity anchorPerson) { + this.anchorPerson = anchorPerson; + return this; + } + + public RelationBuilder withHolder(HsOfficePersonRbacEntity holderPerson) { + this.holderPerson = holderPerson; + return this; + } + + public HsOfficeRelationRealEntity withContact(String caption) { + this.contact = HsOfficeContactRealEntity.builder() + .caption(caption) + .build(); + em.persist(contact); + + final var relation = HsOfficeRelationRealEntity.builder() + .type(relationType) + .anchor(anchorPerson) + .holder(em.getReference(HsOfficePersonRealEntity.class, holderPerson.getUuid())) + .contact(contact) + .build(); + em.persist(relation); + em.flush(); + return relation; + } + } + + private class CredentialsBuilder { + private RbacSubjectEntity subject; + private HsOfficePersonRealEntity person; + + public CredentialsBuilder forSubject(String subjectName) { + this.subject = RbacSubjectEntity.builder() + .name(subjectName) + .build(); + em.persist(subject); + toCleanup(subject); + return this; + } + + public CredentialsBuilder forPerson(HsOfficePersonRealEntity person) { + this.person = person; + return this; + } + + public HsCredentialsEntity withEMailAddress(String emailAddress) { + + final var credentials = HsCredentialsEntity.builder() + .uuid(subject.getUuid()) + .subject(subject) + .person(em.find(HsOfficePersonRbacEntity.class, person.getUuid())) + .emailAddress(emailAddress) + .active(true) + .build(); + em.persist(credentials); + toCleanup(credentials); + em.flush(); + return credentials; + } + } } diff --git a/src/test/java/net/hostsharing/hsadminng/hs/accounts/scenarios/CredentialsScenarioTests.java b/src/test/java/net/hostsharing/hsadminng/hs/accounts/scenarios/CredentialsScenarioTests.java index 7711e195..e660d405 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/accounts/scenarios/CredentialsScenarioTests.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/accounts/scenarios/CredentialsScenarioTests.java @@ -49,6 +49,42 @@ class CredentialsScenarioTests extends ScenarioTest { @Nested @Order(10) @TestMethodOrder(MethodOrderer.OrderAnnotation.class) + class RbacContextScenarios { + + @Test + @Order(1010) + @Produces("RBAC Context") + void shouldFetchRbacContext() { + new FetchRbacContext(scenarioTest) + .given("subjectName", "superuser-fran@hostsharing.net") + .given("assumedRoles", "rbactest.package#xxx00:ADMIN;rbactest.package#yyy00:ADMIN") + .given("expectedToBeGlobalAdmin", true) + .doRun() + .keep(); + } + } + + @Nested + @Order(20) + @TestMethodOrder(MethodOrderer.OrderAnnotation.class) + class CurrentLoginUserScenarios { + + @Test + @Order(2010) + @Produces("Current Login User") + void shouldFetchCurrentLoginUser() { + new CurrentLoginUser(scenarioTest) + .given("subjectName", "superuser-fran@hostsharing.net") + .given("personGivenName", "Fran") + .given("expectedToBeGlobalAdmin", true) + .doRun() + .keep(); + } + } + + @Nested + @Order(30) + @TestMethodOrder(MethodOrderer.OrderAnnotation.class) class CredentialScenarios { @Test diff --git a/src/test/java/net/hostsharing/hsadminng/hs/accounts/scenarios/CurrentLoginUser.java b/src/test/java/net/hostsharing/hsadminng/hs/accounts/scenarios/CurrentLoginUser.java new file mode 100644 index 00000000..bfc3678c --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/accounts/scenarios/CurrentLoginUser.java @@ -0,0 +1,60 @@ +package net.hostsharing.hsadminng.hs.accounts.scenarios; + +import lombok.SneakyThrows; +import net.hostsharing.hsadminng.hs.scenarios.ScenarioTest; +import net.hostsharing.hsadminng.hs.scenarios.UseCase; + + +import static io.restassured.http.ContentType.JSON; +import static net.hostsharing.hsadminng.hs.scenarios.ScenarioTest.resolve; +import static net.hostsharing.hsadminng.hs.scenarios.TemplateResolver.Resolver.DROP_COMMENTS; +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.http.HttpStatus.OK; + +public class CurrentLoginUser extends UseCase { + + public CurrentLoginUser(final ScenarioTest testSuite) { + super(testSuite); + + introduction("Fetches data about the current login user."); + } + + @Override + protected HttpResponse run() { + + obtain("Person: %{personGivenName}", () -> + httpGet("/api/hs/office/persons?name=" + uriEncoded("%{personGivenName}")) + .expecting(OK).expecting(JSON), + response -> response.expectArrayElements(1).getFromBody("[0].uuid"), + "In production, data this query could result in multiple outputs. In that case, you have to find out which is the right one." + ); + + return obtain( + "Current Login User", () -> + httpGet( + "/api/hs/accounts/current", req -> req + .header("Authorization", resolve("Bearer %{subjectName}", DROP_COMMENTS)) + ) + .expecting(OK).expecting(JSON).expectObject() + .extractValue("subject.name", "returnedSubjectName") + .extractValue("person.givenName", "returnedGivenName") + .extractValue("globalAdmin", "returnedGlobalAdmin") + ).expecting(OK).expecting(JSON); + } + + @Override + @SneakyThrows + protected void verify(final UseCase.HttpResponse response) { + + assertThat(resolve("%{returnedSubjectName}", DROP_COMMENTS)) + .isEqualTo(resolve("%{subjectName}", DROP_COMMENTS)); + + assertThat(resolve("%{returnedGivenName}", DROP_COMMENTS)) + .isEqualTo(resolve("%{personGivenName}", DROP_COMMENTS)); + + assertThat(resolve("%{returnedGlobalAdmin}", DROP_COMMENTS)) + .isEqualTo(resolve("%{expectedToBeGlobalAdmin}", DROP_COMMENTS)); + + super.verify(response); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/accounts/scenarios/FetchRbacContext.java b/src/test/java/net/hostsharing/hsadminng/hs/accounts/scenarios/FetchRbacContext.java new file mode 100644 index 00000000..a36b0c7b --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/accounts/scenarios/FetchRbacContext.java @@ -0,0 +1,58 @@ +package net.hostsharing.hsadminng.hs.accounts.scenarios; + +import lombok.SneakyThrows; +import net.hostsharing.hsadminng.hs.scenarios.ScenarioTest; +import net.hostsharing.hsadminng.hs.scenarios.UseCase; + +import java.util.List; + +import static io.restassured.http.ContentType.JSON; +import static net.hostsharing.hsadminng.hs.scenarios.ScenarioTest.resolve; +import static net.hostsharing.hsadminng.hs.scenarios.ScenarioTest.resolveJsonArray; +import static net.hostsharing.hsadminng.hs.scenarios.TemplateResolver.Resolver.DROP_COMMENTS; +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.http.HttpStatus.OK; + +public class FetchRbacContext extends UseCase { + + public FetchRbacContext(final ScenarioTest testSuite) { + super(testSuite); + + introduction("Fetches the RBAC context for the login user / current subject."); + } + + @Override + protected HttpResponse run() { + return obtain( + "RBAC Context", () -> + httpGet( + "/api/rbac/context", req -> req + .header("Authorization", resolve("Bearer %{subjectName}", DROP_COMMENTS)) + .header("assumed-roles", resolve("%{assumedRoles}", DROP_COMMENTS)) + ) + .expecting(OK).expecting(JSON).expectObject() + .extractValue("subject.name", "returnedSubjectName") + .extractValue("assumedRoles", "returnedAssumedRoles") + .extractValue("globalAdmin", "returnedGlobalAdmin") + ).expecting(OK).expecting(JSON); + } + + @Override + @SneakyThrows + protected void verify(final UseCase.HttpResponse response) { + + // HOWTO: assert in UseCase.verify() + + assertThat(resolve("%{returnedSubjectName}", DROP_COMMENTS)) + .isEqualTo(resolve("%{subjectName}", DROP_COMMENTS)); + + assertThat(resolveJsonArray("%{returnedAssumedRoles}") + .stream().map(m -> m.get("roleName")).toList()) + .isEqualTo(List.of(resolve("%{assumedRoles}", DROP_COMMENTS).split(";"))); + + assertThat(resolve("%{returnedGlobalAdmin}", DROP_COMMENTS)) + .isEqualTo(resolve("%{expectedToBeGlobalAdmin}", DROP_COMMENTS)); + + super.verify(response); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorRepositoryIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorRepositoryIntegrationTest.java index 08ef791b..bc8eeac9 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorRepositoryIntegrationTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorRepositoryIntegrationTest.java @@ -78,6 +78,7 @@ class HsOfficeDebitorRepositoryIntegrationTest extends ContextBasedTestWithClean @MockitoBean HttpServletRequest request; + @Nested class CreateDebitor { @@ -242,9 +243,9 @@ class HsOfficeDebitorRepositoryIntegrationTest extends ContextBasedTestWithClean // then allTheseDebitorsAreReturned( result, - "debitor(D-1000111: rel(anchor='LP First GmbH', type='DEBITOR', holder='LP First GmbH'), fir)", - "debitor(D-1000212: rel(anchor='LP Peter Smith - The Second Hand and Thrift Stores-n-Shipping e.K.', type='DEBITOR', holder='LP Peter Smith - The Second Hand and Thrift Stores-n-Shipping e.K.'), sec)", - "debitor(D-1000313: rel(anchor='IF Third OHG', type='DEBITOR', holder='IF Third OHG'), thi)"); + "debitor(D-1000111: rel(anchor='LP First GmbH', type=DEBITOR, holder='LP First GmbH'), fir)", + "debitor(D-1000212: rel(anchor='LP Peter Smith - The Second Hand and Thrift Stores-n-Shipping e.K.', type=DEBITOR, holder='LP Peter Smith - The Second Hand and Thrift Stores-n-Shipping e.K.'), sec)", + "debitor(D-1000313: rel(anchor='IF Third OHG', type=DEBITOR, holder='IF Third OHG'), thi)"); } @ParameterizedTest @@ -293,7 +294,7 @@ class HsOfficeDebitorRepositoryIntegrationTest extends ContextBasedTestWithClean // then assertThat(result).map(Object::toString).contains( - "debitor(D-1000313: rel(anchor='IF Third OHG', type='DEBITOR', holder='IF Third OHG'), thi)"); + "debitor(D-1000313: rel(anchor='IF Third OHG', type=DEBITOR, holder='IF Third OHG'), thi)"); } } @@ -310,7 +311,7 @@ class HsOfficeDebitorRepositoryIntegrationTest extends ContextBasedTestWithClean // then exactlyTheseDebitorsAreReturned(result, - "debitor(D-1000313: rel(anchor='IF Third OHG', type='DEBITOR', holder='IF Third OHG'), thi)"); + "debitor(D-1000313: rel(anchor='IF Third OHG', type=DEBITOR, holder='IF Third OHG'), thi)"); } } @@ -326,7 +327,7 @@ class HsOfficeDebitorRepositoryIntegrationTest extends ContextBasedTestWithClean final var result = debitorRepo.findDebitorsByOptionalNameLike("third contact"); // then - exactlyTheseDebitorsAreReturned(result, "debitor(D-1000313: rel(anchor='IF Third OHG', type='DEBITOR', holder='IF Third OHG'), thi)"); + exactlyTheseDebitorsAreReturned(result, "debitor(D-1000313: rel(anchor='IF Third OHG', type=DEBITOR, holder='IF Third OHG'), thi)"); } } 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 f0b515df..f49cc89d 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 @@ -413,7 +413,7 @@ class HsOfficePartnerControllerAcceptanceTest extends ContextBasedTestWithCleanu final var newPartnerPersonUuid = givenPartner.getPartnerRel().getHolder().getUuid(); assertThat(relationRepo.findRelationRelatedToPersonUuidRelationTypeMarkPersonAndContactData(newPartnerPersonUuid, EX_PARTNER, null, null, null)) .map(HsOfficeRelation::toShortString) - .contains("rel(anchor='NP Winkler, Paul', type='EX_PARTNER', holder='UF Erben Bessler')"); + .contains("rel(anchor='NP Winkler, Paul', type=EX_PARTNER, holder='UF Erben Bessler')"); } @Test 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 0912bc7e..1322e015 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 @@ -144,6 +144,7 @@ class HsOfficePersonEntityUnitTest { assertThat(actualDisplay).isEqualTo("person(salutation='Herr', title='Prof. Dr.', familyName='some family name', givenName='some given name')"); } + @Test void toStringWithSalutationAndWithoutTitleSkipsTitle() { final var givenPersonEntity = HsOfficePersonRbacEntity.builder() diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonRbacRepositoryIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonRbacRepositoryIntegrationTest.java index fb5f3454..cb03c6e1 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonRbacRepositoryIntegrationTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonRbacRepositoryIntegrationTest.java @@ -258,6 +258,23 @@ class HsOfficePersonRbacRepositoryIntegrationTest extends ContextBasedTestWithCl } } + @Test + public void findPersonsrepresentedByPersonWithUuid() { + + // given + context("superuser-alex@hostsharing.net"); + final var personUuid = personRbacRepo.findPersonByOptionalNameLike("Fouler").getFirst().getUuid(); + + // when + @SuppressWarnings("unchecked") final List representedPersons = personRbacRepo.findPersonsrepresentedByPersonWithUuid(personUuid); + + // then + assertThat(representedPersons).map(Object::toString).containsExactlyInAnyOrder( + "person(personType=NP, familyName='Fouler', givenName='Ellie')", + "person(personType=LP, tradeName='Fourth eG')" + ); + } + @Test public void auditJournalLogIsAvailable() { // given @@ -265,7 +282,7 @@ class HsOfficePersonRbacRepositoryIntegrationTest extends ContextBasedTestWithCl select currentTask, targetTable, targetOp, targetdelta->>'tradename', targetdelta->>'lastname' from base.tx_journal_v where targettable = 'hs_office.person'; - """); + """); // when @SuppressWarnings("unchecked") final List customerLogEntries = query.getResultList(); diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRealRelationRepositoryIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRealRelationRepositoryIntegrationTest.java index 16fc6002..e828f26a 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRealRelationRepositoryIntegrationTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRealRelationRepositoryIntegrationTest.java @@ -62,10 +62,10 @@ class HsOfficeRealRelationRepositoryIntegrationTest extends ContextBasedTestWith context("superuser-alex@hostsharing.net"); // just to be able to access RBAc-entities persons+contact exactlyTheseRelationsAreReturned( result, - "rel(anchor='LP Peter Smith - The Second Hand and Thrift Stores-n-Shipping e.K.', type='REPRESENTATIVE', holder='NP Smith, Peter', contact='second contact')", - "rel(anchor='LP Hostsharing eG', type='PARTNER', holder='NP Smith, Peter', contact='sixth contact')", - "rel(anchor='NP Smith, Peter', type='DEBITOR', holder='NP Smith, Peter', contact='third contact')", - "rel(anchor='IF Third OHG', type='SUBSCRIBER', mark='members-announce', holder='NP Smith, Peter', contact='third contact')" + "rel(anchor='LP Peter Smith - The Second Hand and Thrift Stores-n-Shipping e.K.', type=REPRESENTATIVE, holder='NP Smith, Peter', contact='second contact')", + "rel(anchor='LP Hostsharing eG', type=PARTNER, holder='NP Smith, Peter', contact='sixth contact')", + "rel(anchor='NP Smith, Peter', type=DEBITOR, holder='NP Smith, Peter', contact='third contact')", + "rel(anchor='IF Third OHG', type=SUBSCRIBER, mark='members-announce', holder='NP Smith, Peter', contact='third contact')" ); } @@ -81,7 +81,7 @@ class HsOfficeRealRelationRepositoryIntegrationTest extends ContextBasedTestWith context("superuser-alex@hostsharing.net"); // just to be able to access RBAc-entities persons+contact exactlyTheseRelationsAreReturned( result, - "rel(anchor='LP Peter Smith - The Second Hand and Thrift Stores-n-Shipping e.K.', type='REPRESENTATIVE', holder='NP Smith, Peter', contact='second contact')" + "rel(anchor='LP Peter Smith - The Second Hand and Thrift Stores-n-Shipping e.K.', type=REPRESENTATIVE, holder='NP Smith, Peter', contact='second contact')" ); } } diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationRepositoryIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationRepositoryIntegrationTest.java index d316274d..3ee7ba4d 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationRepositoryIntegrationTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationRepositoryIntegrationTest.java @@ -126,7 +126,7 @@ class HsOfficeRelationRepositoryIntegrationTest extends ContextBasedTestWithClea final var stored = relationRbacRepo.findByUuid(result.returnedValue().getUuid()); assertThat(stored).isNotEmpty().map(HsOfficeRelation::toString).get() .isEqualTo( - "rel(anchor='UF Erben Bessler', type='SUBSCRIBER', mark='operations-announce', holder='NP Winkler, Paul', contact='fourth contact')"); + "rel(anchor='UF Erben Bessler', type=SUBSCRIBER, mark='operations-announce', holder='NP Winkler, Paul', contact='fourth contact')"); } @Test @@ -213,9 +213,9 @@ class HsOfficeRelationRepositoryIntegrationTest extends ContextBasedTestWithClea // then allTheseRelationsAreReturned( result, - "rel(anchor='LP Hostsharing eG', type='PARTNER', holder='NP Smith, Peter', contact='sixth contact')", - "rel(anchor='LP Peter Smith - The Second Hand and Thrift Stores-n-Shipping e.K.', type='REPRESENTATIVE', holder='NP Smith, Peter', contact='second contact')", - "rel(anchor='IF Third OHG', type='SUBSCRIBER', mark='members-announce', holder='NP Smith, Peter', contact='third contact')"); + "rel(anchor='LP Hostsharing eG', type=PARTNER, holder='NP Smith, Peter', contact='sixth contact')", + "rel(anchor='LP Peter Smith - The Second Hand and Thrift Stores-n-Shipping e.K.', type=REPRESENTATIVE, holder='NP Smith, Peter', contact='second contact')", + "rel(anchor='IF Third OHG', type=SUBSCRIBER, mark='members-announce', holder='NP Smith, Peter', contact='third contact')"); } @Test @@ -237,10 +237,10 @@ class HsOfficeRelationRepositoryIntegrationTest extends ContextBasedTestWithClea // then: exactlyTheseRelationsAreReturned( result, - "rel(anchor='LP Peter Smith - The Second Hand and Thrift Stores-n-Shipping e.K.', type='REPRESENTATIVE', holder='NP Smith, Peter', contact='second contact')", - "rel(anchor='IF Third OHG', type='SUBSCRIBER', mark='members-announce', holder='NP Smith, Peter', contact='third contact')", - "rel(anchor='LP Hostsharing eG', type='PARTNER', holder='NP Smith, Peter', contact='sixth contact')", - "rel(anchor='NP Smith, Peter', type='DEBITOR', holder='NP Smith, Peter', contact='third contact')"); + "rel(anchor='LP Peter Smith - The Second Hand and Thrift Stores-n-Shipping e.K.', type=REPRESENTATIVE, holder='NP Smith, Peter', contact='second contact')", + "rel(anchor='IF Third OHG', type=SUBSCRIBER, mark='members-announce', holder='NP Smith, Peter', contact='third contact')", + "rel(anchor='LP Hostsharing eG', type=PARTNER, holder='NP Smith, Peter', contact='sixth contact')", + "rel(anchor='NP Smith, Peter', type=DEBITOR, holder='NP Smith, Peter', contact='third contact')"); } } diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/sepamandate/HsOfficeSepaMandateControllerAcceptanceTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/sepamandate/HsOfficeSepaMandateControllerAcceptanceTest.java index 53a90cc6..92f1288e 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/sepamandate/HsOfficeSepaMandateControllerAcceptanceTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/sepamandate/HsOfficeSepaMandateControllerAcceptanceTest.java @@ -383,7 +383,7 @@ class HsOfficeSepaMandateControllerAcceptanceTest extends ContextBasedTestWithCl context.define("superuser-alex@hostsharing.net"); assertThat(sepaMandateRepo.findByUuid(givenSepaMandate.getUuid())).isPresent().get() .matches(mandate -> { - assertThat(mandate.getDebitor().toString()).isEqualTo("debitor(D-1000111: rel(anchor='LP First GmbH', type='DEBITOR', holder='LP First GmbH'), fir)"); + assertThat(mandate.getDebitor().toString()).isEqualTo("debitor(D-1000111: rel(anchor='LP First GmbH', type=DEBITOR, holder='LP First GmbH'), fir)"); assertThat(mandate.getBankAccount().toShortString()).isEqualTo("First GmbH"); assertThat(mandate.getReference()).isEqualTo("temp ref CAT Z - patched"); assertThat(mandate.getValidFrom()).isEqualTo("2020-06-05"); @@ -424,7 +424,8 @@ class HsOfficeSepaMandateControllerAcceptanceTest extends ContextBasedTestWithCl // finally, the sepaMandate is actually updated assertThat(sepaMandateRepo.findByUuid(givenSepaMandate.getUuid())).isPresent().get() .matches(mandate -> { - assertThat(mandate.getDebitor().toString()).isEqualTo("debitor(D-1000111: rel(anchor='LP First GmbH', type='DEBITOR', holder='LP First GmbH'), fir)"); + assertThat(mandate.getDebitor().toString()) + .isEqualTo("debitor(D-1000111: rel(anchor='LP First GmbH', type=DEBITOR, holder='LP First GmbH'), fir)"); assertThat(mandate.getBankAccount().toShortString()).isEqualTo("First GmbH"); assertThat(mandate.getReference()).isEqualTo("temp ref CAT Z"); assertThat(mandate.getValidity().asString()).isEqualTo("[2022-11-01,2023-01-01)"); diff --git a/src/test/java/net/hostsharing/hsadminng/hs/scenarios/ScenarioTest.java b/src/test/java/net/hostsharing/hsadminng/hs/scenarios/ScenarioTest.java index 01ab680e..f5b22217 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/scenarios/ScenarioTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/scenarios/ScenarioTest.java @@ -1,9 +1,11 @@ package net.hostsharing.hsadminng.hs.scenarios; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; import lombok.SneakyThrows; +import net.hostsharing.hsadminng.hs.scenarios.TemplateResolver.Resolver; import net.hostsharing.hsadminng.rbac.context.ContextBasedTest; import net.hostsharing.hsadminng.rbac.test.JpaAttempt; -import net.hostsharing.hsadminng.hs.scenarios.TemplateResolver.Resolver; import org.apache.commons.collections4.SetUtils; import org.apache.commons.lang3.ArrayUtils; import org.jetbrains.annotations.NotNull; @@ -17,6 +19,7 @@ import java.lang.reflect.Method; import java.math.BigDecimal; import java.util.HashMap; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Stack; @@ -159,7 +162,7 @@ public abstract class ScenarioTest extends ContextBasedTest { assertThat(knowVariables().containsKey(declaredAlias)) .as("@Producer method " + currentTestMethod.getName() + " did declare but not produce \"" + declaredAlias + "\"") - .isTrue() ); + .isTrue()); } }); } @@ -203,6 +206,14 @@ public abstract class ScenarioTest extends ContextBasedTest { return resolved; } + @SneakyThrows + public static List> resolveJsonArray(final String text) { + return new ObjectMapper().readValue( + resolve(text, DROP_COMMENTS), + new TypeReference<>() {} + ); + } + public static Object resolveTyped(final String resolvableText) { final var resolved = resolve(resolvableText, DROP_COMMENTS); try { diff --git a/src/test/java/net/hostsharing/hsadminng/hs/scenarios/UseCase.java b/src/test/java/net/hostsharing/hsadminng/hs/scenarios/UseCase.java index 69d687a5..9a4b4fd5 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/scenarios/UseCase.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/scenarios/UseCase.java @@ -155,18 +155,26 @@ public abstract class UseCase> { } @SneakyThrows - public final HttpResponse httpGet(final String uriPathWithPlaceholders) { - final var uriPath = ScenarioTest.resolve(uriPathWithPlaceholders, DROP_COMMENTS); - final var request = HttpRequest.newBuilder() + public final HttpResponse httpGet( + final String uriPathWithPlaceholder, + final Function requestCustomizer) { + final var uriPath = ScenarioTest.resolve(uriPathWithPlaceholder, DROP_COMMENTS); + final var requestBuilder = HttpRequest.newBuilder() .GET() .uri(new URI("http://localhost:" + testSuite.port + uriPath)) - .header("Authorization", "Bearer " + ScenarioTest.RUN_AS_USER) - .timeout(seconds(HTTP_TIMEOUT_SECONDS)) - .build(); + .timeout(seconds(HTTP_TIMEOUT_SECONDS)); + final var customizedRequestBuilder = requestCustomizer.apply(requestBuilder); + final var request = customizedRequestBuilder.build(); final var response = client.send(request, BodyHandlers.ofString()); return new HttpResponse(HttpMethod.GET, uriPath, null, response); } + @SneakyThrows + public final HttpResponse httpGet(final String uriPathWithPlaceholders) { + return httpGet(uriPathWithPlaceholders, + req -> req.header("Authorization", "Bearer " + ScenarioTest.RUN_AS_USER)); + } + @SneakyThrows public final HttpResponse httpPost(final String uriPathWithPlaceholders, final JsonTemplate bodyJsonTemplate) { final var uriPath = ScenarioTest.resolve(uriPathWithPlaceholders, DROP_COMMENTS); diff --git a/src/test/java/net/hostsharing/hsadminng/rbac/context/ContextIntegrationTests.java b/src/test/java/net/hostsharing/hsadminng/rbac/context/ContextIntegrationTests.java index c3381f6c..d47411b8 100644 --- a/src/test/java/net/hostsharing/hsadminng/rbac/context/ContextIntegrationTests.java +++ b/src/test/java/net/hostsharing/hsadminng/rbac/context/ContextIntegrationTests.java @@ -68,7 +68,7 @@ class ContextIntegrationTests { assertThat(context.fetchCurrentSubjectUuid()).isNotNull(); - assertThat(context.fetchAssumedRoles()).isEmpty(); + assertThat(context.fetchAssumedRolesNames()).isEmpty(); assertThat(context.fetchCurrentSubjectOrAssumedRolesUuids()) .containsExactly(context.fetchCurrentSubjectUuid()); @@ -90,7 +90,7 @@ class ContextIntegrationTests { assertThat(context.fetchCurrentSubjectUuid()).isNotNull(); - assertThat(context.fetchAssumedRoles()).isEqualTo(Array.of("rbactest.package#yyy00:ADMIN")); + assertThat(context.fetchAssumedRolesNames()).isEqualTo(Array.of("rbactest.package#yyy00:ADMIN")); assertThat(context.fetchCurrentSubjectOrAssumedRolesUuids()) .containsExactly(context.fetchCurrentSubjectOrAssumedRolesUuids()); @@ -133,7 +133,7 @@ class ContextIntegrationTests { assertThat(currentSubject).isEqualTo("superuser-alex@hostsharing.net"); // then - assertThat(context.fetchAssumedRoles()) + assertThat(context.fetchAssumedRolesNames()) .isEqualTo(Array.of("rbactest.customer#xxx:OWNER", "rbactest.customer#yyy:OWNER")); assertThat(context.fetchCurrentSubjectOrAssumedRolesUuids()).hasSize(2); } diff --git a/src/test/java/net/hostsharing/hsadminng/rbac/context/RbacContextControllerRestTest.java b/src/test/java/net/hostsharing/hsadminng/rbac/context/RbacContextControllerRestTest.java new file mode 100644 index 00000000..36bee5c0 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/rbac/context/RbacContextControllerRestTest.java @@ -0,0 +1,126 @@ +package net.hostsharing.hsadminng.rbac.context; + +import net.hostsharing.hsadminng.config.DisableSecurityConfig; +import net.hostsharing.hsadminng.config.MessageTranslator; +import net.hostsharing.hsadminng.context.Context; +import net.hostsharing.hsadminng.mapper.StrictMapper; +import net.hostsharing.hsadminng.persistence.EntityManagerWrapper; +import net.hostsharing.hsadminng.rbac.role.RbacRoleEntity; +import net.hostsharing.hsadminng.rbac.role.RbacRoleRepository; +import net.hostsharing.hsadminng.rbac.role.RbacRoleType; +import net.hostsharing.hsadminng.rbac.subject.RbacSubjectEntity; +import net.hostsharing.hsadminng.rbac.subject.RbacSubjectRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; + +import jakarta.persistence.EntityManagerFactory; +import jakarta.persistence.SynchronizationType; +import java.util.Arrays; +import java.util.Map; +import java.util.UUID; + +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(RbacContextController.class) +@Import({ StrictMapper.class, DisableSecurityConfig.class, MessageTranslator.class }) +@ActiveProfiles("test") +class RbacContextControllerRestTest { + + private static final String GIVEN_SUBJECT_NAME = "superuser-alex@hostsharing.net"; + private static final boolean GIVEN_GLOBAL_ADMIN = true; + + @Autowired + MockMvc mockMvc; + + @MockitoBean + Context contextMock; + + @MockitoBean + RbacRoleRepository rbacRoleRepository; + + @MockitoBean + RbacSubjectRepository rbacSubjectRepository; + + @MockitoBean + EntityManagerWrapper em; + + @MockitoBean + EntityManagerFactory emf; + + + @BeforeEach + void init() { + when(emf.createEntityManager()).thenReturn(em); + when(emf.createEntityManager(any(Map.class))).thenReturn(em); + when(emf.createEntityManager(any(SynchronizationType.class))).thenReturn(em); + when(emf.createEntityManager(any(SynchronizationType.class), any(Map.class))).thenReturn(em); + + // current subject uuid mock + final var mockUuid = UUID.randomUUID(); + when(contextMock.fetchCurrentSubjectUuid()).thenReturn(mockUuid); + + // find by uuid mock + final var mockSubject = new RbacSubjectEntity(); + mockSubject.setUuid(mockUuid); + mockSubject.setName(GIVEN_SUBJECT_NAME); + when(rbacSubjectRepository.findByUuid(mockUuid)).thenReturn(mockSubject); + } + + @Test + void apiContextWillReturnCurrentContext() throws Exception { + + // given + final var rolesToAssume = "rbactest.package#xxx00:OWNER;rbactest.package#yyy00:OWNER"; + when(contextMock.isGlobalAdmin()).thenReturn(GIVEN_GLOBAL_ADMIN); + when(rbacRoleRepository.fetchAssumedRoles()).thenReturn( + Arrays.stream(rolesToAssume.split(";")) + .map(RbacRoleDescriptor::fromRoleName) + .map(roleDesc -> new RbacRoleEntity( + UUID.randomUUID(), UUID.randomUUID(), + roleDesc.tableName, roleDesc.objectIdName, roleDesc.roleType, + roleDesc.roleName)) + .toList() + ); + + // when + mockMvc.perform(MockMvcRequestBuilders + .get("/api/rbac/context") + .header("Authorization", "Bearer " + GIVEN_SUBJECT_NAME) + .header("assumed-roles", rolesToAssume) + .accept(MediaType.APPLICATION_JSON)) + .andDo(print()) + + // then + .andExpect(status().isOk()) + .andExpect(jsonPath("$.subject.name", is(GIVEN_SUBJECT_NAME))) + .andExpect(jsonPath("$.globalAdmin", is(GIVEN_GLOBAL_ADMIN))) + .andExpect(jsonPath("$.assumedRoles", hasSize(2))) + .andExpect(jsonPath("$.assumedRoles[0].roleName", is("rbactest.package#xxx00:OWNER"))) + .andExpect(jsonPath("$.assumedRoles[1].roleName", is("rbactest.package#yyy00:OWNER"))); + } + + record RbacRoleDescriptor(String roleName, String tableName, String objectIdName, RbacRoleType roleType) { + + private static RbacRoleDescriptor fromRoleName(final String roleName) { + final var tablePlus = roleName.split("#"); + final var tableName = tablePlus[0]; + final var objectId = tablePlus[1].split(":")[0]; + final var roleType = RbacRoleType.valueOf(tablePlus[1].split(":")[1]); + return new RbacRoleDescriptor(roleName, tableName, objectId, roleType); + } + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/rbac/grant/RbacGrantsDiagramServiceIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/rbac/grant/RbacGrantsDiagramServiceIntegrationTest.java index 4ad7ea43..b5a70998 100644 --- a/src/test/java/net/hostsharing/hsadminng/rbac/grant/RbacGrantsDiagramServiceIntegrationTest.java +++ b/src/test/java/net/hostsharing/hsadminng/rbac/grant/RbacGrantsDiagramServiceIntegrationTest.java @@ -100,6 +100,6 @@ class RbacGrantsDiagramServiceIntegrationTest extends ContextBasedTestWithCleanu final var targetObject = (UUID) em.createNativeQuery("SELECT uuid FROM hs_office.coopassettx WHERE reference='ref 1000101-1'").getSingleResult(); final var graph = grantsMermaidService.allGrantsFrom(targetObject, "view", EnumSet.of(Include.USERS)); - RbacGrantsDiagramService.writeToFile(join(";", context.fetchAssumedRoles()), graph, "doc/all-grants.md"); + RbacGrantsDiagramService.writeToFile(join(";", context.fetchAssumedRolesNames()), graph, "doc/all-grants.md"); } } diff --git a/src/test/java/net/hostsharing/hsadminng/rbac/role/RbacRoleRepositoryIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/rbac/role/RbacRoleRepositoryIntegrationTest.java index c77b70f4..309ae2cf 100644 --- a/src/test/java/net/hostsharing/hsadminng/rbac/role/RbacRoleRepositoryIntegrationTest.java +++ b/src/test/java/net/hostsharing/hsadminng/rbac/role/RbacRoleRepositoryIntegrationTest.java @@ -177,6 +177,31 @@ class RbacRoleRepositoryIntegrationTest { } } + @Nested + class FetchAssumedRoles { + + @Test + void someSubject_withoutAssumedRole_fetchesNoAssumedRoles() { + context.define("customer-admin@xxx.example.com"); + + final var result = rbacRoleRepository.fetchAssumedRoles(); + + assertThat(result).isNotNull().hasSize(0); + } + + @Test + void someSubject_withAssumedRoles_fetchesAssumedRoles() { + context.define("customer-admin@xxx.example.com", + "rbactest.package#xxx00:OWNER;rbactest.package#xxx01:OWNER;rbactest.package#xxx02:OWNER"); + + final var result = rbacRoleRepository.fetchAssumedRoles(); + + assertThat(result).isNotNull().hasSize(3) + .extracting(RbacRoleEntity::getRoleName) + .contains("rbactest.package#xxx00:OWNER", "rbactest.package#xxx01:OWNER", "rbactest.package#xxx02:OWNER"); + } + } + void exactlyTheseRbacRolesAreReturned(final List actualResult, final String... expectedRoleNames) { assertThat(actualResult) .extracting(RbacRoleEntity::getRoleName) diff --git a/src/test/java/net/hostsharing/hsadminng/rbac/test/ContextBasedTestWithCleanup.java b/src/test/java/net/hostsharing/hsadminng/rbac/test/ContextBasedTestWithCleanup.java index 6742192c..d7e71810 100644 --- a/src/test/java/net/hostsharing/hsadminng/rbac/test/ContextBasedTestWithCleanup.java +++ b/src/test/java/net/hostsharing/hsadminng/rbac/test/ContextBasedTestWithCleanup.java @@ -1,5 +1,6 @@ package net.hostsharing.hsadminng.rbac.test; +import net.hostsharing.hsadminng.persistence.ImmutableBaseEntity; import net.hostsharing.hsadminng.rbac.context.ContextBasedTest; import net.hostsharing.hsadminng.persistence.BaseEntity; import net.hostsharing.hsadminng.rbac.grant.RbacGrantEntity; @@ -52,7 +53,7 @@ public abstract class ContextBasedTestWithCleanup extends ContextBasedTest { @Autowired JpaAttempt jpaAttempt; - private TreeMap> entitiesToCleanup = new TreeMap<>(); + private TreeMap> entitiesToCleanup = new TreeMap<>(); private static Long latestIntialTestDataSerialId; private static boolean countersInitialized = false; @@ -67,19 +68,19 @@ public abstract class ContextBasedTestWithCleanup extends ContextBasedTest { private TestInfo testInfo; - public T refresh(final T entity) { + public T refresh(final T entity) { final var merged = em.merge(entity); em.refresh(merged); return merged; } - public UUID toCleanup(final Class entityClass, final UUID uuidToCleanup) { + public UUID toCleanup(final Class entityClass, final UUID uuidToCleanup) { out.println("toCleanup(" + entityClass.getSimpleName() + ", " + uuidToCleanup + ")"); entitiesToCleanup.put(uuidToCleanup, entityClass); return uuidToCleanup; } - public E toCleanup(final E entity) { + public E toCleanup(final E entity) { out.println("toCleanup(" + entity.getClass() + ", " + entity.getUuid()); if ( entity.getUuid() == null ) { throw new IllegalArgumentException("only persisted entities with valid uuid allowed");