1
0

credentials validation (#194)

Co-authored-by: Michael Hoennig <michael@hoennig.de>
Reviewed-on: https://dev.hostsharing.net/hostsharing/hs.hsadmin.ng/pulls/194
Reviewed-by: Marc Sandlus <marc.sandlus@hostsharing.net>
This commit is contained in:
Michael Hoennig
2025-09-01 12:13:58 +02:00
parent f1fc1203ae
commit c0991d96d9
23 changed files with 849 additions and 420 deletions
@@ -60,7 +60,7 @@ public class CredentialContextResourceToEntityMapper {
(resource.getQualifier() != null && !existingContextEntity.getQualifier().equals(resource.getQualifier()))) {
throw new EntityNotFoundException(
messageTranslator.translate(
"credentials.existing-{0}-does-not-match-given-resource-{1}",
"credentials.existing-credentials-context-{0}-does-not-match-given-resource-{1}",
existingContextEntity, resource));
}
entities.add(existingContextEntity);
@@ -61,9 +61,13 @@ public abstract class HsCredentialsContext implements Stringifyable, BaseEntity<
@Column(name = "public_access")
private boolean publicAccess;
public boolean isHsadminContext() {
return "HSADMIN".equals(type);
}
@Override
public String toShortString() {
return toString();
return type + (qualifier != null ? ":" + qualifier : "");
}
@Override
@@ -3,6 +3,7 @@ package net.hostsharing.hsadminng.hs.accounts;
import java.util.List;
import io.micrometer.core.annotation.Timed;
import lombok.val;
import net.hostsharing.hsadminng.config.NoSecurityRequirement;
import net.hostsharing.hsadminng.context.Context;
import net.hostsharing.hsadminng.accounts.generated.api.v1.api.ContextsApi;
@@ -37,7 +38,10 @@ public class HsCredentialsContextsController implements ContextsApi {
if (SecurityContextHolder.getContext().getAuthentication().isAuthenticated()) {
context.assumeRoles(assumedRoles);
}
final var loginContexts = contextRepo.findAll();
val isGlobalAdmin = context.isGlobalAdmin();
final var loginContexts = contextRepo.findAll().stream().filter(
context -> context.isPublicAccess() || isGlobalAdmin
).toList();
final var result = mapper.mapList(loginContexts, ContextResource.class);
return ResponseEntity.ok(result);
}
@@ -5,9 +5,11 @@ import java.time.ZoneOffset;
import java.util.List;
import java.util.UUID;
import java.util.function.BiConsumer;
import java.util.stream.Collectors;
import io.micrometer.core.annotation.Timed;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import lombok.val;
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;
@@ -19,7 +21,7 @@ import net.hostsharing.hsadminng.accounts.generated.api.v1.model.CredentialsPatc
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.HsOfficePersonRealRepository;
import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonType;
import net.hostsharing.hsadminng.mapper.StrictMapper;
import net.hostsharing.hsadminng.persistence.EntityManagerWrapper;
@@ -62,7 +64,7 @@ public class HsCredentialsController implements CredentialsApi {
private MessageTranslator messageTranslator;
@Autowired
private HsOfficePersonRbacRepository rbacPersonRepo;
private HsOfficePersonRealRepository realPersonRepo;
@Autowired
private HsCredentialsRepository credentialsRepo;
@@ -77,11 +79,11 @@ public class HsCredentialsController implements CredentialsApi {
context.define(); // without assumed roles, otherwise we cannot access the subject anymore
final var credentialsEntity = credentialsRepo.findByUuid(credentialsUuid);
val credentialsEntity = credentialsRepo.findByUuid(credentialsUuid);
if (credentialsEntity.isEmpty()) {
return ResponseEntity.notFound().build();
}
final var result = mapper.map(
val result = mapper.map(
credentialsEntity.get(), CredentialsResource.class, ENTITY_TO_RESOURCE_POSTMAPPER);
return ResponseEntity.ok(result);
}
@@ -95,10 +97,10 @@ public class HsCredentialsController implements CredentialsApi {
) {
context.assumeRoles(assumedRoles);
final var credentials = personUuid == null
val credentials = personUuid == null
? credentialsRepo.findByCurrentSubject()
: findByPersonUuid(personUuid);
final var result = mapper.mapList(
val result = mapper.mapList(
credentials, CredentialsResource.class, ENTITY_TO_RESOURCE_POSTMAPPER);
return ResponseEntity.ok(result);
}
@@ -112,22 +114,25 @@ public class HsCredentialsController implements CredentialsApi {
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());
val newlySavedSubject = createSubject(body.getNickname());
// afterward, create and save the credentials entity with the subject's UUID
final var newCredentialsEntity = mapper.map(
val newCredentialsEntity = mapper.map(
body, HsCredentialsEntity.class, RESOURCE_TO_ENTITY_POSTMAPPER);
validate(newCredentialsEntity);
newCredentialsEntity.setSubject(newlySavedSubject);
validateOnCreate(newCredentialsEntity);
// switch to the new subject to get access to its own subject RBAC object
context.define("activate newly created self-service subject", null, body.getNickname(), null);
newCredentialsEntity.setSubject(em.merge(newlySavedSubject)); // attached to EM by the new subject
em.persist(newCredentialsEntity); // newCredentialsEntity.uuid == newlySavedSubject.uuid => do not use repository!
// return the new credentials as a resource
final var uri =
val uri =
MvcUriComponentsBuilder.fromController(getClass())
.path("/api/hs/accounts/credentials/{id}")
.buildAndExpand(newCredentialsEntity.getUuid())
.toUri();
final var newCredentialsResource = mapper.map(
val newCredentialsResource = mapper.map(
newCredentialsEntity, CredentialsResource.class, ENTITY_TO_RESOURCE_POSTMAPPER);
return ResponseEntity.created(uri).body(newCredentialsResource);
}
@@ -137,8 +142,9 @@ public class HsCredentialsController implements CredentialsApi {
@Timed("app.credentials.credentials.deleteCredentialsByUuid")
public ResponseEntity<Void> deleteCredentialsByUuid(final UUID credentialsUuid) {
context.define(); // without assumed roles, otherwise we cannot access the subject anymore
final var credentialsEntity = em.getReference(HsCredentialsEntity.class, credentialsUuid);
val credentialsEntity = em.getReference(HsCredentialsEntity.class, credentialsUuid);
credentialsEntity.getLoginContexts().clear();
validateOnDelete(credentialsEntity);
em.flush();
em.remove(credentialsEntity);
em.remove(credentialsEntity.getSubject());
@@ -154,12 +160,13 @@ public class HsCredentialsController implements CredentialsApi {
) {
context.define(); // without assumed roles, otherwise we cannot access the subject anymore
final var current = credentialsRepo.findByUuid(credentialsUuid).orElseThrow();
val current = credentialsRepo.findByUuid(credentialsUuid).orElseThrow();
new HsCredentialsEntityPatcher(contextMapper, current).apply(body);
validateOnUpdate(current);
final var saved = credentialsRepo.save(current);
final var mapped = mapper.map(
val saved = credentialsRepo.save(current);
val mapped = mapper.map(
saved, CredentialsResource.class, ENTITY_TO_RESOURCE_POSTMAPPER);
return ResponseEntity.ok(mapped);
}
@@ -173,56 +180,129 @@ public class HsCredentialsController implements CredentialsApi {
context.define();
// fetch the data
final var currentSubjectUuid = context.fetchCurrentSubjectUuid();
final var currentSubject = rbacSubjectRepo.findByUuid(currentSubjectUuid);
val currentSubjectUuid = context.fetchCurrentSubjectUuid();
val currentSubject = rbacSubjectRepo.findByUuid(currentSubjectUuid);
val person = credentialsRepo.findByUuid(currentSubjectUuid).orElseThrow().getPerson();
final boolean isGlobalAdmin = context.isGlobalAdmin();
final var person = credentialsRepo.findByUuid(currentSubjectUuid).orElseThrow().getPerson();
// finally, return the result
final var result = currentLoginUserResponse(currentSubject, person, isGlobalAdmin);
val result = currentLoginUserResponse(currentSubject, person, isGlobalAdmin);
return ResponseEntity.ok(result);
}
@Override
@Transactional
@Timed("app.credentials.credentials.credentialsUsed")
public ResponseEntity<CredentialsResource> credentialsUsed(
final String assumedRoles,
final UUID credentialsUuid) {
context.assumeRoles(assumedRoles);
public ResponseEntity<CredentialsResource> credentialsUsed(final UUID credentialsUuid) {
context.define();
final var current = credentialsRepo.findByUuid(credentialsUuid).orElseThrow();
val current = credentialsRepo.findByUuid(credentialsUuid).orElseThrow();
current.setOnboardingToken(null);
current.setLastUsed(LocalDateTime.now());
final var saved = credentialsRepo.save(current);
final var mapped = mapper.map(saved, CredentialsResource.class, ENTITY_TO_RESOURCE_POSTMAPPER);
val saved = credentialsRepo.save(current);
val mapped = mapper.map(saved, CredentialsResource.class, ENTITY_TO_RESOURCE_POSTMAPPER);
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)
private void validateOnCreate(final HsCredentialsEntity newCredentialsEntity) {
validateReferencedPersonToBeRepresentedByLoginUserPerson(newCredentialsEntity);
validateNormalUsersOnlyAccessPublicContexts(newCredentialsEntity);
validateNaturalPersonRequirementOfContexts(newCredentialsEntity);
}
private void validateOnUpdate(final HsCredentialsEntity current) {
validateNormalUsersOnlyAccessPublicContexts(current);
validateNaturalPersonRequirementOfContexts(current);
validateOwnHsadminCredentialsMustNotBeRemoved(current);
}
private void validateOnDelete(final HsCredentialsEntity credentialsEntity) {
validateOwnHsadminCredentialsMustNotBeRemoved(credentialsEntity);
}
private void validateReferencedPersonToBeRepresentedByLoginUserPerson(final HsCredentialsEntity newCredentialsEntity) {
if (context.isGlobalAdmin()) {
return;
}
val referredPersonUuid = newCredentialsEntity.getPerson().getUuid();
val currentSubjectUuid = context.fetchCurrentSubjectUuid();
val loginPersonUuid = credentialsRepo.findByUuid(currentSubjectUuid)
.map(HsCredentialsEntity::getPerson)
.map(HsOfficePerson::getUuid)
.orElseThrow();
val representedPersonUuids = realPersonRepo.findPersonsRepresentedByPersonWithUuid(loginPersonUuid)
.stream().map(HsOfficePerson::getUuid).toList();
if ( !representedPersonUuids.contains(personUuid)) {
if ( !representedPersonUuids.contains(referredPersonUuid)) {
throw new ValidationException(
messageTranslator.translate(
"credentials.access-denied-person-uuid-{0}-not-represented-by-currently-logged-in-person",
personUuid));
"credentials.access-denied-to-person-with-uuid-{0}-not-represented-by-currently-logged-in-person",
loginPersonUuid));
}
}
private void validateNormalUsersOnlyAccessPublicContexts(final HsCredentialsEntity newCredentialsEntity) {
val forbiddenContexts = newCredentialsEntity.getLoginContexts().stream()
.filter(c -> !c.isPublicAccess() && !context.isGlobalAdmin() )
.toList();
if (!forbiddenContexts.isEmpty()) {
throw new ValidationException(
messageTranslator.translate(
"credentials.access-denied-for-contexts-{0}",
toDisplay(forbiddenContexts)
));
}
}
private void validateNaturalPersonRequirementOfContexts(final HsCredentialsEntity newCredentialsEntity) {
if (newCredentialsEntity.getPerson().getPersonType().equals(HsOfficePersonType.NATURAL_PERSON)) {
return;
}
val contextsWhichRequireNaturalPerson = newCredentialsEntity.getLoginContexts().stream()
.filter(HsCredentialsContext::isOnlyForNaturalPersons)
.toList();
if (!contextsWhichRequireNaturalPerson.isEmpty()) {
throw new ValidationException(
messageTranslator.translate(
"credentials.context-requires-natural-person-{0}",
toDisplay(contextsWhichRequireNaturalPerson)
));
}
}
private void validateOwnHsadminCredentialsMustNotBeRemoved(final HsCredentialsEntity newCredentialsEntity) {
if (!newCredentialsEntity.getSubject().getUuid().equals(context.fetchCurrentSubjectUuid())) {
return;
}
val hsadminCredentialsContext = newCredentialsEntity.getLoginContexts().stream()
.filter(HsCredentialsContext::isHsadminContext)
.toList();
if (hsadminCredentialsContext.isEmpty()) {
throw new ValidationException(
messageTranslator.translate(
"credentials.own-hsadmin-credentials-must-not-be-removed"
));
}
}
private static String toDisplay(final List<HsCredentialsContextRealEntity> contextsWhichRequireNaturalPerson) {
return contextsWhichRequireNaturalPerson.stream()
.map(HsCredentialsContext::toShortString)
.sorted()
.map(s -> "'" + s + "'")
.collect(Collectors.joining(", "));
}
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-service subject", null, nickname, null);
}
return subjectRepo.findByUuid(newRbacSubject.getUuid()); // now attached to EM
val rbacSubjectEntity = new RbacSubjectEntity(null, nickname);
val newRbacSubject = subjectRepo.create(rbacSubjectEntity);
return newRbacSubject;
}
private List<HsCredentialsEntity> findByPersonUuid(final UUID personUuid) {
final var person = rbacPersonRepo.findByUuid(personUuid).orElseThrow(
val person = realPersonRepo.findByUuid(personUuid).orElseThrow(
() -> new EntityNotFoundException(
messageTranslator.translate("general.{0}-{1}-not-found-or-not-accessible", "personUuid", personUuid)
)
@@ -236,7 +316,7 @@ public class HsCredentialsController implements CredentialsApi {
final RbacSubjectEntity currentSubject,
final HsOfficePerson<?> person,
final boolean isGlobalAdmin) {
final var result = new CurrentLoginUserResource();
val result = new CurrentLoginUserResource();
result.setSubject(mapper.map(currentSubject, RbacSubjectResource.class));
result.setPerson(mapper.map(person, HsOfficePersonResource.class));
result.setGlobalAdmin(isGlobalAdmin);
@@ -267,7 +347,7 @@ public class HsCredentialsController implements CredentialsApi {
}
final BiConsumer<CredentialsInsertResource, HsCredentialsEntity> RESOURCE_TO_ENTITY_POSTMAPPER = (resource, entity) -> {
final var person = rbacPersonRepo.findByUuid(resource.getPersonUuid()).orElseThrow(
val person = realPersonRepo.findByUuid(resource.getPersonUuid()).orElseThrow(
() -> new EntityNotFoundException(
messageTranslator.translate("general.{0}-{1}-not-found-or-not-accessible", "personUuid", resource.getPersonUuid())
)
@@ -2,7 +2,7 @@ package net.hostsharing.hsadminng.hs.accounts;
import jakarta.persistence.*;
import lombok.*;
import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRbacEntity;
import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRealEntity;
import net.hostsharing.hsadminng.persistence.BaseEntity; // Assuming BaseEntity exists
import net.hostsharing.hsadminng.rbac.subject.RbacSubjectEntity;
import net.hostsharing.hsadminng.repr.Stringify;
@@ -46,7 +46,7 @@ public class HsCredentialsEntity implements BaseEntity<HsCredentialsEntity>, Str
@ManyToOne(optional = false, fetch = FetchType.EAGER)
@JoinColumn(name = "person_uuid", nullable = false, updatable = false, referencedColumnName = "uuid")
private HsOfficePersonRbacEntity person;
private HsOfficePersonRealEntity person; // TODO.impl: add RBAC-Support to CredentialsEntity, see Story #
@Version
private int version;
@@ -23,6 +23,22 @@ public interface HsOfficePersonRealRepository extends Repository<HsOfficePersonR
@Timed("app.office.persons.repo.findPersonByOptionalNameLike.real")
List<HsOfficePersonRealEntity> 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.real")
List<HsOfficePersonRealEntity> findPersonsRepresentedByPersonWithUuid(UUID personUuid);
@Timed("app.office.persons.repo.save.real")
HsOfficePersonRealEntity save(final HsOfficePersonRealEntity entity);
@@ -31,7 +31,7 @@ public class PingController implements TestApi {
public ResponseEntity<String> pong() {
final var userName = SecurityContextHolder.getContext().getAuthentication().getName();
// HOWTO translate text with placeholders - also see in resource files i18n/messages_*.properties.
final var translatedMessage = messageTranslator.translate("ping {0} - in English", userName);
final var translatedMessage = messageTranslator.translate("test.ponged-{0}--in-your-language", userName);
return ResponseEntity.ok(translatedMessage + "\n");
}
}
@@ -43,8 +43,7 @@ public class RbacSubjectController implements RbacSubjectsApi {
if (body.getUuid() == null) {
body.setUuid(UUID.randomUUID());
}
final var saved = mapper.map(body, RbacSubjectEntity.class);
rbacSubjectRepository.create(saved);
final var saved = rbacSubjectRepository.create(mapper.map(body, RbacSubjectEntity.class));
final var uri =
MvcUriComponentsBuilder.fromController(getClass())
.path("/api/rbac/subjects/{id}")
@@ -45,6 +45,10 @@ public interface RbacSubjectRepository extends Repository<RbacSubjectEntity, UUI
rbacSubjectEntity.setUuid(UUID.randomUUID());
}
insert(rbacSubjectEntity);
// RbacSubjectEntity binds to 'rbac.subject_rv',
// but the current user might not be allowed to read the newly created row from the restricted view,
// only the newly created subject (or a global admin) is allowed to read the new subject.
// Thus, the code which calls this method needs to switch the login user and fetch an attached entity.
return rbacSubjectEntity; // Not yet attached to EM!
}
@@ -4,7 +4,6 @@ post:
description: 'Is called when credentials got used for a login.'
operationId: credentialsUsed
parameters:
- $ref: 'auth.yaml#/components/parameters/assumedRoles'
- name: credentialsUuid
in: path
required: true
@@ -15,8 +15,11 @@ general.{0}-{1}-not-found-or-not-accessible={0} "{1}" nicht gefunden oder nicht
general.but-is=ist aber
# credentials validations
credentials.existing-{0}-does-not-match-given-resource-{1}=existierender Credentials-Context {0} passt nicht zum angegebenen {1}
credentials.access-denied-person-uuid-{0}-not-represented-by-currently-logged-in-person=Zugriff verweigert: personUuid "{0}" wird von der eingeloggten Person nicht repräsentiert
credentials.existing-credentials-context-{0}-does-not-match-given-resource-{1}=existierender Credentials-Context {0} passt nicht zum angegebenen {1}
credentials.access-denied-to-person-with-uuid-{0}-not-represented-by-currently-logged-in-person=Zugriff verweigert: personUuid "{0}" wird von der eingeloggten Person nicht repräsentiert
credentials.access-denied-for-contexts-{0}=Kontext-Zugriff verweigert: {0}
credentials.context-requires-natural-person-{0}=Kontext verlangt eine natürliche Person: {0}
credentials.own-hsadmin-credentials-must-not-be-removed=die eigenen hsadmin-Credentials dürfen nicht entfernt werden
# office.coop-shares
office.coop-shares.for-transactiontype-{0}-sharecount-must-be-positive-but-is-{1}=für transactionType={0}, muss shareCount positiv sein, ist aber {1}
@@ -15,8 +15,11 @@ general.{0}-{1}-not-found-or-not-accessible={0} "{1}" not found or not accessibl
general.but-is=but is
# credentials validations
credentials.existing-{0}-does-not-match-given-resource-{1}=existing {0} does not match given resource {1}
credentials.access-denied-person-uuid-{0}-not-represented-by-currently-logged-in-person=access denied: personUuid "{0}" not represented by currently logged in person
credentials.existing-credentials-context-{0}-does-not-match-given-resource-{1}=existing {0} does not match given resource {1}
credentials.access-denied-to-person-with-uuid-{0}-not-represented-by-currently-logged-in-person=access denied: personUuid "{0}" not represented by currently logged in person
credentials.access-denied-for-contexts-{0}=context-access denied: {0}
credentials.context-requires-natural-person-{0}=context requires natural person: {0}
credentials.own-hsadmin-credentials-must-not-be-removed=own hsadmin-credentials must not be removed
# office.coop-shares
office.coop-shares.for-transactiontype-{0}-sharecount-must-be-positive-but-is-{1}=for transactiontType {0} shareCount must be positive but is {1}
@@ -15,8 +15,11 @@ general.{0}-{1}-not-found-or-not-accessible={0} "{1}" non trouvé ou non accessi
general.but-is=mais c'est
# credentials validations
credentials.existing-{0}-does-not-match-given-resource-{1}={0} existant ne correspond pas à la ressource donnée {1}
credentials.access-denied-person-uuid-{0}-not-represented-by-currently-logged-in-person=accès refusé : personUuid "{0}" non représenté par la personne actuellement connectée
credentials.existing-credentials-context-{0}-does-not-match-given-resource-{1}={0} existant ne correspond pas à la ressource donnée {1}
credentials.access-denied-to-person-with-uuid-{0}-not-represented-by-currently-logged-in-person=accès refusé : personUuid "{0}" non représenté par la personne actuellement connectée
credentials.access-denied-for-contexts-{0}=accès au contexte refusé : {0}
credentials.context-requires-natural-person-{0}=le contexte requiert une personne physique : {0}
credentials.own-hsadmin-credentials-must-not-be-removed=suppression des identifiants hsadmin propres interdite
# office.coop-shares
office.coop-shares.for-transactiontype-{0}-sharecount-must-be-positive-but-is-{1}=pour le type de transaction {0}, shareCount doit être positif mais est {1}