HsAccountController.java
package net.hostsharing.hsadminng.hs.accounts;
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.api.AccountApi;
import net.hostsharing.hsadminng.accounts.generated.api.v1.model.CurrentLoginUserResource;
import net.hostsharing.hsadminng.accounts.generated.api.v1.model.HsOfficePersonResource;
import net.hostsharing.hsadminng.accounts.generated.api.v1.model.AccountInsertResource;
import net.hostsharing.hsadminng.accounts.generated.api.v1.model.AccountResource;
import net.hostsharing.hsadminng.accounts.generated.api.v1.model.AccountSubjectInsertResource;
import net.hostsharing.hsadminng.accounts.generated.api.v1.model.RbacSubjectResource;
import net.hostsharing.hsadminng.config.MessageTranslator;
import net.hostsharing.hsadminng.errors.ConflictException;
import net.hostsharing.hsadminng.errors.Validate;
import net.hostsharing.hsadminng.hs.office.person.HsOfficePerson;
import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRealEntity;
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;
import net.hostsharing.hsadminng.rbac.context.Context;
import net.hostsharing.hsadminng.rbac.grant.RbacGrantRepository;
import net.hostsharing.hsadminng.rbac.grant.RbacGrantService;
import net.hostsharing.hsadminng.rbac.role.RbacRoleRepository;
import net.hostsharing.hsadminng.rbac.role.RbacRoleService;
import net.hostsharing.hsadminng.rbac.role.RbacRoleType;
import net.hostsharing.hsadminng.rbac.subject.RbacSubjectEntity;
import net.hostsharing.hsadminng.rbac.subject.RbacSubjectRepository;
import net.hostsharing.hsadminng.rbac.subject.RealSubjectEntity;
import net.hostsharing.hsadminng.rbac.subject.RealSubjectRepository;
import net.hostsharing.hsadminng.rbac.subject.SubjectType;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.transaction.annotation.Transactional;
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 java.util.List;
import java.util.NoSuchElementException;
import java.util.UUID;
import java.util.function.BiConsumer;
import static java.util.Optional.of;
@RestController
@PreAuthorize("isAuthenticated()")
@SecurityRequirement(name = "bearerAuth")
public class HsAccountController implements AccountApi {
@Autowired
private Context context;
@Autowired
private EntityManagerWrapper em;
@Autowired
private StrictMapper mapper;
@Autowired
private MessageTranslator messageTranslator;
@Autowired
private HsOfficePersonRealRepository realPersonRepo;
@Autowired
private HsAccountRepository accountRepo;
@Autowired
private RbacSubjectRepository rbacSubjectRepo;
@Autowired
private RealSubjectRepository realSubjectRepo;
@Autowired
private RbacRoleRepository rbacRoleRepo;
@Autowired
private RbacGrantRepository rbacGrantRepo;
@Autowired
private RbacRoleService rbacRoleService;
@Autowired
private RbacGrantService rbacGrantService;
@Override
@Transactional(readOnly = true)
@Timed("app.accounts.account.getSingleAccountByUuid")
public ResponseEntity<AccountResource> getSingleAccountByUuid(final UUID accountUuid) {
context.define(); // without assumed roles, otherwise we cannot access the real subject anymore
val accountEntity = accountRepo.findVisibleByUuid(accountUuid);
if (accountEntity.isEmpty()) {
return ResponseEntity.notFound().build();
}
val result = mapper.map(
accountEntity.get(), AccountResource.class, ENTITY_TO_RESOURCE_POSTMAPPER);
return ResponseEntity.ok(result);
}
@Override
@Transactional(readOnly = true)
@Timed("app.accounts.account.getListOfAccountsByPersonUuid")
public ResponseEntity<List<AccountResource>> getListOfAccounts(
final String assumedRoles,
final UUID personUuid
) {
context.assumeRoles(assumedRoles);
final List<HsAccountEntity> accounts;
if (personUuid != null) {
accounts = findByPersonUuid(personUuid);
} else if (context.hasGlobalAdminRole()) {
accounts = accountRepo.findAll();
} else {
accounts = accountRepo.findByCurrentSubject();
}
val result = mapper.mapList(
accounts, AccountResource.class, ENTITY_TO_RESOURCE_POSTMAPPER);
return ResponseEntity.ok(result);
}
@Override
@Transactional
@Timed("app.accounts.account.postNewAccount")
public ResponseEntity<AccountResource> postNewAccount(
final AccountInsertResource body
) {
// Only with exactly 1 assumed role, we can do explicit grants, because the assumed role is used as 'grantor'.
// Otherwise, only the granting user could revoke the grant.
context.assumeRoles("rbac.global#global:ADMIN");
// determine the assigned person while we still have global-admin privileges
val newAccountEntity = mapper.map(
body, HsAccountEntity.class, RESOURCE_TO_ENTITY_POSTMAPPER);
validateOnCreate(newAccountEntity);
assignGlobalIdsIfMissing(newAccountEntity);
// fetch the existing subject or create and save a new one to get its UUID
val accountSubject = fetchOrCreateSubject(body);
// grant the person's ADMIN role to the account subject
rbacGrantService.grant(rbacRoleService.rbacRole(newAccountEntity.getPerson(), RbacRoleType.ADMIN))
.to(accountSubject);
// switch to the account subject to get access to its own subject RBAC object
context.define("activate the account subject", null, accountSubject.getName(), null);
// afterward, create and save the account entity with the subject's UUID
newAccountEntity.setSubject(em.merge(accountSubject)); // attached to EM by the account subject
em.persist(newAccountEntity); // newAccountEntity.uuid == accountSubject.uuid => do not use repository!
// return the new account as a resource
val uri =
MvcUriComponentsBuilder.fromController(getClass())
.path("/api/hs/accounts/accounts/{id}")
.buildAndExpand(newAccountEntity.getUuid())
.toUri();
val newAccountResource = mapper.map(
newAccountEntity, AccountResource.class, ENTITY_TO_RESOURCE_POSTMAPPER);
return ResponseEntity.created(uri).body(newAccountResource);
}
@Override
@Transactional
@Timed("app.accounts.account.deleteAccountByUuid")
public ResponseEntity<Void> deleteAccountByUuid(final UUID accountUuid) {
// TODO.spec Task#5637: narrow or widen once the spec exists; global-admin-only is the safe interim rule
// also defines the context without assumed roles, otherwise we cannot access the subject anymore
context.requireGlobalAdmin(
messageTranslator.translate("account.deleting-account-{0}-requires-global-admin", accountUuid));
val accountEntity = em.getReference(HsAccountEntity.class, accountUuid);
em.flush();
em.remove(accountEntity);
em.remove(accountEntity.getSubject());
return ResponseEntity.noContent().build();
}
@Override
@Transactional
@Timed("app.accounts.account.getCurrentLoginUser")
public ResponseEntity<CurrentLoginUserResource> getCurrentLoginUser() {
// define a context without assumed roles, otherwise we cannot access the subject anymore
context.define();
// fetch the data, the person is null if the current subject has no account
val currentSubjectUuid = context.fetchCurrentSubjectUuid();
val currentSubject = realSubjectRepo.findCurrentSubject();
val person = accountRepo.findByUuid(currentSubjectUuid).map(HsAccountEntity::getPerson).orElse(null);
final boolean isGlobalAdmin = context.isGlobalAdmin();
// finally, return the result
val result = currentLoginUserResponse(currentSubject, person, isGlobalAdmin);
return ResponseEntity.ok(result);
}
private void validateOnCreate(final HsAccountEntity newAccountEntity) {
validateReferencedPersonToBeANaturalPerson(newAccountEntity);
}
// when the client provides neither uid nor gid, both are auto-assigned equal; a single given
// value fills the other, and two explicitly given values are kept as an override.
private void assignGlobalIdsIfMissing(final HsAccountEntity entity) {
if (entity.getGlobalUid() == null && entity.getGlobalGid() == null) {
val nextFreeGlobalId = accountRepo.findNextFreeGlobalId();
entity.setGlobalUid(nextFreeGlobalId);
entity.setGlobalGid(nextFreeGlobalId);
} else if (entity.getGlobalUid() == null) {
entity.setGlobalUid(entity.getGlobalGid());
} else if (entity.getGlobalGid() == null) {
entity.setGlobalGid(entity.getGlobalUid());
}
}
private void validateReferencedPersonToBeANaturalPerson(final HsAccountEntity accountEntity) {
val referredPerson = accountEntity.getPerson();
if ( referredPerson.getPersonType() != HsOfficePersonType.NATURAL_PERSON) {
throw new ValidationException(
messageTranslator.translate(
"account.only-natural-persons-allowed-but-{0}-is-{1}",
referredPerson.getUuid(), referredPerson.getPersonType().name()));
}
}
private RealSubjectEntity fetchOrCreateSubject(final AccountInsertResource body) {
Validate.validate("subject, subject.uuid").exactlyOne(body.getSubject(), body.getSubjectUuid());
if (body.getSubjectUuid() != null) {
return fetchExistingUserSubject(body.getSubjectUuid());
}
return createSubject(body.getSubject());
}
private RealSubjectEntity fetchExistingUserSubject(final UUID subjectUuid) {
val subject = realSubjectRepo.findVisibleSubjectByUuid(subjectUuid).orElseThrow(
() -> new ValidationException(
messageTranslator.translate("account.no-subject-with-uuid-{0}-found", subjectUuid)));
if (subject.getType() != SubjectType.USER) {
throw new ValidationException(
messageTranslator.translate(
"account.subject-{0}-is-not-a-user-subject-but-{1}",
subject.getUuid(), subject.getType().name()));
}
if (accountRepo.findByUuid(subject.getUuid()).isPresent()) {
throw new ConflictException(
messageTranslator.translate(
"account.subject-{0}-already-has-an-account",
subject.getUuid()));
}
return subject;
}
private RealSubjectEntity createSubject(final AccountSubjectInsertResource newSubject) {
// newSubject.uuid+name are @NotNull-validated and type can only be USER, all enforced by the API definition
if (realSubjectRepo.findVisibleSubjectByUuid(newSubject.getUuid()).isPresent()) {
throw new ConflictException(
messageTranslator.translate(
"account.subject-with-uuid-{0}-already-exists",
newSubject.getUuid()));
}
val rbacSubjectEntity = RbacSubjectEntity.builder().uuid(newSubject.getUuid()).name(newSubject.getName()).build();
val newRbacSubject = rbacSubjectRepo.create(rbacSubjectEntity);
return em.find(RealSubjectEntity.class, newRbacSubject.getUuid());
}
private List<HsAccountEntity> findByPersonUuid(final UUID personUuid) {
if (realPersonRepo.findByUuid(personUuid).isEmpty()) {
throw new EntityNotFoundException(
messageTranslator.translate("general.{0}-{1}-not-found-or-not-accessible", "personUuid", personUuid));
}
return accountRepo.findVisibleByPersonUuid(personUuid);
}
private CurrentLoginUserResource currentLoginUserResponse(
final RealSubjectEntity currentSubject,
final HsOfficePerson<?> person,
final boolean isGlobalAdmin) {
val result = new CurrentLoginUserResource();
result.setSubject(mapper.map(currentSubject, RbacSubjectResource.class));
if (person != null) {
result.setPerson(mapper.map(person, HsOfficePersonResource.class));
}
result.setGlobalAdmin(isGlobalAdmin);
return result;
}
final BiConsumer<HsAccountEntity, AccountResource> ENTITY_TO_RESOURCE_POSTMAPPER = (entity, resource) -> {
of(entity.getSubject()).ifPresent(
subject -> resource.setSubject(mapper.map(subject, RbacSubjectResource.class))
);
of(entity.getPerson()).ifPresent(
person -> resource.setPerson(
mapper.map(person, HsOfficePersonResource.class)
)
);
};
final BiConsumer<AccountInsertResource, HsAccountEntity> RESOURCE_TO_ENTITY_POSTMAPPER = (resource, entity) -> {
Validate.validate("person, person.uuid").exactlyOne(resource.getPerson(), resource.getPersonUuid());
if ( resource.getPersonUuid() != null) {
entity.setPerson(realPersonRepo.findByUuid(resource.getPersonUuid()).orElseThrow(
() -> new NoSuchElementException("cannot find Person by 'person.uuid': " + resource.getPersonUuid())
));
} else {
entity.setPerson(realPersonRepo.save(
mapper.map(resource.getPerson(), HsOfficePersonRealEntity.class)
) );
}
val person = realPersonRepo.findByUuid(entity.getPerson().getUuid()).orElseThrow(
() -> new EntityNotFoundException(
messageTranslator.translate("general.{0}-{1}-not-found-or-not-accessible", "personUuid", resource.getPersonUuid())
)
);
entity.setPerson(person);
};
}