1
0

bugfix: fixes HTTP POST on credentials, including person+subject (#184)

Co-authored-by: Michael Hoennig <michael@hoennig.de>
Reviewed-on: https://dev.hostsharing.net/hostsharing/hs.hsadmin.ng/pulls/184
Reviewed-by: Marc Sandlus <marc.sandlus@hostsharing.net>
This commit is contained in:
Michael Hoennig
2025-07-07 21:09:37 +02:00
parent fee080dbf4
commit 3603ea911e
23 changed files with 482 additions and 126 deletions

View File

@@ -0,0 +1,67 @@
package net.hostsharing.hsadminng.credentials;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityNotFoundException;
import net.hostsharing.hsadminng.config.MessageTranslator;
import net.hostsharing.hsadminng.credentials.generated.api.v1.model.ContextResource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
@Service
public class CredentialContextResourceToEntityMapper {
private final EntityManager em;
private final MessageTranslator messageTranslator;
@Autowired
public CredentialContextResourceToEntityMapper(EntityManager em, MessageTranslator messageTranslator) {
this.em = em;
this.messageTranslator = messageTranslator;
}
public Set<HsCredentialsContextRealEntity> mapCredentialsToContextEntities(
List<ContextResource> resources
) {
final var entities = new HashSet<HsCredentialsContextRealEntity>();
syncCredentialsContextEntities(resources, entities);
return entities;
}
public void syncCredentialsContextEntities(
List<ContextResource> resources,
Set<HsCredentialsContextRealEntity> entities
) {
final var resourceUuids = resources.stream()
.map(ContextResource::getUuid)
.collect(Collectors.toSet());
final var entityUuids = entities.stream()
.map(HsCredentialsContextRealEntity::getUuid)
.collect(Collectors.toSet());
entities.removeIf(e -> !resourceUuids.contains(e.getUuid()));
for (final var resource : resources) {
if (!entityUuids.contains(resource.getUuid())) {
final var existingContextEntity = em.find(HsCredentialsContextRealEntity.class, resource.getUuid());
if (existingContextEntity == null) {
throw new EntityNotFoundException(
messageTranslator.translate("{0} \"{1}\" not found or not accessible",
"credentials uuid", resource.getUuid()));
}
if (!existingContextEntity.getType().equals(resource.getType()) &&
!existingContextEntity.getQualifier().equals(resource.getQualifier())) {
throw new EntityNotFoundException(
messageTranslator.translate("existing {0} does not match given resource {1}",
existingContextEntity, resource));
}
entities.add(existingContextEntity);
}
}
}
}

View File

@@ -10,6 +10,7 @@ import net.hostsharing.hsadminng.credentials.generated.api.v1.model.ContextResou
import net.hostsharing.hsadminng.mapper.StrictMapper;
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;
@RestController
@@ -26,6 +27,7 @@ public class HsCredentialsContextsController implements ContextsApi {
private HsCredentialsContextRbacRepository contextRepo;
@Override
@Transactional(readOnly = true)
@Timed("app.credentials.contexts.getListOfLoginContexts")
public ResponseEntity<List<ContextResource>> getListOfContexts(final String assumedRoles) {
context.assumeRoles(assumedRoles);

View File

@@ -2,7 +2,6 @@ package net.hostsharing.hsadminng.credentials;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.UUID;
import java.util.function.BiConsumer;
@@ -15,21 +14,27 @@ import net.hostsharing.hsadminng.credentials.generated.api.v1.api.CredentialsApi
import net.hostsharing.hsadminng.credentials.generated.api.v1.model.CredentialsInsertResource;
import net.hostsharing.hsadminng.credentials.generated.api.v1.model.CredentialsPatchResource;
import net.hostsharing.hsadminng.credentials.generated.api.v1.model.CredentialsResource;
import net.hostsharing.hsadminng.credentials.generated.api.v1.model.HsOfficePersonResource;
import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRbacRepository;
import net.hostsharing.hsadminng.mapper.StrictMapper;
import net.hostsharing.hsadminng.persistence.EntityManagerWrapper;
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 org.springframework.web.servlet.mvc.method.annotation.MvcUriComponentsBuilder;
import jakarta.persistence.EntityNotFoundException;
import static java.util.Optional.ofNullable;
import static java.util.Optional.of;
@RestController
@SecurityRequirement(name = "casTicket")
public class HsCredentialsController implements CredentialsApi {
private static DateTimeFormatter FULL_TIMESTAMP_FORMAT = DateTimeFormatter.BASIC_ISO_DATE;
@Autowired
private Context context;
@@ -39,16 +44,23 @@ public class HsCredentialsController implements CredentialsApi {
@Autowired
private StrictMapper mapper;
@Autowired
private RbacSubjectRepository subjectRepo;
@Autowired
private CredentialContextResourceToEntityMapper contextMapper;
@Autowired
private MessageTranslator messageTranslator;
@Autowired
private HsOfficePersonRbacRepository personRepo;
private HsOfficePersonRbacRepository rbacPersonRepo;
@Autowired
private HsCredentialsRepository credentialsRepo;
@Override
@Transactional(readOnly = true)
@Timed("app.credentials.credentials.getSingleCredentialsByUuid")
public ResponseEntity<CredentialsResource> getSingleCredentialsByUuid(
final String assumedRoles,
@@ -56,11 +68,16 @@ public class HsCredentialsController implements CredentialsApi {
context.assumeRoles(assumedRoles);
final var credentials = credentialsRepo.findByUuid(credentialsUuid);
final var result = mapper.map(credentials, CredentialsResource.class);
if (credentials.isEmpty()) {
return ResponseEntity.notFound().build();
}
final var result = mapper.map(
credentials.get(), CredentialsResource.class, ENTITY_TO_RESOURCE_POSTMAPPER);
return ResponseEntity.ok(result);
}
@Override
@Transactional(readOnly = true)
@Timed("app.credentials.credentials.getListOfCredentialsByPersonUuid")
public ResponseEntity<List<CredentialsResource>> getListOfCredentialsByPersonUuid(
final String assumedRoles,
@@ -68,18 +85,20 @@ public class HsCredentialsController implements CredentialsApi {
) {
context.assumeRoles(assumedRoles);
final var person = personRepo.findByUuid(personUuid).orElseThrow(
final var person = rbacPersonRepo.findByUuid(personUuid).orElseThrow(
() -> new EntityNotFoundException(
messageTranslator.translate("{0} \"{1}\" not found or not accessible", "personUuid", personUuid)
)
);
final var credentials = credentialsRepo.findByPerson(person);
final var result = mapper.mapList(credentials, CredentialsResource.class);
final var result = mapper.mapList(
credentials, CredentialsResource.class, ENTITY_TO_RESOURCE_POSTMAPPER);
return ResponseEntity.ok(result);
}
@Override
@Transactional
@Timed("app.credentials.credentials.postNewCredentials")
public ResponseEntity<CredentialsResource> postNewCredentials(
final String assumedRoles,
@@ -87,13 +106,28 @@ public class HsCredentialsController implements CredentialsApi {
) {
context.assumeRoles(assumedRoles);
final var newCredentialsEntity = mapper.map(body, HsCredentialsEntity.class);
final var savedCredentialsEntity = credentialsRepo.save(newCredentialsEntity);
final var newCredentialsResource = mapper.map(savedCredentialsEntity, CredentialsResource.class);
return ResponseEntity.ok(newCredentialsResource);
// first create and save the subject to get its UUID
final var newlySavedSubject = createSubject(body.getNickname());
// afterward, create and save the credentials entity with the subject's UUID
final var newCredentialsEntity = mapper.map(
body, HsCredentialsEntity.class, RESOURCE_TO_ENTITY_POSTMAPPER);
newCredentialsEntity.setSubject(newlySavedSubject);
em.persist(newCredentialsEntity); // newCredentialsEntity.uuid == newlySavedSubject.uuid => do not use repository!
// return the new credentials as a resource
final var uri =
MvcUriComponentsBuilder.fromController(getClass())
.path("/api/hs/credentials/credentials/{id}")
.buildAndExpand(newCredentialsEntity.getUuid())
.toUri();
final var newCredentialsResource = mapper.map(
newCredentialsEntity, CredentialsResource.class, ENTITY_TO_RESOURCE_POSTMAPPER);
return ResponseEntity.created(uri).body(newCredentialsResource);
}
@Override
@Transactional
@Timed("app.credentials.credentials.deleteCredentialsByUuid")
public ResponseEntity<Void> deleteCredentialsByUuid(final String assumedRoles, final UUID credentialsUuid) {
context.assumeRoles(assumedRoles);
@@ -103,6 +137,7 @@ public class HsCredentialsController implements CredentialsApi {
}
@Override
@Transactional
@Timed("app.credentials.credentials.patchCredentials")
public ResponseEntity<CredentialsResource> patchCredentials(
final String assumedRoles,
@@ -113,10 +148,11 @@ public class HsCredentialsController implements CredentialsApi {
final var current = credentialsRepo.findByUuid(credentialsUuid).orElseThrow();
new HsCredentialsEntityPatcher(em, messageTranslator, current).apply(body);
new HsCredentialsEntityPatcher(contextMapper, current).apply(body);
final var saved = credentialsRepo.save(current);
final var mapped = mapper.map(saved, CredentialsResource.class);
final var mapped = mapper.map(
saved, CredentialsResource.class, ENTITY_TO_RESOURCE_POSTMAPPER);
return ResponseEntity.ok(mapped);
}
@@ -137,7 +173,38 @@ public class HsCredentialsController implements CredentialsApi {
return ResponseEntity.ok(mapped);
}
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);
}
return subjectRepo.findByUuid(newRbacSubject.getUuid()); // attached to EM
}
final BiConsumer<HsCredentialsEntity, CredentialsResource> ENTITY_TO_RESOURCE_POSTMAPPER = (entity, resource) -> {
resource.setLastUsed(entity.getLastUsed().atOffset(ZoneOffset.UTC));
ofNullable(entity.getLastUsed()).ifPresent(
dt -> resource.setLastUsed(dt.atOffset(ZoneOffset.UTC)));
of(entity.getSubject()).ifPresent(
subject -> resource.setNickname(subject.getName())
);
of(entity.getPerson()).ifPresent(
person -> resource.setPerson(
mapper.map(person, HsOfficePersonResource.class)
)
);
};
final BiConsumer<CredentialsInsertResource, HsCredentialsEntity> 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())
)
);
entity.setLoginContexts(contextMapper.mapCredentialsToContextEntities(resource.getContexts()));
entity.setPerson(person);
};
}

View File

@@ -2,7 +2,7 @@ package net.hostsharing.hsadminng.credentials;
import jakarta.persistence.*;
import lombok.*;
import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRealEntity;
import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRbacEntity;
import net.hostsharing.hsadminng.persistence.BaseEntity; // Assuming BaseEntity exists
import net.hostsharing.hsadminng.rbac.subject.RbacSubjectEntity;
import net.hostsharing.hsadminng.repr.Stringify;
@@ -45,7 +45,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 HsOfficePersonRealEntity person;
private HsOfficePersonRbacEntity person;
@Version
private int version;
@@ -92,6 +92,11 @@ public class HsCredentialsEntity implements BaseEntity<HsCredentialsEntity>, Str
return loginContexts;
}
public void setSubject(final RbacSubjectEntity subject) {
this.uuid = subject.getUuid();
this.subject = subject;
}
@Override
public String toShortString() {
return active + ":" + emailAddress + ":" + globalUid;

View File

@@ -1,26 +1,17 @@
package net.hostsharing.hsadminng.credentials;
import net.hostsharing.hsadminng.config.MessageTranslator;
import net.hostsharing.hsadminng.credentials.generated.api.v1.model.ContextResource;
import net.hostsharing.hsadminng.credentials.generated.api.v1.model.CredentialsPatchResource;
import net.hostsharing.hsadminng.mapper.EntityPatcher;
import net.hostsharing.hsadminng.mapper.OptionalFromJson;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityNotFoundException;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
public class HsCredentialsEntityPatcher implements EntityPatcher<CredentialsPatchResource> {
private final EntityManager em;
private MessageTranslator messageTranslator;
private CredentialContextResourceToEntityMapper contextMapper;
private final HsCredentialsEntity entity;
public HsCredentialsEntityPatcher(final EntityManager em, MessageTranslator messageTranslator, final HsCredentialsEntity entity) {
this.em = em;
this.messageTranslator = messageTranslator;
public HsCredentialsEntityPatcher(final CredentialContextResourceToEntityMapper contextMapper, final HsCredentialsEntity entity) {
this.contextMapper = contextMapper;
this.entity = entity;
}
@@ -38,40 +29,7 @@ public class HsCredentialsEntityPatcher implements EntityPatcher<CredentialsPatc
OptionalFromJson.of(resource.getPhonePassword())
.ifPresent(entity::setPhonePassword);
if (resource.getContexts() != null) {
syncLoginContextEntities(resource.getContexts(), entity.getLoginContexts());
}
}
public void syncLoginContextEntities(
List<ContextResource> resources,
Set<HsCredentialsContextRealEntity> entities
) {
final var resourceUuids = resources.stream()
.map(ContextResource::getUuid)
.collect(Collectors.toSet());
final var entityUuids = entities.stream()
.map(HsCredentialsContextRealEntity::getUuid)
.collect(Collectors.toSet());
entities.removeIf(e -> !resourceUuids.contains(e.getUuid()));
for (final var resource : resources) {
if (!entityUuids.contains(resource.getUuid())) {
final var existingContextEntity = em.find(HsCredentialsContextRealEntity.class, resource.getUuid());
if ( existingContextEntity == null ) {
throw new EntityNotFoundException(
messageTranslator.translate("{0} \"{1}\" not found or not accessible",
"credentials uuid", resource.getUuid()));
}
if (!existingContextEntity.getType().equals(resource.getType()) &&
!existingContextEntity.getQualifier().equals(resource.getQualifier())) {
throw new EntityNotFoundException(
messageTranslator.translate("existing {0} does not match given resource {1}",
existingContextEntity, resource));
}
entities.add(existingContextEntity);
}
contextMapper.syncCredentialsContextEntities(resource.getContexts(), entity.getLoginContexts());
}
}

View File

@@ -16,6 +16,7 @@ import java.util.UUID;
@Table(schema = "rbac", name = "subject_rv")
@Getter
@Setter
@Builder
@ToString
@Immutable
@NoArgsConstructor

View File

@@ -45,7 +45,7 @@ public interface RbacSubjectRepository extends Repository<RbacSubjectEntity, UUI
rbacSubjectEntity.setUuid(UUID.randomUUID());
}
insert(rbacSubjectEntity);
return rbacSubjectEntity;
return rbacSubjectEntity; // Not yet attached to EM!
}
@Timed("app.rbac.subjects.repo.deleteByUuid")

View File

@@ -2,7 +2,7 @@ get:
summary: Returns a list of all accessible contexts.
description: Returns the list of all contexts which are visible to the current subject or any of it's assumed roles.
tags:
- -contexts
- contexts
operationId: getListOfContexts
parameters:
- $ref: 'auth.yaml#/components/parameters/assumedRoles'

View File

@@ -9,6 +9,11 @@ components:
uuid:
type: string
format: uuid
person:
$ref: '../hs-office/hs-office-person-schemas.yaml#/components/schemas/HsOfficePerson'
nickname:
type: string
pattern: '^[a-z][a-z0-9]{1,8}-[a-z0-9]{1,10}$' # TODO.spec: pattern for login nickname
totpSecret:
type: string
telephonePassword:
@@ -64,9 +69,12 @@ components:
CredentialsInsert:
type: object
properties:
uuid:
person.uuid:
type: string
format: uuid
nickname:
type: string
pattern: '^[a-z][a-z0-9]{1,8}-[a-z0-9]{1,10}$' # TODO.spec: pattern for login nickname
totpSecret:
type: string
telephonePassword:

View File

@@ -1,6 +1,6 @@
post:
tags:
- -credentials
- credentials
description: 'Is called when credentials got used for a login.'
operationId: credentialsUsed
parameters:

View File

@@ -1,11 +1,11 @@
get:
tags:
- -credentials
- credentials
description: 'Fetch a single credentials its uuid, if visible for the current subject.'
operationId: getSingleCredentialsByUuid
parameters:
- $ref: 'auth.yaml#/components/parameters/assumedRoles'
- name: CredentialsUuid
- name: credentialsUuid
in: path
required: true
schema:
@@ -27,12 +27,12 @@ get:
patch:
tags:
- -credentials
- credentials
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
- name: credentialsUuid
in: path
required: true
schema:
@@ -57,7 +57,7 @@ patch:
delete:
tags:
- -credentials
- credentials
description: 'Delete a single credentials identified by its uuid, if permitted for the current subject.'
operationId: deleteCredentialsByUuid
parameters:

View File

@@ -2,7 +2,7 @@ get:
summary: Returns a list of all credentials.
description: Returns the list of all credentials which are visible to the current subject or any of it's assumed roles.
tags:
- -credentials
- credentials
operationId: getListOfCredentialsByPersonUuid
parameters:
- $ref: 'auth.yaml#/components/parameters/assumedRoles'
@@ -30,7 +30,7 @@ get:
post:
summary: Adds a new credentials.
tags:
- -credentials
- credentials
operationId: postNewCredentials
parameters:
- $ref: 'auth.yaml#/components/parameters/assumedRoles'