diff --git a/.aliases b/.aliases index 1e6cc249..5da642ab 100644 --- a/.aliases +++ b/.aliases @@ -73,6 +73,12 @@ 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}" +} +alias gw-bootRun=gradlewBootRun + alias podman-start='systemctl --user enable --now podman.socket && systemctl --user status podman.socket && ls -la /run/user/$UID/podman/podman.sock' alias podman-stop='systemctl --user disable --now podman.socket && systemctl --user status podman.socket && ls -la /run/user/$UID/podman/podman.sock' alias podman-use='export DOCKER_HOST="unix:///run/user/$UID/podman/podman.sock"; export TESTCONTAINERS_RYUK_DISABLED=true' diff --git a/README.md b/README.md index 162885ce..f304494f 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,9 @@ Next, compile and run the application on `localhost:8080` and the management ser # this runs the application with test-data and all modules: gw bootRun --args='--spring.profiles.active=dev,fakeCasAuthenticator,complete,test-data' + # there is also an alias which takes an optional port as an argument: + gw-bootRun 8888 + The meaning of these profiles is: - **dev**: the PostgreSQL users are created via Liquibase diff --git a/build.gradle.kts b/build.gradle.kts index 4f219c57..cd21c334 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -209,12 +209,22 @@ openapiProcessor { process("springHsHosting") { processorName("spring") processor("io.openapiprocessor:openapi-processor-spring:2022.5") - apiPath(project.file("src/main/resources/api-definition/hs-hosting//hs-hosting.yaml").path) + apiPath(project.file("src/main/resources/api-definition/hs-hosting/hs-hosting.yaml").path) prop("mapping", project.file("src/main/resources/api-definition/hs-hosting/api-mappings.yaml").path) prop("showWarnings", true) prop("openApiNullable", true) targetDir(layout.buildDirectory.dir("generated/sources/openapi-javax").get().asFile.path) } + + process("springCredentials") { + processorName("spring") + processor("io.openapiprocessor:openapi-processor-spring:2022.5") + apiPath(project.file("src/main/resources/api-definition/credentials/api-paths.yaml").path) + prop("mapping", project.file("src/main/resources/api-definition/credentials/api-mappings.yaml").path) + prop("showWarnings", true) + prop("openApiNullable", true) + targetDir(layout.buildDirectory.dir("generated/sources/openapi-javax").get().asFile.path) + } } // Add generated sources to the main source set @@ -234,7 +244,8 @@ val processSpring = tasks.register("processSpring") { "processSpringTest", "processSpringHsOffice", "processSpringHsBooking", - "processSpringHsHosting" + "processSpringHsHosting", + "processSpringCredentials" ) } diff --git a/doc/ideas/login-credentials-data-model.mermaid b/doc/ideas/login-credentials-data-model.mermaid new file mode 100644 index 00000000..45722bf2 --- /dev/null +++ b/doc/ideas/login-credentials-data-model.mermaid @@ -0,0 +1,49 @@ +classDiagram + direction LR + + OfficePerson o.. "*" LoginCredentials + LoginCredentials "1" o-- "1" RbacSubject + + LoginContext "1..n" --o "1" LoginContextMapping + LoginCredentials "1..n" --o "1" LoginContextMapping + + class LoginCredentials{ + +twoFactorAuth: text + +telephonePassword: text + +emailAdress: text + +smsNumber: text + -active: bool [r/w] + -globalUid: int [w/o] + -globalGid: int [w/o] + -onboardingToken: text [w/o] + } + + class LoginContext{ + -type: Enum [SSH, Matrix, Mastodon, ...] + -qualifier: text + } + + class LoginContextMapping{ + } + note for LoginContextMapping "Assigns LoginCredentials to LoginContexts" + + class RbacSubject{ + +uuid: uuid + +name: text # == nickname + } + + class OfficePerson{ + +type: enum + +tradename: text + +title: text + +familyName: text + +givenName: text + +salutation: text + } + + style LoginContext fill:#00f,color:#fff + style LoginContextMapping fill:#00f,color:#fff + style LoginCredentials fill:#00f,color:#fff + + style RbacSubject fill:#f96,color:#fff + style OfficePerson fill:#f66,color:#000 diff --git a/src/main/java/net/hostsharing/hsadminng/credentials/HsCredentialsContext.java b/src/main/java/net/hostsharing/hsadminng/credentials/HsCredentialsContext.java new file mode 100644 index 00000000..a18dfb7f --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/credentials/HsCredentialsContext.java @@ -0,0 +1,62 @@ +package net.hostsharing.hsadminng.credentials; + +import jakarta.persistence.Column; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.MappedSuperclass; +import jakarta.validation.constraints.NotNull; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.experimental.SuperBuilder; +import net.hostsharing.hsadminng.persistence.BaseEntity; +import net.hostsharing.hsadminng.repr.Stringify; +import net.hostsharing.hsadminng.repr.Stringifyable; + +import java.util.UUID; + +import static net.hostsharing.hsadminng.repr.Stringify.stringify; + +@Getter +@Setter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@SuperBuilder(builderMethodName = "baseBuilder", toBuilder = true) +@MappedSuperclass +public abstract class HsCredentialsContext implements Stringifyable, BaseEntity { + + private static Stringify stringify = stringify(HsCredentialsContext.class, "loginContext") + .withProp(HsCredentialsContext::getType) + .withProp(HsCredentialsContext::getQualifier) + .quotedValues(false) + .withSeparator(":"); + + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + @Column(name = "uuid", nullable = false, updatable = false) + private UUID uuid; + + @NotNull + @Column + private int version; + + @NotNull + @Column(name = "type", length = 16) + private String type; + + @Column(name = "qualifier", length = 80) + private String qualifier; + + @Override + public String toShortString() { + return toString(); + } + + @Override + public String toString() { + return stringify.apply(this); + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/credentials/HsCredentialsContextRbacEntity.java b/src/main/java/net/hostsharing/hsadminng/credentials/HsCredentialsContextRbacEntity.java new file mode 100644 index 00000000..6bf67e63 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/credentials/HsCredentialsContextRbacEntity.java @@ -0,0 +1,55 @@ +package net.hostsharing.hsadminng.credentials; + +import jakarta.persistence.AttributeOverride; +import jakarta.persistence.AttributeOverrides; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.experimental.SuperBuilder; +import net.hostsharing.hsadminng.rbac.generator.RbacSpec; +import net.hostsharing.hsadminng.rbac.generator.RbacSpec.SQL; + +import java.io.IOException; + +import static net.hostsharing.hsadminng.rbac.generator.RbacSpec.GLOBAL; +import static net.hostsharing.hsadminng.rbac.generator.RbacSpec.Permission.*; +import static net.hostsharing.hsadminng.rbac.generator.RbacSpec.Role.GUEST; +import static net.hostsharing.hsadminng.rbac.generator.RbacSpec.Role.OWNER; +import static net.hostsharing.hsadminng.rbac.generator.RbacSpec.Role.ADMIN; +import static net.hostsharing.hsadminng.rbac.generator.RbacSpec.Role.REFERRER; +import static net.hostsharing.hsadminng.rbac.generator.RbacSpec.WITHOUT_IMPLICIT_GRANTS; +import static net.hostsharing.hsadminng.rbac.generator.RbacSpec.rbacViewFor; + +@Entity +@Table(schema = "hs_credentials", name = "context") // TODO_impl: RBAC rules for _rv do not yet work properly +@SuperBuilder(toBuilder = true) +@Getter +@Setter +@NoArgsConstructor +@AttributeOverrides({ + @AttributeOverride(name = "uuid", column = @Column(name = "uuid")) +}) +public class HsCredentialsContextRbacEntity extends HsCredentialsContext { + + // TODO_impl: RBAC rules for _rv do not yet work properly (remove the X) + public static RbacSpec rbacX() { + return rbacViewFor("credentialsContext", HsCredentialsContextRbacEntity.class) + .withIdentityView(SQL.projection("type || ':' || qualifier")) + .withRestrictedViewOrderBy(SQL.expression("type || ':' || qualifier")) + .withoutUpdatableColumns() + .createRole(OWNER, WITHOUT_IMPLICIT_GRANTS) + .createSubRole(ADMIN, WITHOUT_IMPLICIT_GRANTS) + .createSubRole(REFERRER, WITHOUT_IMPLICIT_GRANTS) + .toRole(GLOBAL, ADMIN).grantPermission(INSERT) + .toRole(GLOBAL, ADMIN).grantPermission(DELETE) + .toRole(GLOBAL, GUEST).grantPermission(SELECT); + } + + // TODO_impl: RBAC rules for _rv do not yet work properly (remove the X) + public static void mainX(String[] args) throws IOException { + rbacX().generateWithBaseFileName("9-hs-global/950-credentials/9513-hs-credentials-rbac"); + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/credentials/HsCredentialsContextRbacRepository.java b/src/main/java/net/hostsharing/hsadminng/credentials/HsCredentialsContextRbacRepository.java new file mode 100644 index 00000000..484e0a5c --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/credentials/HsCredentialsContextRbacRepository.java @@ -0,0 +1,24 @@ +package net.hostsharing.hsadminng.credentials; + +import io.micrometer.core.annotation.Timed; +import org.springframework.data.repository.Repository; + +import jakarta.validation.constraints.NotNull; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface HsCredentialsContextRbacRepository extends Repository { + + @Timed("app.login.context.repo.findAll") + List findAll(); + + @Timed("app.login.context.repo.findByUuid") + Optional findByUuid(final UUID id); + + @Timed("app.login.context.repo.findByTypeAndQualifier") + Optional findByTypeAndQualifier(@NotNull String contextType, @NotNull String qualifier); + + @Timed("app.login.context.repo.save") + HsCredentialsContextRbacEntity save(final HsCredentialsContextRbacEntity entity); +} diff --git a/src/main/java/net/hostsharing/hsadminng/credentials/HsCredentialsContextRealEntity.java b/src/main/java/net/hostsharing/hsadminng/credentials/HsCredentialsContextRealEntity.java new file mode 100644 index 00000000..192a079f --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/credentials/HsCredentialsContextRealEntity.java @@ -0,0 +1,23 @@ +package net.hostsharing.hsadminng.credentials; + +import jakarta.persistence.AttributeOverride; +import jakarta.persistence.AttributeOverrides; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.experimental.SuperBuilder; + +@Entity +@Table(schema = "hs_credentials", name = "context") +@SuperBuilder(toBuilder = true) +@Getter +@Setter +@NoArgsConstructor +@AttributeOverrides({ + @AttributeOverride(name = "uuid", column = @Column(name = "uuid")) +}) +public class HsCredentialsContextRealEntity extends HsCredentialsContext { +} diff --git a/src/main/java/net/hostsharing/hsadminng/credentials/HsCredentialsContextRealRepository.java b/src/main/java/net/hostsharing/hsadminng/credentials/HsCredentialsContextRealRepository.java new file mode 100644 index 00000000..842f3040 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/credentials/HsCredentialsContextRealRepository.java @@ -0,0 +1,24 @@ +package net.hostsharing.hsadminng.credentials; + +import io.micrometer.core.annotation.Timed; +import org.springframework.data.repository.Repository; + +import jakarta.validation.constraints.NotNull; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface HsCredentialsContextRealRepository extends Repository { + + @Timed("app.login.context.repo.findAll") + List findAll(); + + @Timed("app.login.context.repo.findByUuid") + Optional findByUuid(final UUID id); + + @Timed("app.login.context.repo.findByTypeAndQualifier") + Optional findByTypeAndQualifier(@NotNull String contextType, @NotNull String qualifier); + + @Timed("app.login.context.repo.save") + HsCredentialsContextRealEntity save(final HsCredentialsContextRealEntity entity); +} diff --git a/src/main/java/net/hostsharing/hsadminng/credentials/HsCredentialsContextsController.java b/src/main/java/net/hostsharing/hsadminng/credentials/HsCredentialsContextsController.java new file mode 100644 index 00000000..cc9e1249 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/credentials/HsCredentialsContextsController.java @@ -0,0 +1,33 @@ +package net.hostsharing.hsadminng.credentials; + +import java.util.List; + +import net.hostsharing.hsadminng.context.Context; +import net.hostsharing.hsadminng.credentials.generated.api.v1.api.LoginContextsApi; +import net.hostsharing.hsadminng.credentials.generated.api.v1.model.LoginContextResource; +import net.hostsharing.hsadminng.mapper.StrictMapper; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class HsCredentialsContextsController implements LoginContextsApi { + + @Autowired + private Context context; + + @Autowired + private StrictMapper mapper; + + @Autowired + private HsCredentialsContextRbacRepository contextRepo; + + @Override + public ResponseEntity> getListOfLoginContexts(final String assumedRoles) { + context.assumeRoles(assumedRoles); + + final var loginContexts = contextRepo.findAll(); + final var result = mapper.mapList(loginContexts, LoginContextResource.class); + return ResponseEntity.ok(result); + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/credentials/HsCredentialsController.java b/src/main/java/net/hostsharing/hsadminng/credentials/HsCredentialsController.java new file mode 100644 index 00000000..62f55dc7 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/credentials/HsCredentialsController.java @@ -0,0 +1,96 @@ +package net.hostsharing.hsadminng.credentials; + +import java.util.List; +import java.util.UUID; +import net.hostsharing.hsadminng.context.Context; +import net.hostsharing.hsadminng.credentials.generated.api.v1.api.LoginCredentialsApi; +import net.hostsharing.hsadminng.credentials.generated.api.v1.model.LoginCredentialsInsertResource; +import net.hostsharing.hsadminng.credentials.generated.api.v1.model.LoginCredentialsPatchResource; +import net.hostsharing.hsadminng.credentials.generated.api.v1.model.LoginCredentialsResource; +import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRbacRepository; +import net.hostsharing.hsadminng.mapper.StrictMapper; +import net.hostsharing.hsadminng.persistence.EntityManagerWrapper; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class HsCredentialsController implements LoginCredentialsApi { + + @Autowired + private Context context; + + @Autowired + private EntityManagerWrapper em; + + @Autowired + private StrictMapper mapper; + + @Autowired + private HsOfficePersonRbacRepository personRepo; + + @Autowired + private HsCredentialsRepository loginCredentialsRepo; + + @Override + public ResponseEntity getSingleLoginCredentialsByUuid( + final String assumedRoles, + final UUID loginCredentialsUuid) { + context.assumeRoles(assumedRoles); + + final var credentials = loginCredentialsRepo.findByUuid(loginCredentialsUuid); + final var result = mapper.map(credentials, LoginCredentialsResource.class); + return ResponseEntity.ok(result); + } + + @Override + public ResponseEntity> getListOfLoginCredentialsByPersonUuid( + final String assumedRoles, + final UUID personUuid + ) { + context.assumeRoles(assumedRoles); + + final var person = personRepo.findByUuid(personUuid).orElseThrow(); // FIXME: use proper exception + final var credentials = loginCredentialsRepo.findByPerson(person); + final var result = mapper.mapList(credentials, LoginCredentialsResource.class); + return ResponseEntity.ok(result); + } + + @Override + public ResponseEntity postNewLoginCredentials( + final String assumedRoles, + final LoginCredentialsInsertResource body + ) { + context.assumeRoles(assumedRoles); + + final var newLoginCredentialsEntity = mapper.map(body, HsCredentialsEntity.class); + final var savedLoginCredentialsEntity = loginCredentialsRepo.save(newLoginCredentialsEntity); + final var newLoginCredentialsResource = mapper.map(savedLoginCredentialsEntity, LoginCredentialsResource.class); + return ResponseEntity.ok(newLoginCredentialsResource); + } + + @Override + public ResponseEntity deleteLoginCredentialsByUuid(final String assumedRoles, final UUID loginCredentialsUuid) { + context.assumeRoles(assumedRoles); + final var loginCredentialsEntity = em.getReference(HsCredentialsEntity.class, loginCredentialsUuid); + em.remove(loginCredentialsEntity); + return ResponseEntity.noContent().build(); + } + + @Override + public ResponseEntity patchLoginCredentials( + final String assumedRoles, + final UUID loginCredentialsUuid, + final LoginCredentialsPatchResource body + ) { + context.assumeRoles(assumedRoles); + + final var current = loginCredentialsRepo.findByUuid(loginCredentialsUuid).orElseThrow(); + + new HsCredentialsEntityPatcher(em, current).apply(body); + + final var saved = loginCredentialsRepo.save(current); + final var mapped = mapper.map(saved, LoginCredentialsResource.class); + return ResponseEntity.ok(mapped); + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/credentials/HsCredentialsEntity.java b/src/main/java/net/hostsharing/hsadminng/credentials/HsCredentialsEntity.java new file mode 100644 index 00000000..fad26941 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/credentials/HsCredentialsEntity.java @@ -0,0 +1,100 @@ +package net.hostsharing.hsadminng.credentials; + +import jakarta.persistence.*; +import lombok.*; +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; +import net.hostsharing.hsadminng.repr.Stringifyable; +// import net.hostsharing.hsadminng.rbac.RbacSubjectEntity; // Assuming RbacSubjectEntity exists for the FK relationship + +import java.util.HashSet; +import java.util.Set; +import java.util.UUID; + +import static jakarta.persistence.CascadeType.MERGE; +import static jakarta.persistence.CascadeType.REFRESH; +import static net.hostsharing.hsadminng.repr.Stringify.stringify; + +@Entity +@Table(schema = "hs_credentials", name = "credentials") +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class HsCredentialsEntity implements BaseEntity, Stringifyable { + + protected static Stringify stringify = stringify(HsCredentialsEntity.class, "loginCredentials") + .withProp(HsCredentialsEntity::isActive) + .withProp(HsCredentialsEntity::getEmailAddress) + .withProp(HsCredentialsEntity::getTwoFactorAuth) + .withProp(HsCredentialsEntity::getPhonePassword) + .withProp(HsCredentialsEntity::getSmsNumber) + .quotedValues(false); + + @Id + private UUID uuid; + + @MapsId + @OneToOne(optional = false, fetch = FetchType.LAZY) + @JoinColumn(name = "uuid", nullable = false, updatable = false, referencedColumnName = "uuid") + private RbacSubjectEntity subject; + + @ManyToOne(optional = false, fetch = FetchType.EAGER) + @JoinColumn(name = "person_uuid", nullable = false, updatable = false, referencedColumnName = "uuid") + private HsOfficePersonRealEntity person; + + @Version + private int version; + + @Column + private boolean active; + + @Column + private Integer globalUid; + + @Column + private Integer globalGid; + + @Column + private String onboardingToken; + + @Column + private String twoFactorAuth; + + @Column + private String phonePassword; + + @Column + private String emailAddress; + + @Column + private String smsNumber; + + @OneToMany(fetch = FetchType.LAZY, cascade = { MERGE, REFRESH }, orphanRemoval = true) + @JoinTable( + name = "context_mapping", schema = "hs_credentials", + joinColumns = @JoinColumn(name = "credentials_uuid", referencedColumnName = "uuid"), + inverseJoinColumns = @JoinColumn(name = "context_uuid", referencedColumnName = "uuid") + ) + private Set loginContexts; + + public Set getLoginContexts() { + if ( loginContexts == null ) { + loginContexts = new HashSet<>(); + } + return loginContexts; + } + + @Override + public String toShortString() { + return active + ":" + emailAddress + ":" + globalUid; + } + + @Override + public String toString() { + return stringify.apply(this); + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/credentials/HsCredentialsEntityPatcher.java b/src/main/java/net/hostsharing/hsadminng/credentials/HsCredentialsEntityPatcher.java new file mode 100644 index 00000000..935cc1d8 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/credentials/HsCredentialsEntityPatcher.java @@ -0,0 +1,74 @@ +package net.hostsharing.hsadminng.credentials; + +import net.hostsharing.hsadminng.credentials.generated.api.v1.model.LoginContextResource; +import net.hostsharing.hsadminng.credentials.generated.api.v1.model.LoginCredentialsPatchResource; +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 { + + private final EntityManager em; + private final HsCredentialsEntity entity; + + public HsCredentialsEntityPatcher(final EntityManager em, final HsCredentialsEntity entity) { + this.em = em; + this.entity = entity; + } + + @Override + public void apply(final LoginCredentialsPatchResource resource) { + if ( resource.getActive() != null ) { + entity.setActive(resource.getActive()); + } + OptionalFromJson.of(resource.getEmailAddress()) + .ifPresent(entity::setEmailAddress); + OptionalFromJson.of(resource.getTwoFactorAuth()) + .ifPresent(entity::setTwoFactorAuth); + OptionalFromJson.of(resource.getSmsNumber()) + .ifPresent(entity::setSmsNumber); + OptionalFromJson.of(resource.getPhonePassword()) + .ifPresent(entity::setPhonePassword); + if (resource.getContexts() != null) { + syncLoginContextEntities(resource.getContexts(), entity.getLoginContexts()); + } + } + + public void syncLoginContextEntities( + List resources, + Set entities + ) { + final var resourceUuids = resources.stream() + .map(LoginContextResource::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 ) { + // FIXME: i18n + throw new EntityNotFoundException( + HsCredentialsContextRealEntity.class.getName() + " with uuid " + resource.getUuid() + " not found."); + } + if (!existingContextEntity.getType().equals(resource.getType().name()) && + !existingContextEntity.getQualifier().equals(resource.getQualifier())) { + // FIXME: i18n + throw new EntityNotFoundException("existing " + existingContextEntity + " does not match given resource " + resource); + } + entities.add(existingContextEntity); + } + } + } + +} diff --git a/src/main/java/net/hostsharing/hsadminng/credentials/HsCredentialsRepository.java b/src/main/java/net/hostsharing/hsadminng/credentials/HsCredentialsRepository.java new file mode 100644 index 00000000..14479e19 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/credentials/HsCredentialsRepository.java @@ -0,0 +1,21 @@ +package net.hostsharing.hsadminng.credentials; + +import io.micrometer.core.annotation.Timed; +import net.hostsharing.hsadminng.hs.office.person.HsOfficePerson; +import org.springframework.data.repository.Repository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface HsCredentialsRepository extends Repository { + + @Timed("app.login.credentials.repo.findByUuid") + Optional findByUuid(final UUID uuid); + + @Timed("app.login.credentials.repo.findByPerson") + List findByPerson(final HsOfficePerson personUuid); + + @Timed("app.login.credentials.repo.save") + HsCredentialsEntity save(final HsCredentialsEntity entity); +} diff --git a/src/main/java/net/hostsharing/hsadminng/rbac/generator/RbacRestrictedViewGenerator.java b/src/main/java/net/hostsharing/hsadminng/rbac/generator/RbacRestrictedViewGenerator.java index f4f78699..3b6d8ff2 100644 --- a/src/main/java/net/hostsharing/hsadminng/rbac/generator/RbacRestrictedViewGenerator.java +++ b/src/main/java/net/hostsharing/hsadminng/rbac/generator/RbacRestrictedViewGenerator.java @@ -4,6 +4,7 @@ package net.hostsharing.hsadminng.rbac.generator; import static java.util.stream.Collectors.joining; import static net.hostsharing.hsadminng.rbac.generator.StringWriter.indented; import static net.hostsharing.hsadminng.rbac.generator.StringWriter.with; +import static net.hostsharing.hsadminng.rbac.generator.StringWriter.withQuoted; public class RbacRestrictedViewGenerator { private final RbacSpec rbacDef; @@ -22,20 +23,21 @@ public class RbacRestrictedViewGenerator { --changeset RbacRestrictedViewGenerator:${liquibaseTagPrefix}-rbac-RESTRICTED-VIEW runOnChange:true validCheckSum:ANY endDelimiter:--// -- ---------------------------------------------------------------------------- call rbac.generateRbacRestrictedView('${rawTableName}', - $orderBy$ - ${orderBy} - $orderBy$, - $updates$ - ${updates} - $updates$); + ${orderBy}, + ${updates} + ); --// """, with("liquibaseTagPrefix", liquibaseTagPrefix), - with("orderBy", indented(2, rbacDef.getOrderBySqlExpression().sql)), - with("updates", indented(2, rbacDef.getUpdatableColumns().stream() - .map(c -> c + " = new." + c) - .collect(joining(",\n")))), + withQuoted("orderBy", indented(2, rbacDef.getOrderBySqlExpression().sql)), + withQuoted("updates", + rbacDef.getUpdatableColumns().isEmpty() + ? null + : indented(2, rbacDef.getUpdatableColumns().stream() + .map(c -> c + " = new." + c) + .collect(joining(",\n"))) + ), with("rawTableName", rawTableName)); } } diff --git a/src/main/java/net/hostsharing/hsadminng/rbac/generator/RbacSpec.java b/src/main/java/net/hostsharing/hsadminng/rbac/generator/RbacSpec.java index 81f9a9b9..15284e1c 100644 --- a/src/main/java/net/hostsharing/hsadminng/rbac/generator/RbacSpec.java +++ b/src/main/java/net/hostsharing/hsadminng/rbac/generator/RbacSpec.java @@ -38,6 +38,8 @@ public class RbacSpec { public static final String GLOBAL = "rbac.global"; public static final String OUTPUT_BASEDIR = "src/main/resources/db/changelog"; + public static Consumer WITHOUT_IMPLICIT_GRANTS = with -> {}; + private final EntityAlias rootEntityAlias; private final Set userDefs = new LinkedHashSet<>(); @@ -109,12 +111,23 @@ public class RbacSpec { * @return * the `this` instance itself to allow chained calls. */ - public RbacSpec withUpdatableColumns(final String... columnNames) { + public RbacSpec withUpdatableColumns(final String columnName, final String... columnNames) { + Collections.addAll(updatableColumns, columnName); Collections.addAll(updatableColumns, columnNames); verifyVersionColumnExists(); return this; } + /** + * Specifies, thta no columns are updatable at all. + * + * @return this + */ + public RbacSpec withoutUpdatableColumns() { + updatableColumns.clear(); + return this; + } + /** Specifies the SQL query which creates the identity view for this entity. * *

An identity view is a view which maps an objectUuid to an idName. @@ -188,7 +201,6 @@ public class RbacSpec { return this; } - /** * Specifies that the given role (OWNER, ADMIN, ...) is to be created for new/updated roles in this table, * which is becomes sub-role of the previously created role. diff --git a/src/main/java/net/hostsharing/hsadminng/rbac/generator/RolesGrantsAndPermissionsGenerator.java b/src/main/java/net/hostsharing/hsadminng/rbac/generator/RolesGrantsAndPermissionsGenerator.java index 9fac93dd..c862b3f1 100644 --- a/src/main/java/net/hostsharing/hsadminng/rbac/generator/RolesGrantsAndPermissionsGenerator.java +++ b/src/main/java/net/hostsharing/hsadminng/rbac/generator/RolesGrantsAndPermissionsGenerator.java @@ -99,6 +99,11 @@ class RolesGrantsAndPermissionsGenerator { .distinct() .map(columnName -> "NEW." + columnName + " is distinct from OLD." + columnName) .collect(joining( "\n or ")); + if (updateConditions.isBlank()) { + // TODO_impl: RBAC rules for _rv do not yet work properly - check if this comment appears in generated output! + plPgSql.writeLn("-- no updatable columns found, skipping update trigger"); + return; + } plPgSql.writeLn(""" /* Called from the AFTER UPDATE TRIGGER to re-wire the grants. diff --git a/src/main/java/net/hostsharing/hsadminng/rbac/generator/StringWriter.java b/src/main/java/net/hostsharing/hsadminng/rbac/generator/StringWriter.java index 2b4c980e..7dcb1ce5 100644 --- a/src/main/java/net/hostsharing/hsadminng/rbac/generator/StringWriter.java +++ b/src/main/java/net/hostsharing/hsadminng/rbac/generator/StringWriter.java @@ -11,7 +11,11 @@ public class StringWriter { private int indentLevel = 0; static VarDef with(final String var, final String name) { - return new VarDef(var, name); + return new VarDef(var, name, false); + } + + static VarDef withQuoted(final String var, final String name) { + return new VarDef(var, name, true); } void writeLn(final String text) { @@ -97,7 +101,7 @@ public class StringWriter { return indented(indentLevel, text); } - record VarDef(String name, String value){} + record VarDef(String name, String value, boolean quoted) {} private static final class VarReplacer { @@ -111,13 +115,22 @@ public class StringWriter { String apply(final String textToAppend) { text = textToAppend; stream(varDefs).forEach(varDef -> { - // TODO.impl: I actually want a case-independent search+replace but ... - // for which the substitution String can contain sequences of "${...}" to be replaced by further varDefs. - text = text.replace("${" + varDef.name() + "}", varDef.value()); - text = text.replace("${" + varDef.name().toUpperCase() + "}", varDef.value()); - text = text.replace("${" + varDef.name().toLowerCase() + "}", varDef.value()); + replacePlaceholder(varDef.name(), + varDef.quoted() + ? varDef.value() != null + ? "$" + varDef.name() + "$\n" + varDef.value() + "\n$" + varDef.name() + "$" + : "null" + : varDef.value()); }); return text; } + + private void replacePlaceholder(final String name, final String value) { + // TODO.impl: I actually want a case-independent search+replace but ... + // for which the substitution String can contain sequences of "${...}" to be replaced by further varDefs. + text = text.replace("${" + name+ "}", value); + text = text.replace("${" + name.toUpperCase() + "}", value); + text = text.replace("${" + name.toLowerCase() + "}", value); + } } } diff --git a/src/main/resources/api-definition/credentials/api-mappings.yaml b/src/main/resources/api-definition/credentials/api-mappings.yaml new file mode 100644 index 00000000..7785703a --- /dev/null +++ b/src/main/resources/api-definition/credentials/api-mappings.yaml @@ -0,0 +1,17 @@ +openapi-processor-mapping: v2 + +options: + package-name: net.hostsharing.hsadminng.credentials.generated.api.v1 + model-name-suffix: Resource + bean-validation: true + +map: + result: org.springframework.http.ResponseEntity + + types: + - type: array => java.util.List + - type: string:uuid => java.util.UUID + + paths: + /api/hs/credentials/credentials/{credentialsUuid}: + null: org.openapitools.jackson.nullable.JsonNullable diff --git a/src/main/resources/api-definition/credentials/api-paths.yaml b/src/main/resources/api-definition/credentials/api-paths.yaml new file mode 100644 index 00000000..7103e204 --- /dev/null +++ b/src/main/resources/api-definition/credentials/api-paths.yaml @@ -0,0 +1,23 @@ +openapi: 3.0.3 +info: + title: Hostsharing hsadmin-ng API + version: v0 +servers: + - url: http://localhost:8080 + description: Local development default URL. + +paths: + + # Contexts + + /api/hs/credentials/contexts: + $ref: "contexts.yaml" + + + # Credentials + + /api/hs/credentials/credentials: + $ref: "api-paths.yaml" + + /api/hs/credentials/credentials/{credentialsUuid}: + $ref: "credentials-with-uuid.yaml" diff --git a/src/main/resources/api-definition/credentials/auth.yaml b/src/main/resources/api-definition/credentials/auth.yaml new file mode 120000 index 00000000..ed775b8e --- /dev/null +++ b/src/main/resources/api-definition/credentials/auth.yaml @@ -0,0 +1 @@ +../auth.yaml \ No newline at end of file diff --git a/src/main/resources/api-definition/credentials/context-schemas.yaml b/src/main/resources/api-definition/credentials/context-schemas.yaml new file mode 100644 index 00000000..d47016e4 --- /dev/null +++ b/src/main/resources/api-definition/credentials/context-schemas.yaml @@ -0,0 +1,21 @@ + +components: + + schemas: + + Context: + type: object + properties: + uuid: + type: string + format: uuid + type: + type: string + maxLength: 16 + qualifier: + type: string + maxLength: 80 + required: + - uuid + - type + - qualifier diff --git a/src/main/resources/api-definition/credentials/contexts.yaml b/src/main/resources/api-definition/credentials/contexts.yaml new file mode 100644 index 00000000..3a7d674f --- /dev/null +++ b/src/main/resources/api-definition/credentials/contexts.yaml @@ -0,0 +1,21 @@ +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 + operationId: getListOfContexts + parameters: + - $ref: 'auth.yaml#/components/parameters/assumedRoles' + responses: + "200": + description: OK + content: + 'application/json': + schema: + type: array + items: + $ref: 'context-schemas.yaml#/components/schemas/Context' + "401": + $ref: 'error-responses.yaml#/components/responses/Unauthorized' + "403": + $ref: 'error-responses.yaml#/components/responses/Forbidden' diff --git a/src/main/resources/api-definition/credentials/credentials-schemas.yaml b/src/main/resources/api-definition/credentials/credentials-schemas.yaml new file mode 100644 index 00000000..1ff4e072 --- /dev/null +++ b/src/main/resources/api-definition/credentials/credentials-schemas.yaml @@ -0,0 +1,91 @@ + +components: + + schemas: + + Credentials: + type: object + properties: + uuid: + type: string + format: uuid + twoFactorAuth: + type: string + telephonePassword: + type: string + emailAddress: + type: string + smsNumber: + type: string + active: + type: boolean + globalUid: + type: number + globalGid: + type: number + onboardingToken: + type: string + contexts: + type: array + items: + $ref: '-context-schemas.yaml#/components/schemas/Context' + required: + - uuid + - active + - contexts + additionalProperties: false + + CredentialsPatch: + type: object + properties: + twoFactorAuth: + type: string + nullable: true + phonePassword: + type: string + nullable: true + emailAddress: + type: string + nullable: true + smsNumber: + type: string + nullable: true + active: + type: boolean + contexts: + type: array + items: + $ref: '-context-schemas.yaml#/components/schemas/Context' + additionalProperties: false + + CredentialsInsert: + type: object + properties: + uuid: + type: string + format: uuid + twoFactorAuth: + type: string + telephonePassword: + type: string + emailAddress: + type: string + smsNumber: + type: string + active: + type: boolean + globalUid: + type: number + globalGid: + type: number + onboardingToken: + type: string + contexts: + type: array + items: + $ref: '-context-schemas.yaml#/components/schemas/Context' + required: + - uuid + - active + additionalProperties: false + diff --git a/src/main/resources/api-definition/credentials/credentials-with-uuid.yaml b/src/main/resources/api-definition/credentials/credentials-with-uuid.yaml new file mode 100644 index 00000000..f0cc7771 --- /dev/null +++ b/src/main/resources/api-definition/credentials/credentials-with-uuid.yaml @@ -0,0 +1,80 @@ +get: + tags: + - -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 + in: path + required: true + schema: + type: string + format: uuid + description: UUID of the credentials to fetch. + responses: + "200": + description: OK + content: + 'application/json': + schema: + $ref: 'credentials-schemas.yaml#/components/schemas/Credentials' + + "401": + $ref: 'error-responses.yaml#/components/responses/Unauthorized' + "403": + $ref: 'error-responses.yaml#/components/responses/Forbidden' + +patch: + tags: + - -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 + in: path + required: true + schema: + type: string + format: uuid + requestBody: + content: + 'application/json': + schema: + $ref: 'credentials-schemas.yaml#/components/schemas/CredentialsPatch' + responses: + "200": + description: OK + content: + 'application/json': + schema: + $ref: 'credentials-schemas.yaml#/components/schemas/Credentials' + "401": + $ref: 'error-responses.yaml#/components/responses/Unauthorized' + "403": + $ref: 'error-responses.yaml#/components/responses/Forbidden' + +delete: + tags: + - -credentials + 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 + in: path + required: true + schema: + type: string + format: uuid + description: UUID of the credentials to delete. + responses: + "204": + description: No Content + "401": + $ref: 'error-responses.yaml#/components/responses/Unauthorized' + "403": + $ref: 'error-responses.yaml#/components/responses/Forbidden' + "404": + $ref: 'error-responses.yaml#/components/responses/NotFound' diff --git a/src/main/resources/api-definition/credentials/credentials.yaml b/src/main/resources/api-definition/credentials/credentials.yaml new file mode 100644 index 00000000..9432705d --- /dev/null +++ b/src/main/resources/api-definition/credentials/credentials.yaml @@ -0,0 +1,56 @@ +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 + operationId: getListOfCredentialsByPersonUuid + parameters: + - $ref: 'auth.yaml#/components/parameters/assumedRoles' + - name: personUuid + in: query + required: true + schema: + type: string + format: uuid + description: The UUID of the person, whose credentials are to be fetched. + responses: + "200": + description: OK + content: + 'application/json': + schema: + type: array + items: + $ref: 'credentials-schemas.yaml#/components/schemas/Credentials' + "401": + $ref: 'error-responses.yaml#/components/responses/Unauthorized' + "403": + $ref: 'error-responses.yaml#/components/responses/Forbidden' + +post: + summary: Adds a new credentials. + tags: + - -credentials + operationId: postNewCredentials + parameters: + - $ref: 'auth.yaml#/components/parameters/assumedRoles' + requestBody: + description: A JSON object describing the new credential. + required: true + content: + application/json: + schema: + $ref: 'credentials-schemas.yaml#/components/schemas/CredentialsInsert' + responses: + "201": + description: Created + content: + 'application/json': + schema: + $ref: 'credentials-schemas.yaml#/components/schemas/Credentials' + "401": + $ref: 'error-responses.yaml#/components/responses/Unauthorized' + "403": + $ref: 'error-responses.yaml#/components/responses/Forbidden' + "409": + $ref: 'error-responses.yaml#/components/responses/Conflict' diff --git a/src/main/resources/api-definition/credentials/error-responses.yaml b/src/main/resources/api-definition/credentials/error-responses.yaml new file mode 100644 index 00000000..e295230e --- /dev/null +++ b/src/main/resources/api-definition/credentials/error-responses.yaml @@ -0,0 +1,40 @@ +components: + + responses: + NotFound: + description: The specified resource was not found. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + Unauthorized: + description: The current subject is unknown or not authorized. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + Forbidden: + description: The current subject or none of the assumed or roles is granted access to the resource. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + Conflict: + description: The request could not be completed due to a conflict with the current state of the target resource. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + schemas: + + Error: + type: object + properties: + code: + type: string + message: + type: string + required: + - code + - message diff --git a/src/main/resources/api-definition/hs-office/hs-office-relation-schemas.yaml b/src/main/resources/api-definition/hs-office/hs-office-relation-schemas.yaml index 616f8039..3f95fe2b 100644 --- a/src/main/resources/api-definition/hs-office/hs-office-relation-schemas.yaml +++ b/src/main/resources/api-definition/hs-office/hs-office-relation-schemas.yaml @@ -16,6 +16,7 @@ components: - OPERATIONS_ALERT - SUBSCRIBER + HsOfficeRelation: type: object properties: diff --git a/src/main/resources/db/changelog/0-base/030-historization.sql b/src/main/resources/db/changelog/0-base/030-historization.sql index c220222c..90c47305 100644 --- a/src/main/resources/db/changelog/0-base/030-historization.sql +++ b/src/main/resources/db/changelog/0-base/030-historization.sql @@ -91,13 +91,16 @@ end; $$; -- ============================================================================ ---changeset michael.hoennig:hs-global-historization-tx-create-historicization endDelimiter:--// +--changeset michael.hoennig:hs-global-historization-tx-create-historicization runOnChange:true validCheckSum:ANY endDelimiter:--// -- ---------------------------------------------------------------------------- - -create or replace procedure base.tx_create_historicization(baseTable varchar) +create or replace procedure base.tx_create_historicization( + basetable varchar -- format 'schemaname.tablename' +) language plpgsql as $$ declare + baseSchemaName varchar; + baseTableName varchar; createHistTableSql varchar; createTriggerSQL varchar; viewName varchar; @@ -106,14 +109,19 @@ declare baseCols varchar; begin + -- determine schema and pure table name + SELECT split_part(basetable, '.', 1), + split_part(basetable, '.', 2) + INTO baseSchemaName, baseTableName; + -- create the history table createHistTableSql = '' || - 'CREATE TABLE ' || baseTable || '_ex (' || + 'CREATE TABLE ' || basetable || '_ex (' || ' version_id serial PRIMARY KEY,' || ' txid xid8 NOT NULL REFERENCES base.tx_context(txid),' || ' trigger_op base.tx_operation NOT NULL,' || ' alive boolean not null,' || - ' LIKE ' || baseTable || + ' LIKE ' || basetable || ' EXCLUDING CONSTRAINTS' || ' EXCLUDING STATISTICS' || ')'; @@ -121,12 +129,12 @@ begin execute createHistTableSql; -- create the historical view - viewName = baseTable || '_hv'; - exVersionsTable = baseTable || '_ex'; + viewName = basetable || '_hv'; + exVersionsTable = basetable || '_ex'; baseCols = (select string_agg(quote_ident(column_name), ', ') from information_schema.columns - where table_schema = 'public' - and table_name = baseTable); + where table_schema = baseSchemaName + and table_name = baseTableName); createViewSQL = format( 'CREATE OR REPLACE VIEW %1$s AS' || @@ -152,7 +160,7 @@ begin -- "-9-" to put the trigger execution after any alphabetically lesser tx-triggers createTriggerSQL = 'CREATE TRIGGER tx_9_historicize_tg' || - ' AFTER INSERT OR DELETE OR UPDATE ON ' || baseTable || + ' AFTER INSERT OR DELETE OR UPDATE ON ' || basetable || ' FOR EACH ROW EXECUTE PROCEDURE base.tx_historicize_tf()'; execute createTriggerSQL; diff --git a/src/main/resources/db/changelog/0-base/040-array-functions.sql b/src/main/resources/db/changelog/0-base/040-array-functions.sql new file mode 100644 index 00000000..d44a21d6 --- /dev/null +++ b/src/main/resources/db/changelog/0-base/040-array-functions.sql @@ -0,0 +1,25 @@ +--liquibase formatted sql + + +-- ============================================================================ +--changeset michael.hoennig:base-array-functions-WITHOUT-NULL-VALUES endDelimiter:--// +-- ---------------------------------------------------------------------------- + +CREATE OR REPLACE FUNCTION base.without_null_values(arr anyarray) + RETURNS anyarray +AS $$ + SELECT array_agg(e) FROM unnest(arr) AS e WHERE e IS NOT NULL +$$ LANGUAGE sql; + + +-- ============================================================================ +--changeset michael.hoennig:base-array-functions-ADD-IF-NOT-NULLCREATE OR REPLACE FUNCTION add_if_not_null(anyarray, anyelement) +-- ---------------------------------------------------------------------------- + +CREATE OR REPLACE FUNCTION base.add_if_not_null(arr anyarray, val anyelement) + RETURNS anyarray +AS $$ + SELECT CASE WHEN val IS NULL THEN arr ELSE arr || val END +$$ LANGUAGE sql; + + diff --git a/src/main/resources/db/changelog/1-rbac/1050-rbac-base.sql b/src/main/resources/db/changelog/1-rbac/1050-rbac-base.sql index 680aa4a0..17de7c11 100644 --- a/src/main/resources/db/changelog/1-rbac/1050-rbac-base.sql +++ b/src/main/resources/db/changelog/1-rbac/1050-rbac-base.sql @@ -536,7 +536,7 @@ $$; -- ============================================================================ ---changeset michael.hoennig:rbac-base-GRANTS endDelimiter:--// +--changeset michael.hoennig:rbac-base-GRANTS endDelimiter:--// -- ---------------------------------------------------------------------------- /* Table to store grants / role- or permission assignments to subjects or roles. diff --git a/src/main/resources/db/changelog/1-rbac/1054-rbac-context.sql b/src/main/resources/db/changelog/1-rbac/1054-rbac-context.sql index 32d67546..d43c644f 100644 --- a/src/main/resources/db/changelog/1-rbac/1054-rbac-context.sql +++ b/src/main/resources/db/changelog/1-rbac/1054-rbac-context.sql @@ -2,7 +2,7 @@ -- ============================================================================ ---changeset michael.hoennig:rbac-context-DETERMINE endDelimiter:--// +--changeset michael.hoennig:rbac-context-DETERMINE runOnChange:true validCheckSum:ANY endDelimiter:--// -- ---------------------------------------------------------------------------- create or replace function rbac.determineCurrentSubjectUuid(currentSubject varchar) diff --git a/src/main/resources/db/changelog/1-rbac/1080-rbac-global.sql b/src/main/resources/db/changelog/1-rbac/1080-rbac-global.sql index 0a4ef7c0..f42b9d25 100644 --- a/src/main/resources/db/changelog/1-rbac/1080-rbac-global.sql +++ b/src/main/resources/db/changelog/1-rbac/1080-rbac-global.sql @@ -148,12 +148,12 @@ commit; -- ============================================================================ ---changeset michael.hoennig:rbac-global-GUEST-ROLE endDelimiter:--// +--changeset michael.hoennig:rbac-global-GUEST-ROLE runOnChange:true validCheckSum:ANY endDelimiter:--// -- ---------------------------------------------------------------------------- /* A rbac.Global guest role. */ -create or replace function rbac.globalglobalGuest(assumed boolean = true) +create or replace function rbac.global_GUEST(assumed boolean = true) returns rbac.RoleDescriptor returns null on null input stable -- leakproof @@ -161,10 +161,17 @@ create or replace function rbac.globalglobalGuest(assumed boolean = true) select 'rbac.global', (select uuid from rbac.object where objectTable = 'rbac.global'), 'GUEST'::rbac.RoleType, assumed; $$; -begin transaction; - call base.defineContext('creating role:rbac.global#global:guest', null, null, null); - select rbac.createRole(rbac.globalglobalGuest()); -commit; +do language plpgsql $$ + begin + call base.defineContext('creating role:rbac.global#global:guest', null, null, null); + begin + perform rbac.createRole(rbac.global_GUEST()); + exception + when unique_violation then + null; -- ignore if it already exists from prev execution of this changeset + end; + end; +$$; --// diff --git a/src/main/resources/db/changelog/5-hs-office/502-person/5028-hs-office-person-test-data-for-credentials.sql b/src/main/resources/db/changelog/5-hs-office/502-person/5028-hs-office-person-test-data-for-credentials.sql new file mode 100644 index 00000000..5e67c268 --- /dev/null +++ b/src/main/resources/db/changelog/5-hs-office/502-person/5028-hs-office-person-test-data-for-credentials.sql @@ -0,0 +1,19 @@ +--liquibase formatted sql + +-- In a separate file to avoid changed checksums in the existing changsets. +-- I presume it's a bug in Liquibase that other changeset checksums are changed by new changesets in the same file + +-- ============================================================================ +--changeset michael.hoennig:hs-office-person-TEST-DATA-GENERATION-FOR-CREDENTIALS context:!without-test-data endDelimiter:--// +-- ---------------------------------------------------------------------------- + +do language plpgsql $$ + begin + call hs_office.person_create_test_data('NP', null,'Hostmaster', 'Alex'); + call hs_office.person_create_test_data('NP', null, 'Hostmaster', 'Fran'); + call hs_office.person_create_test_data('NP', null, 'User', 'Drew'); + call hs_office.person_create_test_data('NP', null, 'User', 'Test'); + end; +$$; +--// + diff --git a/src/main/resources/db/changelog/9-hs-global/950-credentials/9500-hs-credentials-schema.sql b/src/main/resources/db/changelog/9-hs-global/950-credentials/9500-hs-credentials-schema.sql new file mode 100644 index 00000000..fe79ccaa --- /dev/null +++ b/src/main/resources/db/changelog/9-hs-global/950-credentials/9500-hs-credentials-schema.sql @@ -0,0 +1,8 @@ +--liquibase formatted sql + + +-- ============================================================================ +--changeset michael.hoennig:hs-credentials-SCHEMA endDelimiter:--// +-- ---------------------------------------------------------------------------- +CREATE SCHEMA hs_credentials; +--// diff --git a/src/main/resources/db/changelog/9-hs-global/950-credentials/9510-hs-credentials.sql b/src/main/resources/db/changelog/9-hs-global/950-credentials/9510-hs-credentials.sql new file mode 100644 index 00000000..5c054ef5 --- /dev/null +++ b/src/main/resources/db/changelog/9-hs-global/950-credentials/9510-hs-credentials.sql @@ -0,0 +1,91 @@ +--liquibase formatted sql + + +-- ============================================================================ +--changeset michael.hoennig:hs-credentials-CREDENTIALS-TABLE endDelimiter:--// +-- ---------------------------------------------------------------------------- + +create table hs_credentials.credentials +( + uuid uuid PRIMARY KEY references rbac.subject (uuid) initially deferred, + version int not null default 0, + + person_uuid uuid not null references hs_office.person(uuid), + + active bool, + global_uid int unique, -- w/o + global_gid int unique, -- w/o + onboarding_token text, -- w/o + + two_factor_auth text, + phone_password text, + email_address text, + sms_number text +); +--// + + +-- ============================================================================ +--changeset michael.hoennig:hs-credentials-context-CONTEXT-TABLE endDelimiter:--// +-- ---------------------------------------------------------------------------- + +create table hs_credentials.context +( + uuid uuid PRIMARY KEY, + version int not null default 0, + + type varchar(16), + qualifier varchar(80), + + unique (type, qualifier) +); +--// + + +-- ============================================================================ +--changeset michael.hoennig:hs-credentials-CONTEXT-IMMUTABLE-TRIGGER endDelimiter:--// +-- ---------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION hs_credentials.prevent_context_update() +RETURNS TRIGGER AS $$ +BEGIN + RAISE EXCEPTION 'Updates to hs_credentials.context are not allowed.'; +END; +$$ LANGUAGE plpgsql; + +-- Trigger to enforce immutability +CREATE TRIGGER context_immutable_trigger +BEFORE UPDATE ON hs_credentials.context +FOR EACH ROW EXECUTE FUNCTION hs_credentials.prevent_context_update(); +--// + + +-- ============================================================================ +--changeset michael.hoennig:hs_credentials-CONTEXT-MAPPING endDelimiter:--// +-- ---------------------------------------------------------------------------- + +create table hs_credentials.context_mapping +( + uuid uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + credentials_uuid uuid references hs_credentials.credentials(uuid) ON DELETE CASCADE, + context_uuid uuid references hs_credentials.context(uuid) ON DELETE RESTRICT +); +--// + + +-- ============================================================================ +--changeset michael.hoennig:hs-hs_credentials-JOURNALS endDelimiter:--// +-- ---------------------------------------------------------------------------- + +call base.create_journal('hs_credentials.context_mapping'); +call base.create_journal('hs_credentials.context'); +call base.create_journal('hs_credentials.credentials'); +--// + + +-- ============================================================================ +--changeset michael.hoennig:hs_credentials-HISTORICIZATION endDelimiter:--// +-- ---------------------------------------------------------------------------- +call base.tx_create_historicization('hs_credentials.context_mapping'); +call base.tx_create_historicization('hs_credentials.context'); +call base.tx_create_historicization('hs_credentials.credentials'); +--// diff --git a/src/main/resources/db/changelog/9-hs-global/950-credentials/9513-hs-credentials-rbac.md b/src/main/resources/db/changelog/9-hs-global/950-credentials/9513-hs-credentials-rbac.md new file mode 100644 index 00000000..f3959949 --- /dev/null +++ b/src/main/resources/db/changelog/9-hs-global/950-credentials/9513-hs-credentials-rbac.md @@ -0,0 +1,41 @@ +### rbac credentialsContext + +This code generated was by RbacViewMermaidFlowchartGenerator, do not amend manually. + +```mermaid +%%{init:{'flowchart':{'htmlLabels':false}}}%% +flowchart TB + +subgraph credentialsContext["`**credentialsContext**`"] + direction TB + style credentialsContext fill:#dd4901,stroke:#274d6e,stroke-width:8px + + subgraph credentialsContext:roles[ ] + style credentialsContext:roles fill:#dd4901,stroke:white + + role:credentialsContext:OWNER[[credentialsContext:OWNER]] + role:credentialsContext:ADMIN[[credentialsContext:ADMIN]] + role:credentialsContext:REFERRER[[credentialsContext:REFERRER]] + end + + subgraph credentialsContext:permissions[ ] + style credentialsContext:permissions fill:#dd4901,stroke:white + + perm:credentialsContext:INSERT{{credentialsContext:INSERT}} + perm:credentialsContext:UPDATE{{credentialsContext:UPDATE}} + perm:credentialsContext:DELETE{{credentialsContext:DELETE}} + perm:credentialsContext:SELECT{{credentialsContext:SELECT}} + end +end + +%% granting roles to roles +role:credentialsContext:OWNER ==> role:credentialsContext:ADMIN +role:credentialsContext:ADMIN ==> role:credentialsContext:REFERRER + +%% granting permissions to roles +role:rbac.global:ADMIN ==> perm:credentialsContext:INSERT +role:rbac.global:ADMIN ==> perm:credentialsContext:UPDATE +role:rbac.global:ADMIN ==> perm:credentialsContext:DELETE +role:rbac.global:REFERRER ==> perm:credentialsContext:SELECT + +``` diff --git a/src/main/resources/db/changelog/9-hs-global/950-credentials/9519-hs-credentials-test-data.sql b/src/main/resources/db/changelog/9-hs-global/950-credentials/9519-hs-credentials-test-data.sql new file mode 100644 index 00000000..ad74b8c1 --- /dev/null +++ b/src/main/resources/db/changelog/9-hs-global/950-credentials/9519-hs-credentials-test-data.sql @@ -0,0 +1,68 @@ +--liquibase formatted sql + + +-- ============================================================================ +--changeset michael.hoennig:hs_credentials-credentials-TEST-DATA context:!without-test-data endDelimiter:--// +-- ---------------------------------------------------------------------------- + +do language plpgsql $$ + +declare + superuserAlexSubjectUuid uuid; + personAlexUuid uuid; + superuserFranSubjectUuid uuid; + personFranUuid uuid; + + context_HSADMIN_prod hs_credentials.context; + context_SSH_internal hs_credentials.context; + context_MATRIX_internal hs_credentials.context; + +begin + call base.defineContext('creating booking-project test-data', null, 'superuser-alex@hostsharing.net', 'rbac.global#global:ADMIN'); + + superuserAlexSubjectUuid = (SELECT uuid FROM rbac.subject WHERE name='superuser-alex@hostsharing.net'); + 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'); + + -- Add test contexts + INSERT INTO hs_credentials.context (uuid, type, qualifier) VALUES + ('11111111-1111-1111-1111-111111111111', 'HSADMIN', 'prod') + RETURNING * INTO context_HSADMIN_prod; + INSERT INTO hs_credentials.context (uuid, type, qualifier) VALUES + ('22222222-2222-2222-2222-222222222222', 'SSH', 'internal') + RETURNING * INTO context_SSH_internal; + INSERT INTO hs_credentials.context (uuid, type, qualifier) VALUES + ('33333333-3333-3333-3333-333333333333', 'MATRIX', 'internal') + RETURNING * INTO context_MATRIX_internal; + +-- grant general access to public credential contexts +-- TODO_impl: RBAC rules for _rv do not yet work properly +-- call rbac.grantPermissiontoRole( +-- rbac.createPermission(context_HSADMIN_prod.uuid, 'SELECT'), +-- rbac.global_GUEST()); +-- call rbac.grantPermissiontoRole( +-- rbac.createPermission(context_SSH_internal.uuid, 'SELECT'), +-- rbac.global_ADMIN()); +-- call rbac.grantPermissionToRole( +-- rbac.createPermission(context_MATRIX_internal.uuid, 'SELECT'), +-- rbac.global_ADMIN()); +-- call rbac.grantRoleToRole(hs_credentials.context_REFERRER(context_SSH_internal), rbac.global_ADMIN()); +-- call rbac.grantRoleToRole(hs_credentials.context_REFERRER(context_MATRIX_internal), rbac.global_ADMIN()); + + -- Add test credentials (linking to assumed rbac.subject UUIDs) + INSERT INTO hs_credentials.credentials (uuid, version, person_uuid, active, global_uid, global_gid, onboarding_token, two_factor_auth, phone_password, email_address, sms_number) VALUES + ( superuserAlexSubjectUuid, 0, personAlexUuid, true, 1001, 1001, 'token-abc', 'otp-secret-1', 'phone-pw-1', 'alex@example.com', '111-222-3333'), + ( superuserFranSubjectUuid, 0, personFranUuid, true, 1002, 1002, 'token-def', 'otp-secret-2', 'phone-pw-2', 'fran@example.com', '444-555-6666'); + + -- Map credentials to contexts + INSERT INTO hs_credentials.context_mapping (credentials_uuid, context_uuid) VALUES + (superuserAlexSubjectUuid, '11111111-1111-1111-1111-111111111111'), -- HSADMIN context + (superuserFranSubjectUuid, '11111111-1111-1111-1111-111111111111'), -- HSADMIN context + (superuserAlexSubjectUuid, '22222222-2222-2222-2222-222222222222'), -- SSH context + (superuserFranSubjectUuid, '22222222-2222-2222-2222-222222222222'), -- SSH context + (superuserAlexSubjectUuid, '33333333-3333-3333-3333-333333333333'), -- MATRIX context + (superuserFranSubjectUuid, '33333333-3333-3333-3333-333333333333'); -- MATRIX context + +end; $$; +--// diff --git a/src/main/resources/db/changelog/9-hs-global/9100-hs-integration-schema.sql b/src/main/resources/db/changelog/9-hs-global/960-integrations/9600-hs-integration-schema.sql similarity index 85% rename from src/main/resources/db/changelog/9-hs-global/9100-hs-integration-schema.sql rename to src/main/resources/db/changelog/9-hs-global/960-integrations/9600-hs-integration-schema.sql index 1f0a8a44..70255205 100644 --- a/src/main/resources/db/changelog/9-hs-global/9100-hs-integration-schema.sql +++ b/src/main/resources/db/changelog/9-hs-global/960-integrations/9600-hs-integration-schema.sql @@ -4,5 +4,5 @@ -- ============================================================================ --changeset timotheus.pokorra:hs-integration-SCHEMA endDelimiter:--// -- ---------------------------------------------------------------------------- -CREATE SCHEMA hs_integration; +CREATE SCHEMA IF NOT EXISTS hs_integration; --// diff --git a/src/main/resources/db/changelog/9-hs-global/9110-integration-kimai.sql b/src/main/resources/db/changelog/9-hs-global/960-integrations/9610-integration-kimai.sql similarity index 100% rename from src/main/resources/db/changelog/9-hs-global/9110-integration-kimai.sql rename to src/main/resources/db/changelog/9-hs-global/960-integrations/9610-integration-kimai.sql diff --git a/src/main/resources/db/changelog/9-hs-global/9120-integration-znuny.sql b/src/main/resources/db/changelog/9-hs-global/960-integrations/9620-integration-znuny.sql similarity index 100% rename from src/main/resources/db/changelog/9-hs-global/9120-integration-znuny.sql rename to src/main/resources/db/changelog/9-hs-global/960-integrations/9620-integration-znuny.sql diff --git a/src/main/resources/db/changelog/9-hs-global/9130-integration-mlmmj.sql b/src/main/resources/db/changelog/9-hs-global/960-integrations/9630-integration-mlmmj.sql similarity index 100% rename from src/main/resources/db/changelog/9-hs-global/9130-integration-mlmmj.sql rename to src/main/resources/db/changelog/9-hs-global/960-integrations/9630-integration-mlmmj.sql diff --git a/src/main/resources/db/changelog/db.changelog-master.yaml b/src/main/resources/db/changelog/db.changelog-master.yaml index 74a6005e..4c993af5 100644 --- a/src/main/resources/db/changelog/db.changelog-master.yaml +++ b/src/main/resources/db/changelog/db.changelog-master.yaml @@ -27,6 +27,8 @@ databaseChangeLog: file: db/changelog/0-base/020-audit-log.sql - include: file: db/changelog/0-base/030-historization.sql + - include: + file: db/changelog/0-base/040-array-functions.sql - include: file: db/changelog/0-base/090-log-slow-queries-extensions.sql @@ -100,6 +102,9 @@ databaseChangeLog: - include: file: db/changelog/5-hs-office/502-person/5028-hs-office-person-test-data.sql context: "!without-test-data" + - include: + file: db/changelog/5-hs-office/502-person/5028-hs-office-person-test-data-for-credentials.sql + context: "!without-test-data" - include: file: db/changelog/5-hs-office/503-relation/5030-hs-office-relation.sql - include: @@ -212,19 +217,30 @@ databaseChangeLog: file: db/changelog/9-hs-global/9000-statistics.sql context: "!only-office" + - include: + file: db/changelog/9-hs-global/950-credentials/9500-hs-credentials-schema.sql + - include: + file: db/changelog/9-hs-global/950-credentials/9510-hs-credentials.sql + # TODO_impl: RBAC rules for _rv do not yet work properly + # - include: + # file: db/changelog/9-hs-global/950-credentials/9513-hs-credentials-rbac.sql + - include: + file: db/changelog/9-hs-global/950-credentials/9519-hs-credentials-test-data.sql + context: "!without-test-data" + + - include: + file: db/changelog/9-hs-global/960-integrations/9600-hs-integration-schema.sql + - include: + file: db/changelog/9-hs-global/960-integrations/9610-integration-kimai.sql + - include: + file: db/changelog/9-hs-global/960-integrations/9620-integration-znuny.sql + - include: + file: db/changelog/9-hs-global/960-integrations/9630-integration-mlmmj.sql + - include: file: db/changelog/9-hs-global/9800-cleanup.sql context: "without-test-data" - - include: - file: db/changelog/9-hs-global/9100-hs-integration-schema.sql - - include: - file: db/changelog/9-hs-global/9110-integration-kimai.sql - - include: - file: db/changelog/9-hs-global/9120-integration-znuny.sql - - include: - file: db/changelog/9-hs-global/9130-integration-mlmmj.sql - - include: file: db/changelog/9-hs-global/9999-liquibase-migration-test.sql context: liquibase-migration-test diff --git a/src/test/java/net/hostsharing/hsadminng/arch/ArchitectureTest.java b/src/test/java/net/hostsharing/hsadminng/arch/ArchitectureTest.java index 628cf5d7..051bfe91 100644 --- a/src/test/java/net/hostsharing/hsadminng/arch/ArchitectureTest.java +++ b/src/test/java/net/hostsharing/hsadminng/arch/ArchitectureTest.java @@ -49,6 +49,7 @@ public class ArchitectureTest { "..test.pac", "..test.dom", "..context", + "..credentials", "..hash", "..lambda", "..generated..", diff --git a/src/test/java/net/hostsharing/hsadminng/credentials/HsCredentialsContextRbacEntityUnitTest.java b/src/test/java/net/hostsharing/hsadminng/credentials/HsCredentialsContextRbacEntityUnitTest.java new file mode 100644 index 00000000..de87bc42 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/credentials/HsCredentialsContextRbacEntityUnitTest.java @@ -0,0 +1,20 @@ +package net.hostsharing.hsadminng.credentials; + +import org.junit.jupiter.api.Test; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class HsCredentialsContextRbacEntityUnitTest { + + @Test + void toShortString() { + final var entity = HsCredentialsContextRbacEntity.builder() + .uuid(UUID.randomUUID()) + .type("SSH") + .qualifier("prod") + .build(); + assertEquals("loginContext(SSH:prod)", entity.toShortString()); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/credentials/HsCredentialsContextRbacRepositoryIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/credentials/HsCredentialsContextRbacRepositoryIntegrationTest.java new file mode 100644 index 00000000..d99f6b8c --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/credentials/HsCredentialsContextRbacRepositoryIntegrationTest.java @@ -0,0 +1,167 @@ +package net.hostsharing.hsadminng.credentials; + +import net.hostsharing.hsadminng.context.Context; +import net.hostsharing.hsadminng.rbac.context.ContextBasedTest; +import net.hostsharing.hsadminng.rbac.test.JpaAttempt; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.postgresql.util.PSQLException; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import jakarta.persistence.PersistenceException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.transaction.Transactional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowable; + +@DataJpaTest +@ActiveProfiles("test") +@Tag("generalIntegrationTest") +@Import({ Context.class, JpaAttempt.class }) +@Transactional +class HsCredentialsContextRbacRepositoryIntegrationTest extends ContextBasedTest { + + // existing UUIDs from test data (Liquibase changeset 310-login-credentials-test-data.sql) + private static final UUID TEST_CONTEXT_HSADMIN_PROD_UUID = UUID.fromString("11111111-1111-1111-1111-111111111111"); + private static final UUID TEST_CONTEXT_MATRIX_INTERNAL_UUID = UUID.fromString("33333333-3333-3333-3333-333333333333"); + + private static final String SUPERUSER_ALEX_SUBJECT_NAME = "superuser-alex@hostsharing.net"; + private static final String TEST_USER_SUBJECT_NAME = "selfregistered-test-user@hostsharing.org"; + + @MockitoBean + HttpServletRequest request; + + @Autowired + private HsCredentialsContextRbacRepository loginContextRepository; + + @Test + void shouldFindAllByNormalUserUsingTestData() { + context(TEST_USER_SUBJECT_NAME); + + // when + final var allContexts = loginContextRepository.findAll(); + + // then + assertThat(allContexts) + .isNotNull() + .hasSizeGreaterThanOrEqualTo(1) // Expect at least the 1 public context from assumed test data + .extracting(HsCredentialsContext::getUuid) + .contains(TEST_CONTEXT_HSADMIN_PROD_UUID); + } + + @Test + void shouldFindAllByAdminUserUsingTestData() { + context(SUPERUSER_ALEX_SUBJECT_NAME); + + // when + final var allContexts = loginContextRepository.findAll(); + + // then + assertThat(allContexts) + .isNotNull() + .hasSizeGreaterThanOrEqualTo(3); // Expect at least the 1 public context from assumed test data + } + + @Test + void shouldFindByUuidUsingTestData() { + context(TEST_USER_SUBJECT_NAME); + + // when + final var foundEntityOptional = loginContextRepository.findByUuid(TEST_CONTEXT_HSADMIN_PROD_UUID); + + // then + assertThat(foundEntityOptional).isPresent(); + assertThat(foundEntityOptional).map(Object::toString).contains("loginContext(HSADMIN:prod)"); + } + + @Test + void shouldFindByTypeAndQualifierUsingTestData() { + context(SUPERUSER_ALEX_SUBJECT_NAME); + + // when + final var foundEntityOptional = loginContextRepository.findByTypeAndQualifier("SSH", "internal"); + + // then + assertThat(foundEntityOptional).isPresent(); + assertThat(foundEntityOptional).map(Object::toString).contains("loginContext(SSH:internal)"); + } + + @Test + void shouldReturnEmptyOptionalWhenFindByTypeAndQualifierNotFound() { + context(SUPERUSER_ALEX_SUBJECT_NAME); + + // given + final var nonExistentQualifier = "non-existent-qualifier"; + + // when + final var foundEntityOptional = loginContextRepository.findByTypeAndQualifier( + "HSADMIN", nonExistentQualifier); + + // then + assertThat(foundEntityOptional).isNotPresent(); + } + + @Test + void shouldSaveNewLoginContext() { + context(SUPERUSER_ALEX_SUBJECT_NAME); + + // given + final var newQualifier = "test@example.social"; + final var newType = "MASTODON"; + final var newContext = HsCredentialsContextRbacEntity.builder() + .type(newType) + .qualifier(newQualifier) + .build(); + + // when + final var savedEntity = loginContextRepository.save(newContext); + em.flush(); + em.clear(); + + // then + assertThat(savedEntity).isNotNull(); + final var generatedUuid = savedEntity.getUuid(); + assertThat(generatedUuid).isNotNull(); // Verify UUID was generated + + // Fetch again using the generated UUID to confirm persistence + context(SUPERUSER_ALEX_SUBJECT_NAME); // Re-set context if needed after clear + final var foundEntityOptional = loginContextRepository.findByUuid(generatedUuid); + assertThat(foundEntityOptional).isPresent(); + final var foundEntity = foundEntityOptional.get(); + assertThat(foundEntity.getUuid()).isEqualTo(generatedUuid); + assertThat(foundEntity.getType()).isEqualTo(newType); + assertThat(foundEntity.getQualifier()).isEqualTo(newQualifier); + } + + @Test + void shouldPreventUpdateOfExistingLoginContext() { + context(SUPERUSER_ALEX_SUBJECT_NAME); + + // given an existing entity from test data + final var entityToUpdateOptional = loginContextRepository.findByUuid(TEST_CONTEXT_MATRIX_INTERNAL_UUID); + assertThat(entityToUpdateOptional) + .withFailMessage("Could not find existing LoginContext with UUID %s. Ensure test data exists.", + TEST_CONTEXT_MATRIX_INTERNAL_UUID) + .isPresent(); + final var entityToUpdate = entityToUpdateOptional.get(); + + // when + entityToUpdate.setQualifier("updated"); + final var exception = catchThrowable( () -> { + loginContextRepository.save(entityToUpdate); + em.flush(); + }); + + // then + assertThat(exception) + .isInstanceOf(PersistenceException.class) + .hasCauseInstanceOf(PSQLException.class) + .hasMessageContaining("ERROR: Updates to hs_credentials.context are not allowed."); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/credentials/HsCredentialsContextRealEntityUnitTest.java b/src/test/java/net/hostsharing/hsadminng/credentials/HsCredentialsContextRealEntityUnitTest.java new file mode 100644 index 00000000..a25e6476 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/credentials/HsCredentialsContextRealEntityUnitTest.java @@ -0,0 +1,20 @@ +package net.hostsharing.hsadminng.credentials; + +import org.junit.jupiter.api.Test; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class HsCredentialsContextRealEntityUnitTest { + + @Test + void toShortString() { + final var entity = HsCredentialsContextRealEntity.builder() + .uuid(UUID.randomUUID()) + .type("testType") + .qualifier("testQualifier") + .build(); + assertEquals("loginContext(testType:testQualifier)", entity.toShortString()); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/credentials/HsCredentialsContextRealRepositoryIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/credentials/HsCredentialsContextRealRepositoryIntegrationTest.java new file mode 100644 index 00000000..b363e795 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/credentials/HsCredentialsContextRealRepositoryIntegrationTest.java @@ -0,0 +1,177 @@ +package net.hostsharing.hsadminng.credentials; + +import net.hostsharing.hsadminng.context.Context; +import net.hostsharing.hsadminng.rbac.context.ContextBasedTest; +import net.hostsharing.hsadminng.rbac.test.JpaAttempt; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.postgresql.util.PSQLException; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import jakarta.persistence.PersistenceException; +import jakarta.servlet.http.HttpServletRequest; +import java.sql.Timestamp; +import java.time.ZonedDateTime; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowable; + +@DataJpaTest +@ActiveProfiles("test") +@Tag("generalIntegrationTest") +@Import({ Context.class, JpaAttempt.class }) +class HsCredentialsContextRealRepositoryIntegrationTest extends ContextBasedTest { + + // existing UUIDs from test data (Liquibase changeset 310-login-credentials-test-data.sql) + private static final UUID TEST_CONTEXT_HSADMIN_PROD_UUID = UUID.fromString("11111111-1111-1111-1111-111111111111"); + private static final UUID TEST_CONTEXT_SSH_INTERNAL_UUID = UUID.fromString("22222222-2222-2222-2222-222222222222"); + private static final UUID TEST_CONTEXT_MATRIX_INTERNAL_UUID = UUID.fromString("33333333-3333-3333-3333-333333333333"); + + private static final String SUPERUSER_ALEX_SUBJECT_NAME = "superuser-alex@hostsharing.net"; + private static final String TEST_USER_SUBJECT_NAME = "selfregistered-test-user@hostsharing.org"; + + @MockitoBean + HttpServletRequest request; + + @Autowired + private HsCredentialsContextRealRepository loginContextRepository; + + @Test + public void historizationIsAvailable() { + // given + final String nativeQuerySql = "select * from hs_credentials.context_hv"; + + // when + historicalContext(Timestamp.from(ZonedDateTime.now().minusDays(1).toInstant())); + final var query = em.createNativeQuery(nativeQuerySql); + final var rowsBefore = query.getResultList(); + + // then + assertThat(rowsBefore).as("hs_credentials.context_hv only contain no rows for a timestamp before test data creation").hasSize(0); + + // and when + historicalContext(Timestamp.from(ZonedDateTime.now().toInstant())); + em.createNativeQuery(nativeQuerySql, Integer.class); + final var rowsAfter = query.getResultList(); + + // then + assertThat(rowsAfter).as("hs_credentials.context_hv should now contain the test-data rows for the current timestamp").hasSize(3); + } + + @Test + void shouldFindAllUsingTestData() { + context(TEST_USER_SUBJECT_NAME); + + // when + final var allContexts = loginContextRepository.findAll(); + + // then + assertThat(allContexts) + .isNotNull() + .hasSizeGreaterThanOrEqualTo(3) // Expect at least the 3 from assumed test data + .extracting(HsCredentialsContext::getUuid) + .contains(TEST_CONTEXT_HSADMIN_PROD_UUID, TEST_CONTEXT_SSH_INTERNAL_UUID, TEST_CONTEXT_MATRIX_INTERNAL_UUID); + } + + @Test + void shouldFindByUuidUsingTestData() { + context(TEST_USER_SUBJECT_NAME); + + // when + final var foundEntityOptional = loginContextRepository.findByUuid(TEST_CONTEXT_HSADMIN_PROD_UUID); + + // then + assertThat(foundEntityOptional).isPresent(); + assertThat(foundEntityOptional).map(Object::toString).contains("loginContext(HSADMIN:prod)"); + } + + @Test + void shouldFindByTypeAndQualifierUsingTestData() { + context(TEST_USER_SUBJECT_NAME); + + // when + final var foundEntityOptional = loginContextRepository.findByTypeAndQualifier("SSH", "internal"); + + // then + assertThat(foundEntityOptional).isPresent(); + assertThat(foundEntityOptional).map(Object::toString).contains("loginContext(SSH:internal)"); + } + + @Test + void shouldReturnEmptyOptionalWhenFindByTypeAndQualifierNotFound() { + context(TEST_USER_SUBJECT_NAME); + + // given + final var nonExistentQualifier = "non-existent-qualifier"; + + // when + final var foundEntityOptional = loginContextRepository.findByTypeAndQualifier( + "HSADMIN", nonExistentQualifier); + + // then + assertThat(foundEntityOptional).isNotPresent(); + } + + @Test + void shouldSaveNewLoginContext() { + context(SUPERUSER_ALEX_SUBJECT_NAME); + + // given + final var newQualifier = "test@example.social"; + final var newType = "MASTODON"; + final var newContext = HsCredentialsContextRealEntity.builder() + .type(newType) + .qualifier(newQualifier) + .build(); + + // when + final var savedEntity = loginContextRepository.save(newContext); + em.flush(); + em.clear(); + + // then + assertThat(savedEntity).isNotNull(); + final var generatedUuid = savedEntity.getUuid(); + assertThat(generatedUuid).isNotNull(); // Verify UUID was generated + + // Fetch again using the generated UUID to confirm persistence + context(TEST_USER_SUBJECT_NAME); // Re-set context if needed after clear + final var foundEntityOptional = loginContextRepository.findByUuid(generatedUuid); + assertThat(foundEntityOptional).isPresent(); + final var foundEntity = foundEntityOptional.get(); + assertThat(foundEntity.getUuid()).isEqualTo(generatedUuid); + assertThat(foundEntity.getType()).isEqualTo(newType); + assertThat(foundEntity.getQualifier()).isEqualTo(newQualifier); + } + + @Test + void shouldPreventUpdateOfExistingLoginContext() { + context(TEST_USER_SUBJECT_NAME); + + // given an existing entity from test data + final var entityToUpdateOptional = loginContextRepository.findByUuid(TEST_CONTEXT_MATRIX_INTERNAL_UUID); + assertThat(entityToUpdateOptional) + .withFailMessage("Could not find existing LoginContext with UUID %s. Ensure test data exists.", + TEST_CONTEXT_MATRIX_INTERNAL_UUID) + .isPresent(); + final var entityToUpdate = entityToUpdateOptional.get(); + + // when + entityToUpdate.setQualifier("updated"); + final var exception = catchThrowable( () -> { + loginContextRepository.save(entityToUpdate); + em.flush(); + }); + + // then + assertThat(exception) + .isInstanceOf(PersistenceException.class) + .hasCauseInstanceOf(PSQLException.class) + .hasMessageContaining("ERROR: Updates to hs_credentials.context are not allowed."); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/credentials/HsCredentialsContextsControllerRestTest.java b/src/test/java/net/hostsharing/hsadminng/credentials/HsCredentialsContextsControllerRestTest.java new file mode 100644 index 00000000..f66ae4da --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/credentials/HsCredentialsContextsControllerRestTest.java @@ -0,0 +1,122 @@ +package net.hostsharing.hsadminng.credentials; + +import static net.hostsharing.hsadminng.test.JsonMatcher.lenientlyEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +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; + +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import net.hostsharing.hsadminng.config.DisableSecurityConfig; +import net.hostsharing.hsadminng.config.JsonObjectMapperConfiguration; +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 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.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +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.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import jakarta.persistence.SynchronizationType; + +@WebMvcTest(HsCredentialsContextsController.class) +@Import({ StrictMapper.class, JsonObjectMapperConfiguration.class, DisableSecurityConfig.class, MessageTranslator.class}) +@ActiveProfiles("test") +class HsCredentialsContextsControllerRestTest { + + @Autowired + MockMvc mockMvc; + + @MockitoBean + Context contextMock; + + @Autowired + @SuppressWarnings("unused") // not used in test, but in controller class + StrictMapper mapper; + + @MockitoBean + EntityManagerWrapper em; + + @MockitoBean + EntityManagerFactory emf; + + @MockitoBean + HsCredentialsContextRbacRepository loginContextRbacRepo; + + + @TestConfiguration + public static class TestConfig { + + @Bean + public EntityManager entityManager() { + return mock(EntityManager.class); + } + + } + + @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); + } + + @Test + void getListOfLoginContextsReturnsOkWithEmptyList() throws Exception { + + // given + when(loginContextRbacRepo.findAll()).thenReturn(List.of( + HsCredentialsContextRbacEntity.builder() + .uuid(UUID.randomUUID()) + .type("HSADMIN") + .qualifier("prod") + .build(), + HsCredentialsContextRbacEntity.builder() + .uuid(UUID.randomUUID()) + .type("SSH") + .qualifier("prod") + .build() + )); + + // when + mockMvc.perform(MockMvcRequestBuilders + .get("/api/login/contexts") + .header("Authorization", "Bearer superuser-alex@hostsharing.net") + .accept(MediaType.APPLICATION_JSON)) + .andDo(print()) + + // then + .andExpect(status().isOk()) + .andExpect(jsonPath( + "$", lenientlyEquals(""" + [ + { + "type": "HSADMIN", + "qualifier": "prod" + }, + { + "type": "SSH", + "qualifier": "prod" + } + ] + """ + ))); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/credentials/HsCredentialsEntityPatcherUnitTest.java b/src/test/java/net/hostsharing/hsadminng/credentials/HsCredentialsEntityPatcherUnitTest.java new file mode 100644 index 00000000..3b481afb --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/credentials/HsCredentialsEntityPatcherUnitTest.java @@ -0,0 +1,165 @@ +package net.hostsharing.hsadminng.credentials; + +import net.hostsharing.hsadminng.credentials.generated.api.v1.model.LoginContextResource; +import net.hostsharing.hsadminng.credentials.generated.api.v1.model.LoginContextTypeResource; +import net.hostsharing.hsadminng.credentials.generated.api.v1.model.LoginCredentialsPatchResource; +import net.hostsharing.hsadminng.rbac.test.PatchUnitTestBase; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import jakarta.persistence.EntityManager; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; + +@TestInstance(PER_CLASS) +@ExtendWith(MockitoExtension.class) +class HsCredentialsEntityPatcherUnitTest extends PatchUnitTestBase< + LoginCredentialsPatchResource, + HsCredentialsEntity + > { + + private static final UUID INITIAL_CREDENTIALS_UUID = UUID.randomUUID(); + + private static final Boolean INITIAL_ACTIVE = true; + private static final String INITIAL_EMAIL_ADDRESS = "initial@example.com"; + private static final String INITIAL_TWO_FACTOR_AUTH = "initial_2fa"; + private static final String INITIAL_SMS_NUMBER = "initial_sms"; + private static final String INITIAL_PHONE_PASSWORD = "initial_phone_pw"; + + private static final Boolean PATCHED_ACTIVE = false; + private static final String PATCHED_EMAIL_ADDRESS = "patched@example.com"; + private static final String PATCHED_TWO_FACTOR_AUTH = "patched_2fa"; + private static final String PATCHED_SMS_NUMBER = "patched_sms"; + private static final String PATCHED_PHONE_PASSWORD = "patched_phone_pw"; + + // Contexts + private static final UUID CONTEXT_UUID_1 = UUID.randomUUID(); + private static final UUID CONTEXT_UUID_2 = UUID.randomUUID(); + private static final UUID CONTEXT_UUID_3 = UUID.randomUUID(); + + private final HsCredentialsContextRealEntity initialContextEntity1 = HsCredentialsContextRealEntity.builder() + .uuid(CONTEXT_UUID_1) + .type("HSADMIN") + .qualifier("prod") + .build(); + private final HsCredentialsContextRealEntity initialContextEntity2 = HsCredentialsContextRealEntity.builder() + .uuid(CONTEXT_UUID_2) + .type("SSH") + .qualifier("dev") + .build(); + + private LoginContextResource patchContextResource2; + private LoginContextResource patchContextResource3; + + // This is what em.find should return for CONTEXT_UUID_3 + private final HsCredentialsContextRealEntity newContextEntity3 = HsCredentialsContextRealEntity.builder() + .uuid(CONTEXT_UUID_3) + .type("HSADMIN") + .qualifier("test") + .build(); + + private final Set initialContextEntities = Set.of(initialContextEntity1, initialContextEntity2); + private List patchedContextResources; + private final Set expectedPatchedContextEntities = Set.of(initialContextEntity2, newContextEntity3); + + @Mock + private EntityManager em; + + @BeforeEach + void initMocks() { + // Mock em.find for contexts that are part of the patch and need to be fetched + lenient().when(em.find(eq(HsCredentialsContextRealEntity.class), eq(CONTEXT_UUID_1))).thenReturn(initialContextEntity1); + lenient().when(em.find(eq(HsCredentialsContextRealEntity.class), eq(CONTEXT_UUID_2))).thenReturn(initialContextEntity2); + lenient().when(em.find(eq(HsCredentialsContextRealEntity.class), eq(CONTEXT_UUID_3))).thenReturn(newContextEntity3); + + patchContextResource2 = new LoginContextResource(); + patchContextResource2.setUuid(CONTEXT_UUID_2); + patchContextResource2.setType(LoginContextTypeResource.SSH); + patchContextResource2.setQualifier("dev"); + + patchContextResource3 = new LoginContextResource(); + patchContextResource3.setUuid(CONTEXT_UUID_3); + patchContextResource3.setType(LoginContextTypeResource.HSADMIN); + patchContextResource3.setQualifier("test"); + + patchedContextResources = List.of(patchContextResource2, patchContextResource3); + } + + @Override + protected HsCredentialsEntity newInitialEntity() { + final var entity = new HsCredentialsEntity(); + entity.setUuid(INITIAL_CREDENTIALS_UUID); + entity.setActive(INITIAL_ACTIVE); + entity.setEmailAddress(INITIAL_EMAIL_ADDRESS); + entity.setTwoFactorAuth(INITIAL_TWO_FACTOR_AUTH); + entity.setSmsNumber(INITIAL_SMS_NUMBER); + entity.setPhonePassword(INITIAL_PHONE_PASSWORD); + // Ensure loginContexts is a mutable set for the patcher + entity.setLoginContexts(new HashSet<>(initialContextEntities)); + return entity; + } + + @Override + protected LoginCredentialsPatchResource newPatchResource() { + return new LoginCredentialsPatchResource(); + } + + @Override + protected HsCredentialsEntityPatcher createPatcher(final HsCredentialsEntity entity) { + return new HsCredentialsEntityPatcher(em, entity); + } + + @Override + protected Stream propertyTestDescriptors() { + return Stream.of( + new SimpleProperty<>( + "active", + LoginCredentialsPatchResource::setActive, + PATCHED_ACTIVE, + HsCredentialsEntity::setActive, + PATCHED_ACTIVE) + .notNullable(), + new JsonNullableProperty<>( + "emailAddress", + LoginCredentialsPatchResource::setEmailAddress, + PATCHED_EMAIL_ADDRESS, + HsCredentialsEntity::setEmailAddress, + PATCHED_EMAIL_ADDRESS), + new JsonNullableProperty<>( + "twoFactorAuth", + LoginCredentialsPatchResource::setTwoFactorAuth, + PATCHED_TWO_FACTOR_AUTH, + HsCredentialsEntity::setTwoFactorAuth, + PATCHED_TWO_FACTOR_AUTH), + new JsonNullableProperty<>( + "smsNumber", + LoginCredentialsPatchResource::setSmsNumber, + PATCHED_SMS_NUMBER, + HsCredentialsEntity::setSmsNumber, + PATCHED_SMS_NUMBER), + new JsonNullableProperty<>( + "phonePassword", + LoginCredentialsPatchResource::setPhonePassword, + PATCHED_PHONE_PASSWORD, + HsCredentialsEntity::setPhonePassword, + PATCHED_PHONE_PASSWORD), + new SimpleProperty<>( + "contexts", + LoginCredentialsPatchResource::setContexts, + patchedContextResources, + HsCredentialsEntity::setLoginContexts, + expectedPatchedContextEntities) + .notNullable() + ); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/credentials/HsCredentialsRepositoryIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/credentials/HsCredentialsRepositoryIntegrationTest.java new file mode 100644 index 00000000..c9daa72f --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/credentials/HsCredentialsRepositoryIntegrationTest.java @@ -0,0 +1,243 @@ +package net.hostsharing.hsadminng.credentials; + +import net.hostsharing.hsadminng.context.Context; +import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRealEntity; +import net.hostsharing.hsadminng.rbac.context.ContextBasedTest; +import net.hostsharing.hsadminng.rbac.subject.RbacSubjectEntity; +import net.hostsharing.hsadminng.rbac.test.JpaAttempt; +import org.hibernate.TransientObjectException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import jakarta.persistence.NoResultException; +import jakarta.persistence.Query; +import jakarta.servlet.http.HttpServletRequest; +import java.sql.Timestamp; +import java.time.ZonedDateTime; +import java.util.HashSet; +import java.util.Set; + +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 { + + private static final String SUPERUSER_ALEX_SUBJECT_NAME = "superuser-alex@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"; + + // HOWTO fix UnsatisfiedDependencyException with cause "No qualifying bean of type 'jakarta.servlet.http.HttpServletRequest'" + // This dependency comes from class net.hostsharing.hsadminng.context.Context, + // which is not automatically wired in a @DataJpaTest, but just in @SpringBootTest. + // If, e.g. for validators, the current user or assumed roles are needed, the values need to be mocked. + @MockitoBean + HttpServletRequest request; + + @Autowired + private HsCredentialsRepository loginCredentialsRepository; + + @Autowired + private HsCredentialsContextRealRepository loginContextRealRepo; + + // fetched UUIDs from test-data + private RbacSubjectEntity alexSubject; + private RbacSubjectEntity drewSubject; + private RbacSubjectEntity testUserSubject; + private HsOfficePersonRealEntity drewPerson; + private HsOfficePersonRealEntity testUserPerson; + + @BeforeEach + void setUp() { + alexSubject = fetchSubjectByName(SUPERUSER_ALEX_SUBJECT_NAME); + drewSubject = fetchSubjectByName(USER_DREW_SUBJECT_NAME); + testUserSubject = fetchSubjectByName(TEST_USER_SUBJECT_NAME); + drewPerson = fetchPersonByGivenName("Drew"); + testUserPerson = fetchPersonByGivenName("Test"); + } + + @Test + public void historizationIsAvailable() { + // given + final String nativeQuerySql = "select * from hs_credentials.credentials_hv"; + + // when + historicalContext(Timestamp.from(ZonedDateTime.now().minusDays(1).toInstant())); + final var query = em.createNativeQuery(nativeQuerySql); + final var rowsBefore = query.getResultList(); + + // then + assertThat(rowsBefore).as("hs_credentials.credentials_hv only contain no rows for a timestamp before test data creation").hasSize(0); + + // and when + historicalContext(Timestamp.from(ZonedDateTime.now().toInstant())); + em.createNativeQuery(nativeQuerySql); + final var rowsAfter = query.getResultList(); + + // then + assertThat(rowsAfter).as("hs_credentials.credentials_hv should now contain the test-data rows for the current timestamp").hasSize(2); + } + + @Test + void shouldFindByUuidUsingTestData() { + // when + final var foundEntityOptional = loginCredentialsRepository.findByUuid(alexSubject.getUuid()); + + // then + assertThat(foundEntityOptional).isPresent() + .map(HsCredentialsEntity::getEmailAddress).contains("alex@example.com"); + } + + @Test + void shouldSaveLoginCredentialsWithExistingContext() { + // given + final var existingContext = loginContextRealRepo.findByTypeAndQualifier("HSADMIN", "prod") + .orElseThrow(); + final var newCredentials = HsCredentialsEntity.builder() + .subject(drewSubject) + .person(drewPerson) + .active(true) + .emailAddress("drew.new@example.com") + .globalUid(2001) + .globalGid(2001) + .loginContexts(mutableSetOf(existingContext)) + .build(); + + // when + loginCredentialsRepository.save(newCredentials); + em.flush(); + em.clear(); + + // then + final var foundEntityOptional = loginCredentialsRepository.findByUuid(drewSubject.getUuid()); + assertThat(foundEntityOptional).isPresent(); + final var foundEntity = foundEntityOptional.get(); + assertThat(foundEntity.getEmailAddress()).isEqualTo("drew.new@example.com"); + assertThat(foundEntity.isActive()).isTrue(); + assertThat(foundEntity.getVersion()).isEqualTo(0); // Initial version + assertThat(foundEntity.getGlobalUid()).isEqualTo(2001); + + assertThat(foundEntity.getLoginContexts()).hasSize(1) + .map(HsCredentialsContextRealEntity::toString).contains("loginContext(HSADMIN:prod)"); + } + + @Test + void shouldNotSaveLoginCredentialsWithNewContext() { + // given + final var newContext = HsCredentialsContextRealEntity.builder() + .type("MATRIX") + .qualifier("forbidden") + .build(); + final var newCredentials = HsCredentialsEntity.builder() + .subject(drewSubject) + .active(true) + .emailAddress("drew.new@example.com") + .globalUid(2001) + .globalGid(2001) + .loginContexts(mutableSetOf(newContext)) + .build(); + + // when + final var exception = catchThrowable(() -> { + loginCredentialsRepository.save(newCredentials); + em.flush(); + }); + + // then + assertThat(exception).isNotNull().hasCauseInstanceOf(TransientObjectException.class); + } + + @Test + void shouldSaveNewLoginCredentialsWithoutContext() { + // given + final var newCredentials = HsCredentialsEntity.builder() + .subject(testUserSubject) + .person(testUserPerson) + .active(true) + .emailAddress("test.user.new@example.com") + .globalUid(20002) + .globalGid(2002) + .build(); + + // when + loginCredentialsRepository.save(newCredentials); + em.flush(); + em.clear(); + + // then + final var foundEntityOptional = loginCredentialsRepository.findByUuid(testUserSubject.getUuid()); + assertThat(foundEntityOptional).isPresent(); + final var foundEntity = foundEntityOptional.get(); + assertThat(foundEntity.getEmailAddress()).isEqualTo("test.user.new@example.com"); + assertThat(foundEntity.isActive()).isTrue(); + assertThat(foundEntity.getGlobalUid()).isEqualTo(20002); + assertThat(foundEntity.getGlobalGid()).isEqualTo(2002); + assertThat(foundEntity.getLoginContexts()).isEmpty(); + } + + @Test + void shouldUpdateExistingLoginCredentials() { + // given + final var entityToUpdate = loginCredentialsRepository.findByUuid(alexSubject.getUuid()).orElseThrow(); + final var initialVersion = entityToUpdate.getVersion(); + + // when + entityToUpdate.setActive(false); + entityToUpdate.setEmailAddress("updated.user1@example.com"); + final var savedEntity = loginCredentialsRepository.save(entityToUpdate); + em.flush(); + em.clear(); + + // then + assertThat(savedEntity.getVersion()).isGreaterThan(initialVersion); + final var updatedEntityOptional = loginCredentialsRepository.findByUuid(alexSubject.getUuid()); + assertThat(updatedEntityOptional).isPresent(); + final var updatedEntity = updatedEntityOptional.get(); + assertThat(updatedEntity.isActive()).isFalse(); + assertThat(updatedEntity.getEmailAddress()).isEqualTo("updated.user1@example.com"); + } + + + private RbacSubjectEntity fetchSubjectByName(final String name) { + final String jpql = "SELECT s FROM RbacSubjectEntity s WHERE s.name = :name"; + final Query query = em.createQuery(jpql, RbacSubjectEntity.class); + query.setParameter("name", name); + try { + context(SUPERUSER_ALEX_SUBJECT_NAME); + return notNull((RbacSubjectEntity) query.getSingleResult()); + } catch (final NoResultException e) { + throw new AssertionError( + "Failed to find subject with name '" + name + "'. Ensure test data is present.", e); + } + } + + private HsOfficePersonRealEntity fetchPersonByGivenName(final String givenName) { + final String jpql = "SELECT p FROM HsOfficePersonRealEntity p WHERE p.givenName = :givenName"; + final Query query = em.createQuery(jpql, HsOfficePersonRealEntity.class); + query.setParameter("givenName", givenName); + try { + context(SUPERUSER_ALEX_SUBJECT_NAME); + return notNull((HsOfficePersonRealEntity) query.getSingleResult()); + } catch (final NoResultException e) { + throw new AssertionError( + "Failed to find person with name '" + givenName + "'. Ensure test data is present.", e); + } + } + + private T notNull(final T result) { + assertThat(result).isNotNull(); + return result; + } + + @SafeVarargs + private Set mutableSetOf(final T... elements) { + return new HashSet(Set.of(elements)); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemRepositoryIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemRepositoryIntegrationTest.java index 19bb96d8..bcbddc01 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemRepositoryIntegrationTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemRepositoryIntegrationTest.java @@ -92,26 +92,23 @@ class HsBookingItemRepositoryIntegrationTest extends ContextBasedTestWithCleanup @Test public void historizationIsAvailable() { // given - final String nativeQuerySql = """ - select count(*) - from hs_booking.item_hv ha; - """; + final String nativeQuerySql = "select * from hs_booking.item_hv"; // when historicalContext(Timestamp.from(ZonedDateTime.now().minusDays(1).toInstant())); - final var query = em.createNativeQuery(nativeQuerySql, Integer.class); - @SuppressWarnings("unchecked") final var countBefore = (Integer) query.getSingleResult(); + final var query = em.createNativeQuery(nativeQuerySql); + final var rowsBefore = query.getResultList(); // then - assertThat(countBefore).as("hs_booking.item should not contain rows for a timestamp in the past").isEqualTo(0); + assertThat(rowsBefore).as("hs_booking.item should not contain rows for a timestamp in the past").hasSize(0); // and when historicalContext(Timestamp.from(ZonedDateTime.now().plusHours(1).toInstant())); - em.createNativeQuery(nativeQuerySql, Integer.class); - @SuppressWarnings("unchecked") final var countAfter = (Integer) query.getSingleResult(); + em.createNativeQuery(nativeQuerySql); + final var rowsAfter = query.getResultList(); // then - assertThat(countAfter).as("hs_booking.item should contain rows for a timestamp in the future").isGreaterThan(1); + assertThat(rowsAfter).as("hs_booking.item should contain rows for a timestamp in the future").hasSize(21); } @Nested diff --git a/src/test/java/net/hostsharing/hsadminng/hs/booking/project/HsBookingProjectRepositoryIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/hs/booking/project/HsBookingProjectRepositoryIntegrationTest.java index e88edcb2..87969a8a 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/booking/project/HsBookingProjectRepositoryIntegrationTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/booking/project/HsBookingProjectRepositoryIntegrationTest.java @@ -83,26 +83,23 @@ class HsBookingProjectRepositoryIntegrationTest extends ContextBasedTestWithClea @Test public void historizationIsAvailable() { // given - final String nativeQuerySql = """ - select count(*) - from hs_booking.project_hv ha; - """; + final String nativeQuerySql = "select * from hs_booking.project_hv ha"; // when historicalContext(Timestamp.from(ZonedDateTime.now().minusDays(1).toInstant())); - final var query = em.createNativeQuery(nativeQuerySql, Integer.class); - @SuppressWarnings("unchecked") final var countBefore = (Integer) query.getSingleResult(); + final var query = em.createNativeQuery(nativeQuerySql); + final var rowsBefore = query.getResultList(); // then - assertThat(countBefore).as("hs_booking.project_hv should not contain rows for a timestamp in the past").isEqualTo(0); + assertThat(rowsBefore).as("hs_booking.project_hv should not contain rows for a timestamp in the past").hasSize(0); // and when historicalContext(Timestamp.from(ZonedDateTime.now().plusHours(1).toInstant())); em.createNativeQuery(nativeQuerySql, Integer.class); - @SuppressWarnings("unchecked") final var countAfter = (Integer) query.getSingleResult(); + final var rowsAfter = query.getResultList(); // then - assertThat(countAfter).as("hs_booking.project_hv should contain rows for a timestamp in the future").isGreaterThan(1); + assertThat(rowsAfter).as("hs_booking.project_hv should contain test-data rows for current timestamp").hasSize(3); } @Nested diff --git a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetRepositoryIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetRepositoryIntegrationTest.java index 7417313b..3a218faf 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetRepositoryIntegrationTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetRepositoryIntegrationTest.java @@ -113,26 +113,23 @@ class HsHostingAssetRepositoryIntegrationTest extends ContextBasedTestWithCleanu @Test public void historizationIsAvailable() { // given - final String nativeQuerySql = """ - select count(*) - from hs_hosting.asset_hv ha; - """; + final String nativeQuerySql = "select * from hs_hosting.asset_hv"; // when historicalContext(Timestamp.from(ZonedDateTime.now().minusDays(1).toInstant())); - final var query = em.createNativeQuery(nativeQuerySql, Integer.class); - @SuppressWarnings("unchecked") final var countBefore = (Integer) query.getSingleResult(); + final var query = em.createNativeQuery(nativeQuerySql); + @SuppressWarnings("unchecked") final var rowsBefore = query.getResultList(); // then - assertThat(countBefore).as("hs_hosting.asset_hv should not contain rows for a timestamp in the past").isEqualTo(0); + assertThat(rowsBefore).as("hs_hosting.asset_hv should not contain rows for a timestamp in the past").hasSize(0); // and when - historicalContext(Timestamp.from(ZonedDateTime.now().plusHours(1).toInstant())); + historicalContext(Timestamp.from(ZonedDateTime.now().toInstant())); em.createNativeQuery(nativeQuerySql, Integer.class); - @SuppressWarnings("unchecked") final var countAfter = (Integer) query.getSingleResult(); + @SuppressWarnings("unchecked") final var rowsAfter = query.getResultList(); // then - assertThat(countAfter).as("hs_hosting.asset_hv should contain rows for a timestamp in the future").isGreaterThan(1); + assertThat(rowsAfter).as("hs_hosting.asset_hv should contain test-data rows for current timestamp").hasSize(54); } @Nested diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonControllerAcceptanceTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonControllerAcceptanceTest.java index bb7b3c96..c0d42c3e 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonControllerAcceptanceTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonControllerAcceptanceTest.java @@ -68,7 +68,7 @@ class HsOfficePersonControllerAcceptanceTest extends ContextBasedTestWithCleanup .then().log().all().assertThat() .statusCode(200) .contentType("application/json") - .body("", hasSize(13)); + .body("", hasSize(17)); // @formatter:on } }