1
0

Story#5617: amend account module to Keycloak primary (#213)

Co-authored-by: Michael Hoennig <michael@hoennig.de>
Reviewed-on: https://dev.hostsharing.net/hostsharing/hs.hsadmin.ng/pulls/213
This commit is contained in:
Michael Hoennig
2026-03-09 11:22:32 +01:00
parent 865830071e
commit 69e2fc09a6
82 changed files with 1813 additions and 3371 deletions
+28 -4
View File
@@ -75,13 +75,37 @@ alias gw-importHostingAssets='importLegacyData importHostingAssets'
function gradlewBootRun() { function gradlewBootRun() {
local serverPort=${1:-8080}; shift local serverPort=${1:-8080}; shift
local managementPort=${2:-$((serverPort + 1))}; shift local managementPort
local additional_args="$@" if [[ -n "${1:-}" && "${1}" != -* ]]; then
managementPort=$1
shift
else
managementPort=$((serverPort + 1))
fi
# everything before `--` is treated as application args (go into --args="...")
# everything after `--` is treated as Gradle/task args (e.g. --debug-jvm, --stacktrace, --info, ...)
local -a app_args=()
while [[ $# -gt 0 && "${1}" != "--" ]]; do
app_args+=("$1")
shift
done
if [[ "${1:-}" == "--" ]]; then
shift
fi
local -a gradle_args=("$@")
# preserve previous behavior: app args are appended as plain space-separated tokens
local additional_args=""
if [[ ${#app_args[@]} -gt 0 ]]; then
additional_args="$(printf ' %s' "${app_args[@]}")"
fi
unset HSADMINNG_JWT_ISSUER unset HSADMINNG_JWT_ISSUER
unset HSADMINNG_JWT_JWKS_URL unset HSADMINNG_JWT_JWKS_URL
unset HSADMINNG_JWT_TOKEN_URL unset HSADMINNG_JWT_TOKEN_URL
set -x set -x
./gradlew bootRun --args="--spring.profiles.active=dev,fake-jwt,complete,test-data --server.port=${serverPort} --management.server.port=${managementPort} ${additional_args}" ./gradlew bootRun "${gradle_args[@]}" --args="--spring.profiles.active=dev,fake-jwt,complete,test-data --server.port=${serverPort} --management.server.port=${managementPort}${additional_args}"
set +x set +x
} }
alias gw-bootRun=gradlewBootRun alias gw-bootRun=gradlewBootRun
@@ -91,7 +115,7 @@ alias podman-stop='systemctl --user disable --now podman.socket && systemctl --u
alias podman-use='export DOCKER_HOST="unix:///run/user/$UID/podman/podman.sock"; export TESTCONTAINERS_RYUK_DISABLED=true' alias podman-use='export DOCKER_HOST="unix:///run/user/$UID/podman/podman.sock"; export TESTCONTAINERS_RYUK_DISABLED=true'
alias gw=gradleWrapper alias gw=gradleWrapper
alias pg-sql-run='docker run --name hsadmin-ng-postgres -e POSTGRES_PASSWORD=password -p 5432:5432 -d postgres:15.5-bookworm' alias pg-sql-run='docker run --name hsadmin-ng-postgres -e POSTGRES_PASSWORD=password -p 5432:5432 -d postgres:17.7-trixie'
alias pg-sql-stop='docker stop hsadmin-ng-postgres' alias pg-sql-stop='docker stop hsadmin-ng-postgres'
alias pg-sql-start='docker container start hsadmin-ng-postgres' alias pg-sql-start='docker container start hsadmin-ng-postgres'
alias pg-sql-remove='docker rm hsadmin-ng-postgres' alias pg-sql-remove='docker rm hsadmin-ng-postgres'
+22 -14
View File
@@ -87,20 +87,27 @@ If you have at least Docker and the Java JDK installed in appropriate versions a
# if the container has been built already and you want to keep the data, run this: # if the container has been built already and you want to keep the data, run this:
pg-sql-start pg-sql-start
Next, compile and run the application with in dev-mode with all modules, test-data and fake-JWT-authentication:: Next, compile and run the application with in dev-mode with all modules, test-data and fake-JWT-authentication usind the `gw-bootRun` alias:
# on `localhost:8080` and the management server on `localhost:8081`: # on `localhost:8080` and the management server on `localhost:8081`:
gw-bootRun gw-bootRun
# there is also an alias which takes an optional port as an argument: # you can also pass optional arguments:
gw-bootRun 8888 gw-bootRun 8888 # will set the management port to 8888+1 = 8889
gw-bootRun 8888 9999 # with explicit management port 9999
The meaning of these profiles is: At the beginning of the output, you'll see the full `./gradlew`-call like this:
```
+ ./gradlew bootRun '--args=--spring.profiles.active=dev,fake-jwt,complete,test-data --server.port=8080 --management.server.port=8081'
```
The meaning of the listed profiles is:
- **dev**: the PostgreSQL users are created via Liquibase - **dev**: the PostgreSQL users are created via Liquibase
- **fake-jwt**: the app starts with a build-in fake OAuth2/JWT server - **fake-jwt**: the app starts with a build-in fake OAuth2/JWT server
- **complete**: all modules are started - **complete**: all modules (rbac, office, account, hosting) are started
- **test-data**: some test data inserted - **test-data**: some test data gets inserted at startup
Now we can access the REST API, e.g. using curl. But you need to use JWT authentication. Now we can access the REST API, e.g. using curl. But you need to use JWT authentication.
To make this a bit easier to handle, we use `bin/jwt-curl` (or `jwt-curl` alias). To make this a bit easier to handle, we use `bin/jwt-curl` (or `jwt-curl` alias).
@@ -130,7 +137,7 @@ Make sure you replace `8080` with the port you used to run the application.`
# the following command should return a JSON array with just all customers: # the following command should return a JSON array with just all customers:
jwt-curl GET http://localhost:8080/api/test/customers \ jwt-curl GET http://localhost:8080/api/test/customers \
| jq # just if `jq` is installed, to prettyprint the output | jq` # just if `jq` is installed, to prettyprint the output
# the following command should return a JSON array with just all packages visible for the admin of the customer yyy: # the following command should return a JSON array with just all packages visible for the admin of the customer yyy:
jwt-curl ASSUME 'rbactest.customer#yyy:ADMIN' jwt-curl ASSUME 'rbactest.customer#yyy:ADMIN'
@@ -138,7 +145,7 @@ Make sure you replace `8080` with the port you used to run the application.`
| jq | jq
jwt-curl UNASSUME jwt-curl UNASSUME
# add a new customer # add a new customer (this is a test-are, not to confuse with a Hostsharing partner)
jwt-curl POST \ jwt-curl POST \
-d '{ "prefix":"ttt", "reference":80001, "adminUserName":"admin@ttt.example.com" }' \ -d '{ "prefix":"ttt", "reference":80001, "adminUserName":"admin@ttt.example.com" }' \
http://localhost:8080/api/test/customers \ http://localhost:8080/api/test/customers \
@@ -177,14 +184,14 @@ But the easiest way to run PostgreSQL is via Docker.
Initially, pull an image compatible to the current PostgreSQL version of Hostsharing: Initially, pull an image compatible to the current PostgreSQL version of Hostsharing:
docker pull postgres:15.5-bookworm docker pull postgres:17.7-trixie
<big>**&#9888;**</big> <big>**&#9888;**</big>
If we switch the version, please also amend the documentation as well as the aliases file. Thanks! If we switch the version, please also amend the documentation as well as the aliases file. Thanks!
Create and run a container with the given PostgreSQL version: Create and run a container with the given PostgreSQL version:
docker run --name hsadmin-ng-postgres -e POSTGRES_PASSWORD=password -p 5432:5432 -d postgres:15.5-bookworm docker run --name hsadmin-ng-postgres -e POSTGRES_PASSWORD=password -p 5432:5432 -d postgres:17.7-trixie
# or via alias: # or via alias:
pg-sql-run pg-sql-run
@@ -705,14 +712,15 @@ These profiles mean:
### How to Run the Application in a Debugger ### How to Run the Application in a Debugger
Add `' --debug-jvm` to the command line: Add `'-- --debug-jvm` to the command line ('...' stands any other args).
The `--debug-jvm` is a so-called *Gradle side knob, which goes outside of the `--args="..."` application arguments,
thus we separate it by `--`; this is treated by the `gw-bootRun` alias.
```sh ```sh
gw bootRun ... --debug-jvm gw-bootRun ... -- --debug-jvm
``` ```
At the very beginning, the application is going to wait for a debugger with a message like this: In the very beginning, the application is going to wait for a debugger with a message like this:
> Listening for transport dt_socket at address: 5005 > Listening for transport dt_socket at address: 5005
+2 -2
View File
@@ -14,11 +14,11 @@ if [ "$1" == "--trace" ]; then
function doCurl() { function doCurl() {
set -x set -x
if [ -z "$HSADMINNG_JWT_ASSUME" ]; then if [ -z "$HSADMINNG_JWT_ASSUME" ]; then
curl --fail-with-body \ curl --no-progress-meter --show-error --fail-with-body \
--header "Authorization: Bearer $HSADMINNG_JWT_TOKEN" \ --header "Authorization: Bearer $HSADMINNG_JWT_TOKEN" \
"$@" "$@"
else else
curl --fail-with-body \ curl --no-progress-meter --show-error --fail-with-body \
--header "Authorization: Bearer $HSADMINNG_JWT_TOKEN" \ --header "Authorization: Bearer $HSADMINNG_JWT_TOKEN" \
--header "assumed-roles: $HSADMINNG_JWT_ASSUME" \ --header "assumed-roles: $HSADMINNG_JWT_ASSUME" \
"$@" "$@"
+18
View File
@@ -29,6 +29,7 @@ plugins {
java java
id("org.springframework.boot") version "3.5.5" id("org.springframework.boot") version "3.5.5"
id("io.spring.dependency-management") version "1.1.7" // manages implicit dependencies id("io.spring.dependency-management") version "1.1.7" // manages implicit dependencies
id("com.gorylenko.gradle-git-properties") version "2.5.0" // exposes git commit info via Actuator info endpoint
id("io.openapiprocessor.openapi-processor") version "2023.2" // generates Controller-interface and resources from API-spec id("io.openapiprocessor.openapi-processor") version "2023.2" // generates Controller-interface and resources from API-spec
id("com.github.jk1.dependency-license-report") version "2.9" // checks dependency-license compatibility id("com.github.jk1.dependency-license-report") version "2.9" // checks dependency-license compatibility
id("org.owasp.dependencycheck") version "12.1.1" // checks dependencies for known vulnerabilities id("org.owasp.dependencycheck") version "12.1.1" // checks dependencies for known vulnerabilities
@@ -39,6 +40,10 @@ plugins {
id("com.github.ben-manes.versions") version "0.52.0" // determines which dependencies have updates id("com.github.ben-manes.versions") version "0.52.0" // determines which dependencies have updates
} }
springBoot {
buildInfo()
}
// HOWTO: find out which dependency versions are managed by Spring Boot: // HOWTO: find out which dependency versions are managed by Spring Boot:
// https://docs.spring.io/spring-boot/appendix/dependency-versions/coordinates.html // https://docs.spring.io/spring-boot/appendix/dependency-versions/coordinates.html
@@ -702,3 +707,16 @@ tasks.named<org.springframework.boot.gradle.tasks.run.BootRun>("bootRun") {
// Or always enable debug (remove the if condition) // Or always enable debug (remove the if condition)
// jvmArgs = listOf("-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005") // jvmArgs = listOf("-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005")
} }
// Generate git.properties for Spring Boot Actuator /actuator/info
gitProperties {
// Keep output small but useful for build identification
keys = listOf(
"git.commit.id",
"git.commit.id.abbrev",
"git.branch",
"git.commit.time"
)
// Allow builds from exported sources without .git
failOnNoGitDirectory = false
}
@@ -1,49 +0,0 @@
classDiagram
direction LR
OfficePerson "1" o.. "*" Profile
Profile "1" o-- "1" RbacSubject
Scope "1..n" --o "1" ScopeMapping
Profile "1..n" --o "1" ScopeMapping
class Profile{
+emailAdress: text
+smsNumber: text
+password: text
+totpSecrets: text
+phonePassword: text
-active: bool [r/w]
-globalUid: int [w/o]
-globalGid: int [w/o]
}
class Scope{
-type: Enum [SSH, Matrix, Mastodon, ...]
-qualifier: text
}
class ScopeMapping{
note for ScopeMapping "Assigns Profile to Scopes"
}
class RbacSubject{
+uuid: uuid
+name: text # == nickname
}
class OfficePerson{
+type: enum
+tradename: text
+title: text
+familyName: text
+givenName: text
+salutation: text
}
style Scope fill:#00f,color:#fff
style ScopeMapping fill:#00f,color:#fff
style Profile fill:#00f,color:#fff
style RbacSubject fill:#f96,color:#fff
style OfficePerson fill:#f66,color:#000
+29
View File
@@ -0,0 +1,29 @@
classDiagram
direction LR
OfficePerson "1" o.. "*" Account
Account "1" o-- "1" RbacSubject
class Account{
-globalUid: int [w/o]
-globalGid: int [w/o]
}
class RbacSubject{
+uuid: uuid
+name: text # == subjectName
}
class OfficePerson{
+type: enum
+tradename: text
+title: text
+familyName: text
+givenName: text
+salutation: text
}
style Account fill:#00f,color:#fff
style RbacSubject fill:#f96,color:#fff
style OfficePerson fill:#f66,color:#000
+1
View File
@@ -1,4 +1,5 @@
# Gradle Java Toolchain-support # Gradle Java Toolchain-support
org.gradle.toolchain.auto-download=true
org.gradle.java.installations.auto-detect=true org.gradle.java.installations.auto-detect=true
org.gradle.java.installations.auto-download=true org.gradle.java.installations.auto-download=true
# org.gradle.jvm.toolchain.install.adoptopenjdk.baseUri # org.gradle.jvm.toolchain.install.adoptopenjdk.baseUri
@@ -9,6 +9,7 @@ import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import jakarta.servlet.http.HttpServletRequest;
import java.util.Map; import java.util.Map;
@@ -21,8 +22,9 @@ public class FakeJwtController {
@PostMapping(value = "/fake-jwt/token", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE) @PostMapping(value = "/fake-jwt/token", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
@Timed("app.config.jwt.token") @Timed("app.config.jwt.token")
public ResponseEntity<Map<String, Object>> token( public ResponseEntity<Map<String, Object>> token(
@RequestParam String username, HttpServletRequest request,
@RequestParam String password, @RequestParam(name = "username", required = false) String username,
@RequestParam(name = "password", required = false) String password,
@RequestParam(defaultValue = "openid profile") String scope) { @RequestParam(defaultValue = "openid profile") String scope) {
log.info("Fake JWT: Issuing token for user: {}", username); log.info("Fake JWT: Issuing token for user: {}", username);
@@ -1,12 +1,13 @@
package net.hostsharing.hsadminng.context; package net.hostsharing.hsadminng.context;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import jakarta.servlet.FilterChain; import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException; import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import org.springframework.web.util.ContentCachingRequestWrapper;
import java.io.IOException; import java.io.IOException;
@Component @Component
@@ -18,6 +19,7 @@ public class HttpServletRequestBodyCachingFilter extends OncePerRequestFilter {
final HttpServletResponse response, final HttpServletResponse response,
final FilterChain filterChain) final FilterChain filterChain)
throws ServletException, IOException { throws ServletException, IOException {
filterChain.doFilter(new HttpServletRequestWithCachedBody(request), response);
filterChain.doFilter(new ContentCachingRequestWrapper(request), response);
} }
} }
@@ -1,33 +0,0 @@
package net.hostsharing.hsadminng.context;
import org.springframework.util.StreamUtils;
import jakarta.servlet.ServletInputStream;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletRequestWrapper;
import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStreamReader;
public class HttpServletRequestWithCachedBody extends HttpServletRequestWrapper {
private byte[] cachedBody;
public HttpServletRequestWithCachedBody(HttpServletRequest request) throws IOException {
super(request);
final var requestInputStream = request.getInputStream();
this.cachedBody = StreamUtils.copyToByteArray(requestInputStream);
}
@Override
public ServletInputStream getInputStream() {
return new HttpServletRequestBodyCache(this.cachedBody);
}
@Override
public BufferedReader getReader() {
ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(this.cachedBody);
return new BufferedReader(new InputStreamReader(byteArrayInputStream));
}
}
@@ -0,0 +1,296 @@
package net.hostsharing.hsadminng.hs.accounts;
import io.micrometer.core.annotation.Timed;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import lombok.AllArgsConstructor;
import lombok.val;
import net.hostsharing.hsadminng.accounts.generated.api.v1.api.AccountApi;
import net.hostsharing.hsadminng.accounts.generated.api.v1.model.CurrentLoginUserResource;
import net.hostsharing.hsadminng.accounts.generated.api.v1.model.HsOfficePersonResource;
import net.hostsharing.hsadminng.accounts.generated.api.v1.model.AccountInsertResource;
import net.hostsharing.hsadminng.accounts.generated.api.v1.model.AccountResource;
import net.hostsharing.hsadminng.accounts.generated.api.v1.model.RbacSubjectResource;
import net.hostsharing.hsadminng.config.MessageTranslator;
import net.hostsharing.hsadminng.errors.ForbiddenException;
import net.hostsharing.hsadminng.errors.Validate;
import net.hostsharing.hsadminng.hs.office.person.HsOfficePerson;
import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRealEntity;
import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRealRepository;
import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonType;
import net.hostsharing.hsadminng.mapper.StrictMapper;
import net.hostsharing.hsadminng.persistence.EntityManagerWrapper;
import net.hostsharing.hsadminng.rbac.context.Context;
import net.hostsharing.hsadminng.rbac.grant.RbacGrantRepository;
import net.hostsharing.hsadminng.rbac.grant.RbacGrantService;
import net.hostsharing.hsadminng.rbac.role.RbacRoleRepository;
import net.hostsharing.hsadminng.rbac.role.RbacRoleService;
import net.hostsharing.hsadminng.rbac.role.RbacRoleType;
import net.hostsharing.hsadminng.rbac.subject.RbacSubjectEntity;
import net.hostsharing.hsadminng.rbac.subject.RbacSubjectRepository;
import net.hostsharing.hsadminng.rbac.subject.RealSubjectEntity;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.mvc.method.annotation.MvcUriComponentsBuilder;
import jakarta.persistence.EntityNotFoundException;
import jakarta.validation.ValidationException;
import java.util.List;
import java.util.NoSuchElementException;
import java.util.UUID;
import java.util.function.BiConsumer;
import static java.util.Optional.of;
@RestController
@PreAuthorize("isAuthenticated()")
@SecurityRequirement(name = "bearerAuth")
public class HsAccountController implements AccountApi {
@Autowired
private Context context;
@Autowired
private EntityManagerWrapper em;
@Autowired
private StrictMapper mapper;
@Autowired
private MessageTranslator messageTranslator;
@Autowired
private HsOfficePersonRealRepository realPersonRepo;
@Autowired
private HsAccountRepository accountRepo;
@Autowired
private RbacSubjectRepository rbacSubjectRepo;
@Autowired
private RbacRoleRepository rbacRoleRepo;
@Autowired
private RbacGrantRepository rbacGrantRepo;
@Autowired
private RbacRoleService rbacRoleService;
@Autowired
private RbacGrantService rbacGrantService;
@Override
@Transactional(readOnly = true)
@Timed("app.accounts.account.getSingleAccountByUuid")
public ResponseEntity<AccountResource> getSingleAccountByUuid(final UUID accountUuid) {
context.define(); // without assumed roles, otherwise we cannot access the real subject anymore
val accountEntity = accountRepo.findByUuid(accountUuid);
if (accountEntity.isEmpty()) {
return ResponseEntity.notFound().build();
}
val result = mapper.map(
accountEntity.get(), AccountResource.class, ENTITY_TO_RESOURCE_POSTMAPPER);
return ResponseEntity.ok(result);
}
@Override
@Transactional(readOnly = true)
@Timed("app.accounts.account.getListIfAccountByPersonUuid")
public ResponseEntity<List<AccountResource>> getListIfAccount(
final String assumedRoles,
final UUID personUuid
) {
context.assumeRoles(assumedRoles);
val account = personUuid == null
? accountRepo.findByCurrentSubject()
: findByPersonUuid(personUuid);
val result = mapper.mapList(
account, AccountResource.class, ENTITY_TO_RESOURCE_POSTMAPPER);
return ResponseEntity.ok(result);
}
@Override
@Transactional
@Timed("app.accounts.account.postNewAccount")
public ResponseEntity<AccountResource> postNewAccount(
final AccountInsertResource body
) {
// Only with exactly 1 assumed role, we can do explicit grants, because the assumed role is used as 'grantor'.
// Otherwise, only the granting user could revoke the grant.
context.assumeRoles("rbac.global#global:ADMIN");
val originalLoginContext = new LoginContext(context);
// TODO.spec: for now, only global admins can create new accounts, auto-creation has to be specified. which person?
if (!originalLoginContext.isGlobalAdmin) {
throw new ForbiddenException(
messageTranslator.translate(
//"account.access-denied-to-person-with-uuid-{0}-not-represented-by-currently-logged-in-person",
"account.access-denied-to-create-new-account-subject-{0}-is-not-a-global-admin",
originalLoginContext.subjectUuid));
}
// first create and save the subject to get its UUID
val newlySavedSubject = createSubject(body.getSubjectName());
// determine the assigned person while we still have global-admin privileges
val newAccountEntity = mapper.map(
body, HsAccountEntity.class, RESOURCE_TO_ENTITY_POSTMAPPER);
validateOnCreate(originalLoginContext, newAccountEntity);
// grant the person's ADMIN role to the new subject
rbacGrantService.grant(rbacRoleService.rbacRole(newAccountEntity.getPerson(), RbacRoleType.ADMIN))
.to(newlySavedSubject);
// switch to the new subject to get access to its own subject RBAC object
context.define("activate newly created self-service subject", null, body.getSubjectName(), null);
// afterward, create and save the account entity with the subject's UUID
newAccountEntity.setSubject(em.merge(newlySavedSubject)); // attached to EM by the new subject
em.persist(newAccountEntity); // newAccountEntity.uuid == newlySavedSubject.uuid => do not use repository!
// return the new account as a resource
val uri =
MvcUriComponentsBuilder.fromController(getClass())
.path("/api/hs/accounts/accounts/{id}")
.buildAndExpand(newAccountEntity.getUuid())
.toUri();
val newAccountResource = mapper.map(
newAccountEntity, AccountResource.class, ENTITY_TO_RESOURCE_POSTMAPPER);
return ResponseEntity.created(uri).body(newAccountResource);
}
@Override
@Transactional
@Timed("app.accounts.account.deleteAccountByUuid")
public ResponseEntity<Void> deleteAccountByUuid(final UUID accountUuid) {
context.define(); // without assumed roles, otherwise we cannot access the subject anymore
val accountEntity = em.getReference(HsAccountEntity.class, accountUuid);
validateOnDelete(accountEntity);
em.flush();
em.remove(accountEntity);
em.remove(accountEntity.getSubject());
return ResponseEntity.noContent().build();
}
@Override
@Transactional
@Timed("app.accounts.account.getCurrentLoginUser")
public ResponseEntity<CurrentLoginUserResource> getCurrentLoginUser() {
// define a context without assumed roles, otherwise we cannot access the subject anymore
context.define();
// fetch the data
val currentSubjectUuid = context.fetchCurrentSubjectUuid();
val currentSubject = rbacSubjectRepo.findByUuid(currentSubjectUuid);
val person = accountRepo.findByUuid(currentSubjectUuid).orElseThrow().getPerson();
final boolean isGlobalAdmin = context.isGlobalAdmin();
// finally, return the result
val result = currentLoginUserResponse(currentSubject, person, isGlobalAdmin);
return ResponseEntity.ok(result);
}
private void validateOnCreate(final LoginContext originalLoginContext, final HsAccountEntity newAccountEntity) {
validateReferencedPersonToBeANaturalPerson(newAccountEntity);
}
private void validateOnDelete(final HsAccountEntity current) {
// TODO.spec Task#5637: still needed? can the own account even be removed, even the last one?
}
private void validateReferencedPersonToBeANaturalPerson(final HsAccountEntity accountEntity) {
val referredPerson = accountEntity.getPerson();
if ( referredPerson.getPersonType() != HsOfficePersonType.NATURAL_PERSON) {
throw new ValidationException(
messageTranslator.translate(
"account.only-natural-persons-allowed-but-{0}-is-{1}",
referredPerson.getUuid(), referredPerson.getPersonType().name()));
}
}
private RealSubjectEntity createSubject(final String subjectName) {
val rbacSubjectEntity = RbacSubjectEntity.builder().name(subjectName).build();
val newRbacSubject = rbacSubjectRepo.create(rbacSubjectEntity);
return em.find(RealSubjectEntity.class, newRbacSubject.getUuid());
}
private List<HsAccountEntity> findByPersonUuid(final UUID personUuid) {
val person = realPersonRepo.findByUuid(personUuid).orElseThrow(
() -> new EntityNotFoundException(
messageTranslator.translate("general.{0}-{1}-not-found-or-not-accessible", "personUuid", personUuid)
)
);
return accountRepo.findByPerson(person);
}
private CurrentLoginUserResource currentLoginUserResponse(
final RbacSubjectEntity currentSubject,
final HsOfficePerson<?> person,
final boolean isGlobalAdmin) {
val result = new CurrentLoginUserResource();
result.setSubject(mapper.map(currentSubject, RbacSubjectResource.class));
result.setPerson(mapper.map(person, HsOfficePersonResource.class));
result.setGlobalAdmin(isGlobalAdmin);
return result;
}
final BiConsumer<HsAccountEntity, AccountResource> ENTITY_TO_RESOURCE_POSTMAPPER = (entity, resource) -> {
of(entity.getSubject()).ifPresent(
subject -> resource.setSubjectName(subject.getName())
);
of(entity.getPerson()).ifPresent(
person -> resource.setPerson(
mapper.map(person, HsOfficePersonResource.class)
)
);
};
final BiConsumer<AccountInsertResource, HsAccountEntity> RESOURCE_TO_ENTITY_POSTMAPPER = (resource, entity) -> {
Validate.validate("person, person.uuid").exactlyOne(resource.getPerson(), resource.getPersonUuid());
if ( resource.getPersonUuid() != null) {
entity.setPerson(realPersonRepo.findByUuid(resource.getPersonUuid()).orElseThrow(
() -> new NoSuchElementException("cannot find Person by 'person.uuid': " + resource.getPersonUuid())
));
} else {
entity.setPerson(realPersonRepo.save(
mapper.map(resource.getPerson(), HsOfficePersonRealEntity.class)
) );
}
val person = realPersonRepo.findByUuid(entity.getPerson().getUuid()).orElseThrow(
() -> new EntityNotFoundException(
messageTranslator.translate("general.{0}-{1}-not-found-or-not-accessible", "personUuid", resource.getPersonUuid())
)
);
entity.setPerson(person);
};
@AllArgsConstructor
private class LoginContext {
final HsAccountEntity account;
final boolean isGlobalAdmin;
final UUID subjectUuid;
public LoginContext(final Context context) {
subjectUuid = context.fetchCurrentSubjectUuid();
account = accountRepo.findByUuid(subjectUuid)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR,
"subject " + context.fetchCurrentSubject() + " has no account"));
isGlobalAdmin = context.isGlobalAdmin();
}
}
}
@@ -0,0 +1,78 @@
package net.hostsharing.hsadminng.hs.accounts;
import jakarta.persistence.*;
import jakarta.validation.ValidationException;
import lombok.*;
import net.hostsharing.hsadminng.hash.LdapArgon2Hash;
import net.hostsharing.hsadminng.hash.LdapSshaHash;
import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRealEntity;
import net.hostsharing.hsadminng.persistence.BaseEntity; // Assuming BaseEntity exists
import net.hostsharing.hsadminng.rbac.subject.RealSubjectEntity;
import net.hostsharing.hsadminng.repr.Stringify;
import net.hostsharing.hsadminng.repr.Stringifyable;
import java.util.UUID;
import static net.hostsharing.hsadminng.repr.Stringify.stringify;
@Entity
@Table(schema = "hs_accounts", name = "account")
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class HsAccountEntity implements BaseEntity<HsAccountEntity>, Stringifyable {
protected static Stringify<HsAccountEntity> stringify = stringify(HsAccountEntity.class, "account")
.withProp(e -> e.getSubject().getName())
.quotedValues(false);
@Id
private UUID uuid;
@MapsId
@OneToOne(optional = false, fetch = FetchType.EAGER, cascade = CascadeType.ALL, orphanRemoval = true)
@JoinColumn(name = "uuid", nullable = false, updatable = false, referencedColumnName = "uuid")
// Must be the real subject, not the RBAC-subject,
// so that representative persons can access accounts+subjects of represented persons.
// Otherwise, we would also need to allow RBAC grants to subject roles.
// This also means that each access has to be checked explicitly (same subject or represented subject).
private RealSubjectEntity subject;
@ManyToOne(optional = false, fetch = FetchType.EAGER)
@JoinColumn(name = "person_uuid", nullable = false, updatable = false, referencedColumnName = "uuid")
private HsOfficePersonRealEntity person; // TODO.spec: Do we need ReBAC-Support for AccountEntity?
@Version
private int version;
@Column
private Integer globalUid;
@Column
private Integer globalGid;
public void setSubject(final RealSubjectEntity subject) {
this.uuid = subject.getUuid();
this.subject = subject;
}
@Override
public String toShortString() {
return subject.getName();
}
@Override
public String toString() {
return stringify.apply(this);
}
private static void validatePasswordHash(final String passwordHash) {
if (!LdapSshaHash.isValid(passwordHash) && !LdapArgon2Hash.isValid(passwordHash)) {
throw new ValidationException("passwordHash must be SSHA or ARGON2 hash valid for LDAP");
}
}
}
@@ -0,0 +1,43 @@
package net.hostsharing.hsadminng.hs.accounts;
import io.micrometer.core.annotation.Timed;
import net.hostsharing.hsadminng.hs.office.person.HsOfficePerson;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.Repository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface HsAccountRepository extends Repository<HsAccountEntity, UUID> {
@Timed("app.login.account.repo.findByUuid")
Optional<HsAccountEntity> findByUuid(final UUID uuid);
@Timed("app.login.account.repo.findByPerson")
List<HsAccountEntity> findByPerson(final HsOfficePerson<?> personUuid);
@Timed("app.login.account.repo.findByCurrentSubject")
@Query(nativeQuery = true, value = """
WITH RECURSIVE
same_person AS (
SELECT own_account.person_uuid
FROM hs_accounts.account own_account
WHERE own_account.uuid = rbac.currentSubjectUuid()
),
represented_persons AS (
SELECT relation.anchorUuid person_uuid
FROM hs_office.relation relation
WHERE relation.type = 'REPRESENTATIVE'
AND relation.holderUuid IN (SELECT person_uuid FROM same_person)
)
SELECT DISTINCT account.*
FROM hs_accounts.account account
WHERE account.person_uuid IN (SELECT person_uuid FROM same_person)
OR account.person_uuid IN (SELECT person_uuid FROM represented_persons)
""")
List<HsAccountEntity> findByCurrentSubject();
@Timed("app.login.account.repo.save")
HsAccountEntity save(final HsAccountEntity entity);
}
@@ -1,375 +0,0 @@
package net.hostsharing.hsadminng.hs.accounts;
import io.micrometer.core.annotation.Timed;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import lombok.AllArgsConstructor;
import lombok.val;
import net.hostsharing.hsadminng.accounts.generated.api.v1.api.ProfileApi;
import net.hostsharing.hsadminng.accounts.generated.api.v1.model.CurrentLoginUserResource;
import net.hostsharing.hsadminng.accounts.generated.api.v1.model.HsOfficePersonResource;
import net.hostsharing.hsadminng.accounts.generated.api.v1.model.ProfileInsertResource;
import net.hostsharing.hsadminng.accounts.generated.api.v1.model.ProfilePatchResource;
import net.hostsharing.hsadminng.accounts.generated.api.v1.model.ProfileResource;
import net.hostsharing.hsadminng.accounts.generated.api.v1.model.RbacSubjectResource;
import net.hostsharing.hsadminng.accounts.generated.api.v1.model.ScopeResource;
import net.hostsharing.hsadminng.config.MessageTranslator;
import net.hostsharing.hsadminng.errors.ForbiddenException;
import net.hostsharing.hsadminng.errors.Validate;
import net.hostsharing.hsadminng.hs.office.person.HsOfficePerson;
import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRealEntity;
import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRealRepository;
import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonType;
import net.hostsharing.hsadminng.mapper.StrictMapper;
import net.hostsharing.hsadminng.persistence.EntityManagerWrapper;
import net.hostsharing.hsadminng.rbac.context.Context;
import net.hostsharing.hsadminng.rbac.subject.RbacSubjectEntity;
import net.hostsharing.hsadminng.rbac.subject.RbacSubjectRepository;
import net.hostsharing.hsadminng.rbac.subject.RealSubjectEntity;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.mvc.method.annotation.MvcUriComponentsBuilder;
import jakarta.persistence.EntityNotFoundException;
import jakarta.validation.ValidationException;
import java.util.List;
import java.util.NoSuchElementException;
import java.util.UUID;
import java.util.function.BiConsumer;
import java.util.stream.Collectors;
import static java.util.Optional.of;
@RestController
@PreAuthorize("isAuthenticated()")
@SecurityRequirement(name = "bearerAuth")
public class HsProfileController implements ProfileApi {
@Autowired
private Context context;
@Autowired
private EntityManagerWrapper em;
@Autowired
private StrictMapper mapper;
@Autowired
private ScopeResourceToEntityMapper scopeMapper;
@Autowired
private MessageTranslator messageTranslator;
@Autowired
private HsOfficePersonRealRepository realPersonRepo;
@Autowired
private HsProfileRepository profileRepo;
@Autowired
private RbacSubjectRepository rbacSubjectRepo;
@Override
@Transactional(readOnly = true)
@Timed("app.accounts.profile.getSingleProfileByUuid")
public ResponseEntity<ProfileResource> getSingleProfileByUuid(final UUID profileUuid) {
context.define(); // without assumed roles, otherwise we cannot access the subject anymore
val profileEntity = profileRepo.findByUuid(profileUuid);
if (profileEntity.isEmpty()) {
return ResponseEntity.notFound().build();
}
val result = mapper.map(
profileEntity.get(), ProfileResource.class, ENTITY_TO_RESOURCE_POSTMAPPER);
return ResponseEntity.ok(result);
}
@Override
@Transactional(readOnly = true)
@Timed("app.accounts.profile.getListOfProfileByPersonUuid")
public ResponseEntity<List<ProfileResource>> getListOfProfile(
final String assumedRoles,
final UUID personUuid
) {
context.assumeRoles(assumedRoles);
val profile = personUuid == null
? profileRepo.findByCurrentSubject()
: findByPersonUuid(personUuid);
val result = mapper.mapList(
profile, ProfileResource.class, ENTITY_TO_RESOURCE_POSTMAPPER);
return ResponseEntity.ok(result);
}
@Override
@Transactional
@Timed("app.accounts.profile.postNewProfile")
public ResponseEntity<ProfileResource> postNewProfile(
final ProfileInsertResource body
) {
context.define(); // without assumed roles, otherwise we cannot access the subject anymore
final LoginContext originalLoginContext = new LoginContext(context);
// first create and save the subject to get its UUID
val newlySavedSubject = createSubject(body.getNickname());
// switch to the new subject to get access to its own subject RBAC object
context.define("activate newly created self-service subject", null, body.getNickname(), null);
// afterward, create and save the profile entity with the subject's UUID
val newProfileEntity = mapper.map(
body, HsProfileEntity.class, RESOURCE_TO_ENTITY_POSTMAPPER);
validateOnCreate(originalLoginContext, newProfileEntity);
newProfileEntity.setSubject(em.merge(newlySavedSubject)); // attached to EM by the new subject
em.persist(newProfileEntity); // newProfileEntity.uuid == newlySavedSubject.uuid => do not use repository!
// return the new profile as a resource
val uri =
MvcUriComponentsBuilder.fromController(getClass())
.path("/api/hs/accounts/profiles/{id}")
.buildAndExpand(newProfileEntity.getUuid())
.toUri();
val newProfileResource = mapper.map(
newProfileEntity, ProfileResource.class, ENTITY_TO_RESOURCE_POSTMAPPER);
return ResponseEntity.created(uri).body(newProfileResource);
}
@Override
@Transactional
@Timed("app.accounts.profile.deleteProfileByUuid")
public ResponseEntity<Void> deleteProfileByUuid(final UUID profileUuid) {
context.define(); // without assumed roles, otherwise we cannot access the subject anymore
val profileEntity = em.getReference(HsProfileEntity.class, profileUuid);
profileEntity.getScopes().clear();
validateOnDelete(profileEntity);
em.flush();
em.remove(profileEntity);
em.remove(profileEntity.getSubject());
return ResponseEntity.noContent().build();
}
@Override
@Transactional
@Timed("app.accounts.profile.patchProfile")
public ResponseEntity<ProfileResource> patchProfile(
final UUID profileUuid,
final ProfilePatchResource body
) {
context.define(); // without assumed roles, otherwise we cannot access the subject anymore
final LoginContext originalLoginContext = new LoginContext(context);
val current = profileRepo.findByUuid(profileUuid).orElseThrow();
validateBeforePatch(originalLoginContext, current, body);
new HsProfileEntityPatcher(scopeMapper, current).apply(body);
validateOnUpdate(current);
val saved = profileRepo.save(current);
val mapped = mapper.map(
saved, ProfileResource.class, ENTITY_TO_RESOURCE_POSTMAPPER);
return ResponseEntity.ok(mapped);
}
@Override
@Transactional
@Timed("app.accounts.profile.getCurrentLoginUser")
public ResponseEntity<CurrentLoginUserResource> getCurrentLoginUser() {
// define a context without assumed roles, otherwise we cannot access the subject anymore
context.define();
// fetch the data
val currentSubjectUuid = context.fetchCurrentSubjectUuid();
val currentSubject = rbacSubjectRepo.findByUuid(currentSubjectUuid);
val person = profileRepo.findByUuid(currentSubjectUuid).orElseThrow().getPerson();
final boolean isGlobalAdmin = context.isGlobalAdmin();
// finally, return the result
val result = currentLoginUserResponse(currentSubject, person, isGlobalAdmin);
return ResponseEntity.ok(result);
}
private void validateBeforePatch(final LoginContext originalLoginContext, final HsProfileEntity current, final ProfilePatchResource body) {
validateReferencedPersonToBeRepresentedByLoginUserPerson(originalLoginContext, current);
if (!context.isGlobalAdmin() && !current.isActive() && body.getActive())
throw new ForbiddenException("Only global admins are allowed to activate an inactive profile");
}
private void validateOnCreate(final LoginContext originalLoginContext, final HsProfileEntity newProfileEntity) {
validateReferencedPersonToBeRepresentedByLoginUserPerson(originalLoginContext, newProfileEntity);
validateNormalUsersOnlyAccessPublicScopes(newProfileEntity);
validateNaturalPersonRequirementOfScopes(newProfileEntity);
}
private void validateOnUpdate(final HsProfileEntity current) {
validateNormalUsersOnlyAccessPublicScopes(current);
validateNaturalPersonRequirementOfScopes(current);
validateOwnHsadminProfileMustNotBeRemoved(current);
}
private void validateOnDelete(final HsProfileEntity profileEntity) {
validateOwnHsadminProfileMustNotBeRemoved(profileEntity);
}
private void validateReferencedPersonToBeRepresentedByLoginUserPerson(final LoginContext originalLoginContext, final HsProfileEntity profileEntity) {
if (originalLoginContext.isGlobalAdmin) {
return;
}
val referredPersonUuid = profileEntity.getPerson().getUuid();
val loginPersonUuid = originalLoginContext.profile.getPerson().getUuid();
val representedPersonUuids = realPersonRepo.findPersonsRepresentedByPersonWithUuid(loginPersonUuid)
.stream().map(HsOfficePerson::getUuid).toList();
if ( !representedPersonUuids.contains(referredPersonUuid)) {
throw new ForbiddenException(
messageTranslator.translate(
"profile.access-denied-to-person-with-uuid-{0}-not-represented-by-currently-logged-in-person",
loginPersonUuid));
}
}
private void validateNormalUsersOnlyAccessPublicScopes(final HsProfileEntity newProfileEntity) {
val forbiddenScopes = newProfileEntity.getScopes().stream()
.filter(c -> !c.isPublicAccess() && !context.isGlobalAdmin() )
.toList();
if (!forbiddenScopes.isEmpty()) {
throw new ForbiddenException(
messageTranslator.translate(
"profile.access-denied-for-scopes-{0}",
toDisplay(forbiddenScopes)
));
}
}
private void validateNaturalPersonRequirementOfScopes(final HsProfileEntity newProfileEntity) {
if (newProfileEntity.getPerson().getPersonType().equals(HsOfficePersonType.NATURAL_PERSON)) {
return;
}
val scopesWhichRequireNaturalPerson = newProfileEntity.getScopes().stream()
.filter(HsProfileScope::isOnlyForNaturalPersons)
.toList();
if (!scopesWhichRequireNaturalPerson.isEmpty()) {
throw new ValidationException(
messageTranslator.translate(
"profile.scope-requires-natural-person-{0}",
toDisplay(scopesWhichRequireNaturalPerson)
));
}
}
private void validateOwnHsadminProfileMustNotBeRemoved(final HsProfileEntity newProfileEntity) {
if (!newProfileEntity.getSubject().getUuid().equals(context.fetchCurrentSubjectUuid())) {
return;
}
val hsadminProfileScope = newProfileEntity.getScopes().stream()
.filter(HsProfileScope::isHsadminScope)
.toList();
if (hsadminProfileScope.isEmpty()) {
throw new ValidationException(
messageTranslator.translate(
"profile.own-hsadmin-profile-must-not-be-removed"
));
}
}
private static String toDisplay(final List<HsProfileScopeRealEntity> scopesWhichRequireNaturalPerson) {
return scopesWhichRequireNaturalPerson.stream()
.map(HsProfileScope::toShortString)
.sorted()
.map(s -> "'" + s + "'")
.collect(Collectors.joining(", "));
}
private RealSubjectEntity createSubject(final String nickname) {
val rbacSubjectEntity = RbacSubjectEntity.builder().name(nickname).build();
val newRbacSubject = rbacSubjectRepo.create(rbacSubjectEntity);
return em.find(RealSubjectEntity.class, newRbacSubject.getUuid());
}
private List<HsProfileEntity> findByPersonUuid(final UUID personUuid) {
val person = realPersonRepo.findByUuid(personUuid).orElseThrow(
() -> new EntityNotFoundException(
messageTranslator.translate("general.{0}-{1}-not-found-or-not-accessible", "personUuid", personUuid)
)
);
return profileRepo.findByPerson(person);
}
private CurrentLoginUserResource currentLoginUserResponse(
final RbacSubjectEntity currentSubject,
final HsOfficePerson<?> person,
final boolean isGlobalAdmin) {
val result = new CurrentLoginUserResource();
result.setSubject(mapper.map(currentSubject, RbacSubjectResource.class));
result.setPerson(mapper.map(person, HsOfficePersonResource.class));
result.setGlobalAdmin(isGlobalAdmin);
return result;
}
final BiConsumer<HsProfileEntity, ProfileResource> ENTITY_TO_RESOURCE_POSTMAPPER = (entity, resource) -> {
of(entity.getSubject()).ifPresent(
subject -> resource.setNickname(subject.getName())
);
of(entity.getPerson()).ifPresent(
person -> resource.setPerson(
mapper.map(person, HsOfficePersonResource.class)
)
);
resource.setScopes(mapToValidScopeResources(entity));
};
private List<ScopeResource> mapToValidScopeResources(final HsProfileEntity entity) {
var allScopes = mapper.mapList(entity.getScopes().stream().toList(), ScopeResource.class);
return allScopes.stream()
.filter(scope -> !scope.getOnlyForNaturalPersons() ||
entity.getPerson().getPersonType() == HsOfficePersonType.NATURAL_PERSON)
.toList();
}
final BiConsumer<ProfileInsertResource, HsProfileEntity> RESOURCE_TO_ENTITY_POSTMAPPER = (resource, entity) -> {
Validate.validate("person, person.uuid").exactlyOne(resource.getPerson(), resource.getPersonUuid());
if ( resource.getPersonUuid() != null) {
entity.setPerson(realPersonRepo.findByUuid(resource.getPersonUuid()).orElseThrow(
() -> new NoSuchElementException("cannot find Person by 'person.uuid': " + resource.getPersonUuid())
));
} else {
entity.setPerson(realPersonRepo.save(
mapper.map(resource.getPerson(), HsOfficePersonRealEntity.class)
) );
}
val person = realPersonRepo.findByUuid(entity.getPerson().getUuid()).orElseThrow(
() -> new EntityNotFoundException(
messageTranslator.translate("general.{0}-{1}-not-found-or-not-accessible", "personUuid", resource.getPersonUuid())
)
);
entity.setPerson(person);
entity.setScopes(scopeMapper.mapProfileToScopeEntities(resource.getScopes()));
entity.setPassword(resource.getPassword());
};
@AllArgsConstructor
private class LoginContext {
final HsProfileEntity profile;
final boolean isGlobalAdmin;
public LoginContext(final Context context) {
val subjectUuid = context.fetchCurrentSubjectUuid();
profile = profileRepo.findByUuid(subjectUuid)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR,
"subject " + context.fetchCurrentSubject() + " has no profile"));
isGlobalAdmin = context.isGlobalAdmin();
}
}
}
@@ -1,133 +0,0 @@
package net.hostsharing.hsadminng.hs.accounts;
import jakarta.persistence.*;
import jakarta.validation.ValidationException;
import lombok.*;
import net.hostsharing.hsadminng.hash.HashGenerator;
import net.hostsharing.hsadminng.hash.LdapArgon2Hash;
import net.hostsharing.hsadminng.hash.LdapSshaHash;
import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRealEntity;
import net.hostsharing.hsadminng.persistence.BaseEntity; // Assuming BaseEntity exists
import net.hostsharing.hsadminng.rbac.subject.RealSubjectEntity;
import net.hostsharing.hsadminng.repr.Stringify;
import net.hostsharing.hsadminng.repr.Stringifyable;
import java.util.HashSet;
import java.util.List;
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_accounts", name = "profile")
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class HsProfileEntity implements BaseEntity<HsProfileEntity>, Stringifyable {
protected static Stringify<HsProfileEntity> stringify = stringify(HsProfileEntity.class, "profile")
.withProp(HsProfileEntity::isActive)
.withProp(HsProfileEntity::getEmailAddress)
.withProp(HsProfileEntity::getTotpSecrets)
.withProp(HsProfileEntity::getPhonePassword)
.withProp(HsProfileEntity::getSmsNumber)
.quotedValues(false);
@Id
private UUID uuid;
@MapsId
@OneToOne(optional = false, fetch = FetchType.EAGER, cascade = CascadeType.ALL, orphanRemoval = true)
@JoinColumn(name = "uuid", nullable = false, updatable = false, referencedColumnName = "uuid")
// Must be the real subject, so that representative persons can access profiles+subjects of represented persons.
// Otherwise, we would also need to allow RBAC grants to subject roles.
// This also means that each access has to be checked explicitly (same subject or represented subject).
private RealSubjectEntity subject;
@ManyToOne(optional = false, fetch = FetchType.EAGER)
@JoinColumn(name = "person_uuid", nullable = false, updatable = false, referencedColumnName = "uuid")
private HsOfficePersonRealEntity person; // TODO.impl: add RBAC-Support to ProfileEntity, see Story #
@Version
private int version;
@Column
private boolean active;
@Column
private Integer globalUid;
@Column
private Integer globalGid;
@Column(name = "password_hash")
private String passwordHash;
@Column
private List<String> totpSecrets;
@Column
private String phonePassword;
@Column
private String emailAddress;
@Column
private String smsNumber;
@OneToMany(fetch = FetchType.EAGER, cascade = { MERGE, REFRESH })
@JoinTable(
name = "scope_mapping", schema = "hs_accounts",
joinColumns = @JoinColumn(name = "profile_uuid", referencedColumnName = "uuid"),
inverseJoinColumns = @JoinColumn(name = "scope_uuid", referencedColumnName = "uuid")
)
private Set<HsProfileScopeRealEntity> scopes;
public Set<HsProfileScopeRealEntity> getScopes() {
if ( scopes == null ) {
scopes = new HashSet<>();
}
return scopes;
}
public void setSubject(final RealSubjectEntity subject) {
this.uuid = subject.getUuid();
this.subject = subject;
}
public void setPassword(final String password) {
setPasswordHash(
HashGenerator.fromEnv("ACCOUNT_PROFILE_PASSWORD_HASH_ALGORITHM", "{SSHA}")
.withRandomSalt().hash(password));
}
public void setPasswordHash(final String passwordHash) {
if (passwordHash != null) {
validatePasswordHash(passwordHash);
}
this.passwordHash = passwordHash;
}
@Override
public String toShortString() {
return active + ":" + emailAddress + ":" + globalUid;
}
@Override
public String toString() {
return stringify.apply(this);
}
private static void validatePasswordHash(final String passwordHash) {
if (!LdapSshaHash.isValid(passwordHash) && !LdapArgon2Hash.isValid(passwordHash)) {
throw new ValidationException("passwordHash must be SSHA or ARGON2 hash valid for LDAP");
}
}
}
@@ -1,37 +0,0 @@
package net.hostsharing.hsadminng.hs.accounts;
import net.hostsharing.hsadminng.accounts.generated.api.v1.model.ProfilePatchResource;
import net.hostsharing.hsadminng.mapper.EntityPatcher;
import net.hostsharing.hsadminng.mapper.OptionalFromJson;
import java.util.Optional;
public class HsProfileEntityPatcher implements EntityPatcher<ProfilePatchResource> {
private ScopeResourceToEntityMapper scopeMapper;
private final HsProfileEntity entity;
public HsProfileEntityPatcher(final ScopeResourceToEntityMapper scopeMapper, final HsProfileEntity entity) {
this.scopeMapper = scopeMapper;
this.entity = entity;
}
@Override
public void apply(final ProfilePatchResource resource) {
Optional.ofNullable(resource.getActive())
.ifPresent(entity::setActive);
OptionalFromJson.of(resource.getEmailAddress())
.ifPresent(entity::setEmailAddress);
Optional.ofNullable(resource.getTotpSecrets())
.ifPresent(entity::setTotpSecrets);
OptionalFromJson.of(resource.getSmsNumber())
.ifPresent(entity::setSmsNumber);
Optional.ofNullable(resource.getPassword())
.ifPresent(entity::setPassword);
OptionalFromJson.of(resource.getPhonePassword())
.ifPresent(entity::setPhonePassword);
if (resource.getScopes() != null) {
scopeMapper.syncProfileScopeEntities(resource.getScopes(), entity.getScopes());
}
}
}
@@ -1,43 +0,0 @@
package net.hostsharing.hsadminng.hs.accounts;
import io.micrometer.core.annotation.Timed;
import net.hostsharing.hsadminng.hs.office.person.HsOfficePerson;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.Repository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface HsProfileRepository extends Repository<HsProfileEntity, UUID> {
@Timed("app.login.profile.repo.findByUuid")
Optional<HsProfileEntity> findByUuid(final UUID uuid);
@Timed("app.login.profile.repo.findByPerson")
List<HsProfileEntity> findByPerson(final HsOfficePerson<?> personUuid);
@Timed("app.login.profile.repo.findByCurrentSubject")
@Query(nativeQuery = true, value = """
WITH RECURSIVE
same_person AS (
SELECT own_profile.person_uuid
FROM hs_accounts.profile own_profile
WHERE own_profile.uuid = rbac.currentSubjectUuid()
),
represented_persons AS (
SELECT relation.anchorUuid person_uuid
FROM hs_office.relation relation
WHERE relation.type = 'REPRESENTATIVE'
AND relation.holderUuid IN (SELECT person_uuid FROM same_person)
)
SELECT DISTINCT profile.*
FROM hs_accounts.profile profile
WHERE profile.person_uuid IN (SELECT person_uuid FROM same_person)
OR profile.person_uuid IN (SELECT person_uuid FROM represented_persons)
""")
List<HsProfileEntity> findByCurrentSubject();
@Timed("app.login.profile.repo.save")
HsProfileEntity save(final HsProfileEntity entity);
}
@@ -1,79 +0,0 @@
package net.hostsharing.hsadminng.hs.accounts;
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;
import static net.hostsharing.hsadminng.repr.Symbol.symbol;
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PROTECTED)
@SuperBuilder(builderMethodName = "baseBuilder", toBuilder = true)
@MappedSuperclass
public abstract class HsProfileScope implements Stringifyable, BaseEntity<HsProfileScope> {
private static Stringify<HsProfileScope> stringify = stringify(HsProfileScope.class, "scope")
.withProp(HsProfileScope::getType)
.withProp(HsProfileScope::getQualifier)
.withProp(
HsProfileScope::isOnlyForNaturalPersons,
value -> value ? symbol("NP-ONLY") : null)
.withProp(
HsProfileScope::isPublicAccess,
value -> value ? symbol("PUBLIC") : symbol("INTERNAL"))
.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;
@Column(name = "only_for_natural_persons")
private boolean onlyForNaturalPersons;
@Column(name = "public_access")
private boolean publicAccess;
public boolean isHsadminScope() {
return "HSADMIN".equals(type);
}
@Override
public String toShortString() {
return type + (qualifier != null ? ":" + qualifier : "");
}
@Override
public String toString() {
return stringify.apply(this);
}
}
@@ -1,45 +0,0 @@
package net.hostsharing.hsadminng.hs.accounts;
import java.util.List;
import io.micrometer.core.annotation.Timed;
import lombok.val;
import net.hostsharing.hsadminng.config.NoSecurityRequirement;
import net.hostsharing.hsadminng.rbac.context.Context;
import net.hostsharing.hsadminng.accounts.generated.api.v1.api.ScopesApi;
import net.hostsharing.hsadminng.accounts.generated.api.v1.model.ScopeResource;
import net.hostsharing.hsadminng.mapper.StrictMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.RestController;
@RestController
@NoSecurityRequirement
public class HsProfileScopeController implements ScopesApi {
@Autowired
private Context context;
@Autowired
private StrictMapper mapper;
@Autowired
private HsProfileScopeRbacRepository scopeRepo;
@Override
@Transactional(readOnly = true)
@Timed("app.accounts.scopes.getListOfScopes")
public ResponseEntity<List<ScopeResource>> getListOfScopes(final String assumedRoles) {
if (SecurityContextHolder.getContext().getAuthentication().isAuthenticated()) {
context.assumeRoles(assumedRoles);
}
val isGlobalAdmin = context.isGlobalAdmin();
final var scopes = scopeRepo.findAll().stream().filter(
scope -> scope.isPublicAccess() || isGlobalAdmin
).toList();
final var result = mapper.mapList(scopes, ScopeResource.class);
return ResponseEntity.ok(result);
}
}
@@ -1,55 +0,0 @@
package net.hostsharing.hsadminng.hs.accounts;
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_accounts", name = "scope") // 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 HsProfileScopeRbacEntity extends HsProfileScope {
// TODO_impl: RBAC rules for _rv do not yet work properly (remove the X)
public static RbacSpec rbacX() {
return rbacViewFor("profileScope", HsProfileScopeRbacEntity.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-accounts/9513-hs-profile-rbac");
}
}
@@ -1,24 +0,0 @@
package net.hostsharing.hsadminng.hs.accounts;
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 HsProfileScopeRbacRepository extends Repository<HsProfileScopeRbacEntity, UUID> {
@Timed("app.accounts.scope.repo.findAll.rbac")
List<HsProfileScopeRbacEntity> findAll();
@Timed("app.accounts.scope.repo.findByUuid.rbac")
Optional<HsProfileScopeRbacEntity> findByUuid(final UUID id);
@Timed("app.accounts.scope.repo.findByTypeAndQualifier.rbac")
Optional<HsProfileScopeRbacEntity> findByTypeAndQualifier(@NotNull String contextType, @NotNull String qualifier);
@Timed("app.accounts.scope.repo.save.rbac")
HsProfileScopeRbacEntity save(final HsProfileScopeRbacEntity entity);
}
@@ -1,23 +0,0 @@
package net.hostsharing.hsadminng.hs.accounts;
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_accounts", name = "scope")
@SuperBuilder(toBuilder = true)
@Getter
@Setter
@NoArgsConstructor
@AttributeOverrides({
@AttributeOverride(name = "uuid", column = @Column(name = "uuid"))
})
public class HsProfileScopeRealEntity extends HsProfileScope {
}
@@ -1,24 +0,0 @@
package net.hostsharing.hsadminng.hs.accounts;
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 HsProfileScopeRealRepository extends Repository<HsProfileScopeRealEntity, UUID> {
@Timed("app.account.scope.repo.findAll.real")
List<HsProfileScopeRealEntity> findAll();
@Timed("app.account.scope.repo.findByUuid.real")
Optional<HsProfileScopeRealEntity> findByUuid(final UUID id);
@Timed("app.account.scope.repo.findByTypeAndQualifier.real")
Optional<HsProfileScopeRealEntity> findByTypeAndQualifier(@NotNull String type, @NotNull String qualifier);
@Timed("app.account.scope.repo.save.real")
HsProfileScopeRealEntity save(final HsProfileScopeRealEntity entity);
}
@@ -1,70 +0,0 @@
package net.hostsharing.hsadminng.hs.accounts;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityNotFoundException;
import net.hostsharing.hsadminng.accounts.generated.api.v1.model.ScopeResource;
import net.hostsharing.hsadminng.config.MessageTranslator;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
@Service
public class ScopeResourceToEntityMapper {
private final EntityManager em;
private final MessageTranslator messageTranslator;
@Autowired
public ScopeResourceToEntityMapper(final EntityManager em, final MessageTranslator messageTranslator) {
this.em = em;
this.messageTranslator = messageTranslator;
}
public Set<HsProfileScopeRealEntity> mapProfileToScopeEntities(
final List<ScopeResource> resources
) {
final var entities = new HashSet<HsProfileScopeRealEntity>();
syncProfileScopeEntities(resources, entities);
return entities;
}
public void syncProfileScopeEntities(
final List<ScopeResource> resources,
final Set<HsProfileScopeRealEntity> entities
) {
final var resourceUuids = resources.stream()
.map(ScopeResource::getUuid)
.collect(Collectors.toSet());
final var entityUuids = entities.stream()
.map(HsProfileScopeRealEntity::getUuid)
.collect(Collectors.toSet());
entities.removeIf(e -> !resourceUuids.contains(e.getUuid()));
for (final var resource : resources) {
if (!entityUuids.contains(resource.getUuid())) {
final var existingScopeEntity = em.find(HsProfileScopeRealEntity.class, resource.getUuid());
if (existingScopeEntity == null) {
throw new EntityNotFoundException(
messageTranslator.translate(
"general.{0}-{1}-not-found-or-not-accessible",
"profile uuid", resource.getUuid()));
}
if ((resource.getType() != null && !existingScopeEntity.getType().equals(resource.getType())) ||
(resource.getQualifier() != null && !existingScopeEntity.getQualifier().equals(resource.getQualifier()))) {
throw new EntityNotFoundException(
messageTranslator.translate(
"profile.existing-profile-scope-{0}-does-not-match-given-resource-{1}",
existingScopeEntity, resource));
}
entities.add(existingScopeEntity);
}
}
}
}
@@ -2,12 +2,14 @@ package net.hostsharing.hsadminng.hs.office.person;
import io.micrometer.core.annotation.Timed; import io.micrometer.core.annotation.Timed;
import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import net.hostsharing.hsadminng.mapper.StrictMapper; import lombok.val;
import net.hostsharing.hsadminng.rbac.context.Context;
import net.hostsharing.hsadminng.hs.office.generated.api.v1.api.HsOfficePersonsApi; import net.hostsharing.hsadminng.hs.office.generated.api.v1.api.HsOfficePersonsApi;
import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficePersonInsertResource; import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficePersonInsertResource;
import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficePersonPatchResource; import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficePersonPatchResource;
import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficePersonResource; import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficePersonResource;
import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficePersonTypeResource;
import net.hostsharing.hsadminng.mapper.StrictMapper;
import net.hostsharing.hsadminng.rbac.context.Context;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
@@ -38,14 +40,23 @@ public class HsOfficePersonController implements HsOfficePersonsApi {
public ResponseEntity<List<HsOfficePersonResource>> getListOfPersons( public ResponseEntity<List<HsOfficePersonResource>> getListOfPersons(
final String assumedRoles, final String assumedRoles,
final String name, final String name,
final HsOfficePersonTypeResource type,
final UUID representedByPersonUuid) { final UUID representedByPersonUuid) {
context.assumeRoles(assumedRoles); context.assumeRoles(assumedRoles);
final var entities = representedByPersonUuid != null val personType = type != null ? HsOfficePersonType.valueOf(type.name()) : null;
// @formatter:off
val entities = (
representedByPersonUuid != null
? personRepo.findPersonsRepresentedByPersonWithUuid(representedByPersonUuid) ? personRepo.findPersonsRepresentedByPersonWithUuid(representedByPersonUuid)
: personRepo.findPersonByOptionalNameLike(name); : personRepo.findPersonByOptionalNameLike(name)
).stream()
// TODO.perf: this could be moved into the queries to improve the performance a bit
.filter(p -> personType == null || p.getPersonType() == personType)
.toList();
// @formatter:on
final var resources = mapper.mapList(entities, HsOfficePersonResource.class); val resources = mapper.mapList(entities, HsOfficePersonResource.class);
return ResponseEntity.ok(resources); return ResponseEntity.ok(resources);
} }
@@ -0,0 +1,34 @@
package net.hostsharing.hsadminng.rbac.grant;
import net.hostsharing.hsadminng.rbac.role.RbacRoleEntity;
import net.hostsharing.hsadminng.rbac.subject.Subject;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class RbacGrantService {
@Autowired
private RbacGrantRepository rbacGrantRepo;
public class RbacRoleGranter{
private final RbacRoleEntity role;
public RbacRoleGranter(final RbacRoleEntity role) {
this.role = role;
}
public void to(final Subject subject) {
rbacGrantRepo.save(RbacGrantEntity.builder()
.grantedRoleUuid(role.getUuid())
.granteeSubjectUuid(subject.getUuid())
.assumed(true)
.build());
}
}
public RbacRoleGranter grant(final RbacRoleEntity role) {
return new RbacRoleGranter(role);
}
}
@@ -24,6 +24,19 @@ public interface RbacRoleRepository extends Repository<RbacRoleEntity, UUID> {
@Timed("app.rbac.roles.repo.findByRoleName") @Timed("app.rbac.roles.repo.findByRoleName")
RbacRoleEntity findByRoleName(String roleName); RbacRoleEntity findByRoleName(String roleName);
@Timed("app.rbac.roles.repo.findByObjectUuidAndRoleType")
@Query(value = """
SELECT rev.*, rev.objectTable||'#'||rev.objectIdName||':'||rev.roleType AS roleName
FROM rbac.role_rv rev
WHERE rev.objectuuid = :objectUuid
AND rev.roletype = cast(:roleType as rbac.roletype)
""", nativeQuery = true)
RbacRoleEntity findByObjectUuidAndRoleType(UUID objectUuid, String roleType);
default RbacRoleEntity findByObjectUuidAndRoleType(UUID objectUuid, RbacRoleType roleType) {
return findByObjectUuidAndRoleType(objectUuid, roleType.name());
}
@Timed("app.rbac.roles.repo.fetchAssumedRoles") @Timed("app.rbac.roles.repo.fetchAssumedRoles")
@Query(value = """ @Query(value = """
SELECT rev.*, rev.objectTable||'#'||rev.objectIdName||':'||rev.roleType AS roleName SELECT rev.*, rev.objectTable||'#'||rev.objectIdName||':'||rev.roleType AS roleName
@@ -0,0 +1,31 @@
package net.hostsharing.hsadminng.rbac.role;
import lombok.val;
import net.hostsharing.hsadminng.persistence.BaseEntity;
import net.hostsharing.hsadminng.repr.Stringifyable;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
/// Just a APIs to programmatically handle RBAC roles.
@Service
public class RbacRoleService{
@Autowired
private RbacRoleRepository rbacRoleRepo;
public RbacRoleEntity rbacRole(final BaseEntity<?> rbacEntity, final RbacRoleType roleType) {
val personAdminRole = rbacRoleRepo.findByObjectUuidAndRoleType(rbacEntity.getUuid(), roleType);
if (personAdminRole == null) {
throw new ResponseStatusException(
HttpStatus.INTERNAL_SERVER_ERROR,
"no ADMIN role not found for %s %s".formatted(
Stringifyable.toShortString(rbacEntity),
rbacEntity.getUuid()
));
}
return personAdminRole;
}
}
@@ -126,7 +126,7 @@ public final class Stringify<B> {
private class Property<B, V> { private class Property<B, V> {
String name; String name;
Function<B, V> getter; Function<B, V> getter;
Function<V, ?> mapper; // FIXME: better generics? Function<V, ?> mapper; // TODO.impl: better generics?
Property(String name, Function<B, V> getter) { Property(String name, Function<B, V> getter) {
this(name, getter, v -> v); this(name, getter, v -> v);
@@ -1,6 +1,14 @@
package net.hostsharing.hsadminng.repr; package net.hostsharing.hsadminng.repr;
import net.hostsharing.hsadminng.persistence.BaseEntity;
public interface Stringifyable { public interface Stringifyable {
static String toShortString(final BaseEntity<?> entity) {
return entity instanceof Stringifyable stringifyableEntity
? stringifyableEntity.toShortString()
: entity.getUuid().toString();
}
String toShortString(); String toShortString();
} }
@@ -0,0 +1,77 @@
components:
schemas:
CurrentLoginUser:
type: object
properties:
subject:
$ref: '../rbac/rbac-subject-schemas.yaml#/components/schemas/RbacSubject'
person:
$ref: '../hs-office/hs-office-person-schemas.yaml#/components/schemas/HsOfficePerson'
globalAdmin:
type: boolean
Account:
type: object
properties:
uuid:
type: string
format: uuid
person:
$ref: '../hs-office/hs-office-person-schemas.yaml#/components/schemas/HsOfficePerson'
subjectName:
type: string
description: "
Two parts separated by a single '-'.
The first part is the Keycloak realm name of 3-9 chars length.
The second part a account name local to that realm, which is 3-32 chars in length.
Each part starts with a lowercase letter and may contain lowercase letters, digits, '.' or '_',
but '.'/'_' must be followed by a letter or digit.
"
pattern: '^[a-z](?:[a-z0-9]|[._](?=[a-z0-9])){2,8}-[a-z](?:[a-z0-9]|[._](?=[a-z0-9])){2,31}$'
globalUid:
type: number
globalGid:
type: number
required:
- uuid
- subjectName
- person
- globalUid
- globalGid
additionalProperties: false
AccountInsert:
type: object
properties:
person.uuid:
type: string
format: uuid
person:
$ref: '../hs-office/hs-office-person-schemas.yaml#/components/schemas/HsOfficePersonInsert'
subjectName:
type: string
description: "
Two parts separated by a single '-'.
The first part is the Keycloak realm name of 3-9 chars length.
The second part a account name local to that realm, which is 3-32 chars in length.
Each part starts with a lowercase letter and may contain lowercase letters, digits, '.' or '_',
but '.'/'_' must be followed by a letter or digit.
"
pattern: '^[a-z](?:[a-z0-9]|[._](?=[a-z0-9])){2,8}-[a-z](?:[a-z0-9]|[._](?=[a-z0-9])){2,31}$'
globalUid:
type: number
globalGid:
type: number
required:
- subjectName
- globalUid
- globalGid
# soon we might need to be able to use this:
# https://community.smartbear.com/discussions/swaggerostools/defining-conditional-attributes-in-openapi/222410
# For now we just describe the conditionally required properties:
description:
Either `person.uuid` or `person` need to be given.
additionalProperties: false
@@ -1,9 +1,9 @@
get: get:
summary: Returns a list of all profile. summary: Returns a list of all account.
description: Returns the list of all profile which are visible to the current subject or any of it's assumed roles. description: Returns the list of all account which are visible to the current subject or any of it's assumed roles.
tags: tags:
- profile - account
operationId: getListOfProfile operationId: getListIfAccount
parameters: parameters:
- $ref: 'auth.yaml#/components/parameters/assumedRoles' - $ref: 'auth.yaml#/components/parameters/assumedRoles'
- name: personUuid - name: personUuid
@@ -12,7 +12,7 @@ get:
schema: schema:
type: string type: string
format: uuid format: uuid
description: The UUID of the person, whose profile are to be fetched. Or null, if all profile of the login-use should be fetched. description: The UUID of the person, whose account are to be fetched. Or null, if all account of the login-use should be fetched.
responses: responses:
"200": "200":
description: OK description: OK
@@ -21,31 +21,31 @@ get:
schema: schema:
type: array type: array
items: items:
$ref: 'profile-schemas.yaml#/components/schemas/Profile' $ref: 'account-schemas.yaml#/components/schemas/Account'
"401": "401":
$ref: 'error-responses.yaml#/components/responses/Unauthorized' $ref: 'error-responses.yaml#/components/responses/Unauthorized'
"403": "403":
$ref: 'error-responses.yaml#/components/responses/Forbidden' $ref: 'error-responses.yaml#/components/responses/Forbidden'
post: post:
summary: Adds a new profile. summary: Adds a new account.
tags: tags:
- profile - account
operationId: postNewProfile operationId: postNewAccount
requestBody: requestBody:
description: A JSON object describing the new credential. description: A JSON object describing the new credential.
required: true required: true
content: content:
application/json: application/json:
schema: schema:
$ref: 'profile-schemas.yaml#/components/schemas/ProfileInsert' $ref: 'account-schemas.yaml#/components/schemas/AccountInsert'
responses: responses:
"201": "201":
description: Created description: Created
content: content:
'application/json': 'application/json':
schema: schema:
$ref: 'profile-schemas.yaml#/components/schemas/Profile' $ref: 'account-schemas.yaml#/components/schemas/Account'
"401": "401":
$ref: 'error-responses.yaml#/components/responses/Unauthorized' $ref: 'error-responses.yaml#/components/responses/Unauthorized'
"403": "403":
@@ -0,0 +1,48 @@
get:
tags:
- account
description: 'Fetch a single account its uuid, if visible for the current subject.'
operationId: getSingleAccountByUuid
parameters:
- name: accountUuid
in: path
required: true
schema:
type: string
format: uuid
description: UUID of the account to fetch.
responses:
"200":
description: OK
content:
'application/json':
schema:
$ref: 'account-schemas.yaml#/components/schemas/Account'
"401":
$ref: 'error-responses.yaml#/components/responses/Unauthorized'
"403":
$ref: 'error-responses.yaml#/components/responses/Forbidden'
delete:
tags:
- account
description: 'Delete a single account identified by its uuid, if permitted for the current subject.'
operationId: deleteAccountByUuid
parameters:
- name: accountUuid
in: path
required: true
schema:
type: string
format: uuid
description: UUID of the account 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'
@@ -13,5 +13,5 @@ map:
- type: string:uuid => java.util.UUID - type: string:uuid => java.util.UUID
paths: paths:
/api/hs/accounts/profiles/{profileUuid}: /api/hs/accounts/accounts/{accountUuid}:
null: org.openapitools.jackson.nullable.JsonNullable null: org.openapitools.jackson.nullable.JsonNullable
@@ -13,17 +13,11 @@ paths:
/api/hs/accounts/current: /api/hs/accounts/current:
$ref: "current.yaml" $ref: "current.yaml"
# Scopes # Account
/api/hs/accounts/scopes: /api/hs/accounts/accounts/{accountUuid}:
$ref: "scopes.yaml" $ref: "accout-with-uuid.yaml"
/api/hs/accounts/accounts:
# Profile $ref: "accounts.yaml"
/api/hs/accounts/profiles/{profileUuid}:
$ref: "profile-with-uuid.yaml"
/api/hs/accounts/profiles:
$ref: "profiles.yaml"
@@ -2,7 +2,7 @@ get:
summary: Currently logged in user data. summary: Currently logged in user data.
description: Returns information about the currently logged in user. description: Returns information about the currently logged in user.
tags: tags:
- profile - account
operationId: getCurrentLoginUser operationId: getCurrentLoginUser
responses: responses:
"200": "200":
@@ -10,7 +10,7 @@ get:
content: content:
'application/json': 'application/json':
schema: schema:
$ref: 'profile-schemas.yaml#/components/schemas/CurrentLoginUser' $ref: 'account-schemas.yaml#/components/schemas/CurrentLoginUser'
"401": "401":
$ref: 'error-responses.yaml#/components/responses/Unauthorized' $ref: 'error-responses.yaml#/components/responses/Unauthorized'
"403": "403":
@@ -1,124 +0,0 @@
components:
schemas:
CurrentLoginUser:
type: object
properties:
subject:
$ref: '../rbac/rbac-subject-schemas.yaml#/components/schemas/RbacSubject'
person:
$ref: '../hs-office/hs-office-person-schemas.yaml#/components/schemas/HsOfficePerson'
globalAdmin:
type: boolean
Profile:
type: object
properties:
uuid:
type: string
format: uuid
person:
$ref: '../hs-office/hs-office-person-schemas.yaml#/components/schemas/HsOfficePerson'
nickname:
type: string
pattern: '^[a-z][a-z0-9]{1,8}-[a-z0-9]{1,10}$' # TODO.spec: pattern for login nickname
emailAddress:
type: string
smsNumber:
type: string
totpSecrets:
type: array
items:
type: string
phonePassword:
type: string
active:
type: boolean
globalUid:
type: number
globalGid:
type: number
scopes:
type: array
items:
$ref: 'scope-schemas.yaml#/components/schemas/Scope'
required:
- uuid
- active
- scopes
additionalProperties: false
ProfilePatch:
type: object
properties:
emailAddress:
type: string
nullable: true
smsNumber:
type: string
nullable: true
totpSecrets:
type: array
items:
type: string
password:
type: string
minLength: 8
description: plaintext password or valid hash
phonePassword:
type: string
nullable: true
active:
type: boolean
scopes:
type: array
items:
$ref: 'scope-schemas.yaml#/components/schemas/Scope'
additionalProperties: false
ProfileInsert:
type: object
properties:
person.uuid:
type: string
format: uuid
person:
$ref: '../hs-office/hs-office-person-schemas.yaml#/components/schemas/HsOfficePersonInsert'
nickname:
type: string
pattern: '^[a-z][a-z0-9]{1,8}-[a-z0-9]{1,10}$' # TODO.spec: pattern for login nickname
emailAddress:
type: string
smsNumber:
type: string
active:
type: boolean
globalUid:
type: number
globalGid:
type: number
password:
type: string
minLength: 8
description: plaintext password or valid hash
phonePassword:
type: string
totpSecrets:
type: array
items:
type: string
scopes:
type: array
items:
$ref: 'scope-schemas.yaml#/components/schemas/Scope'
required:
- nickname
- active
# soon we might need to be able to use this:
# https://community.smartbear.com/discussions/swaggerostools/defining-conditional-attributes-in-openapi/222410
# For now we just describe the conditionally required properties:
description:
Either `person.uuid` or `person` need to be given.
additionalProperties: false
@@ -1,77 +0,0 @@
get:
tags:
- profile
description: 'Fetch a single profile its uuid, if visible for the current subject.'
operationId: getSingleProfileByUuid
parameters:
- name: profileUuid
in: path
required: true
schema:
type: string
format: uuid
description: UUID of the profile to fetch.
responses:
"200":
description: OK
content:
'application/json':
schema:
$ref: 'profile-schemas.yaml#/components/schemas/Profile'
"401":
$ref: 'error-responses.yaml#/components/responses/Unauthorized'
"403":
$ref: 'error-responses.yaml#/components/responses/Forbidden'
patch:
tags:
- profile
description: 'Updates a single profile identified by its uuid, if permitted for the current subject.'
operationId: patchProfile
parameters:
- name: profileUuid
in: path
required: true
schema:
type: string
format: uuid
requestBody:
content:
'application/json':
schema:
$ref: 'profile-schemas.yaml#/components/schemas/ProfilePatch'
responses:
"200":
description: OK
content:
'application/json':
schema:
$ref: 'profile-schemas.yaml#/components/schemas/Profile'
"401":
$ref: 'error-responses.yaml#/components/responses/Unauthorized'
"403":
$ref: 'error-responses.yaml#/components/responses/Forbidden'
delete:
tags:
- profile
description: 'Delete a single profile identified by its uuid, if permitted for the current subject.'
operationId: deleteProfileByUuid
parameters:
- name: profileUuid
in: path
required: true
schema:
type: string
format: uuid
description: UUID of the profile 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'
@@ -1,23 +0,0 @@
components:
schemas:
Scope:
type: object
properties:
uuid:
type: string
format: uuid
type:
type: string
maxLength: 16
qualifier:
type: string
maxLength: 80
onlyForNaturalPersons:
type: boolean
publicAccess:
type: boolean
required:
- uuid
@@ -1,21 +0,0 @@
get:
summary: Returns a list of all accessible scopes.
description: Returns the list of all scopes which are visible to the current subject or any of it's assumed roles.
tags:
- scopes
operationId: getListOfScopes
parameters:
- $ref: 'auth.yaml#/components/parameters/assumedRoles'
responses:
"200":
description: OK
content:
'application/json':
schema:
type: array
items:
$ref: 'scope-schemas.yaml#/components/schemas/Scope'
"401":
$ref: 'error-responses.yaml#/components/responses/Unauthorized'
"403":
$ref: 'error-responses.yaml#/components/responses/Forbidden'
@@ -12,6 +12,12 @@ get:
schema: schema:
type: string type: string
description: Prefix of caption to filter the results. description: Prefix of caption to filter the results.
- name: type
in: query
required: false
schema:
$ref: 'hs-office-person-schemas.yaml#/components/schemas/HsOfficePersonType'
description: Filter the results by person type.
- name: representedByPersonUuid - name: representedByPersonUuid
in: query in: query
required: false required: false
+6 -2
View File
@@ -5,6 +5,12 @@ management:
server: server:
port: 8081 port: 8081
address: 127.0.0.1 address: 127.0.0.1
info:
build:
enabled: true
git:
enabled: true
mode: full
endpoints: endpoints:
web: web:
exposure: exposure:
@@ -96,5 +102,3 @@ spring:
jwt: jwt:
issuer-uri: "http://localhost:${server.port}/fake-jwt" issuer-uri: "http://localhost:${server.port}/fake-jwt"
jwk-set-uri: "http://localhost:${server.port}/fake-jwt/.well-known/jwks.json" jwk-set-uri: "http://localhost:${server.port}/fake-jwt/.well-known/jwks.json"
@@ -2,7 +2,7 @@
-- ============================================================================ -- ============================================================================
--changeset michael.hoennig:hs-profile-SCHEMA endDelimiter:--// --changeset michael.hoennig:hs-accounts-SCHEMA endDelimiter:--//
-- ---------------------------------------------------------------------------- -- ----------------------------------------------------------------------------
CREATE SCHEMA hs_accounts; CREATE SCHEMA hs_accounts;
--// --//
@@ -2,76 +2,18 @@
-- ============================================================================ -- ============================================================================
--changeset michael.hoennig:hs-profile-PROFILE-TABLE endDelimiter:--// --changeset michael.hoennig:hs-accounts-ACCOUNT-TABLE endDelimiter:--//
-- ---------------------------------------------------------------------------- -- ----------------------------------------------------------------------------
create table hs_accounts.profile create table hs_accounts.account
( (
uuid uuid PRIMARY KEY references rbac.subject (uuid) initially deferred, uuid uuid PRIMARY KEY references rbac.subject (uuid) initially deferred,
version int not null default 0, version int not null default 0,
person_uuid uuid not null references hs_office.person(uuid), person_uuid uuid not null references hs_office.person(uuid),
active bool,
global_uid int unique, -- w/o global_uid int unique, -- w/o
global_gid int unique, -- w/o global_gid int unique -- w/o
password_hash text,
totp_secrets text[],
phone_password text,
email_address text,
sms_number text
);
--//
-- ============================================================================
--changeset michael.hoennig:hs-profile-scope-SCOPE-TABLE endDelimiter:--//
-- ----------------------------------------------------------------------------
create table hs_accounts.scope
(
uuid uuid PRIMARY KEY,
version int not null default 0,
type varchar(16),
qualifier varchar(80),
only_for_natural_persons boolean default false,
public_access boolean default false,
unique (type, qualifier)
);
--//
-- ============================================================================
--changeset michael.hoennig:hs-profile-SCOPE-IMMUTABLE-TRIGGER endDelimiter:--//
-- ----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION hs_accounts.prevent_scope_update()
RETURNS TRIGGER AS $$
BEGIN
RAISE EXCEPTION 'Updates to hs_accounts.scope are not allowed.';
END;
$$ LANGUAGE plpgsql;
-- Trigger to enforce immutability
CREATE TRIGGER scope_immutable_trigger
BEFORE UPDATE ON hs_accounts.scope
FOR EACH ROW EXECUTE FUNCTION hs_accounts.prevent_scope_update();
--//
-- ============================================================================
--changeset michael.hoennig:hs_accounts-SCOPE-MAPPING endDelimiter:--//
-- ----------------------------------------------------------------------------
create table hs_accounts.scope_mapping
(
uuid uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
profile_uuid uuid references hs_accounts.profile(uuid) ON DELETE CASCADE,
scope_uuid uuid references hs_accounts.scope(uuid) ON DELETE RESTRICT
); );
--// --//
@@ -80,16 +22,12 @@ create table hs_accounts.scope_mapping
--changeset michael.hoennig:hs-hs_accounts-JOURNALS endDelimiter:--// --changeset michael.hoennig:hs-hs_accounts-JOURNALS endDelimiter:--//
-- ---------------------------------------------------------------------------- -- ----------------------------------------------------------------------------
call base.create_journal('hs_accounts.scope_mapping'); call base.create_journal('hs_accounts.account');
call base.create_journal('hs_accounts.scope');
call base.create_journal('hs_accounts.profile');
--// --//
-- ============================================================================ -- ============================================================================
--changeset michael.hoennig:hs_accounts-HISTORICIZATION endDelimiter:--// --changeset michael.hoennig:hs_accounts-HISTORICIZATION endDelimiter:--//
-- ---------------------------------------------------------------------------- -- ----------------------------------------------------------------------------
call base.tx_create_historicization('hs_accounts.scope_mapping'); call base.tx_create_historicization('hs_accounts.account');
call base.tx_create_historicization('hs_accounts.scope');
call base.tx_create_historicization('hs_accounts.profile');
--// --//
@@ -1,41 +0,0 @@
### rbac profileContext
This code generated was by RbacViewMermaidFlowchartGenerator, do not amend manually.
```mermaid
%%{init:{'flowchart':{'htmlLabels':false}}}%%
flowchart TB
subgraph profileContext["`**profileContext**`"]
direction TB
style profileContext fill:#dd4901,stroke:#274d6e,stroke-width:8px
subgraph profileContext:roles[ ]
style profileContext:roles fill:#dd4901,stroke:white
role:profileContext:OWNER[[profileContext:OWNER]]
role:profileContext:ADMIN[[profileContext:ADMIN]]
role:profileContext:REFERRER[[profileContext:REFERRER]]
end
subgraph profileContext:permissions[ ]
style profileContext:permissions fill:#dd4901,stroke:white
perm:profileContext:INSERT{{profileContext:INSERT}}
perm:profileContext:UPDATE{{profileContext:UPDATE}}
perm:profileContext:DELETE{{profileContext:DELETE}}
perm:profileContext:SELECT{{profileContext:SELECT}}
end
end
%% granting roles to roles
role:profileContext:OWNER ==> role:profileContext:ADMIN
role:profileContext:ADMIN ==> role:profileContext:REFERRER
%% granting permissions to roles
role:rbac.global:ADMIN ==> perm:profileContext:INSERT
role:rbac.global:ADMIN ==> perm:profileContext:UPDATE
role:rbac.global:ADMIN ==> perm:profileContext:DELETE
role:rbac.global:REFERRER ==> perm:profileContext:SELECT
```
@@ -2,7 +2,7 @@
-- ============================================================================ -- ============================================================================
--changeset michael.hoennig:hs_accounts-profile-TEST-DATA context:!without-test-data endDelimiter:--// --changeset michael.hoennig:hs_accounts-account-TEST-DATA context:!without-test-data endDelimiter:--//
-- ---------------------------------------------------------------------------- -- ----------------------------------------------------------------------------
do language plpgsql $$ do language plpgsql $$
@@ -15,13 +15,6 @@ declare
userDrewSubjectUuid uuid; userDrewSubjectUuid uuid;
personDrewUuid uuid; personDrewUuid uuid;
scope_HSADMIN_prod hs_accounts.scope;
scope_SSH_internal hs_accounts.scope;
scope_SSH_external hs_accounts.scope;
scope_MATRIX_internal hs_accounts.scope;
scope_MATRIX_external hs_accounts.scope;
begin begin
call base.defineContext('creating booking-project test-data', null, 'superuser-alex@hostsharing.net', 'rbac.global#global:ADMIN'); call base.defineContext('creating booking-project test-data', null, 'superuser-alex@hostsharing.net', 'rbac.global#global:ADMIN');
@@ -32,57 +25,11 @@ begin
userDrewSubjectUuid = (SELECT uuid FROM rbac.subject WHERE name='selfregistered-user-drew@hostsharing.org'); userDrewSubjectUuid = (SELECT uuid FROM rbac.subject WHERE name='selfregistered-user-drew@hostsharing.org');
personDrewUuid = (SELECT uuid FROM hs_office.person WHERE givenName='Drew'); personDrewUuid = (SELECT uuid FROM hs_office.person WHERE givenName='Drew');
-- Add test scopes -- Add test account (linking to assumed rbac.subject UUIDs)
INSERT INTO hs_accounts.scope (uuid, type, qualifier, only_for_natural_persons, public_access) VALUES INSERT INTO hs_accounts.account (uuid, version, person_uuid, global_uid, global_gid) VALUES
('11111111-1111-1111-1111-111111111111', 'HSADMIN', 'prod', true, true) ( superuserAlexSubjectUuid, 0, personAlexUuid, 1001, 1001),
RETURNING * INTO scope_HSADMIN_prod; ( superuserFranSubjectUuid, 0, personFranUuid, 1002, 1002),
INSERT INTO hs_accounts.scope (uuid, type, qualifier, only_for_natural_persons, public_access) VALUES ( userDrewSubjectUuid, 0, personDrewUuid, 1003, 1003);
('22222222-2222-2222-2222-222222222222', 'SSH', 'internal', true, false)
RETURNING * INTO scope_SSH_internal;
INSERT INTO hs_accounts.scope (uuid, type, qualifier, only_for_natural_persons, public_access) VALUES
('33333333-3333-3333-3333-333333333333', 'SSH', 'external', false, true)
RETURNING * INTO scope_SSH_external;
INSERT INTO hs_accounts.scope (uuid, type, qualifier, only_for_natural_persons, public_access) VALUES
('44444444-4444-4444-4444-444444444444', 'MATRIX', 'internal', true, false)
RETURNING * INTO scope_MATRIX_internal;
INSERT INTO hs_accounts.scope (uuid, type, qualifier, only_for_natural_persons, public_access) VALUES
('55555555-5555-5555-5555-555555555555', 'MATRIX', 'external', true, true)
RETURNING * INTO scope_MATRIX_external;
INSERT INTO hs_accounts.scope (uuid, type, qualifier, only_for_natural_persons, public_access) VALUES
('66666666-6666-6666-6666-666666666666', 'MASTODON', 'external', false, true);
INSERT INTO hs_accounts.scope (uuid, type, qualifier, only_for_natural_persons, public_access) VALUES
('77777777-7777-7777-7777-777777777777', 'BBB', 'external', false, true);
-- grant general access to public credential scopes
-- 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_accounts.scope_REFERRER(context_SSH_internal), rbac.global_ADMIN());
-- call rbac.grantRoleToRole(hs_accounts.scope_REFERRER(context_MATRIX_internal), rbac.global_ADMIN());
-- Add test profile (linking to assumed rbac.subject UUIDs)
INSERT INTO hs_accounts.profile (uuid, version, person_uuid, active, global_uid, global_gid, totp_secrets, phone_password, email_address, sms_number) VALUES
( superuserAlexSubjectUuid, 0, personAlexUuid, true, 1001, 1001, ARRAY['otp-secret-1a', 'otp-secret-1b'], 'phone-pw-1', 'alex@example.com', '111-222-3333'),
( superuserFranSubjectUuid, 0, personFranUuid, true, 1002, 1002, ARRAY['otp-secret-2'], 'phone-pw-2', 'fran@example.com', '444-555-6666'),
( userDrewSubjectUuid, 0, personDrewUuid, true, 1003, 1003, ARRAY['otp-secret-3'], 'phone-pw-3', 'drew@example.org', '999-888-7777');
-- Map profile to contexts
INSERT INTO hs_accounts.scope_mapping (profile_uuid, scope_uuid) VALUES
(superuserAlexSubjectUuid, scope_HSADMIN_prod.uuid),
(superuserFranSubjectUuid, scope_HSADMIN_prod.uuid),
(userDrewSubjectUuid, scope_HSADMIN_prod.uuid),
(superuserAlexSubjectUuid, scope_SSH_internal.uuid),
(superuserFranSubjectUuid, scope_SSH_internal.uuid),
(userDrewSubjectUuid, scope_SSH_external.uuid),
(superuserAlexSubjectUuid, scope_MATRIX_internal.uuid),
(superuserFranSubjectUuid, scope_MATRIX_internal.uuid);
end; $$; end; $$;
--// --//
@@ -10,12 +10,9 @@ general.{0}-{1}-not-found={0} "{1}" nicht gefunden
general.{0}-{1}-not-found-or-not-accessible={0} "{1}" nicht gefunden oder nicht zugänglich general.{0}-{1}-not-found-or-not-accessible={0} "{1}" nicht gefunden oder nicht zugänglich
general.but-is=ist aber general.but-is=ist aber
# profile validations # account validations
profile.existing-profile-scope-{0}-does-not-match-given-resource-{1}=existierender Gültigkeitsbereich {0} passt nicht zum angegebenen {1} account.access-denied-to-create-new-account-subject-{0}-is-not-a-global-admin=Zugriff verweigert: Neue Accounts können nur von globalen Admins angelegt werden, {0} ist kein solcher
profile.access-denied-to-person-with-uuid-{0}-not-represented-by-currently-logged-in-person=Zugriff verweigert: personUuid "{0}" wird von der eingeloggten Person nicht repräsentiert account.only-natural-persons-allowed-but-{0}-is-{1}=Nur natürliche Personen sind erlaubt, aber {0} ist {1}
profile.access-denied-for-scopes-{0}=Zugriff auf Geltungsbereich verweigert: {0}
profile.scope-requires-natural-person-{0}=Geltungsbereich verlangt eine natürliche Person: {0}
profile.own-hsadmin-profile-must-not-be-removed=die eigenen hsadmin-Profile dürfen nicht entfernt werden
# office.coop-shares # office.coop-shares
office.coop-shares.for-transactiontype-{0}-sharecount-must-be-positive-but-is-{1}=für transactionType={0}, muss shareCount positiv sein, ist aber {1} office.coop-shares.for-transactiontype-{0}-sharecount-must-be-positive-but-is-{1}=für transactionType={0}, muss shareCount positiv sein, ist aber {1}
@@ -10,12 +10,9 @@ general.{0}-{1}-not-found={0} "{1}" not found
general.{0}-{1}-not-found-or-not-accessible={0} "{1}" not found or not accessible general.{0}-{1}-not-found-or-not-accessible={0} "{1}" not found or not accessible
general.but-is=but is general.but-is=but is
# profile validations # account validations
profile.existing-profile-scope-{0}-does-not-match-given-resource-{1}=existing {0} does not match given resource {1} account.access-denied-to-create-new-account-subject-{0}-is-not-a-global-admin=Access denied: new accounts can only be created by global admins, {0} is not
profile.access-denied-to-person-with-uuid-{0}-not-represented-by-currently-logged-in-person=access denied: personUuid "{0}" not represented by currently logged in person account.only-natural-persons-allowed-but-{0}-is-{1}=only natural persons allowed, but {0} is {1}
profile.access-denied-for-scopes-{0}=scope-access denied: {0}
profile.scope-requires-natural-person-{0}=scope requires natural person: {0}
profile.own-hsadmin-profile-must-not-be-removed=own hsadmin-profile must not be removed
# office.coop-shares # office.coop-shares
office.coop-shares.for-transactiontype-{0}-sharecount-must-be-positive-but-is-{1}=for transactiontType {0} shareCount must be positive but is {1} office.coop-shares.for-transactiontype-{0}-sharecount-must-be-positive-but-is-{1}=for transactiontType {0} shareCount must be positive but is {1}
@@ -11,11 +11,8 @@ general.{0}-{1}-not-found-or-not-accessible={0} "{1}" non trouvé ou non accessi
general.but-is=mais c'est general.but-is=mais c'est
# profile validations # profile validations
profile.existing-profile-scope-{0}-does-not-match-given-resource-{1}={0} existant ne correspond pas à la ressource donnée {1} account.access-denied-to-create-new-account-subject-{0}-is-not-a-global-admin=Accès refusé : seuls les administrateurs globaux peuvent créer de nouveaux comptes, {0} ne lest pas
profile.access-denied-to-person-with-uuid-{0}-not-represented-by-currently-logged-in-person=accès refusé : personUuid "{0}" non représenté par la personne actuellement connectée account.only-natural-persons-allowed-but-{0}-is-{1}=seulement personnes physiques sont accepté, mais {0} est {1}
profile.access-denied-for-scopes-{0}=accès au domaine d'application refusé : {0}
profile.scope-requires-natural-person-{0}=le domaine d'application requiert une personne physique : {0}
profile.own-hsadmin-profile-must-not-be-removed=suppression des identifiants hsadmin propres interdite
# office.coop-shares # office.coop-shares
office.coop-shares.for-transactiontype-{0}-sharecount-must-be-positive-but-is-{1}=pour le type de transaction {0}, shareCount doit être positif mais est {1} office.coop-shares.for-transactiontype-{0}-sharecount-must-be-positive-but-is-{1}=pour le type de transaction {0}, shareCount doit être positif mais est {1}
@@ -0,0 +1,253 @@
package net.hostsharing.hsadminng.hs.accounts;
import io.restassured.RestAssured;
import io.restassured.http.ContentType;
import lombok.val;
import net.hostsharing.hsadminng.rbac.context.Context;
import net.hostsharing.hsadminng.hs.accounts.HsAccountEntity.HsAccountEntityBuilder;
import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRealEntity;
import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRealRepository;
import net.hostsharing.hsadminng.rbac.subject.RbacSubjectEntity;
import net.hostsharing.hsadminng.rbac.subject.RbacSubjectRepository;
import net.hostsharing.hsadminng.rbac.subject.RealSubjectEntity;
import net.hostsharing.hsadminng.rbac.test.ContextBasedTestWithCleanup;
import net.hostsharing.hsadminng.rbac.test.JpaAttempt;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.transaction.annotation.Transactional;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import java.util.function.Consumer;
import static net.hostsharing.hsadminng.config.JwtFakeBearer.bearer;
import static net.hostsharing.hsadminng.hs.office.person.HsOfficePersonType.LEGAL_PERSON;
import static net.hostsharing.hsadminng.hs.office.person.HsOfficePersonType.NATURAL_PERSON;
import static net.hostsharing.hsadminng.test.JsonMatcher.lenientlyEquals;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.equalTo;
@Tag("generalIntegrationTest")
@Transactional
@SpringBootTest(
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT
)
@ActiveProfiles("fake-jwt")
// too complex database interaction for just a RestTest, thus a fully integrated test
class HsAccountControllerAcceptanceTest extends ContextBasedTestWithCleanup {
@LocalServerPort
Integer port;
@Autowired
Context context;
@Autowired
RbacSubjectRepository rbacSubjectRepo;
@Autowired
HsOfficePersonRealRepository realPersonRepo;
@Autowired
HsAccountRepository accountRepo;
@Autowired
JpaAttempt jpaAttempt;
@PersistenceContext
EntityManager em;
@BeforeEach
void setUp() {
context.define("superuser-alex@hostsharing.net");
}
@Nested
class GetCurrentUser {
@Test
void shouldFetchCurrentLoginUser() throws Exception {
// given
context.define("superuser-alex@hostsharing.net");
RestAssured // @formatter:off
.given()
.header("Authorization", bearer("superuser-alex@hostsharing.net"))
.port(port)
.when()
.get("http://localhost/api/hs/accounts/current")
.then().log().all().assertThat()
.statusCode(200)
.contentType("application/json")
.body("subject.name", equalTo("superuser-alex@hostsharing.net"))
.body("globalAdmin", equalTo(true));
// @formatter:on
}
}
@Nested
class GetAccountByUuid {
@Test
void shouldGetAccountByUuid() {
// given
val legalPerson = givenLegalPerson("selfregistered-user-drew@hostsharing.org");
val accountEntity = givenNewAccount("selfregistered-user-drew@hostsharing.org",
"test-subject1", legalPerson, builder -> {
});
RestAssured // @formatter:off
.given()
.header("Authorization", bearer(accountEntity.getSubject().getName()))
.port(port)
.when()
.get("http://localhost/api/hs/accounts/accounts/" + accountEntity.getUuid())
.then().log().all().assertThat()
.statusCode(200)
.contentType("application/json")
.body("$", lenientlyEquals("""
{
"person": {
"personType": "LEGAL_PERSON",
"tradeName": "Test Company",
"salutation": null,
"title": null,
"givenName": null,
"familyName": null
},
"subjectName": "test-subject1",
"globalUid": null,
"globalGid": null
}
"""));
// @formatter:on
}
}
@Nested
class PostNewAccount {
@Test
void shouldRejectCreatingAccountForUnrepresentedPerson() {
// given
val testPerson = givenPersonWithUuid("selfregistered-user-drew@hostsharing.org");
RestAssured // @formatter:off
.given()
.header("Authorization", bearer("selfregistered-user-drew@hostsharing.org"))
.header("Accept-Language", "de")
.contentType(ContentType.JSON)
.body("""
{
"person.uuid": "%s",
"subjectName": "new-user",
"globalUid": 30001,
"globalGid": 40001
}
""".formatted(testPerson.getUuid()))
.port(port)
.when()
.post("http://localhost/api/hs/accounts/accounts")
.then().log().all().assertThat()
.statusCode(403)
.contentType("application/json")
.body("message", containsString("wird von der eingeloggten Person nicht repräsentiert"));
// @formatter:on
}
@Test
void shouldRejectCreatingAccountForNonNaturalPerson() {
// given
val firstGmbHPerson = realPersonRepo.findPersonByOptionalNameLike("First").getFirst();
RestAssured // @formatter:off
.given()
.header("Authorization", bearer("superuser-alex@hostsharing.net"))
.header("Accept-Language", "de")
.contentType(ContentType.JSON)
.body("""
{
"person.uuid": "%s",
"subjectName": "new-user",
"globalUid": 30001,
"globalGid": 40001
}
""".formatted(firstGmbHPerson.getUuid()))
.port(port)
.when()
.post("http://localhost/api/hs/accounts/accounts")
.then().log().all().assertThat()
.statusCode(400)
.contentType("application/json")
.body("message",
containsString("Nur natürliche Personen sind erlaubt, aber ${personUuid} ist LEGAL_PERSON"
.replace("${personUuid}", firstGmbHPerson.getUuid().toString())));
// @formatter:on
}
}
// TODO.spec Task#5637: add @Nested class DeleteAccount and tests for delete, when we have a spec
// Helper methods
private HsOfficePersonRealEntity givenLegalPerson(final String executingSubjectName) {
return jpaAttempt.transacted(() -> {
context.define(executingSubjectName);
return toCleanup(realPersonRepo.save(HsOfficePersonRealEntity.builder()
.personType(LEGAL_PERSON)
.tradeName("Test Company")
.build()));
}).assertSuccessful().returnedValue();
}
private HsOfficePersonRealEntity givenNaturalPerson(final String executingSubjectName) {
return jpaAttempt.transacted(() -> {
context.define(executingSubjectName);
return toCleanup(realPersonRepo.save(HsOfficePersonRealEntity.builder()
.personType(NATURAL_PERSON)
.familyName("Test")
.givenName("User")
.build()));
}).assertSuccessful().returnedValue();
}
private HsOfficePersonRealEntity givenPersonWithUuid(final String executingSubjectName) {
return jpaAttempt.transacted(() -> {
context.define(executingSubjectName);
return toCleanup(realPersonRepo.save(HsOfficePersonRealEntity.builder()
.personType(NATURAL_PERSON)
.familyName("Test")
.givenName("Person")
.build()));
}).returnedValue();
}
private HsAccountEntity givenNewAccount(
final String executingSubjectName,
final String newSubjectName, final HsOfficePersonRealEntity person,
final Consumer<HsAccountEntityBuilder> modifier
) {
return jpaAttempt.transacted(() -> {
context.define(executingSubjectName);
// only RbacSubject entities can be created
val subject = rbacSubjectRepo.create(RbacSubjectEntity.builder()
.name(newSubjectName)
.build());
context.define(subject.getName());
val attachedPerson = em.find(HsOfficePersonRealEntity.class, person.getUuid());
val accountBuilder = HsAccountEntity.builder()
.person(attachedPerson)
.subject(em.find(RealSubjectEntity.class, subject.getUuid()));
modifier.accept(accountBuilder);
return toCleanup(accountRepo.save(accountBuilder.build()));
}).assertSuccessful().returnedValue();
}
}
@@ -0,0 +1,38 @@
package net.hostsharing.hsadminng.hs.accounts;
import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRealEntity;
import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonType;
import net.hostsharing.hsadminng.rbac.subject.RealSubjectEntity;
import org.junit.jupiter.api.Test;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
class HsAccountEntityUnitTest {
static final HsAccountEntity GIVEN_ACCOUNT_ENTITY = HsAccountEntity.builder()
.uuid(UUID.fromString("11111111-1111-1111-1111-111111111111"))
.subject(
RealSubjectEntity.builder().uuid(UUID.randomUUID()).name("test-subject").build())
.person(
HsOfficePersonRealEntity.builder()
.personType(HsOfficePersonType.NATURAL_PERSON)
.familyName("Miller")
.givenName("John")
.build()
)
.globalUid(10001)
.globalUid(20002)
.build();
@Test
void toShortStringContainsJustTheSubjectName() {
assertThat(GIVEN_ACCOUNT_ENTITY.toShortString()).isEqualTo("test-subject");
}
@Test
void toStringContainsJustTheSubjectNam() {
assertThat(GIVEN_ACCOUNT_ENTITY.toString()).isEqualTo("account(test-subject)");
}
}
@@ -12,7 +12,6 @@ import net.hostsharing.hsadminng.rbac.subject.RbacSubjectRepository;
import net.hostsharing.hsadminng.rbac.subject.RealSubjectEntity; import net.hostsharing.hsadminng.rbac.subject.RealSubjectEntity;
import net.hostsharing.hsadminng.rbac.test.ContextBasedTestWithCleanup; import net.hostsharing.hsadminng.rbac.test.ContextBasedTestWithCleanup;
import net.hostsharing.hsadminng.rbac.test.JpaAttempt; import net.hostsharing.hsadminng.rbac.test.JpaAttempt;
import org.hibernate.TransientObjectException;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -32,12 +31,11 @@ import java.util.Set;
import static net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationType.REPRESENTATIVE; import static net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationType.REPRESENTATIVE;
import static net.hostsharing.hsadminng.rbac.test.JpaAttempt.attempt; import static net.hostsharing.hsadminng.rbac.test.JpaAttempt.attempt;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.catchThrowable;
@DataJpaTest @DataJpaTest
@Tag("generalIntegrationTest") @Tag("generalIntegrationTest")
@Import({ Context.class, JpaAttempt.class }) @Import({ Context.class, JpaAttempt.class })
class HsProfileRepositoryIntegrationTest extends ContextBasedTestWithCleanup { class HsAccountRepositoryIntegrationTest extends ContextBasedTestWithCleanup {
private static final String SUPERUSER_ALEX_SUBJECT_NAME = "superuser-alex@hostsharing.net"; private static final String SUPERUSER_ALEX_SUBJECT_NAME = "superuser-alex@hostsharing.net";
private static final String SUPERUSER_FRAN_SUBJECT_NAME = "superuser-fran@hostsharing.net"; private static final String SUPERUSER_FRAN_SUBJECT_NAME = "superuser-fran@hostsharing.net";
@@ -58,10 +56,7 @@ class HsProfileRepositoryIntegrationTest extends ContextBasedTestWithCleanup {
private HsOfficePersonRealRepository personRepo; private HsOfficePersonRealRepository personRepo;
@Autowired @Autowired
private HsProfileRepository profileRepository; private HsAccountRepository accountRepository;
@Autowired
private HsProfileScopeRealRepository scopeRealRepo;
// fetched UUIDs from test-data // fetched UUIDs from test-data
private RealSubjectEntity alexSubject; private RealSubjectEntity alexSubject;
@@ -82,7 +77,7 @@ class HsProfileRepositoryIntegrationTest extends ContextBasedTestWithCleanup {
@Test @Test
public void historizationIsAvailable() { public void historizationIsAvailable() {
// given // given
final String nativeQuerySql = "select * from hs_accounts.profile_hv"; final String nativeQuerySql = "select * from hs_accounts.account_hv";
// when // when
historicalContext(Timestamp.from(ZonedDateTime.now().minusDays(1).toInstant())); historicalContext(Timestamp.from(ZonedDateTime.now().minusDays(1).toInstant()));
@@ -91,7 +86,7 @@ class HsProfileRepositoryIntegrationTest extends ContextBasedTestWithCleanup {
// then // then
assertThat(rowsBefore) assertThat(rowsBefore)
.as("hs_accounts.profile_hv only contain no rows for a timestamp before test data creation") .as("hs_accounts.account_hv only contain no rows for a timestamp before test data creation")
.hasSize(0); .hasSize(0);
// and when // and when
@@ -101,12 +96,12 @@ class HsProfileRepositoryIntegrationTest extends ContextBasedTestWithCleanup {
// then // then
assertThat(rowsAfter) assertThat(rowsAfter)
.as("hs_accounts.profile_hv should now contain the test-data rows for the current timestamp") .as("hs_accounts.account_hv should now contain the test-data rows for the current timestamp")
.hasSize(3); .hasSize(3);
} }
@Test @Test
void representativeShouldFindOwnAndRepresentedProfileByCurrentSubject() { void representativeShouldFindOwnAndRepresentedAccountByCurrentSubject() {
// given // given
final var firstGmbHPerson = givenPerson("First GmbH"); final var firstGmbHPerson = givenPerson("First GmbH");
givenRelation(REPRESENTATIVE) givenRelation(REPRESENTATIVE)
@@ -114,163 +109,76 @@ class HsProfileRepositoryIntegrationTest extends ContextBasedTestWithCleanup {
.withHolder(drewPerson) .withHolder(drewPerson)
.withContact("some test contact") .withContact("some test contact")
.inDatabase(); .inDatabase();
givenProfile() givenAccount()
.forSubject("first-gmbh") .forSubject("first-gmbh")
.forPerson(firstGmbHPerson) .forPerson(firstGmbHPerson)
.withEMailAddress("first-gmbh@example.com")
.inDatabase(); .inDatabase();
// when // when
final var foundProfile = attempt( final var foundAccount = attempt(
em, () -> { em, () -> {
context(USER_DREW_SUBJECT_NAME); context(USER_DREW_SUBJECT_NAME);
return profileRepository.findByCurrentSubject(); return accountRepository.findByCurrentSubject();
}) })
.assertNotNull().returnedValue(); .assertNotNull().returnedValue();
// then // then
assertThat(foundProfile).hasSize(2) assertThat(foundAccount).hasSize(2)
.map(HsProfileEntity::getEmailAddress) .map(e -> e.getSubject().getName())
.containsExactlyInAnyOrder("drew@example.org", "first-gmbh@example.com"); .containsExactlyInAnyOrder("drew@example.org", "first-gmbh@example.com");
} }
@Test @Test
void globalAdminShouldFindOnlyOwnProfileByCurrentSubject() { void globalAdminShouldFindOnlyOwnAccountByCurrentSubject() {
// when // when
final var foundProfile = attempt( final var foundAccount = attempt(
em, () -> { em, () -> {
context(SUPERUSER_FRAN_SUBJECT_NAME); context(SUPERUSER_FRAN_SUBJECT_NAME);
return profileRepository.findByCurrentSubject(); return accountRepository.findByCurrentSubject();
}) })
.assertNotNull().returnedValue(); .assertNotNull().returnedValue();
// then // then
assertThat(foundProfile).hasSize(1) assertThat(foundAccount).hasSize(1)
.map(HsProfileEntity::getEmailAddress) .map(e -> e.getSubject().getName())
.containsExactlyInAnyOrder("fran@example.com"); .containsExactlyInAnyOrder("fran@example.com");
} }
@Test @Test
void shouldFindByUuidUsingTestData() { void shouldFindByUuidUsingTestData() {
// when // when
final var foundEntityOptional = profileRepository.findByUuid(alexSubject.getUuid()); final var foundEntityOptional = accountRepository.findByUuid(alexSubject.getUuid());
// then // then
assertThat(foundEntityOptional).isPresent() assertThat(foundEntityOptional).isPresent()
.map(HsProfileEntity::getEmailAddress).contains("alex@example.com"); .map(e -> e.getSubject().getName())
.contains("alex@example.com");
} }
@Test @Test
void shouldSaveProfileWithExistingScope() { void shouldSaveAccount() {
// given // given
final var existingScope = scopeRealRepo.findByTypeAndQualifier("HSADMIN", "prod") final var newAccount = HsAccountEntity.builder()
.orElseThrow();
final var newProfile = HsProfileEntity.builder()
.subject(testUserSubject) .subject(testUserSubject)
.person(testUserPerson) .person(testUserPerson)
.active(true)
.emailAddress("test-user@example.com")
.globalUid(2011) .globalUid(2011)
.globalGid(2011) .globalGid(2011)
.scopes(mutableSetOf(existingScope))
.build(); .build();
// when // when
toCleanup(profileRepository.save(newProfile)); toCleanup(accountRepository.save(newAccount));
em.flush(); em.flush();
em.clear(); em.clear();
// then // then
final var foundEntityOptional = profileRepository.findByUuid(testUserSubject.getUuid()); final var foundEntityOptional = accountRepository.findByUuid(testUserSubject.getUuid());
assertThat(foundEntityOptional).isPresent(); assertThat(foundEntityOptional).isPresent();
final var foundEntity = foundEntityOptional.get(); final var foundEntity = foundEntityOptional.get();
assertThat(foundEntity.getEmailAddress()).isEqualTo("test-user@example.com");
assertThat(foundEntity.isActive()).isTrue();
assertThat(foundEntity.getVersion()).isEqualTo(0); // Initial version assertThat(foundEntity.getVersion()).isEqualTo(0); // Initial version
assertThat(foundEntity.getGlobalUid()).isEqualTo(2011); assertThat(foundEntity.getGlobalUid()).isEqualTo(2011);
assertThat(foundEntity.getScopes()).hasSize(1)
.map(HsProfileScopeRealEntity::toString).contains("scope(HSADMIN:prod:NP-ONLY:PUBLIC)");
} }
@Test
void shouldNotSaveProfileWithNewScope() {
// given
final var newScope = HsProfileScopeRealEntity.builder()
.type("MATRIX")
.qualifier("forbidden")
.build();
final var newProfile = HsProfileEntity.builder()
.subject(drewSubject)
.active(true)
.emailAddress("drew.new@example.com")
.globalUid(2001)
.globalGid(2001)
.scopes(mutableSetOf(newScope))
.build();
// when
final var exception = catchThrowable(() -> {
profileRepository.save(newProfile);
em.flush();
});
// then
assertThat(exception).isNotNull().hasCauseInstanceOf(TransientObjectException.class);
}
@Test
void shouldSaveNewProfileWithoutScope() {
// given
final var newProfile = HsProfileEntity.builder()
.subject(testUserSubject)
.person(testUserPerson)
.active(true)
.emailAddress("test.user.new@example.com")
.globalUid(20002)
.globalGid(2002)
.build();
// when
profileRepository.save(newProfile);
em.flush();
em.clear();
// then
final var foundEntityOptional = profileRepository.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.getScopes()).isEmpty();
}
@Test
void shouldUpdateExistingProfile() {
// given
final var entityToUpdate = profileRepository.findByUuid(alexSubject.getUuid()).orElseThrow();
final var initialVersion = entityToUpdate.getVersion();
// when
entityToUpdate.setActive(false);
entityToUpdate.setEmailAddress("updated.user1@example.com");
final var savedEntity = profileRepository.save(entityToUpdate);
em.flush();
em.clear();
// then
assertThat(savedEntity.getVersion()).isGreaterThan(initialVersion);
final var updatedEntityOptional = profileRepository.findByUuid(alexSubject.getUuid());
assertThat(updatedEntityOptional).isPresent();
final var updatedEntity = updatedEntityOptional.get();
assertThat(updatedEntity.isActive()).isFalse();
assertThat(updatedEntity.getEmailAddress()).isEqualTo("updated.user1@example.com");
}
private RealSubjectEntity fetchSubjectByName(final String name) { private RealSubjectEntity fetchSubjectByName(final String name) {
final String jpql = "SELECT s FROM RealSubjectEntity s WHERE s.name = :name"; final String jpql = "SELECT s FROM RealSubjectEntity s WHERE s.name = :name";
final Query query = em.createQuery(jpql, RealSubjectEntity.class); final Query query = em.createQuery(jpql, RealSubjectEntity.class);
@@ -315,8 +223,8 @@ class HsProfileRepositoryIntegrationTest extends ContextBasedTestWithCleanup {
return new RelationBuilder(relationType); return new RelationBuilder(relationType);
} }
private ProfileBuilder givenProfile() { private AccountBuilder givenAccount() {
return new ProfileBuilder(); return new AccountBuilder();
} }
private class RelationBuilder { private class RelationBuilder {
@@ -361,12 +269,11 @@ class HsProfileRepositoryIntegrationTest extends ContextBasedTestWithCleanup {
} }
} }
private class ProfileBuilder { private class AccountBuilder {
private RealSubjectEntity subject; private RealSubjectEntity subject;
private HsOfficePersonRealEntity person; private HsOfficePersonRealEntity person;
private String emailAddress;
public ProfileBuilder forSubject(String subjectName) { public AccountBuilder forSubject(String subjectName) {
// only the RbacSubject can be created // only the RbacSubject can be created
val rbacSubject = toCleanup(rbacSubjectRepo.create(RbacSubjectEntity.builder() val rbacSubject = toCleanup(rbacSubjectRepo.create(RbacSubjectEntity.builder()
.name(subjectName) .name(subjectName)
@@ -376,36 +283,22 @@ class HsProfileRepositoryIntegrationTest extends ContextBasedTestWithCleanup {
return this; return this;
} }
public ProfileBuilder forPerson(HsOfficePersonRealEntity person) { public AccountBuilder forPerson(HsOfficePersonRealEntity person) {
this.person = person; this.person = person;
return this; return this;
} }
public ProfileBuilder withEMailAddress(String emailAddress) { public HsAccountEntity inDatabase() {
this.emailAddress = emailAddress;
final var profile = HsProfileEntity.builder() final var account = HsAccountEntity.builder()
.uuid(subject.getUuid()) .uuid(subject.getUuid())
.subject(subject) .subject(subject)
.person(em.find(HsOfficePersonRealEntity.class, person.getUuid())) .person(em.find(HsOfficePersonRealEntity.class, person.getUuid()))
.emailAddress(emailAddress)
.active(true)
.build(); .build();
return this; em.persist(account);
} toCleanup(account);
public HsProfileEntity inDatabase() {
final var profile = HsProfileEntity.builder()
.uuid(subject.getUuid())
.subject(subject)
.person(em.find(HsOfficePersonRealEntity.class, person.getUuid()))
.emailAddress(emailAddress)
.active(true)
.build();
em.persist(profile);
toCleanup(profile);
em.flush(); em.flush();
return profile; return account;
} }
} }
} }
@@ -1,488 +0,0 @@
package net.hostsharing.hsadminng.hs.accounts;
import io.restassured.RestAssured;
import io.restassured.http.ContentType;
import lombok.val;
import net.hostsharing.hsadminng.rbac.context.Context;
import net.hostsharing.hsadminng.hs.accounts.HsProfileEntity.HsProfileEntityBuilder;
import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRealEntity;
import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRealRepository;
import net.hostsharing.hsadminng.rbac.subject.RbacSubjectEntity;
import net.hostsharing.hsadminng.rbac.subject.RbacSubjectRepository;
import net.hostsharing.hsadminng.rbac.subject.RealSubjectEntity;
import net.hostsharing.hsadminng.rbac.test.ContextBasedTestWithCleanup;
import net.hostsharing.hsadminng.rbac.test.JpaAttempt;
import org.jetbrains.annotations.NotNull;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.transaction.annotation.Transactional;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import java.util.HashSet;
import java.util.Set;
import java.util.function.Consumer;
import static net.hostsharing.hsadminng.config.JwtFakeBearer.bearer;
import static net.hostsharing.hsadminng.hs.office.person.HsOfficePersonType.LEGAL_PERSON;
import static net.hostsharing.hsadminng.hs.office.person.HsOfficePersonType.NATURAL_PERSON;
import static net.hostsharing.hsadminng.test.JsonMatcher.lenientlyEquals;
import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.equalTo;
@Tag("generalIntegrationTest")
@Transactional
@SpringBootTest(
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT
)
@ActiveProfiles("fake-jwt")
// too complex database interaction for just a RestTest, thus a fully integrated test
class HsProfileControllerAcceptanceTest extends ContextBasedTestWithCleanup {
@LocalServerPort
Integer port;
@Autowired
Context context;
@Autowired
RbacSubjectRepository rbacSubjectRepo;
@Autowired
HsOfficePersonRealRepository realPersonRepo;
@Autowired
HsProfileScopeRealRepository scopeRepo;
@Autowired
HsProfileRepository profileRepo;
@Autowired
HsProfileScopeRbacRepository scopeRbacRepo;
@Autowired
JpaAttempt jpaAttempt;
@PersistenceContext
EntityManager em;
@BeforeEach
void setUp() {
context.define("superuser-alex@hostsharing.net");
}
@Nested
class GetCurrentUser {
@Test
void shouldFetchCurrentLoginUser() throws Exception {
// given
context.define("superuser-alex@hostsharing.net");
RestAssured // @formatter:off
.given()
.header("Authorization", bearer("superuser-alex@hostsharing.net"))
.port(port)
.when()
.get("http://localhost/api/hs/accounts/current")
.then().log().all().assertThat()
.statusCode(200)
.contentType("application/json")
.body("subject.name", equalTo("superuser-alex@hostsharing.net"))
.body("globalAdmin", equalTo(true));
// @formatter:on
}
}
@Nested
class GetProfileByUuid {
@Test
void shouldFilterInvalidScopesRegardingNonNaturalPerson() {
// given
val legalPerson = givenLegalPerson("selfregistered-user-drew@hostsharing.org");
val profileEntity = givenNewProfile("selfregistered-user-drew@hostsharing.org",
"test-subject1", legalPerson, builder -> {
builder.scopes(new HashSet<>(scopeRepo.findAll()));
});
RestAssured // @formatter:off
.given()
.header("Authorization", bearer(profileEntity.getSubject().getName()))
.port(port)
.when()
.get("http://localhost/api/hs/accounts/profiles/" + profileEntity.getUuid())
.then().log().all().assertThat()
.statusCode(200)
.contentType("application/json")
.body("$", lenientlyEquals("""
{
"person": {
"personType": "LEGAL_PERSON",
"tradeName": "Test Company",
"salutation": null,
"title": null,
"givenName": null,
"familyName": null
},
"nickname": "test-subject1",
"totpSecrets": null,
"phonePassword": null,
"emailAddress": null,
"smsNumber": null,
"active": false,
"globalUid": null,
"globalGid": null,
"scopes": [
{
"uuid": "33333333-3333-3333-3333-333333333333",
"type": "SSH",
"qualifier": "external",
"onlyForNaturalPersons": false,
"publicAccess": true
},
{
"uuid": "66666666-6666-6666-6666-666666666666",
"type": "MASTODON",
"qualifier": "external",
"onlyForNaturalPersons": false,
"publicAccess": true
},
{
"uuid": "77777777-7777-7777-7777-777777777777",
"type": "BBB",
"qualifier": "external",
"onlyForNaturalPersons": false,
"publicAccess": true
}
]
}
"""));
// @formatter:on
}
}
@Nested
class PostNewProfile {
@Test
void shouldRejectCreatingProfileForUnrepresentedPerson() {
// given
val testPerson = givenPersonWithUuid("selfregistered-user-drew@hostsharing.org");
val publicScope = scopeRepo.findByTypeAndQualifier("SSH", "external").orElseThrow();
assertThat(publicScope.isPublicAccess()).as("precondition failed").isTrue();
RestAssured // @formatter:off
.given()
.header("Authorization", bearer("selfregistered-user-drew@hostsharing.org"))
.header("Accept-Language", "de")
.contentType(ContentType.JSON)
.body("""
{
"person.uuid": "%s",
"nickname": "new-user",
"active": true,
"globalUid": 30001,
"globalGid": 40001,
"scopes": [
{
"uuid" : "%s"
}
]
}
""".formatted(testPerson.getUuid(), publicScope.getUuid()))
.port(port)
.when()
.post("http://localhost/api/hs/accounts/profiles")
.then().log().all().assertThat()
.statusCode(403)
.contentType("application/json")
.body("message", containsString("wird von der eingeloggten Person nicht repräsentiert"));
// @formatter:on
}
@Test
void shouldRejectCreatingProfileWithPrivateScopeForNormalUser() {
// given
val drewPerson = realPersonRepo.findPersonByOptionalNameLike("Drew").getFirst();
val privateInternalSshScope = scopeRepo.findByTypeAndQualifier("SSH", "internal")
.map(HsProfileControllerAcceptanceTest::asPrivateScope).orElseThrow();
val privateInternalMatrixScope = scopeRepo.findByTypeAndQualifier("MATRIX", "internal")
.map(HsProfileControllerAcceptanceTest::asPrivateScope).orElseThrow();
val publicExternalMatrixScope = scopeRepo.findByTypeAndQualifier("MATRIX", "external")
.map(HsProfileControllerAcceptanceTest::asPublicScope).orElseThrow();
RestAssured // @formatter:off
.given()
.header("Authorization", bearer("selfregistered-user-drew@hostsharing.org"))
.header("Accept-Language", "de")
.contentType(ContentType.JSON)
.body("""
{
"person.uuid": "%s",
"nickname": "new-user",
"active": true,
"globalUid": 30001,
"globalGid": 40001,
"scopes": [
{ "uuid" : "%s" },
{ "uuid" : "%s" },
{ "uuid" : "%s" }
]
}
""".formatted(
drewPerson.getUuid(),
publicExternalMatrixScope.getUuid(),
privateInternalSshScope.getUuid(),
privateInternalMatrixScope.getUuid()))
.port(port)
.when()
.post("http://localhost/api/hs/accounts/profiles")
.then().log().all().assertThat()
.statusCode(403)
.contentType("application/json")
.body("message", containsString("Zugriff auf Geltungsbereich verweigert: 'MATRIX:internal', 'SSH:internal'"));
// @formatter:on
}
@Test
void shouldRejectCreatingProfileWithNaturalPersonRequirementForNonNaturalPerson() {
// given
val firstGmbHPerson = realPersonRepo.findPersonByOptionalNameLike("First").getFirst();
val hsadminProdScopeOnlyForNaturalPersons = scopeRepo.findByTypeAndQualifier("HSADMIN", "prod")
.map(HsProfileControllerAcceptanceTest::asNaturalPersonScope).orElseThrow();
RestAssured // @formatter:off
.given()
.header("Authorization", bearer("superuser-alex@hostsharing.net"))
.header("Accept-Language", "de")
.contentType(ContentType.JSON)
.body("""
{
"person.uuid": "%s",
"nickname": "new-user",
"active": true,
"globalUid": 30001,
"globalGid": 40001,
"scopes": [
{ "uuid" : "%s" }
]
}
""".formatted(
firstGmbHPerson.getUuid(),
hsadminProdScopeOnlyForNaturalPersons.getUuid()))
.port(port)
.when()
.post("http://localhost/api/hs/accounts/profiles")
.then().log().all().assertThat()
.statusCode(400)
.contentType("application/json")
.body("message", containsString("Geltungsbereich verlangt eine natürliche Person: 'HSADMIN:prod'"));
// @formatter:on
}
}
@Nested
class PatchProfile {
@Test
void shouldRejectPatchingProfileWithPrivateScopeForNormalUser() {
// given
context.define("selfregistered-user-drew@hostsharing.org");
val drewProfileUuid = profileRepo.findByCurrentSubject().stream().findFirst().orElseThrow()
.getSubject().getUuid();
val privateInternalSshScope = scopeRepo.findByTypeAndQualifier("SSH", "internal")
.map(HsProfileControllerAcceptanceTest::asPrivateScope).orElseThrow();
val privateInternalMatrixScope = scopeRepo.findByTypeAndQualifier("MATRIX", "internal")
.map(HsProfileControllerAcceptanceTest::asPrivateScope).orElseThrow();
val publicExternalMatrixScope = scopeRepo.findByTypeAndQualifier("MATRIX", "external")
.map(HsProfileControllerAcceptanceTest::asPublicScope).orElseThrow();
RestAssured // @formatter:off
.given()
.header("Authorization", bearer("selfregistered-user-drew@hostsharing.org"))
.header("Accept-Language", "de")
.contentType(ContentType.JSON)
.body("""
{
"scopes": [
{ "uuid" : "%s" },
{ "uuid" : "%s" },
{ "uuid" : "%s" }
]
}
""".formatted(
privateInternalSshScope.getUuid(),
publicExternalMatrixScope.getUuid(),
privateInternalMatrixScope.getUuid()))
.port(port)
.when()
.patch("http://localhost/api/hs/accounts/profiles/" + drewProfileUuid)
.then().log().all().assertThat()
.statusCode(403)
.contentType("application/json")
.body("message", containsString("Zugriff auf Geltungsbereich verweigert: 'MATRIX:internal', 'SSH:internal'"));
// @formatter:on
}
@Test
void shouldRejectPatchingProfileAndRemovingTheOwnHsadminProfile() {
// given
context.define("selfregistered-user-drew@hostsharing.org");
val drewProfileUuid = profileRepo.findByCurrentSubject().stream().findFirst().orElseThrow()
.getSubject().getUuid();
val publicExternalMatrixScope = scopeRepo.findByTypeAndQualifier("MATRIX", "external")
.map(HsProfileControllerAcceptanceTest::asPublicScope).orElseThrow();
RestAssured // @formatter:off
.given()
.header("Authorization", bearer("selfregistered-user-drew@hostsharing.org"))
.header("Accept-Language", "de")
.contentType(ContentType.JSON)
.body("""
{
"scopes": [
{ "uuid" : "%s" }
]
}
""".formatted(publicExternalMatrixScope.getUuid()))
.port(port)
.when()
.patch("http://localhost/api/hs/accounts/profiles/" + drewProfileUuid)
.then().log().all().assertThat()
.statusCode(400)
.contentType("application/json")
.body("message", containsString("die eigenen hsadmin-Profile dürfen nicht entfernt werden"));
// @formatter:on
}
@Test
void shouldRejectActivatingProfileForNormalUser() {
// given
context.define("selfregistered-user-drew@hostsharing.org");
val drewProfile = profileRepo.findByCurrentSubject().stream().findFirst().orElseThrow();
val inactiveProfileUuid = createNewInactiveProfile(drewProfile.getPerson()).getSubject().getUuid();
RestAssured // @formatter:off
.given()
.header("Authorization", bearer("selfregistered-user-drew@hostsharing.org"))
.header("Accept-Language", "de")
.contentType(ContentType.JSON)
.body("""
{
"active": true
}
""")
.port(port)
.when()
.patch("http://localhost/api/hs/accounts/profiles/" + inactiveProfileUuid)
.then().log().all().assertThat()
.statusCode(403)
.contentType("application/json")
.body("message", containsString("Only global admins are allowed to activate an inactive profile"));
// @formatter:on
}
}
// Helper methods
private HsProfileEntity createNewInactiveProfile(final HsOfficePersonRealEntity person) {
return jpaAttempt.transacted(() -> {
context.define("superuser-alex@hostsharing.net");
// only RbacSubject entities can be created
val rbacSubjectEntity = rbacSubjectRepo.create(RbacSubjectEntity.builder()
.name("some-inactive-profile")
.build());
// but we need the RealSubjectEntity to be attached to the profile entity
val realSubjectEntity = em.find(RealSubjectEntity.class, rbacSubjectEntity.getUuid());
val inactiveCopy = HsProfileEntity.builder()
.person(person)
.subject(realSubjectEntity)
.active(false).build();
em.persist(inactiveCopy);
em.flush();
return toCleanup(inactiveCopy);
}).assertSuccessful().returnedValue();
}
private HsOfficePersonRealEntity givenLegalPerson(final String executingSubjectName) {
return jpaAttempt.transacted(() -> {
context.define(executingSubjectName);
return toCleanup(realPersonRepo.save(HsOfficePersonRealEntity.builder()
.personType(LEGAL_PERSON)
.tradeName("Test Company")
.build()));
}).assertSuccessful().returnedValue();
}
private HsOfficePersonRealEntity givenNaturalPerson(final String executingSubjectName) {
return jpaAttempt.transacted(() -> {
context.define(executingSubjectName);
return toCleanup(realPersonRepo.save(HsOfficePersonRealEntity.builder()
.personType(NATURAL_PERSON)
.familyName("Test")
.givenName("User")
.build()));
}).assertSuccessful().returnedValue();
}
private HsOfficePersonRealEntity givenPersonWithUuid(final String executingSubjectName) {
return jpaAttempt.transacted(() -> {
context.define(executingSubjectName);
return toCleanup(realPersonRepo.save(HsOfficePersonRealEntity.builder()
.personType(NATURAL_PERSON)
.familyName("Test")
.givenName("Person")
.build()));
}).returnedValue();
}
private static HsProfileScopeRealEntity asNaturalPersonScope(@NotNull HsProfileScopeRealEntity scope) {
assertThat(scope.isOnlyForNaturalPersons()).as("precondition failed").isTrue();
return scope;
}
private static HsProfileScopeRealEntity asPrivateScope(@NotNull HsProfileScopeRealEntity scope) {
assertThat(scope.isPublicAccess()).as("precondition failed").isFalse();
return scope;
}
private static HsProfileScopeRealEntity asPublicScope(@NotNull HsProfileScopeRealEntity scope) {
assertThat(scope.isPublicAccess()).as("precondition failed").isTrue();
return scope;
}
private HsProfileEntity givenNewProfile(
final String executingSubjectName,
final String newSubjectName, final HsOfficePersonRealEntity person,
final Consumer<HsProfileEntityBuilder> modifier
) {
return jpaAttempt.transacted(() -> {
context.define(executingSubjectName);
// only RbacSubject entities can be created
val subject = rbacSubjectRepo.create(RbacSubjectEntity.builder()
.name(newSubjectName)
.build());
context.define(subject.getName());
val attachedPerson = em.find(HsOfficePersonRealEntity.class, person.getUuid());
val profileBuilder = HsProfileEntity.builder()
.person(attachedPerson)
.subject(em.find(RealSubjectEntity.class, subject.getUuid()))
.scopes(Set.of());
modifier.accept(profileBuilder);
return toCleanup(profileRepo.save(profileBuilder.build()));
}).assertSuccessful().returnedValue();
}
}
@@ -1,167 +0,0 @@
package net.hostsharing.hsadminng.hs.accounts;
import lombok.val;
import net.hostsharing.hsadminng.config.MessageTranslator;
import net.hostsharing.hsadminng.accounts.generated.api.v1.model.ScopeResource;
import net.hostsharing.hsadminng.accounts.generated.api.v1.model.ProfilePatchResource;
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;
import static org.mockito.Mockito.mock;
@TestInstance(PER_CLASS)
@ExtendWith(MockitoExtension.class)
class HsProfileEntityPatcherUnitTest extends PatchUnitTestBase<
ProfilePatchResource,
HsProfileEntity
> {
private static final UUID INITIAL_PROFILE_UUID = UUID.randomUUID();
private static final Boolean INITIAL_ACTIVE = true;
private static final String INITIAL_EMAIL_ADDRESS = "initial@example.com";
private static final List<String> INITIAL_TOTP_SECRETS = List.of("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 List<String> PATCHED_TOTP_SECRETS = List.of("patched_2fa");
private static final String PATCHED_SMS_NUMBER = "patched_sms";
private static final String PATCHED_PHONE_PASSWORD = "patched_phone_pw";
// Scopes
private static final UUID SCOPE_UUID_1 = UUID.randomUUID();
private static final UUID SCOPE_UUID_2 = UUID.randomUUID();
private static final UUID SCOPE_UUID_3 = UUID.randomUUID();
private final HsProfileScopeRealEntity initialScopeEntity1 = HsProfileScopeRealEntity.builder()
.uuid(SCOPE_UUID_1)
.type("HSADMIN")
.qualifier("prod")
.build();
private final HsProfileScopeRealEntity initialScopeEntity2 = HsProfileScopeRealEntity.builder()
.uuid(SCOPE_UUID_2)
.type("SSH")
.qualifier("dev")
.build();
// This is what em.find should return for SCOPE_UUID_3
private final HsProfileScopeRealEntity newScopeEntity3 = HsProfileScopeRealEntity.builder()
.uuid(SCOPE_UUID_3)
.type("HSADMIN")
.qualifier("test")
.build();
private final Set<HsProfileScopeRealEntity> initialScopeEntities = Set.of(initialScopeEntity1, initialScopeEntity2);
private List<ScopeResource> patchedScopeResources;
private final Set<HsProfileScopeRealEntity> expectedPatchedScopeEntities = Set.of(initialScopeEntity2,
newScopeEntity3);
@Mock
private EntityManager em;
@BeforeEach
void initMocks() {
// Mock em.find for scopes that are part of the patch and need to be fetched
lenient().when(em.find(eq(HsProfileScopeRealEntity.class), eq(SCOPE_UUID_1))).thenReturn(initialScopeEntity1);
lenient().when(em.find(eq(HsProfileScopeRealEntity.class), eq(SCOPE_UUID_2))).thenReturn(initialScopeEntity2);
lenient().when(em.find(eq(HsProfileScopeRealEntity.class), eq(SCOPE_UUID_3))).thenReturn(newScopeEntity3);
val patchScopeResource2 = new ScopeResource();
patchScopeResource2.setUuid(SCOPE_UUID_2);
patchScopeResource2.setType("SSH");
patchScopeResource2.setQualifier("dev");
val patchScopeResource3 = new ScopeResource();
patchScopeResource3.setUuid(SCOPE_UUID_3);
patchScopeResource3.setType("HSADMIN");
patchScopeResource3.setQualifier("test");
patchedScopeResources = List.of(patchScopeResource2, patchScopeResource3);
}
@Override
protected HsProfileEntity newInitialEntity() {
final var entity = new HsProfileEntity();
entity.setUuid(INITIAL_PROFILE_UUID);
entity.setActive(INITIAL_ACTIVE);
entity.setEmailAddress(INITIAL_EMAIL_ADDRESS);
entity.setTotpSecrets(INITIAL_TOTP_SECRETS);
entity.setSmsNumber(INITIAL_SMS_NUMBER);
entity.setPhonePassword(INITIAL_PHONE_PASSWORD);
// Ensure scopes is a mutable set for the patcher
entity.setScopes(new HashSet<>(initialScopeEntities));
return entity;
}
@Override
protected ProfilePatchResource newPatchResource() {
return new ProfilePatchResource();
}
@Override
protected HsProfileEntityPatcher createPatcher(final HsProfileEntity entity) {
final var scopeMapper = new ScopeResourceToEntityMapper(em, mock(MessageTranslator.class));
return new HsProfileEntityPatcher(scopeMapper, entity);
}
@Override
protected Stream<Property> propertyTestDescriptors() {
return Stream.of(
new SimpleProperty<>(
"active",
ProfilePatchResource::setActive,
PATCHED_ACTIVE,
HsProfileEntity::setActive,
PATCHED_ACTIVE)
.notNullable(),
new JsonNullableProperty<>(
"emailAddress",
ProfilePatchResource::setEmailAddress,
PATCHED_EMAIL_ADDRESS,
HsProfileEntity::setEmailAddress,
PATCHED_EMAIL_ADDRESS),
new SimpleProperty<>(
"totpSecret",
ProfilePatchResource::setTotpSecrets,
PATCHED_TOTP_SECRETS,
HsProfileEntity::setTotpSecrets,
PATCHED_TOTP_SECRETS)
.notNullable(),
new JsonNullableProperty<>(
"smsNumber",
ProfilePatchResource::setSmsNumber,
PATCHED_SMS_NUMBER,
HsProfileEntity::setSmsNumber,
PATCHED_SMS_NUMBER),
new JsonNullableProperty<>(
"phonePassword",
ProfilePatchResource::setPhonePassword,
PATCHED_PHONE_PASSWORD,
HsProfileEntity::setPhonePassword,
PATCHED_PHONE_PASSWORD),
new SimpleProperty<>(
"scopes",
ProfilePatchResource::setScopes,
patchedScopeResources,
HsProfileEntity::setScopes,
expectedPatchedScopeEntities)
.notNullable()
);
}
}
@@ -1,79 +0,0 @@
package net.hostsharing.hsadminng.hs.accounts;
import lombok.val;
import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRealEntity;
import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonType;
import net.hostsharing.hsadminng.rbac.subject.RealSubjectEntity;
import org.junit.jupiter.api.Test;
import jakarta.validation.ValidationException;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.*;
class HsProfileEntityUnitTest {
static final HsProfileEntity GIVEN_PROFILE_ENTITY = HsProfileEntity.builder()
.uuid(UUID.fromString("11111111-1111-1111-1111-111111111111"))
.subject(
RealSubjectEntity.builder().uuid(UUID.randomUUID()).name("testSubject").build())
.person(
HsOfficePersonRealEntity.builder()
.personType(HsOfficePersonType.NATURAL_PERSON)
.familyName("Miller")
.givenName("John")
.build()
)
.emailAddress("john.miller@example.com")
.smsNumber("+49 1234567890")
.globalUid(10001)
.globalUid(20002)
.phonePassword("hello world")
.totpSecrets(List.of("secret1", "secret2"))
.active(true)
.build();
@Test
void toShortStringContainsJustTypeAndQualifier() {
assertThat(GIVEN_PROFILE_ENTITY.toShortString()).isEqualTo("true:john.miller@example.com:20002");
}
@Test
void toStringContainsAllPropertiesExceptUuidAndPasswordHash() {
assertThat(GIVEN_PROFILE_ENTITY.toString()).isEqualTo("profile(true, john.miller@example.com, [secret1, secret2], hello world, +49 1234567890)");
}
@Test
void setPasswordSetsPasswordHash() {
val profile = HsProfileEntity.builder().build();
profile.setPassword("my password");
assertThat(profile.getPasswordHash()).startsWith("{SSHA}");
}
@Test
void acceptsValidSshaPasswordHash() {
val givenSshaHash = "{SSHA}SNBnIh5QomfgrvDLDwBR+JOcc8Y17H+4";
val profile = HsProfileEntity.builder().build();
profile.setPasswordHash(givenSshaHash);
assertThat(profile.getPasswordHash()).isEqualTo(givenSshaHash);
}
@Test
void acceptsValidArgon2PasswordHash() {
val givenArgon2Hash = "{ARGON2}$argon2id$v=19$m=65536,t=3,p=1$pEabRksh7EJQV+OwPR5n7Q$83qQtZe2J8+fteWm7g/uvXksfhJKGsipZFsuAaJtBjs";
val profile = HsProfileEntity.builder().build();
profile.setPasswordHash(givenArgon2Hash);
assertThat(profile.getPasswordHash()).isEqualTo(givenArgon2Hash);
}
@Test
void rejectInvalidPasswordHash() {
val profile = HsProfileEntity.builder().build();
val throwable = assertThrows(
ValidationException.class,
() -> profile.setPasswordHash("{whatever} but not a valid hash"));
assertThat(throwable.getMessage()).isEqualTo("passwordHash must be SSHA or ARGON2 hash valid for LDAP");
}
}
@@ -1,210 +0,0 @@
package net.hostsharing.hsadminng.hs.accounts;
import static java.util.Collections.emptyList;
import static net.hostsharing.hsadminng.config.JwtFakeBearer.bearer;
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.WebSecurityConfigForWebMvcTests;
import net.hostsharing.hsadminng.config.JsonObjectMapperConfiguration;
import net.hostsharing.hsadminng.config.MessageTranslator;
import net.hostsharing.hsadminng.rbac.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(HsProfileScopeController.class)
@Import({ StrictMapper.class,
MessageTranslator.class,
JsonObjectMapperConfiguration.class,
WebSecurityConfigForWebMvcTests.class })
@ActiveProfiles({"fake-jwt", "test"})
class HsProfileScopeControllerRestTest {
@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
HsProfileScopeRbacRepository scopeRbacRepo;
@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 getListOfScopesReturnsOkWithEmptyList() throws Exception {
// given
givenNoScopesInTheRepository();
// when
mockMvc.perform(MockMvcRequestBuilders
.get("/api/hs/accounts/scopes")
.header("Authorization", bearer("superuser-alex@hostsharing.net"))
.accept(MediaType.APPLICATION_JSON))
.andDo(print())
// then
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray())
.andExpect(jsonPath("$").isEmpty());
}
@Test
void getListOfScopesReturnsAllScopesForGlobalAdmin() throws Exception {
// given
givenSomeScopesInTheRepository();
when(contextMock.isGlobalAdmin()).thenReturn(true);
// when
mockMvc.perform(MockMvcRequestBuilders
.get("/api/hs/accounts/scopes")
.header("Authorization", bearer("Bearer superuser-alex@hostsharing.net"))
.accept(MediaType.APPLICATION_JSON))
.andDo(print())
// then
.andExpect(status().isOk())
.andExpect(jsonPath(
"$", lenientlyEquals("""
[
{
"type": "HSADMIN",
"qualifier": "prod",
"onlyForNaturalPersons": true,
"publicAccess": true
},
{
"type": "SSH",
"qualifier": "public",
"onlyForNaturalPersons": false,
"publicAccess": true
},
{
"type": "SSH",
"qualifier": "internal",
"onlyForNaturalPersons": false,
"publicAccess": false
}
]
"""
)));
}
@Test
void getListOfScopesReturnsOnlyPublicScopesForNormalUser() throws Exception {
// given
givenSomeScopesInTheRepository();
when(contextMock.isGlobalAdmin()).thenReturn(false);
// when
mockMvc.perform(MockMvcRequestBuilders
.get("/api/hs/accounts/scopes")
.header("Authorization", bearer("drew@hostsharing.org"))
.accept(MediaType.APPLICATION_JSON))
.andDo(print())
// then
.andExpect(status().isOk())
.andExpect(jsonPath(
"$", lenientlyEquals("""
[
{
"type": "HSADMIN",
"qualifier": "prod",
"onlyForNaturalPersons": true,
"publicAccess": true
},
{
"type": "SSH",
"qualifier": "public",
"onlyForNaturalPersons": false,
"publicAccess": true
}
]
"""
)));
}
private void givenNoScopesInTheRepository() {
when(scopeRbacRepo.findAll()).thenReturn(emptyList());
}
private void givenSomeScopesInTheRepository() {
when(scopeRbacRepo.findAll()).thenReturn(List.of(
HsProfileScopeRbacEntity.builder()
.uuid(UUID.randomUUID())
.type("HSADMIN")
.qualifier("prod")
.publicAccess(true)
.onlyForNaturalPersons(true)
.build(),
HsProfileScopeRbacEntity.builder()
.uuid(UUID.randomUUID())
.type("SSH")
.qualifier("public")
.publicAccess(true)
.onlyForNaturalPersons(false)
.build(),
HsProfileScopeRbacEntity.builder()
.uuid(UUID.randomUUID())
.type("SSH")
.qualifier("internal")
.publicAccess(false)
.onlyForNaturalPersons(false)
.build()
));
}
}
@@ -1,32 +0,0 @@
package net.hostsharing.hsadminng.hs.accounts;
import org.junit.jupiter.api.Test;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.assertEquals;
class HsProfileScopeRbacEntityUnitTest {
@Test
void toShortStringContainsJustTypeAndQualifier() {
final var entity = HsProfileScopeRbacEntity.builder()
.uuid(UUID.randomUUID())
.type("SSH")
.qualifier("prod")
.publicAccess(true)
.build();
assertEquals("SSH:prod", entity.toShortString());
}
@Test
void toStringContainsAllNonNullFields() {
final var entity = HsProfileScopeRbacEntity.builder()
.uuid(UUID.randomUUID())
.type("SSH")
.qualifier("prod")
.publicAccess(true)
.build();
assertEquals("scope(SSH:prod:PUBLIC)", entity.toString());
}
}
@@ -1,167 +0,0 @@
package net.hostsharing.hsadminng.hs.accounts;
import net.hostsharing.hsadminng.rbac.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 org.springframework.transaction.annotation.Transactional;
import jakarta.persistence.PersistenceException;
import jakarta.servlet.http.HttpServletRequest;
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 HsProfileScopeRbacRepositoryIntegrationTest extends ContextBasedTest {
// existing UUIDs from test data (Liquibase changeset 310-login-profile-test-data.sql)
private static final UUID TEST_SCOPE_HSADMIN_PROD_UUID = UUID.fromString("11111111-1111-1111-1111-111111111111");
private static final UUID TEST_SCOPE_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 HsProfileScopeRbacRepository scopesRepository;
@Test
void shouldFindAllByNormalUserUsingTestData() {
context(TEST_USER_SUBJECT_NAME);
// when
final var allScopes = scopesRepository.findAll();
// then
assertThat(allScopes)
.isNotNull()
.hasSizeGreaterThanOrEqualTo(1) // Expect at least the 1 public context from assumed test data
.extracting(HsProfileScope::getUuid)
.contains(TEST_SCOPE_HSADMIN_PROD_UUID);
}
@Test
void shouldFindAllByAdminUserUsingTestData() {
context(SUPERUSER_ALEX_SUBJECT_NAME);
// when
final var allScopes = scopesRepository.findAll();
// then
assertThat(allScopes)
.isNotNull()
.hasSizeGreaterThanOrEqualTo(3); // Expect at least the 1 public scope from assumed test data
}
@Test
void shouldFindByUuidUsingTestData() {
context(TEST_USER_SUBJECT_NAME);
// when
final var foundEntityOptional = scopesRepository.findByUuid(TEST_SCOPE_HSADMIN_PROD_UUID);
// then
assertThat(foundEntityOptional).isPresent();
assertThat(foundEntityOptional).map(Object::toString).contains("scope(HSADMIN:prod:NP-ONLY:PUBLIC)");
}
@Test
void shouldFindByTypeAndQualifierUsingTestData() {
context(SUPERUSER_ALEX_SUBJECT_NAME);
// when
final var foundEntityOptional = scopesRepository.findByTypeAndQualifier("SSH", "internal");
// then
assertThat(foundEntityOptional).isPresent();
assertThat(foundEntityOptional).map(Object::toString).contains("scope(SSH:internal:NP-ONLY:INTERNAL)");
}
@Test
void shouldReturnEmptyOptionalWhenFindByTypeAndQualifierNotFound() {
context(SUPERUSER_ALEX_SUBJECT_NAME);
// given
final var nonExistentQualifier = "non-existent-qualifier";
// when
final var foundEntityOptional = scopesRepository.findByTypeAndQualifier(
"HSADMIN", nonExistentQualifier);
// then
assertThat(foundEntityOptional).isNotPresent();
}
@Test
void shouldSaveNewScope() {
context(SUPERUSER_ALEX_SUBJECT_NAME);
// given
final var newQualifier = "test@example.social";
final var newType = "MASTODON";
final var newScope = HsProfileScopeRbacEntity.builder()
.type(newType)
.qualifier(newQualifier)
.build();
// when
final var savedEntity = scopesRepository.save(newScope);
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 = scopesRepository.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 shouldPreventUpdateOfExistingScope() {
context(SUPERUSER_ALEX_SUBJECT_NAME);
// given an existing entity from test data
final var entityToUpdateOptional = scopesRepository.findByUuid(TEST_SCOPE_MATRIX_INTERNAL_UUID);
assertThat(entityToUpdateOptional)
.withFailMessage("Could not find existing scope with UUID %s. Ensure test data exists.",
TEST_SCOPE_MATRIX_INTERNAL_UUID)
.isPresent();
final var entityToUpdate = entityToUpdateOptional.get();
// when
entityToUpdate.setQualifier("updated");
final var exception = catchThrowable( () -> {
scopesRepository.save(entityToUpdate);
em.flush();
});
// then
assertThat(exception)
.isInstanceOf(PersistenceException.class)
.hasCauseInstanceOf(PSQLException.class)
.hasMessageContaining("ERROR: Updates to hs_accounts.scope are not allowed.");
}
}
@@ -1,32 +0,0 @@
package net.hostsharing.hsadminng.hs.accounts;
import org.junit.jupiter.api.Test;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.assertEquals;
class HsProfileScopeRealEntityUnitTest {
@Test
void toShortStringContainsJustTypeAndQualifier() {
final var entity = HsProfileScopeRealEntity.builder()
.uuid(UUID.randomUUID())
.type("SSH")
.qualifier("prod")
.publicAccess(true)
.build();
assertEquals("SSH:prod", entity.toShortString());
}
@Test
void toStringContainsAllNonNullFields() {
final var entity = HsProfileScopeRealEntity.builder()
.uuid(UUID.randomUUID())
.type("SSH")
.qualifier("prod")
.publicAccess(true)
.build();
assertEquals("scope(SSH:prod:PUBLIC)", entity.toString());
}
}
@@ -1,181 +0,0 @@
package net.hostsharing.hsadminng.hs.accounts;
import net.hostsharing.hsadminng.rbac.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 HsProfileScopeRealRepositoryIntegrationTest extends ContextBasedTest {
// existing UUIDs from test data (Liquibase changeset 310-login-profile-test-data.sql)
private static final UUID TEST_SCOPE_HSADMIN_PROD_UUID = UUID.fromString("11111111-1111-1111-1111-111111111111");
private static final UUID TEST_SCOPE_SSH_INTERNAL_UUID = UUID.fromString("22222222-2222-2222-2222-222222222222");
private static final UUID TEST_SCOPE_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 HsProfileScopeRealRepository scopeRepository;
@Test
public void historizationIsAvailable() {
// given
final String nativeQuerySql = "select * from hs_accounts.scope_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_accounts.scope_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_accounts.scope_hv should now contain the test-data rows for the current timestamp")
.hasSize(7);
}
@Test
void shouldFindAllUsingTestData() {
context(TEST_USER_SUBJECT_NAME);
// when
final var allScopes = scopeRepository.findAll();
// then
assertThat(allScopes)
.isNotNull()
.hasSizeGreaterThanOrEqualTo(3) // Expect at least the 3 from assumed test data
.extracting(HsProfileScope::getUuid)
.contains(TEST_SCOPE_HSADMIN_PROD_UUID, TEST_SCOPE_SSH_INTERNAL_UUID, TEST_SCOPE_MATRIX_INTERNAL_UUID);
}
@Test
void shouldFindByUuidUsingTestData() {
context(TEST_USER_SUBJECT_NAME);
// when
final var foundEntityOptional = scopeRepository.findByUuid(TEST_SCOPE_HSADMIN_PROD_UUID);
// then
assertThat(foundEntityOptional).isPresent();
assertThat(foundEntityOptional).map(Object::toString).contains("scope(HSADMIN:prod:NP-ONLY:PUBLIC)");
}
@Test
void shouldFindByTypeAndQualifierUsingTestData() {
context(TEST_USER_SUBJECT_NAME);
// when
final var foundEntityOptional = scopeRepository.findByTypeAndQualifier("SSH", "internal");
// then
assertThat(foundEntityOptional).isPresent();
assertThat(foundEntityOptional).map(Object::toString).contains("scope(SSH:internal:NP-ONLY:INTERNAL)");
}
@Test
void shouldReturnEmptyOptionalWhenFindByTypeAndQualifierNotFound() {
context(TEST_USER_SUBJECT_NAME);
// given
final var nonExistentQualifier = "non-existent-qualifier";
// when
final var foundEntityOptional = scopeRepository.findByTypeAndQualifier(
"HSADMIN", nonExistentQualifier);
// then
assertThat(foundEntityOptional).isNotPresent();
}
@Test
void shouldSaveNewScope() {
context(SUPERUSER_ALEX_SUBJECT_NAME);
// given
final var newQualifier = "test@example.social";
final var newType = "MASTODON";
final var newScope = HsProfileScopeRealEntity.builder()
.type(newType)
.qualifier(newQualifier)
.build();
// when
final var savedEntity = scopeRepository.save(newScope);
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 = scopeRepository.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 shouldPreventUpdateOfExistingScope() {
context(TEST_USER_SUBJECT_NAME);
// given an existing entity from test data
final var entityToUpdateOptional = scopeRepository.findByUuid(TEST_SCOPE_MATRIX_INTERNAL_UUID);
assertThat(entityToUpdateOptional)
.withFailMessage("Could not find existing Scope with UUID %s. Ensure test data exists.",
TEST_SCOPE_MATRIX_INTERNAL_UUID)
.isPresent();
final var entityToUpdate = entityToUpdateOptional.get();
// when
entityToUpdate.setQualifier("updated");
final var exception = catchThrowable( () -> {
scopeRepository.save(entityToUpdate);
em.flush();
});
// then
assertThat(exception)
.isInstanceOf(PersistenceException.class)
.hasCauseInstanceOf(PSQLException.class)
.hasMessageContaining("ERROR: Updates to hs_accounts.scope are not allowed.");
}
}
@@ -0,0 +1,53 @@
package net.hostsharing.hsadminng.hs.accounts.scenarios;
import net.hostsharing.hsadminng.hs.scenarios.FakeLoginUser;
import net.hostsharing.hsadminng.hs.scenarios.ScenarioTest;
import static io.restassured.http.ContentType.JSON;
import static net.hostsharing.hsadminng.hs.scenarios.TemplateResolver.Resolver.DROP_COMMENTS;
import static net.hostsharing.hsadminng.test.JsonMatcher.lenientlyEquals;
import static org.springframework.http.HttpStatus.OK;
public class AccountCanViewTheirOwnMemberships extends BaseAccountUseCase<AccountCanViewTheirOwnMemberships> {
public AccountCanViewTheirOwnMemberships(final ScenarioTest scenarioTest, final FakeLoginUser asLoginUser) {
super(scenarioTest, asLoginUser);
}
@Override
protected HttpResponse run() {
obtain(
"personUuid",
() -> httpGet( asLoginUser, "/api/hs/accounts/accounts")
.expecting(OK).expecting(JSON),
response -> response.expectArrayElements(1).getFromBody("[0].person.uuid"),
"Fetch the account for the current subject to resolve the related person."
);
withTitle("Resolve partner trade name", () ->
httpGet( asLoginUser,
"/api/hs/office/relations?relationType=REPRESENTATIVE&personUuid=%{personUuid}")
.expecting(OK).expecting(JSON).expectArrayElements(1)
.extractValue("[0].anchor.tradeName", "partnerTradeName")
);
withTitle("Resolve partner UUID", () ->
httpGet( asLoginUser,
"/api/hs/office/partners?name=" + uriEncoded("%{partnerTradeName}")
)
.expecting(OK).expecting(JSON).expectArrayElements(1)
.extractUuidAlias("[0].uuid", "partnerUuid")
);
return withTitle("View their memberships", () ->
httpGet( asLoginUser, "/api/hs/office/memberships?partnerUuid=%{partnerUuid}")
.expecting(OK).expecting(JSON)
);
}
@Override
protected void verify(final HttpResponse response) {
final var expectedMembershipsJson = ScenarioTest.resolve("%{expectedMembershipsJson}", DROP_COMMENTS);
lenientlyEquals(expectedMembershipsJson).matches(response.getBody());
}
}
@@ -0,0 +1,42 @@
package net.hostsharing.hsadminng.hs.accounts.scenarios;
import net.hostsharing.hsadminng.hs.scenarios.FakeLoginUser;
import net.hostsharing.hsadminng.hs.scenarios.ScenarioTest;
import static io.restassured.http.ContentType.JSON;
import static net.hostsharing.hsadminng.hs.scenarios.FakeLoginUser.asSubject;
import static org.springframework.http.HttpStatus.OK;
public class AccountCanViewTheirOwnPerson extends BaseAccountUseCase<AccountCanViewTheirOwnPerson> {
public AccountCanViewTheirOwnPerson(final ScenarioTest scenarioTest, final FakeLoginUser asLoginUser) {
super(scenarioTest, asLoginUser);
}
@Override
protected HttpResponse run() {
obtain(
"personUuid",
() -> httpGet( asSubject("%{subjectName}"),
"/api/hs/accounts/accounts"
)
.expecting(OK).expecting(JSON),
response -> response.expectArrayElements(1).getFromBody("[0].person.uuid"),
"Fetch the account for the current subject to resolve the related person."
);
return withTitle("View Own Person", () ->
httpGet( asSubject("%{subjectName}"),
"/api/hs/office/persons/%{personUuid}"
)
.expecting(OK).expecting(JSON)
);
}
@Override
protected void verify(final HttpResponse response) {
path("uuid").contains("%{personUuid}").accept(response);
path("givenName").contains("%{personGivenName}").accept(response);
path("familyName").contains("%{personFamilyName}").accept(response);
}
}
@@ -0,0 +1,40 @@
package net.hostsharing.hsadminng.hs.accounts.scenarios;
import lombok.val;
import net.hostsharing.hsadminng.hs.scenarios.FakeLoginUser;
import net.hostsharing.hsadminng.hs.scenarios.ScenarioTest;
import static io.restassured.http.ContentType.JSON;
import static net.hostsharing.hsadminng.hs.scenarios.FakeLoginUser.asSubject;
import static net.hostsharing.hsadminng.hs.scenarios.TemplateResolver.Resolver.DROP_COMMENTS;
import static net.hostsharing.hsadminng.test.JsonMatcher.lenientlyEquals;
import static org.springframework.http.HttpStatus.OK;
public class AccountCanViewTheirOwnRelations extends BaseAccountUseCase<AccountCanViewTheirOwnRelations> {
public AccountCanViewTheirOwnRelations(final ScenarioTest scenarioTest, final FakeLoginUser asLoginUser) {
super(scenarioTest, asLoginUser);
}
@Override
protected HttpResponse run() {
obtain(
"personUuid",
() -> httpGet( asSubject("%{subjectName}"), "/api/hs/accounts/accounts")
.expecting(OK).expecting(JSON),
response -> response.expectArrayElements(1).getFromBody("[0].person.uuid"),
"Fetch the account for the current subject to resolve the related person."
);
return withTitle("View their relations", () ->
httpGet(asSubject("%{subjectName}"), "/api/hs/office/relations?personUuid%{personUuid}")
.expecting(OK).expecting(JSON)
);
}
@Override
protected void verify(final HttpResponse response) {
val expectedRelationsJson = ScenarioTest.resolve("%{expectedRelationsJson}", DROP_COMMENTS);
lenientlyEquals(expectedRelationsJson).matches(response.getBody());
}
}
@@ -0,0 +1,232 @@
package net.hostsharing.hsadminng.hs.accounts.scenarios;
import lombok.SneakyThrows;
import net.hostsharing.hsadminng.hs.scenarios.Produces;
import net.hostsharing.hsadminng.hs.scenarios.Requires;
import net.hostsharing.hsadminng.hs.scenarios.ScenarioTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInfo;
import org.junit.jupiter.api.TestMethodOrder;
import org.springframework.http.HttpStatus;
import static net.hostsharing.hsadminng.hs.scenarios.FakeLoginUser.asSubject;
import static net.hostsharing.hsadminng.hs.scenarios.FakeLoginUser.asGlobalAgent;
class AccountScenarioTests extends ScenarioTest {
@SneakyThrows
@BeforeEach
protected void beforeScenario(final TestInfo testInfo) {
super.beforeScenario(testInfo);
}
@Nested
@Order(90)
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class RbacContextScenarios {
@Test
@Order(9010)
@Produces("RBAC Context")
void shouldFetchRbacContext() {
new FetchRbacContext(scenarioTest)
.given("subjectName", "superuser-fran@hostsharing.net")
.given("assumedRoles", "rbactest.package#xxx00:ADMIN;rbactest.package#yyy00:ADMIN")
.given("expectedToBeGlobalAdmin", true)
.thenExpect(HttpStatus.OK)
.keep();
}
}
@Nested
@Order(91)
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class CurrentLoginUserScenarios {
@Test
@Order(9110)
@Produces("Current Login User")
void shouldFetchCurrentLoginUser() {
new CurrentLoginUser(scenarioTest)
.given("subjectName", "superuser-fran@hostsharing.net")
.given("personGivenName", "Fran")
.given("expectedToBeGlobalAdmin", true)
.thenExpect(HttpStatus.OK)
.keep();
}
}
@Nested
@Order(92)
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class ScenariosForExistingPersons {
@Test
@Order(9210)
@Produces(
explicitly = "Account: peter-smith",
implicitly = { "Person: Peter Smith" })
void shouldCreateInitialAccountForExistingNaturalPerson() {
new CreateAccountForExistingPerson(scenarioTest, asGlobalAgent())
// to find a specific existing person
.given("personFamilyName", "Smith")
.given("personGivenName", "Peter")
.given("personGivenType", "NATURAL_PERSON")
// a login name, to be stored in the new RBAC subject
.given("subjectName", "xyz-peter.smith")
// initial account
.given("globalUid", 21011)
.given("globalGid", 21011)
.thenExpect(HttpStatus.OK)
.keep();
}
@Test
@Order(9211)
@Requires("Account: peter-smith")
void newlyCreatedAccountForExistingNaturalPersonShouldBeAbleToViewThatPerson() {
new AccountCanViewTheirOwnPerson(scenarioTest, asSubject("xyz-peter.smith"))
// to find a specific existing person
.given("subjectName", "xyz-peter.smith")
// some expected person data
.expected("personFamilyName", "Smith")
.expected("personGivenName", "Peter")
.thenExpect(HttpStatus.OK);
}
@Test
@Order(9212)
@Requires("Account: peter-smith")
void newlyCreatedAccountForExistingNaturalPersonShouldBeAbleToViewExistingRelations() {
new AccountCanViewTheirOwnRelations(scenarioTest, asSubject("xyz-peter.smith"))
// to find a specific existing person
.given("subjectName", "xyz-peter.smith")
// some expected person data ... which might change if test-data changes
.expected("expectedRelationsJson", """
[
{
"type": "PARTNER",
"mark": null,
"anchor": { "tradeName": "Hostsharing eG" },
"holder": { "tradeName": "Peter Smith - The Second Hand and Thrift Stores-n-Shipping e.K." },
"contact": { "emailAddresses": { "main": "contact-admin@secondcontact.example.com" } }
},
{
"type": "DEBITOR",
"mark": null,
"anchor": { "tradeName": "Peter Smith - The Second Hand and Thrift Stores-n-Shipping e.K." },
"holder": { "tradeName": "Peter Smith - The Second Hand and Thrift Stores-n-Shipping e.K." },
"contact": { "emailAddresses": { "main": "contact-admin@secondcontact.example.com" } }
},
{
"type": "REPRESENTATIVE",
"mark": null,
"anchor": { "tradeName": "Peter Smith - The Second Hand and Thrift Stores-n-Shipping e.K." },
"holder": { "givenName": "Peter", "familyName": "Smith" },
"contact": { "emailAddresses": { "main": "contact-admin@secondcontact.example.com" } }
},
{
"type": "PARTNER",
"mark": null,
"anchor": { "tradeName": "Hostsharing eG" },
"holder": { "givenName": "Peter", "familyName": "Smith" },
"contact": { "emailAddresses": { "main": "contact-admin@sixthcontact.example.com" } }
},
{
"type": "DEBITOR",
"mark": null,
"anchor": { "givenName": "Peter", "familyName": "Smith" },
"holder": { "givenName": "Peter", "familyName": "Smith" },
"contact": { "emailAddresses": { "main": "contact-admin@thirdcontact.example.com" } }
},
{
"type": "SUBSCRIBER",
"mark": "members-announce",
"anchor": { "tradeName": "Third OHG" },
"holder": { "givenName": "Peter", "familyName": "Smith" },
"contact": { "emailAddresses": { "main": "contact-admin@thirdcontact.example.com" } }
}
]
""")
.thenExpect(HttpStatus.OK);
}
@Test
@Order(9213)
@Requires("Account: peter-smith")
void newlyCreatedAccountForExistingNaturalPersonShouldBeAbleToViewExistingMemberships() {
new AccountCanViewTheirOwnMemberships(scenarioTest, asSubject("xyz-peter.smith"))
// to find a specific existing person
.given("subjectName", "xyz-peter.smith")
// some expected membership data ... which might change if test-data changes
.expected("expectedMembershipsJson", """
[
{
"partner": {
"partnerNumber": "P-10002",
"partnerRel": {
"type": "PARTNER",
"anchor": { "tradeName": "Hostsharing eG" },
"holder": { "tradeName": "Peter Smith - The Second Hand and Thrift Stores-n-Shipping e.K." },
"contact": { "emailAddresses": { "main": "contact-admin@secondcontact.example.com" } }
},
"details": {
"registrationOffice": "Hamburg",
"registrationNumber": "RegNo123456789"
}
},
"memberNumber": "M-1000202",
"memberNumberSuffix": "02",
"validFrom": "2022-10-01",
"validTo": "2025-12-31",
"status": "CANCELLED",
"membershipFeeBillable": true
}
]
""")
.thenExpect(HttpStatus.OK);
}
}
@Nested
@Order(93)
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class ScenariosForImplicitlyCreatedPersons {
@Test
@Order(9310)
@Produces(
explicitly = "Account: peter-newman",
implicitly = { "Person: Peter Newman" })
void shouldCreateInitialAccountForNewNaturalPerson() {
new CreateAccountForNewPerson(scenarioTest, asGlobalAgent())
// to find a specific existing person
.given("personFamilyName", "Newman")
.given("personGivenName", "Peter")
// a login name, to be stored in the new RBAC subject
.given("subjectName", "xyz-peter.newman")
// initial account
.given("globalUid", 21012)
.given("globalGid", 21012)
.thenExpect(HttpStatus.OK)
.keep();
}
@Test
@Order(9311)
@Requires("Account: peter-newman")
void newlyCreatedAccountForNewNaturalPersonShouldBeAbleToViewThatPerson() {
new AccountCanViewTheirOwnPerson(scenarioTest, asSubject("xyz-peter.newman"))
// to find a specific existing person
.given("subjectName", "xyz-peter.newman")
// some expected person data
.expected("personFamilyName", "Newman")
.expected("personGivenName", "Peter")
.thenExpect(HttpStatus.OK);
}
}
}
@@ -0,0 +1,15 @@
package net.hostsharing.hsadminng.hs.accounts.scenarios;
import net.hostsharing.hsadminng.hs.scenarios.FakeLoginUser;
import net.hostsharing.hsadminng.hs.scenarios.ScenarioTest;
import net.hostsharing.hsadminng.hs.scenarios.UseCase;
public abstract class BaseAccountUseCase<T extends UseCase<?>> extends UseCase<T> {
protected final FakeLoginUser asLoginUser;
public BaseAccountUseCase(final ScenarioTest testSuite, final FakeLoginUser asLoginUser) {
super(testSuite);
this.asLoginUser = asLoginUser;
}
}
@@ -1,43 +0,0 @@
package net.hostsharing.hsadminng.hs.accounts.scenarios;
import lombok.SneakyThrows;
import net.hostsharing.hsadminng.accounts.generated.api.v1.model.ScopeResource;
import net.hostsharing.hsadminng.hs.scenarios.FakeLoginUser;
import net.hostsharing.hsadminng.hs.scenarios.ScenarioTest;
import net.hostsharing.hsadminng.hs.scenarios.UseCase;
import org.apache.commons.lang3.tuple.Pair;
import java.util.Arrays;
import static io.restassured.http.ContentType.JSON;
import static net.hostsharing.hsadminng.hs.scenarios.FakeLoginUser.asGlobalAgent;
import static org.springframework.http.HttpStatus.OK;
public abstract class BaseProfileUseCase<T extends UseCase<?>> extends UseCase<T> {
protected final FakeLoginUser asLoginUser;
public BaseProfileUseCase(final ScenarioTest testSuite, final FakeLoginUser asLoginUser) {
super(testSuite);
this.asLoginUser = asLoginUser;
}
@SneakyThrows
protected ScopeResource[] fetchScopeResourcesByDescriptorPairs(final String descriptPairsVarName) {
final var requestedScopes = ScenarioTest.getTypedVariable("scopes", Pair[].class);
final var existingScopesJson = withTitle("Fetch Available Account Scopes", () ->
httpGet(asGlobalAgent(), "/api/hs/accounts/scopes").expecting(OK).expecting(JSON)
).getResponse().body();
final var existingScopes = objectMapper.readValue(existingScopesJson, ScopeResource[].class);
return Arrays.stream(requestedScopes)
.map(pair -> Arrays.stream(existingScopes)
.filter(scope -> scope.getType().equals(pair.getLeft())
&& scope.getQualifier().equals(pair.getRight()))
.findFirst()
.orElseThrow(() -> new IllegalStateException(
"No matching scope found for type=" + pair.getLeft()
+ " and qualifier=" + pair.getRight()))
)
.toArray(ScopeResource[]::new);
}
}
@@ -9,42 +9,31 @@ import org.springframework.http.HttpStatus;
import static io.restassured.http.ContentType.JSON; import static io.restassured.http.ContentType.JSON;
import static org.springframework.http.HttpStatus.OK; import static org.springframework.http.HttpStatus.OK;
public class CreateProfileForExistingPerson extends BaseProfileUseCase<CreateProfileForExistingPerson> { public class CreateAccountForExistingPerson extends BaseAccountUseCase<CreateAccountForExistingPerson> {
public CreateProfileForExistingPerson(final ScenarioTest testSuite, final FakeLoginUser asLoginUser) { public CreateAccountForExistingPerson(final ScenarioTest testSuite, final FakeLoginUser asLoginUser) {
super(testSuite, asLoginUser); super(testSuite, asLoginUser);
introduction("A set of profile contains the login data for an RBAC subject."); introduction("An account combines an RBAC subject with a natural person and thus grant's access to data in hsadmin-NG.");
} }
@Override @Override
protected HttpResponse run() { protected HttpResponse run() {
obtain("Person: %{personGivenName} %{personFamilyName}", () -> obtain("Person: %{personGivenName} %{personFamilyName}", () ->
httpGet(asLoginUser, "/api/hs/office/persons?name=%{personFamilyName}") httpGet(asLoginUser, "/api/hs/office/persons?name=%{personFamilyName}&type=%{personGivenType}")
.expecting(OK).expecting(JSON), .expecting(OK).expecting(JSON),
response -> response.expectArrayElements(1).getFromBody("[0].uuid"), response -> response.expectArrayElements(1).getFromBody("[0].uuid"),
"In real situations we have more precise measures to find the related person." "In real situations we have more precise measures to find the related person."
); );
given("resolvedScopes", return obtain("newAccount", () ->
fetchScopeResourcesByDescriptorPairs("scopes") httpPost(asLoginUser, "/api/hs/accounts/accounts", usingJsonBody("""
);
return obtain("newProfile", () ->
httpPost(asLoginUser, "/api/hs/accounts/profiles", usingJsonBody("""
{ {
"person.uuid": ${Person: %{personGivenName} %{personFamilyName}}, "person.uuid": ${Person: %{personGivenName} %{personFamilyName}},
"nickname": ${nickname}, "subjectName": ${subjectName},
"emailAddress": ${emailAddress},
"smsNumber": ${smsNumber},
"password": ${password},
"totpSecrets": @{totpSecrets},
"phonePassword": ${phonePassword},
"globalUid": %{globalUid}, "globalUid": %{globalUid},
"globalGid": %{globalGid}, "globalGid": %{globalGid}
"active": %{active},
"scopes": @{resolvedScopes}
} }
""")) """))
.expecting(HttpStatus.CREATED).expecting(ContentType.JSON) .expecting(HttpStatus.CREATED).expecting(ContentType.JSON)
@@ -52,15 +41,14 @@ public class CreateProfileForExistingPerson extends BaseProfileUseCase<CreatePro
} }
@Override @Override
protected void verify(final UseCase<CreateProfileForExistingPerson>.HttpResponse response) { protected void verify(final UseCase<CreateAccountForExistingPerson>.HttpResponse response) {
verify( verify(
"Verify the new Profile", "Verify the new Account",
() -> httpGet(asLoginUser, "/api/hs/accounts/profiles/%{newProfile}") () -> httpGet(asLoginUser, "/api/hs/accounts/accounts/%{newAccount}")
.expecting(OK).expecting(JSON), .expecting(OK).expecting(JSON),
path("uuid").contains("%{newProfile}"), path("uuid").contains("%{newAccount}"),
path("nickname").contains("%{nickname}"), path("subjectName").contains("%{subjectName}"),
path("person.uuid").contains("%{Person: %{personGivenName} %{personFamilyName}}"), path("person.uuid").contains("%{Person: %{personGivenName} %{personFamilyName}}")
path("totpSecrets").contains("@{totpSecrets}")
); );
} }
} }
@@ -9,23 +9,19 @@ import org.springframework.http.HttpStatus;
import static io.restassured.http.ContentType.JSON; import static io.restassured.http.ContentType.JSON;
import static org.springframework.http.HttpStatus.OK; import static org.springframework.http.HttpStatus.OK;
public class CreateProfileForNewPerson extends BaseProfileUseCase<CreateProfileForNewPerson> { public class CreateAccountForNewPerson extends BaseAccountUseCase<CreateAccountForNewPerson> {
public CreateProfileForNewPerson(final ScenarioTest testSuite, final FakeLoginUser asLoginUser) { public CreateAccountForNewPerson(final ScenarioTest testSuite, final FakeLoginUser asLoginUser) {
super(testSuite, asLoginUser); super(testSuite, asLoginUser);
introduction("A set of profile contains the login data for an RBAC subject."); introduction("An account combines an RBAC subject with a natural person and thus grant's access to data in hsadmin-NG.");
} }
@Override @Override
protected HttpResponse run() { protected HttpResponse run() {
given("resolvedScopes", return obtain("newAccount", () ->
fetchScopeResourcesByDescriptorPairs("scopes") httpPost(asLoginUser, "/api/hs/accounts/accounts", usingJsonBody("""
);
return obtain("newProfile", () ->
httpPost(asLoginUser, "/api/hs/accounts/profiles", usingJsonBody("""
{ {
"person": { "person": {
"personType": "NATURAL_PERSON", "personType": "NATURAL_PERSON",
@@ -34,16 +30,9 @@ public class CreateProfileForNewPerson extends BaseProfileUseCase<CreateProfileF
"givenName": ${personGivenName}, "givenName": ${personGivenName},
"familyName": ${personFamilyName} "familyName": ${personFamilyName}
}, },
"nickname": ${nickname}, "subjectName": ${subjectName},
"emailAddress": ${emailAddress},
"smsNumber": ${smsNumber},
"password": ${password},
"totpSecrets": @{totpSecrets},
"phonePassword": ${phonePassword},
"globalUid": %{globalUid}, "globalUid": %{globalUid},
"globalGid": %{globalGid}, "globalGid": %{globalGid}
"active": %{active},
"scopes": @{resolvedScopes}
} }
""")) """))
.expecting(HttpStatus.CREATED).expecting(ContentType.JSON) .expecting(HttpStatus.CREATED).expecting(ContentType.JSON)
@@ -51,7 +40,7 @@ public class CreateProfileForNewPerson extends BaseProfileUseCase<CreateProfileF
} }
@Override @Override
protected void verify(final UseCase<CreateProfileForNewPerson>.HttpResponse response) { protected void verify(final UseCase<CreateAccountForNewPerson>.HttpResponse response) {
obtain("Person: %{personGivenName} %{personFamilyName}", () -> obtain("Person: %{personGivenName} %{personFamilyName}", () ->
httpGet(asLoginUser, "/api/hs/office/persons?name=%{personFamilyName}") httpGet(asLoginUser, "/api/hs/office/persons?name=%{personFamilyName}")
.expecting(OK).expecting(JSON), .expecting(OK).expecting(JSON),
@@ -60,13 +49,12 @@ public class CreateProfileForNewPerson extends BaseProfileUseCase<CreateProfileF
); );
verify( verify(
"Verify the new Profile", "Verify the new Account",
() -> httpGet(asLoginUser, "/api/hs/accounts/profiles/%{newProfile}") () -> httpGet(asLoginUser, "/api/hs/accounts/accounts/%{newAccount}")
.expecting(OK).expecting(JSON), .expecting(OK).expecting(JSON),
path("uuid").contains("%{newProfile}"), path("uuid").contains("%{newAccount}"),
path("nickname").contains("%{nickname}"), path("subjectName").contains("%{subjectName}"),
path("person.uuid").contains("%{Person: %{personGivenName} %{personFamilyName}}"), path("person.uuid").contains("%{Person: %{personGivenName} %{personFamilyName}}")
path("totpSecrets").contains("@{totpSecrets}")
); );
} }
} }
@@ -7,7 +7,7 @@ import net.hostsharing.hsadminng.hs.scenarios.UseCase;
import static io.restassured.http.ContentType.JSON; import static io.restassured.http.ContentType.JSON;
import static net.hostsharing.hsadminng.hs.scenarios.FakeLoginUser.asGlobalAgent; import static net.hostsharing.hsadminng.hs.scenarios.FakeLoginUser.asGlobalAgent;
import static net.hostsharing.hsadminng.hs.scenarios.ScenarioTest.bearerTemplate; import static net.hostsharing.hsadminng.hs.scenarios.FakeLoginUser.asSubject;
import static net.hostsharing.hsadminng.hs.scenarios.ScenarioTest.resolve; import static net.hostsharing.hsadminng.hs.scenarios.ScenarioTest.resolve;
import static net.hostsharing.hsadminng.hs.scenarios.TemplateResolver.Resolver.DROP_COMMENTS; import static net.hostsharing.hsadminng.hs.scenarios.TemplateResolver.Resolver.DROP_COMMENTS;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
@@ -33,10 +33,7 @@ public class CurrentLoginUser extends UseCase<CurrentLoginUser> {
return obtain( return obtain(
"Current Login User", () -> "Current Login User", () ->
httpGet( httpGet( asSubject("%{subjectName}"), "/api/hs/accounts/current")
"/api/hs/accounts/current", req -> req
.header("Authorization", bearerTemplate("%{subjectName}"))
)
.expecting(OK).expecting(JSON).expectObject() .expecting(OK).expecting(JSON).expectObject()
.extractValue("subject.name", "returnedSubjectName") .extractValue("subject.name", "returnedSubjectName")
.extractValue("person.givenName", "returnedGivenName") .extractValue("person.givenName", "returnedGivenName")
@@ -7,7 +7,7 @@ import net.hostsharing.hsadminng.hs.scenarios.UseCase;
import java.util.List; import java.util.List;
import static io.restassured.http.ContentType.JSON; import static io.restassured.http.ContentType.JSON;
import static net.hostsharing.hsadminng.hs.scenarios.ScenarioTest.bearerTemplate; import static net.hostsharing.hsadminng.hs.scenarios.FakeLoginUser.asSubject;
import static net.hostsharing.hsadminng.hs.scenarios.ScenarioTest.resolve; import static net.hostsharing.hsadminng.hs.scenarios.ScenarioTest.resolve;
import static net.hostsharing.hsadminng.hs.scenarios.ScenarioTest.resolveJsonArray; import static net.hostsharing.hsadminng.hs.scenarios.ScenarioTest.resolveJsonArray;
import static net.hostsharing.hsadminng.hs.scenarios.TemplateResolver.Resolver.DROP_COMMENTS; import static net.hostsharing.hsadminng.hs.scenarios.TemplateResolver.Resolver.DROP_COMMENTS;
@@ -26,9 +26,8 @@ public class FetchRbacContext extends UseCase<FetchRbacContext> {
protected HttpResponse run() { protected HttpResponse run() {
return obtain( return obtain(
"RBAC Context", () -> "RBAC Context", () ->
httpGet( httpGet( asSubject("%{subjectName}"),
"/api/rbac/context", req -> req "/api/rbac/context", req -> req
.header("Authorization", bearerTemplate("%{subjectName}"))
.header("assumed-roles", resolve("%{assumedRoles}", DROP_COMMENTS)) .header("assumed-roles", resolve("%{assumedRoles}", DROP_COMMENTS))
) )
.expecting(OK).expecting(JSON).expectObject() .expecting(OK).expecting(JSON).expectObject()
@@ -1,186 +0,0 @@
package net.hostsharing.hsadminng.hs.accounts.scenarios;
import lombok.SneakyThrows;
import net.hostsharing.hsadminng.hs.scenarios.Produces;
import net.hostsharing.hsadminng.hs.scenarios.Requires;
import net.hostsharing.hsadminng.hs.scenarios.ScenarioTest;
import net.hostsharing.hsadminng.mapper.Array;
import org.apache.commons.lang3.tuple.Pair;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInfo;
import org.junit.jupiter.api.TestMethodOrder;
import org.springframework.http.HttpStatus;
import static net.hostsharing.hsadminng.hs.scenarios.FakeLoginUser.as;
import static net.hostsharing.hsadminng.hs.scenarios.FakeLoginUser.asGlobalAgent;
class ProfileScenarioTests extends ScenarioTest {
@SneakyThrows
@BeforeEach
protected void beforeScenario(final TestInfo testInfo) {
super.beforeScenario(testInfo);
}
@Nested
@Order(10)
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class RbacContextScenarios {
@Test
@Order(1010)
@Produces("RBAC Context")
void shouldFetchRbacContext() {
new FetchRbacContext(scenarioTest)
.given("subjectName", "superuser-fran@hostsharing.net")
.given("assumedRoles", "rbactest.package#xxx00:ADMIN;rbactest.package#yyy00:ADMIN")
.given("expectedToBeGlobalAdmin", true)
.thenExpect(HttpStatus.OK)
.keep();
}
}
@Nested
@Order(20)
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class CurrentLoginUserScenarios {
@Test
@Order(2010)
@Produces("Current Login User")
void shouldFetchCurrentLoginUser() {
new CurrentLoginUser(scenarioTest)
.given("subjectName", "superuser-fran@hostsharing.net")
.given("personGivenName", "Fran")
.given("expectedToBeGlobalAdmin", true)
.thenExpect(HttpStatus.OK)
.keep();
}
}
@Nested
@Order(30)
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class ProfileScenarios {
@Test
@Order(1010)
@Produces(
explicitly = "Profile: susan-firby",
implicitly = {"Person: Susan Firby"})
void shouldCreateInitialProfileForExistingNaturalPerson() {
new CreateProfileForExistingPerson(scenarioTest, asGlobalAgent())
// to find a specific existing person
.given("personFamilyName", "Firby")
.given("personGivenName", "Susan")
// a login name, to be stored in the new RBAC subject
.given("nickname", "firby-susan")
// initial profile
.given("emailAddress", "susan.firby@example.com")
.given("smsNumber", "+49123456789")
.given("password", "my raw password")
.given("totpSecrets", Array.of("initialSecret"))
.given("phonePassword", "securePass123")
.given("globalUid", 21011)
.given("globalGid", 21011)
.given("active", true)
.given(
"scopes", Array.of(
Pair.of("HSADMIN", "prod")
))
.thenExpect(HttpStatus.OK)
.keep();
}
@Test
@Order(1020)
@Requires("Profile: susan-firby")
void naturalPersonShouldBeAbleToUpdateTheirOwnProfile() {
new UpdateProfile(scenarioTest, as("firby-susan"))
// the profile to update
.given("profileUuid", "%{Profile: susan-firby}")
// updated profile
.given("active", false)
.given("totpSecrets", Array.of("initialSecret", "additionalSecret"))
.given("emailAddress", "susan.firby@example.org")
.given("password", "my new raw password")
.given("phonePassword", "securePass987")
.given("smsNumber", "+49987654321")
.given("scopes", Array.of(Pair.of("HSADMIN", "prod"), Pair.of("SSH", "external")))
.thenExpect(HttpStatus.OK);
}
@Test
@Order(1100)
@Produces(
explicitly = "Profile: peter-newman",
implicitly = {"Person: Peter Newman"})
void shouldCreateInitialProfileForNewNaturalPerson() {
new CreateProfileForNewPerson(scenarioTest, asGlobalAgent())
// to find a specific existing person
.given("personFamilyName", "Newman")
.given("personGivenName", "Peter")
// a login name, to be stored in the new RBAC subject
.given("nickname", "newman-peter")
// initial profile
.given("emailAddress", "peter.newman@example.com")
.given("smsNumber", "+49123456789")
.given("password", "my raw password")
.given("totpSecrets", Array.of("initialSecret"))
.given("phonePassword", "securePass123")
.given("globalUid", 21012)
.given("globalGid", 21012)
.given("active", true)
.given("scopes", Array.of(Pair.of("HSADMIN", "prod")))
.thenExpect(HttpStatus.OK)
.keep();
}
@Test
@Order(1110)
@Requires("Profile: peter-newman")
void newNaturalPersonShouldBeAbleToUpdateTheirOwnProfile() {
new UpdateProfile(scenarioTest, as("newman-peter"))
// the profile to update
.given("profileUuid", "%{Profile: peter-newman}")
// updated profile
.given("active", false)
.given("totpSecrets", Array.of("initialSecret", "additionalSecret"))
.given("emailAddress", "peter.newman@example.org")
.given("password", "my new raw password")
.given("phonePassword", "securePass987")
.given("smsNumber", "+49987654321")
.given(
"scopes", Array.of(
Pair.of("HSADMIN", "prod"),
Pair.of("SSH", "external")
))
.thenExpect(HttpStatus.OK);
}
@Test
@Order(1120)
@Requires({"Profile: peter-newman", "Profile: susan-firby"})
// Usually, scenario tests just test positive cases, but in the case of account profiles, security is extra important,
// thus I've added also some negative cases like this one for documentation reasons.
// More negative cases are tested in "so-called" Acceptance and in RestTests.
void anotherNaturalPersonShouldNotBeAbleToUpdateOthersProfile() {
new UpdateProfile(scenarioTest, as("firby-susan"))
// the profile to update
.given("profileUuid", "%{Profile: peter-newman}")
// updated profile
.given("active", false)
.given("totpSecrets", Array.of("initialSecret", "additionalSecret"))
.given("emailAddress", "peter.newman@example.org")
.given("password", "my new raw password")
.given("phonePassword", "securePass987")
.given("smsNumber", "+49987654321")
.given("scopes", Array.of(Pair.of("HSADMIN", "prod"), Pair.of("SSH", "external")))
.thenExpect(HttpStatus.FORBIDDEN);
}
}
}
@@ -1,65 +0,0 @@
package net.hostsharing.hsadminng.hs.accounts.scenarios;
import io.restassured.http.ContentType;
import lombok.val;
import net.hostsharing.hsadminng.hs.scenarios.FakeLoginUser;
import net.hostsharing.hsadminng.hs.scenarios.ScenarioTest;
import net.hostsharing.hsadminng.hs.scenarios.UseCase;
import org.springframework.http.HttpStatus;
import static io.restassured.http.ContentType.JSON;
import static org.assertj.core.api.Fail.fail;
import static org.springframework.http.HttpStatus.OK;
public class UpdateProfile extends BaseProfileUseCase<UpdateProfile> {
public UpdateProfile(final ScenarioTest testSuite, final FakeLoginUser asLoginUser) {
super(testSuite, asLoginUser);
introduction("A set of profile contains the login data for an RBAC subject.");
}
@Override
protected HttpResponse run(final HttpStatus expectedStatus) {
given("resolvedScopes",
fetchScopeResourcesByDescriptorPairs("scopes")
);
return withTitle("Patch the Changes to the existing Profile", () -> {
val response = httpPatch(
asLoginUser, "/api/hs/accounts/profiles/%{profileUuid}", usingJsonBody("""
{
"active": %{active},
"totpSecrets": @{totpSecrets},
"emailAddress": ${emailAddress},
"phonePassword": ${phonePassword},
"smsNumber": ${smsNumber},
"scopes": @{resolvedScopes}
}
"""))
.reportWithResponse().expecting(expectedStatus);
return switch (expectedStatus) {
case OK -> response.expecting(ContentType.JSON)
.extractValue("nickname", "nickname")
.extractValue("totpSecrets", "totpSecrets");
case FORBIDDEN -> response.expecting(ContentType.JSON);
default -> fail("unexpected response: " + response);
};
}
);
}
@Override
protected void verify(final UseCase<UpdateProfile>.HttpResponse response) {
verify(
"Verify the Patched Profile",
() -> httpGet(asLoginUser, "/api/hs/accounts/profiles/%{profileUuid}")
.expecting(OK).expecting(JSON),
path("uuid").contains("%{newProfile}"),
path("nickname").contains("%{nickname}"),
path("totpSecrets").contains("%{totpSecrets}")
);
}
}
@@ -360,8 +360,9 @@ class HsDomainSetupHostingAssetValidatorUnitTest {
@Test @Test
void rejectSetupOfUnregisteredSubdomainOfUnregisteredSuperDomain() { void rejectSetupOfUnregisteredSubdomainOfUnregisteredSuperDomain() {
domainSetupFor("sub.sub.example.org").notRegistered() Dns.fakeResultForDomain("unreg.example.org", DOMAIN_NOT_REGISTERED);
.isRejectedWithCauseDomainNameNotFound("sub.example.org"); domainSetupFor("sub.unreg.example.org").notRegistered()
.isRejectedWithCauseDomainNameNotFound("unreg.example.org");
} }
@Test @Test
@@ -60,6 +60,21 @@ class HsOfficePersonControllerAcceptanceTest extends ContextBasedTestWithCleanup
@Test @Test
void globalAdmin_withoutAssumedRoles_canViewAllPersons_ifNoCriteriaGiven() { void globalAdmin_withoutAssumedRoles_canViewAllPersons_ifNoCriteriaGiven() {
RestAssured // @formatter:off
.given()
.header("Authorization", bearer("superuser-alex@hostsharing.net"))
.port(port)
.when()
.get("http://localhost/api/hs/office/persons")
.then().log().all().assertThat()
.statusCode(200)
.contentType("application/json")
.body("", hasSize(17));
// @formatter:on
}
@Test
void globalAdmin_withoutAssumedRoles_canViewAllPersons_byNameAndPersonType() {
RestAssured // @formatter:off RestAssured // @formatter:off
.given() .given()
.header("Authorization", bearer("superuser-alex@hostsharing.net")) .header("Authorization", bearer("superuser-alex@hostsharing.net"))
@@ -1,17 +1,20 @@
package net.hostsharing.hsadminng.hs.scenarios; package net.hostsharing.hsadminng.hs.scenarios;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.val;
import net.hostsharing.hsadminng.config.JwtFakeBearer; import net.hostsharing.hsadminng.config.JwtFakeBearer;
import static net.hostsharing.hsadminng.hs.scenarios.TemplateResolver.Resolver.DROP_COMMENTS;
@AllArgsConstructor @AllArgsConstructor
public class FakeLoginUser { public class FakeLoginUser {
final static String GLOBAL_AGENT = "superuser-alex@hostsharing.net"; // TODO.test: use global:AGENT when implemented final static String GLOBAL_AGENT = "superuser-alex@hostsharing.net"; // TODO.test: use global:AGENT when implemented
private String name; private String name;
public static FakeLoginUser as(final String name) { public static FakeLoginUser asSubject(final String name) {
return new FakeLoginUser(name); val resolvedName = ScenarioTest.resolve( name, DROP_COMMENTS);
return new FakeLoginUser(resolvedName);
} }
public static FakeLoginUser asGlobalAgent() { public static FakeLoginUser asGlobalAgent() {
@@ -21,4 +24,8 @@ public class FakeLoginUser {
public String bearer() { public String bearer() {
return JwtFakeBearer.bearer(name); return JwtFakeBearer.bearer(name);
} }
String name() {
return name;
}
} }
@@ -0,0 +1,63 @@
package net.hostsharing.hsadminng.hs.scenarios;
import lombok.experimental.UtilityClass;
import lombok.val;
@UtilityClass
public class MarkdownTableCellRenderer {
public static String toMarkdownTableCell(final Object value) {
val raw = String.valueOf(value);
if (raw.isEmpty()) {
return "";
}
val normalized = raw.replace("\r\n", "\n").replace("\r", "\n");
val lines = normalized.split("\n", -1);
val out = new StringBuilder(normalized.length() + (lines.length * 4));
for (int i = 0; i < lines.length; i++) {
if (i > 0) {
out.append("<br>");
}
out.append(escapeWithIndent(lines[i]));
}
return out.toString();
}
private String escapeWithIndent(final String line) {
int i = 0;
while (i < line.length()) {
final char c = line.charAt(i);
if (c == ' ' || c == '\t') {
i++;
} else {
break;
}
}
val out = new StringBuilder(line.length() + 16);
for (int j = 0; j < i; j++) {
out.append(line.charAt(j) == '\t' ? "&nbsp;&nbsp;" : "&nbsp;");
}
for (int j = i; j < line.length(); j++) {
final char c = line.charAt(j);
if (c == '&') {
out.append("&amp;");
} else if (c == '<') {
out.append("&lt;");
} else if (c == '>') {
out.append("&gt;");
} else if (c == '|') {
out.append("&#124;");
} else {
out.append(c);
}
}
return out.toString();
}
}
@@ -274,17 +274,11 @@ public abstract class ScenarioTest extends ContextBasedTest {
//noinspection unchecked //noinspection unchecked
return (T) new BigDecimal(resolvedValue); return (T) new BigDecimal(resolvedValue);
} }
if (valueType == Integer.class) {
//noinspection unchecked
return (T) Integer.valueOf(resolvedValue);
}
//noinspection unchecked //noinspection unchecked
return (T) resolvedValue; return (T) resolvedValue;
} }
public static <T> T getTypedVariable(final String varName, final Class<T> expectedValueClass) {
final var value = knowVariables().get(varName);
if (value != null && !expectedValueClass.isAssignableFrom(value.getClass())) {
throw new IllegalArgumentException("variable '" + varName + "'" +
" expected to be of type " + expectedValueClass + " " +
" but got " + value.getClass());
}
return (T) value;
}
} }
@@ -27,7 +27,7 @@ import static org.assertj.core.api.Assertions.assertThat;
public class TestReport { public class TestReport {
public static final File BUILD_DOC_SCENARIOS = new File("build/doc/scenarios"); public static final File BUILD_DOC_SCENARIOS = new File("build/doc/scenarios");
public static final SimpleDateFormat MM_DD_YYYY_HH_MM_SS = new SimpleDateFormat("MM-dd-yyyy hh:mm:ss"); public static final SimpleDateFormat YYYY_MM_DD_HH_MM_SS = new SimpleDateFormat("yyyy-MM-dd hh:mm:ss");
private static final File markdownLogFile = new File(BUILD_DOC_SCENARIOS, ".last-debug-log.md"); private static final File markdownLogFile = new File(BUILD_DOC_SCENARIOS, ".last-debug-log.md");
private static final ObjectMapper objectMapper = JsonObjectMapperConfiguration.build(); private static final ObjectMapper objectMapper = JsonObjectMapperConfiguration.build();
@@ -96,7 +96,7 @@ public class TestReport {
public void close() { public void close() {
if (markdownReport != null) { if (markdownReport != null) {
printPara("---"); printPara("---");
printPara("generated on " + MM_DD_YYYY_HH_MM_SS.format(new Date()) + " for branch " + currentGitBranch()); printPara("generated on " + YYYY_MM_DD_HH_MM_SS.format(new Date()) + " for branch " + currentGitBranch());
markdownReport.close(); markdownReport.close();
System.out.println("SCENARIO REPORT: " + asClickableLink(markdownReportFile)); System.out.println("SCENARIO REPORT: " + asClickableLink(markdownReportFile));
} }
@@ -35,6 +35,7 @@ import java.util.function.Supplier;
import static java.net.URLEncoder.encode; import static java.net.URLEncoder.encode;
import static java.util.stream.Collectors.joining; import static java.util.stream.Collectors.joining;
import static net.hostsharing.hsadminng.hs.scenarios.MarkdownTableCellRenderer.toMarkdownTableCell;
import static net.hostsharing.hsadminng.hs.scenarios.TemplateResolver.Resolver.DROP_COMMENTS; import static net.hostsharing.hsadminng.hs.scenarios.TemplateResolver.Resolver.DROP_COMMENTS;
import static net.hostsharing.hsadminng.hs.scenarios.TemplateResolver.Resolver.KEEP_COMMENTS; import static net.hostsharing.hsadminng.hs.scenarios.TemplateResolver.Resolver.KEEP_COMMENTS;
import static net.hostsharing.hsadminng.test.DebuggerDetection.isDebuggerAttached; import static net.hostsharing.hsadminng.test.DebuggerDetection.isDebuggerAttached;
@@ -46,7 +47,7 @@ import static org.junit.platform.commons.util.StringUtils.isNotBlank;
public abstract class UseCase<T extends UseCase<?>> { public abstract class UseCase<T extends UseCase<?>> {
private static final HttpClient client = HttpClient.newHttpClient(); private static final HttpClient client = HttpClient.newHttpClient();
private static final int HTTP_TIMEOUT_SECONDS = 20; // FIXME: configurable in environment private static final int HTTP_TIMEOUT_SECONDS = 20; // TODO.test: configurable in environment
protected final ObjectMapper objectMapper = new ObjectMapper(); protected final ObjectMapper objectMapper = new ObjectMapper();
protected final ScenarioTest testSuite; protected final ScenarioTest testSuite;
@@ -54,6 +55,7 @@ public abstract class UseCase<T extends UseCase<?>> {
private final Map<String, Function<String, UseCase<?>>> requirements = new LinkedMap<>(); private final Map<String, Function<String, UseCase<?>>> requirements = new LinkedMap<>();
private final String resultAlias; private final String resultAlias;
private final Map<String, Object> givenProperties = new LinkedHashMap<>(); private final Map<String, Object> givenProperties = new LinkedHashMap<>();
private final Map<String, Object> expectedProperties = new LinkedHashMap<>();
private String nextTitle; // just temporary to override resultAlias for sub-use-cases private String nextTitle; // just temporary to override resultAlias for sub-use-cases
private String introduction; private String introduction;
@@ -79,13 +81,10 @@ public abstract class UseCase<T extends UseCase<?>> {
if (introduction != null) { if (introduction != null) {
testReport.printPara(introduction); testReport.printPara(introduction);
} }
testReport.printPara("### Given Properties"); testReport.printPara("### Properties");
testReport.printLine(""" renderProperties("Given", givenProperties);
| name | value | renderProperties("Expected", expectedProperties);
|------|-------|""");
givenProperties.forEach((key, value) ->
testReport.printLine("| " + key + " | " + value.toString().replace("\n", "<br>") + " |"));
testReport.printLine("");
testReport.silent(() -> testReport.silent(() ->
requirements.forEach((alias, factory) -> { requirements.forEach((alias, factory) -> {
final var resolvedAlias = ScenarioTest.resolve(alias, DROP_COMMENTS); final var resolvedAlias = ScenarioTest.resolve(alias, DROP_COMMENTS);
@@ -106,6 +105,21 @@ public abstract class UseCase<T extends UseCase<?>> {
return response; return response;
} }
private void renderProperties(final String title, final Map<String, Object> properties) {
if (properties.isEmpty()) {
return;
}
testReport.printLine("");
testReport.printLine("#### " + title);
testReport.printLine("");
testReport.printLine("""
| name | value |
|------|-------|""");
properties.forEach((key, value) ->
testReport.printLine("| " + key + " | " + toMarkdownTableCell(value) + " |"));
testReport.printLine("");
}
// this method is called by the test framework, override, but do not call from subclass // this method is called by the test framework, override, but do not call from subclass
protected HttpResponse run(final HttpStatus expectedStatus) { protected HttpResponse run(final HttpStatus expectedStatus) {
assertThat(expectedStatus).as("legacy signature only defined for HttpStatus.OK").isEqualTo(HttpStatus.OK); assertThat(expectedStatus).as("legacy signature only defined for HttpStatus.OK").isEqualTo(HttpStatus.OK);
@@ -129,6 +143,15 @@ public abstract class UseCase<T extends UseCase<?>> {
return this; return this;
} }
// To keep things simple, both given and expected properties are available everywhere in all templates.
// The distinction is mostly for readability.
// It would be a bit tricky to make the expected values available just for validations.
public final UseCase<T> expected(final String propName, final Object propValue) {
expectedProperties.put(propName, ScenarioTest.resolve(propValue == null ? null : propValue.toString(), TemplateResolver.Resolver.KEEP_COMMENTS));
ScenarioTest.putProperty(propName, propValue);
return this;
}
public final JsonTemplate usingJsonBody(final String jsonTemplate) { public final JsonTemplate usingJsonBody(final String jsonTemplate) {
return new JsonTemplate(jsonTemplate); return new JsonTemplate(jsonTemplate);
} }
@@ -166,23 +189,25 @@ public abstract class UseCase<T extends UseCase<?>> {
@SneakyThrows @SneakyThrows
public final HttpResponse httpGet( public final HttpResponse httpGet(
final FakeLoginUser loginUser,
final String uriPathWithPlaceholder, final String uriPathWithPlaceholder,
final Function<HttpRequest.Builder, HttpRequest.Builder> requestCustomizer) { final Function<HttpRequest.Builder, HttpRequest.Builder> requestCustomizer) {
final var uriPath = ScenarioTest.resolve(uriPathWithPlaceholder, DROP_COMMENTS); final var uriPath = ScenarioTest.resolve(uriPathWithPlaceholder, DROP_COMMENTS);
final var requestBuilder = HttpRequest.newBuilder() final var requestBuilder = HttpRequest.newBuilder()
.GET() .GET()
.uri(new URI("http://localhost:" + testSuite.port + uriPath)) .uri(new URI("http://localhost:" + testSuite.port + uriPath))
.timeout(seconds(HTTP_TIMEOUT_SECONDS)); .header("Authorization", loginUser.bearer())
.header("X-Fake-Authorization", "Bearer [" + loginUser.name() + "]");
final var customizedRequestBuilder = requestCustomizer.apply(requestBuilder); final var customizedRequestBuilder = requestCustomizer.apply(requestBuilder);
final var request = customizedRequestBuilder.build(); final var request = customizedRequestBuilder.build();
final var response = client.send(request, BodyHandlers.ofString()); final var response = client.send(request, BodyHandlers.ofString());
return new HttpResponse(HttpMethod.GET, uriPath, null, response); return new HttpResponse(HttpMethod.GET, uriPath, null, response, null);
} }
@SneakyThrows public final HttpResponse httpGet(
public final HttpResponse httpGet(final FakeLoginUser loginUser, final String uriPathWithPlaceholders) { final FakeLoginUser loginUser,
return httpGet(uriPathWithPlaceholders, final String uriPathWithPlaceholder) {
req -> req.header("Authorization", loginUser.bearer())); return httpGet(loginUser, uriPathWithPlaceholder, requestBuilder -> requestBuilder);
} }
@SneakyThrows @SneakyThrows
@@ -196,10 +221,11 @@ public abstract class UseCase<T extends UseCase<?>> {
.uri(new URI("http://localhost:" + testSuite.port + uriPath)) .uri(new URI("http://localhost:" + testSuite.port + uriPath))
.header("Content-Type", "application/json") .header("Content-Type", "application/json")
.header("Authorization", loginUser.bearer()) .header("Authorization", loginUser.bearer())
.header("X-Fake-Authorization", "Bearer [" + loginUser.name() + "]")
.timeout(seconds(HTTP_TIMEOUT_SECONDS)) .timeout(seconds(HTTP_TIMEOUT_SECONDS))
.build(); .build();
final var response = client.send(request, BodyHandlers.ofString()); final var response = client.send(request, BodyHandlers.ofString());
return new HttpResponse(HttpMethod.POST, uriPath, requestBody, response); return new HttpResponse(HttpMethod.POST, uriPath, requestBody, response, loginUser);
} }
@SneakyThrows @SneakyThrows
@@ -214,10 +240,11 @@ public abstract class UseCase<T extends UseCase<?>> {
.uri(new URI("http://localhost:" + testSuite.port + uriPath)) .uri(new URI("http://localhost:" + testSuite.port + uriPath))
.header("Content-Type", "application/json") .header("Content-Type", "application/json")
.header("Authorization", loginUser.bearer()) .header("Authorization", loginUser.bearer())
.header("X-Fake-Authorization", "Bearer [" + loginUser.name() + "]")
.timeout(seconds(HTTP_TIMEOUT_SECONDS)) .timeout(seconds(HTTP_TIMEOUT_SECONDS))
.build(); .build();
final var response = client.send(request, BodyHandlers.ofString()); final var response = client.send(request, BodyHandlers.ofString());
return new HttpResponse(HttpMethod.PATCH, uriPath, requestBody, response); return new HttpResponse(HttpMethod.PATCH, uriPath, requestBody, response, loginUser);
} }
@SneakyThrows @SneakyThrows
@@ -228,10 +255,11 @@ public abstract class UseCase<T extends UseCase<?>> {
.uri(new URI("http://localhost:" + testSuite.port + uriPath)) .uri(new URI("http://localhost:" + testSuite.port + uriPath))
.header("Content-Type", "application/json") .header("Content-Type", "application/json")
.header("Authorization", loginUser.bearer()) .header("Authorization", loginUser.bearer())
.header("X-Fake-Authorization", "Bearer [" + loginUser.name() + "]")
.timeout(seconds(HTTP_TIMEOUT_SECONDS)) .timeout(seconds(HTTP_TIMEOUT_SECONDS))
.build(); .build();
final var response = client.send(request, BodyHandlers.ofString()); final var response = client.send(request, BodyHandlers.ofString());
return new HttpResponse(HttpMethod.DELETE, uriPath, null, response); return new HttpResponse(HttpMethod.DELETE, uriPath, null, response, loginUser);
} }
protected PathAssertion path(final String path) { protected PathAssertion path(final String path) {
@@ -289,9 +317,11 @@ public abstract class UseCase<T extends UseCase<?>> {
public final class HttpResponse { public final class HttpResponse {
private static final String AUTH_HEADER_KEY = "Authorization";
private final HttpMethod httpMethod; private final HttpMethod httpMethod;
private final String uri; private final String uri;
private final String requestBody; private final String requestBody;
private final @Nullable String authUserName;
@Getter @Getter
private final java.net.http.HttpResponse<String> response; private final java.net.http.HttpResponse<String> response;
@@ -310,12 +340,14 @@ public abstract class UseCase<T extends UseCase<?>> {
final HttpMethod httpMethod, final HttpMethod httpMethod,
final String uri, final String uri,
final String requestBody, final String requestBody,
final java.net.http.HttpResponse<String> response final java.net.http.HttpResponse<String> response,
final @Nullable FakeLoginUser loginUser
) { ) {
this.httpMethod = httpMethod; this.httpMethod = httpMethod;
this.uri = uri; this.uri = uri;
this.requestBody = requestBody; this.requestBody = requestBody;
this.response = response; this.response = response;
this.authUserName = loginUser == null ? null : loginUser.name();
this.status = HttpStatus.valueOf(response.statusCode()); this.status = HttpStatus.valueOf(response.statusCode());
if (this.status == HttpStatus.CREATED) { if (this.status == HttpStatus.CREATED) {
final var location = response.headers().firstValue("Location").orElseThrow(); final var location = response.headers().firstValue("Location").orElseThrow();
@@ -386,6 +418,10 @@ public abstract class UseCase<T extends UseCase<?>> {
return this; return this;
} }
public String getBody() {
return response.body();
}
@SneakyThrows @SneakyThrows
public <V> V getFromBody(final String path) { public <V> V getFromBody(final String path) {
final var body = response.body(); final var body = response.body();
@@ -431,7 +467,8 @@ public abstract class UseCase<T extends UseCase<?>> {
// the request // the request
testReport.printLine("```"); testReport.printLine("```");
testReport.printLine(httpMethod.name() + " " + uri); testReport.printLine(httpMethod.name() + " '" + uri + "'");
printRequestHeaders();
testReport.printJson(requestBody); testReport.printJson(requestBody);
// the response // the response
@@ -446,6 +483,33 @@ public abstract class UseCase<T extends UseCase<?>> {
return this; return this;
} }
private void printRequestHeaders() {
final var request = response.request();
if (request == null) {
return;
}
request.headers().map().entrySet().stream()
.sorted(Map.Entry.comparingByKey(String.CASE_INSENSITIVE_ORDER))
// the Authorization header with the long Bearer token is of no value here
.filter(entry -> !AUTH_HEADER_KEY.equalsIgnoreCase(entry.getKey()))
// instead, use the X-Fake-Authorization header as if it was the real Authorization header
.map(entry -> Map.entry(AUTH_HEADER_KEY, entry.getValue()))
.forEach(entry -> {
testReport.printLine("- " + entry.getKey() + ": " + headerValue(entry));
});
}
private String headerValue(final Map.Entry<String, List<String>> entry) {
if ("X-Fake-Authorization".equalsIgnoreCase(entry.getKey())) {
return fakeAuthorizationValue();
}
return String.join(", ", entry.getValue());
}
private String fakeAuthorizationValue() {
return authUserName == null ? "" : "Bearer <" + authUserName + ">";
}
@SneakyThrows @SneakyThrows
private void optionallyReportRequestAndResponse() { private void optionallyReportRequestAndResponse() {
if (!reportGenerated) { if (!reportGenerated) {
@@ -0,0 +1,81 @@
package net.hostsharing.hsadminng.repr;
import lombok.val;
import net.hostsharing.hsadminng.persistence.BaseEntity;
import org.junit.jupiter.api.Test;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.assertEquals;
class StringifyableUnitTest {
@Test
void toShortString_whenEntityImplementsStringifyable_usesItsToShortString() {
// given
val entity = new StringifyableTestEntity(
UUID.fromString("00000000-0000-0000-0000-000000000001"),
"short-repr"
);
// when
val result = Stringifyable.toShortString(entity);
// then
assertEquals("short-repr", result);
}
@Test
void toShortString_whenEntityDoesNotImplementStringifyable_returnsUuidString() {
// given
val entity = new NonStringifyableTestEntity(UUID.fromString("00000000-0000-0000-0000-000000000002"));
// when
val result = Stringifyable.toShortString(entity);
// then
assertEquals("00000000-0000-0000-0000-000000000002", result);
}
private static final class NonStringifyableTestEntity implements BaseEntity<NonStringifyableTestEntity> {
private final UUID uuid;
private NonStringifyableTestEntity(final UUID uuid) {
this.uuid = uuid;
}
@Override
public UUID getUuid() {
return uuid;
}
@Override
public int getVersion() {
return 0;
}
}
private static final class StringifyableTestEntity implements BaseEntity<StringifyableTestEntity>, Stringifyable {
private final UUID uuid;
private final String shortString;
private StringifyableTestEntity(final UUID uuid, final String shortString) {
this.uuid = uuid;
this.shortString = shortString;
}
@Override
public UUID getUuid() {
return uuid;
}
@Override
public int getVersion() {
return 0;
}
@Override
public String toShortString() {
return shortString;
}
}
}
@@ -45,11 +45,13 @@ public class JsonMatcher extends BaseMatcher<CharSequence> {
@Override @Override
public boolean matches(final Object actual) { public boolean matches(final Object actual) {
if (actual == null || actual.getClass().isAssignableFrom(CharSequence.class)) { if (actual == null) {
return false; return false;
} }
try { try {
final var actualJson = new ObjectMapper().enable(INDENT_OUTPUT).writeValueAsString(actual); var actualJson = (actual instanceof CharSequence)
? actual.toString()
: new ObjectMapper().enable(INDENT_OUTPUT).writeValueAsString(actual);
JSONAssert.assertEquals(expectedJson, actualJson, compareMode); JSONAssert.assertEquals(expectedJson, actualJson, compareMode);
return true; return true;
} catch (final JSONException | JsonProcessingException e) { } catch (final JSONException | JsonProcessingException e) {