Compare commits
75 Commits
upgrade-io
...
master
Author | SHA1 | Date | |
---|---|---|---|
e97b177a92 | |||
6191bf16e0 | |||
63af33d003 | |||
3b94f117fb | |||
c181500a1d | |||
c26ae77a09 | |||
cb8a5190ce | |||
60341bf644 | |||
cc2b04472f | |||
4811c0328c | |||
cb4aecb9c8 | |||
d949604d70 | |||
f33a3a2df7 | |||
23b60641e3 | |||
285e6fbeb5 | |||
1eed0e9b21 | |||
80d79de5f4 | |||
860df4c69f | |||
b1ab1afbb6 | |||
13f258fb90 | |||
a7d586f0f7 | |||
8e02610679 | |||
fbd17a21e2 | |||
e57f4bf0c8 | |||
8b5cf8adc1 | |||
0c9931d73a | |||
2bacea7ad9 | |||
a1163bfc8d | |||
1eaeade155 | |||
2138b3eed0 | |||
0763511edd | |||
99a26aed8b | |||
5046e9a296 | |||
085876c772 | |||
e4e1216a85 | |||
d6a0511d98 | |||
e1fda412ae | |||
4d27a98c9a | |||
c191af2ea1 | |||
05e97f4844 | |||
c32361a83a | |||
46fce275ae | |||
9d2692add3 | |||
0af389d7c6 | |||
afb6771ed7 | |||
f6d66d5712 | |||
a77eaefb94 | |||
c5722e494f | |||
409f5e97c7 | |||
3391ec6cc9 | |||
6167ef2221 | |||
de88f1d842 | |||
9418303b7c | |||
d157730de7 | |||
04d9b43301 | |||
62867a4cac | |||
cbadc6e2c7 | |||
46dc653174 | |||
fc2b437a55 | |||
c23baca47a | |||
23a6f89943 | |||
2e9e5d6ef0 | |||
6c25dddcda | |||
|
85376d51af | ||
a93c097f64 | |||
1201c16094 | |||
c953b815d5 | |||
e09a09cf92 | |||
dbe695c214 | |||
66332b6de2 | |||
9806bcd78f | |||
4eda99b95a | |||
|
d8b1d18952 | ||
5b18681e96 | |||
65a4647af9 |
46
.aliases
46
.aliases
@ -1,9 +1,6 @@
|
|||||||
# For using the alias import-office-tables, # copy these exports to .environment (ignored by git)
|
# For using the alias gw-importOfficeData or gw-importHostingAssets,
|
||||||
# and amend them according to your external DB:
|
# copy the file .tc-environment to .environment (ignored by git)
|
||||||
export HSADMINNG_POSTGRES_JDBC_URL=jdbc:tc:postgresql:15.5-bookworm:///spring_boot_testcontainers
|
# and amend them according to your external DB.
|
||||||
export HSADMINNG_POSTGRES_ADMIN_USERNAME=admin
|
|
||||||
export HSADMINNG_POSTGRES_ADMIN_PASSWORD=
|
|
||||||
export HSADMINNG_POSTGRES_RESTRICTED_USERNAME=restricted
|
|
||||||
|
|
||||||
gradleWrapper () {
|
gradleWrapper () {
|
||||||
if [ ! -f gradlew ]; then
|
if [ ! -f gradlew ]; then
|
||||||
@ -45,23 +42,29 @@ postgresAutodoc () {
|
|||||||
}
|
}
|
||||||
alias postgres-autodoc=postgresAutodoc
|
alias postgres-autodoc=postgresAutodoc
|
||||||
|
|
||||||
function importOfficeData() {
|
function importLegacyData() {
|
||||||
export HSADMINNG_POSTGRES_JDBC_URL=jdbc:tc:postgresql:15.5-bookworm:///spring_boot_testcontainers
|
export target=$1
|
||||||
export HSADMINNG_POSTGRES_ADMIN_USERNAME=admin
|
if [ -z "$target" ]; then
|
||||||
export HSADMINNG_POSTGRES_ADMIN_PASSWORD=password
|
echo "importLegacyData needs target argument, but none was given" >&2
|
||||||
export HSADMINNG_POSTGRES_RESTRICTED_USERNAME=restricted
|
else
|
||||||
export HSADMINNG_SUPERUSER=superuser-alex@hostsharing.net
|
source .tc-environment
|
||||||
|
|
||||||
if [ -f .environment ]; then
|
if [ -f .environment ]; then
|
||||||
source .environment
|
source .environment
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "using environment (with ending ';' for use in IntelliJ IDEA):"
|
echo "using environment (with ending ';' for use in IntelliJ IDEA):"
|
||||||
|
echo "--- BEGIN: ---"
|
||||||
set | grep ^HSADMINNG_ | sed 's/$/;/'
|
set | grep ^HSADMINNG_ | sed 's/$/;/'
|
||||||
|
echo "---- END. ----"
|
||||||
|
echo
|
||||||
|
|
||||||
./gradlew importOfficeData --rerun
|
echo ./gradlew $target --rerun
|
||||||
|
./gradlew $target --rerun
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
alias gw-importOfficeData=importOfficeData
|
alias gw-importOfficeData='importLegacyData importOfficeData'
|
||||||
|
alias gw-importHostingAssets='importLegacyData importHostingAssets'
|
||||||
|
|
||||||
alias podman-start='systemctl --user enable --now podman.socket && systemctl --user status podman.socket && ls -la /run/user/$UID/podman/podman.sock'
|
alias podman-start='systemctl --user enable --now podman.socket && systemctl --user status podman.socket && ls -la /run/user/$UID/podman/podman.sock'
|
||||||
alias podman-stop='systemctl --user disable --now podman.socket && systemctl --user status podman.socket && ls -la /run/user/$UID/podman/podman.sock'
|
alias podman-stop='systemctl --user disable --now podman.socket && systemctl --user status podman.socket && ls -la /run/user/$UID/podman/podman.sock'
|
||||||
@ -79,5 +82,16 @@ alias pg-sql-restore='gunzip --stdout | docker exec -i hsadmin-ng-postgres psql
|
|||||||
alias fp='grep -r '@Accepts' src | sed -e 's/^.*@/@/g' | sort -u | wc -l'
|
alias fp='grep -r '@Accepts' src | sed -e 's/^.*@/@/g' | sort -u | wc -l'
|
||||||
|
|
||||||
alias gw-spotless='./gradlew spotlessApply -x pitest -x test -x :processResources'
|
alias gw-spotless='./gradlew spotlessApply -x pitest -x test -x :processResources'
|
||||||
alias gw-test='. .aliases; ./gradlew test importOfficeData'
|
alias gw-test='. .aliases; ./gradlew test'
|
||||||
alias gw-check='. .aliases; gw test importOfficeData check -x pitest -x :dependencyCheckAnalyze'
|
alias gw-check='. .aliases; gw test check -x pitest'
|
||||||
|
|
||||||
|
# etc/docker-compose.yml limits CPUs+MEM and includes a PostgreSQL config for analysing slow queries
|
||||||
|
alias gw-importOfficeData-in-docker-compose='
|
||||||
|
docker-compose -f etc/docker-compose.yml down &&
|
||||||
|
docker-compose -f etc/docker-compose.yml up -d && sleep 10 &&
|
||||||
|
time gw-importHostingAssets'
|
||||||
|
|
||||||
|
if [ ! -f .environment ]; then
|
||||||
|
cp .tc-environment .environment
|
||||||
|
fi
|
||||||
|
source .environment
|
||||||
|
5
.gitignore
vendored
5
.gitignore
vendored
@ -136,4 +136,9 @@ Desktop.ini
|
|||||||
# ESLint
|
# ESLint
|
||||||
######################
|
######################
|
||||||
.eslintcache
|
.eslintcache
|
||||||
|
|
||||||
|
######################
|
||||||
|
# Project Related
|
||||||
|
######################
|
||||||
/.environment*
|
/.environment*
|
||||||
|
/src/test/resources/migration-prod/*
|
||||||
|
37
.run/ImportHostingAssets into local.run.xml
Normal file
37
.run/ImportHostingAssets into local.run.xml
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
<component name="ProjectRunConfigurationManager">
|
||||||
|
<configuration default="false" name="ImportHostingAssets into local" type="GradleRunConfiguration" factoryName="Gradle">
|
||||||
|
<ExternalSystemSettings>
|
||||||
|
<option name="env">
|
||||||
|
<map>
|
||||||
|
<entry key="HSADMINNG_POSTGRES_ADMIN_PASSWORD" value="password" />
|
||||||
|
<entry key="HSADMINNG_POSTGRES_ADMIN_USERNAME" value="postgres" />
|
||||||
|
<entry key="HSADMINNG_POSTGRES_JDBC_URL" value="jdbc:postgresql://localhost:5432/postgres" />
|
||||||
|
<entry key="HSADMINNG_POSTGRES_RESTRICTED_USERNAME" value="restricted" />
|
||||||
|
</map>
|
||||||
|
</option>
|
||||||
|
<option name="executionName" />
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||||
|
<option name="externalSystemIdString" value="GRADLE" />
|
||||||
|
<option name="scriptParameters" value="" />
|
||||||
|
<option name="taskDescriptions">
|
||||||
|
<list />
|
||||||
|
</option>
|
||||||
|
<option name="taskNames">
|
||||||
|
<list>
|
||||||
|
<option value=":importHostingAssets" />
|
||||||
|
<option value="--tests" />
|
||||||
|
<option value=""net.hostsharing.hsadminng.hs.migration.ImportHostingAssets"" />
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
<option name="vmOptions" />
|
||||||
|
</ExternalSystemSettings>
|
||||||
|
<ExternalSystemDebugServerProcess>false</ExternalSystemDebugServerProcess>
|
||||||
|
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
|
||||||
|
<EXTENSION ID="com.intellij.execution.ExternalSystemRunConfigurationJavaExtension">
|
||||||
|
<extension name="coverage" sample_coverage="false" />
|
||||||
|
</EXTENSION>
|
||||||
|
<DebugAllEnabled>false</DebugAllEnabled>
|
||||||
|
<RunAsTest>true</RunAsTest>
|
||||||
|
<method v="2" />
|
||||||
|
</configuration>
|
||||||
|
</component>
|
36
.run/ImportHostingAssets.run.xml
Normal file
36
.run/ImportHostingAssets.run.xml
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
<component name="ProjectRunConfigurationManager">
|
||||||
|
<configuration default="false" name="ImportHostingAssets" type="GradleRunConfiguration" factoryName="Gradle">
|
||||||
|
<ExternalSystemSettings>
|
||||||
|
<option name="env">
|
||||||
|
<map>
|
||||||
|
<entry key="HSADMINNG_MIGRATION_DATA_PATH" value="migration" />
|
||||||
|
<entry key="HSADMINNG_POSTGRES_ADMIN_USERNAME" value="admin" />
|
||||||
|
<entry key="HSADMINNG_POSTGRES_RESTRICTED_USERNAME" value="restricted" />
|
||||||
|
</map>
|
||||||
|
</option>
|
||||||
|
<option name="executionName" />
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||||
|
<option name="externalSystemIdString" value="GRADLE" />
|
||||||
|
<option name="scriptParameters" value="" />
|
||||||
|
<option name="taskDescriptions">
|
||||||
|
<list />
|
||||||
|
</option>
|
||||||
|
<option name="taskNames">
|
||||||
|
<list>
|
||||||
|
<option value=":importHostingAssets" />
|
||||||
|
<option value="--tests" />
|
||||||
|
<option value=""net.hostsharing.hsadminng.hs.migration.ImportHostingAssets"" />
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
<option name="vmOptions" />
|
||||||
|
</ExternalSystemSettings>
|
||||||
|
<ExternalSystemDebugServerProcess>false</ExternalSystemDebugServerProcess>
|
||||||
|
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
|
||||||
|
<EXTENSION ID="com.intellij.execution.ExternalSystemRunConfigurationJavaExtension">
|
||||||
|
<extension name="coverage" sample_coverage="false" />
|
||||||
|
</EXTENSION>
|
||||||
|
<DebugAllEnabled>false</DebugAllEnabled>
|
||||||
|
<RunAsTest>true</RunAsTest>
|
||||||
|
<method v="2" />
|
||||||
|
</configuration>
|
||||||
|
</component>
|
103
.run/ImportOfficeData.run.xml
Normal file
103
.run/ImportOfficeData.run.xml
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
<component name="ProjectRunConfigurationManager">
|
||||||
|
<configuration default="false" name="ImportOfficeData" type="GradleRunConfiguration" factoryName="Gradle">
|
||||||
|
<ExternalSystemSettings>
|
||||||
|
<option name="env">
|
||||||
|
<map>
|
||||||
|
<entry key="HSADMINNG_MIGRATION_DATA_PATH" value="migration" />
|
||||||
|
<entry key="HSADMINNG_POSTGRES_ADMIN_USERNAME" value="admin" />
|
||||||
|
<entry key="HSADMINNG_POSTGRES_RESTRICTED_USERNAME" value="restricted" />
|
||||||
|
</map>
|
||||||
|
</option>
|
||||||
|
<option name="executionName" />
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||||
|
<option name="externalSystemIdString" value="GRADLE" />
|
||||||
|
<option name="scriptParameters" value="" />
|
||||||
|
<option name="taskDescriptions">
|
||||||
|
<list />
|
||||||
|
</option>
|
||||||
|
<option name="taskNames">
|
||||||
|
<list>
|
||||||
|
<option value=":importOfficeData" />
|
||||||
|
<option value="--tests" />
|
||||||
|
<option value=""net.hostsharing.hsadminng.hs.migration.ImportOfficeData"" />
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
<option name="vmOptions" />
|
||||||
|
</ExternalSystemSettings>
|
||||||
|
<ExternalSystemDebugServerProcess>false</ExternalSystemDebugServerProcess>
|
||||||
|
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
|
||||||
|
<EXTENSION ID="com.intellij.execution.ExternalSystemRunConfigurationJavaExtension">
|
||||||
|
<extension name="coverage" sample_coverage="false" />
|
||||||
|
</EXTENSION>
|
||||||
|
<DebugAllEnabled>false</DebugAllEnabled>
|
||||||
|
<RunAsTest>true</RunAsTest>
|
||||||
|
<method v="2" />
|
||||||
|
</configuration>
|
||||||
|
<configuration default="false" name="ImportOfficeData" type="GradleRunConfiguration" factoryName="Gradle">
|
||||||
|
<ExternalSystemSettings>
|
||||||
|
<option name="env">
|
||||||
|
<map>
|
||||||
|
<entry key="HSADMINNG_MIGRATION_DATA_PATH" value="migration" />
|
||||||
|
<entry key="HSADMINNG_POSTGRES_ADMIN_USERNAME" value="admin" />
|
||||||
|
<entry key="HSADMINNG_POSTGRES_RESTRICTED_USERNAME" value="restricted" />
|
||||||
|
</map>
|
||||||
|
</option>
|
||||||
|
<option name="executionName" />
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||||
|
<option name="externalSystemIdString" value="GRADLE" />
|
||||||
|
<option name="scriptParameters" value="" />
|
||||||
|
<option name="taskDescriptions">
|
||||||
|
<list />
|
||||||
|
</option>
|
||||||
|
<option name="taskNames">
|
||||||
|
<list>
|
||||||
|
<option value=":importOfficeData" />
|
||||||
|
<option value="--tests" />
|
||||||
|
<option value=""net.hostsharing.hsadminng.hs.office.migration.ImportOfficeData"" />
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
<option name="vmOptions" />
|
||||||
|
</ExternalSystemSettings>
|
||||||
|
<ExternalSystemDebugServerProcess>false</ExternalSystemDebugServerProcess>
|
||||||
|
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
|
||||||
|
<EXTENSION ID="com.intellij.execution.ExternalSystemRunConfigurationJavaExtension">
|
||||||
|
<extension name="coverage" sample_coverage="false" />
|
||||||
|
</EXTENSION>
|
||||||
|
<DebugAllEnabled>false</DebugAllEnabled>
|
||||||
|
<RunAsTest>true</RunAsTest>
|
||||||
|
<method v="2" />
|
||||||
|
</configuration>
|
||||||
|
<configuration default="false" name="ImportOfficeData" type="GradleRunConfiguration" factoryName="Gradle">
|
||||||
|
<ExternalSystemSettings>
|
||||||
|
<option name="env">
|
||||||
|
<map>
|
||||||
|
<entry key="HSADMINNG_POSTGRES_ADMIN_USERNAME" value="admin" />
|
||||||
|
<entry key="HSADMINNG_POSTGRES_RESTRICTED_USERNAME" value="restricted" />
|
||||||
|
</map>
|
||||||
|
</option>
|
||||||
|
<option name="executionName" />
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||||
|
<option name="externalSystemIdString" value="GRADLE" />
|
||||||
|
<option name="scriptParameters" value="" />
|
||||||
|
<option name="taskDescriptions">
|
||||||
|
<list />
|
||||||
|
</option>
|
||||||
|
<option name="taskNames">
|
||||||
|
<list>
|
||||||
|
<option value=":importOfficeData" />
|
||||||
|
<option value="--tests" />
|
||||||
|
<option value=""net.hostsharing.hsadminng.hs.migration.ImportOfficeData"" />
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
<option name="vmOptions" />
|
||||||
|
</ExternalSystemSettings>
|
||||||
|
<ExternalSystemDebugServerProcess>false</ExternalSystemDebugServerProcess>
|
||||||
|
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
|
||||||
|
<EXTENSION ID="com.intellij.execution.ExternalSystemRunConfigurationJavaExtension">
|
||||||
|
<extension name="coverage" sample_coverage="false" />
|
||||||
|
</EXTENSION>
|
||||||
|
<DebugAllEnabled>false</DebugAllEnabled>
|
||||||
|
<RunAsTest>true</RunAsTest>
|
||||||
|
<method v="2" />
|
||||||
|
</configuration>
|
||||||
|
</component>
|
1
.run/README.txt
Normal file
1
.run/README.txt
Normal file
@ -0,0 +1 @@
|
|||||||
|
Stored run-Configurations for IntelliJ IDEA.
|
8
.tc-environment
Normal file
8
.tc-environment
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
unset HSADMINNG_POSTGRES_JDBC_URL # dynamically set, different for normal tests and imports
|
||||||
|
export HSADMINNG_POSTGRES_ADMIN_USERNAME=admin
|
||||||
|
export HSADMINNG_POSTGRES_ADMIN_PASSWORD=
|
||||||
|
export HSADMINNG_POSTGRES_RESTRICTED_USERNAME=restricted
|
||||||
|
export HSADMINNG_SUPERUSER=superuser-alex@hostsharing.net
|
||||||
|
export HSADMINNG_MIGRATION_DATA_PATH=migration
|
||||||
|
export LIQUIBASE_CONTEXT=
|
||||||
|
export LANG=en_US.UTF-8
|
8
.unset-environment
Normal file
8
.unset-environment
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
unset HSADMINNG_POSTGRES_JDBC_URL
|
||||||
|
unset HSADMINNG_POSTGRES_ADMIN_USERNAME
|
||||||
|
unset HSADMINNG_POSTGRES_ADMIN_PASSWORD
|
||||||
|
unset HSADMINNG_POSTGRES_RESTRICTED_USERNAME
|
||||||
|
unset HSADMINNG_SUPERUSER
|
||||||
|
unset HSADMINNG_MIGRATION_DATA_PATH
|
||||||
|
unset LIQUIBASE_CONTEXT
|
||||||
|
|
10
Dockerfile
Normal file
10
Dockerfile
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
# build using:
|
||||||
|
# docker build -t postgres-with-contrib:15.5-bookworm .
|
||||||
|
|
||||||
|
FROM postgres:15.5-bookworm
|
||||||
|
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y postgresql-contrib && \
|
||||||
|
apt-get clean
|
||||||
|
|
||||||
|
COPY etc/postgresql-log-slow-queries.conf /etc/postgresql/postgresql.conf
|
84
Jenkinsfile
vendored
Normal file
84
Jenkinsfile
vendored
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
pipeline {
|
||||||
|
agent {
|
||||||
|
dockerfile {
|
||||||
|
filename 'etc/jenkinsAgent.Dockerfile'
|
||||||
|
// additionalBuildArgs ...
|
||||||
|
args '--network=bridge --user root -v $PWD:$PWD -v /var/run/docker.sock:/var/run/docker.sock --group-add 984'
|
||||||
|
reuseNode true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
environment {
|
||||||
|
DOCKER_HOST = 'unix:///var/run/docker.sock'
|
||||||
|
HSADMINNG_POSTGRES_ADMIN_USERNAME = 'admin'
|
||||||
|
HSADMINNG_POSTGRES_RESTRICTED_USERNAME = 'restricted'
|
||||||
|
HSADMINNG_MIGRATION_DATA_PATH = 'migration'
|
||||||
|
}
|
||||||
|
|
||||||
|
triggers {
|
||||||
|
pollSCM('H/1 * * * *')
|
||||||
|
}
|
||||||
|
|
||||||
|
stages {
|
||||||
|
stage('Checkout') {
|
||||||
|
steps {
|
||||||
|
checkout scm
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage ('Compile') {
|
||||||
|
steps {
|
||||||
|
sh './gradlew clean processSpring compileJava compileTestJava --no-daemon'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage ('Tests') {
|
||||||
|
parallel {
|
||||||
|
stage('Unit-/Integration/Acceptance-Tests') {
|
||||||
|
steps {
|
||||||
|
sh './gradlew check --no-daemon -x pitest -x dependencyCheckAnalyze -x importOfficeData -x importHostingAssets'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stage('Import-Tests') {
|
||||||
|
steps {
|
||||||
|
sh './gradlew importOfficeData importHostingAssets --no-daemon'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stage ('Scenario-Tests') {
|
||||||
|
steps {
|
||||||
|
sh './gradlew scenarioTests --no-daemon'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage ('Check') {
|
||||||
|
steps {
|
||||||
|
sh './gradlew check -x pitest -x dependencyCheckAnalyze --no-daemon'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
post {
|
||||||
|
always {
|
||||||
|
// archive test results
|
||||||
|
junit 'build/test-results/test/*.xml'
|
||||||
|
|
||||||
|
// archive the JaCoCo coverage report in XML and HTML format
|
||||||
|
jacoco(
|
||||||
|
execPattern: 'build/jacoco/*.exec',
|
||||||
|
classPattern: 'build/classes/java/main',
|
||||||
|
sourcePattern: 'src/main/java'
|
||||||
|
)
|
||||||
|
|
||||||
|
// archive scenario-test reports in HTML format
|
||||||
|
sh '''
|
||||||
|
./gradlew convertMarkdownToHtml
|
||||||
|
'''
|
||||||
|
archiveArtifacts artifacts: 'doc/scenarios/*.html', allowEmptyArchive: true
|
||||||
|
|
||||||
|
// cleanup workspace
|
||||||
|
cleanWs()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
45
README.md
45
README.md
@ -77,17 +77,17 @@ If you have at least Docker and the Java JDK installed in appropriate versions a
|
|||||||
|
|
||||||
# the following command should return a JSON array with just all customers:
|
# the following command should return a JSON array with just all customers:
|
||||||
curl \
|
curl \
|
||||||
-H 'current-user: superuser-alex@hostsharing.net' \
|
-H 'current-subject: superuser-alex@hostsharing.net' \
|
||||||
http://localhost:8080/api/test/customers
|
http://localhost:8080/api/test/customers
|
||||||
|
|
||||||
# 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:
|
||||||
curl \
|
curl \
|
||||||
-H 'current-user: superuser-alex@hostsharing.net' -H 'assumed-roles: test_customer#yyy:ADMIN' \
|
-H 'current-subject: superuser-alex@hostsharing.net' -H 'assumed-roles: rbactest.customer#yyy:ADMIN' \
|
||||||
http://localhost:8080/api/test/packages
|
http://localhost:8080/api/test/packages
|
||||||
|
|
||||||
# add a new customer
|
# add a new customer
|
||||||
curl \
|
curl \
|
||||||
-H 'current-user: superuser-alex@hostsharing.net' -H "Content-Type: application/json" \
|
-H 'current-subject: superuser-alex@hostsharing.net' -H "Content-Type: application/json" \
|
||||||
-d '{ "prefix":"ttt", "reference":80001, "adminUserName":"admin@ttt.example.com" }' \
|
-d '{ "prefix":"ttt", "reference":80001, "adminUserName":"admin@ttt.example.com" }' \
|
||||||
-X POST http://localhost:8080/api/test/customers
|
-X POST http://localhost:8080/api/test/customers
|
||||||
|
|
||||||
@ -497,9 +497,19 @@ We'll see if this changes when the project progresses and more validations are a
|
|||||||
|
|
||||||
### OWASP Security Vulnerability Check
|
### OWASP Security Vulnerability Check
|
||||||
|
|
||||||
An OWASP security vulnerability is configured and can be utilized by running:
|
An OWASP security vulnerability is configured, but you need an API key.
|
||||||
|
Fetch it from https://nvd.nist.gov/developers/request-an-api-key.
|
||||||
|
|
||||||
|
Then add it to your `~/.gradle/gradle.properties` file:
|
||||||
|
|
||||||
|
```
|
||||||
|
OWASP_API_KEY=........-....-....-....-............
|
||||||
|
```
|
||||||
|
|
||||||
|
Now you can run the dependency vulnerability check:
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
|
gw dependencyCheckUpdate
|
||||||
gw dependencyCheckAnalyze
|
gw dependencyCheckAnalyze
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -550,12 +560,37 @@ Dependency versions can be automatically upgraded to the latest available versio
|
|||||||
gw useLatestVersions
|
gw useLatestVersions
|
||||||
```
|
```
|
||||||
|
|
||||||
Afterwards, `gw check` is automatically started.
|
Afterward, `gw check` is automatically started.
|
||||||
Please only commit+push to master if the check run shows no errors.
|
Please only commit+push to master if the check run shows no errors.
|
||||||
|
|
||||||
More infos, e.g. on blacklists see on the [project's website](https://github.com/patrikerdes/gradle-use-latest-versions-plugin).
|
More infos, e.g. on blacklists see on the [project's website](https://github.com/patrikerdes/gradle-use-latest-versions-plugin).
|
||||||
|
|
||||||
|
|
||||||
|
## Biggest Flaws in our Architecture
|
||||||
|
|
||||||
|
### The RBAC System is too Complicated
|
||||||
|
|
||||||
|
Now, where we have a better experience with what we really need from the RBAC system, we have learned
|
||||||
|
that and creates too many (grant- and role-) rows and too even tables which could be avoided completely.
|
||||||
|
|
||||||
|
The basic idea is always to always have a fixed set of ordered role-types which apply for all DB-tables under RBAC,
|
||||||
|
e.g. OWNER>ADMIN>AGENT\[>PROXY?\]>TENENT>REFERRER.
|
||||||
|
Grants between these for the same DB-row would be implicit by order comparision.
|
||||||
|
This way we would get rid of all explicit grants within the same DB-row
|
||||||
|
and would not need the `rbac.role` table anymore.
|
||||||
|
We would also reduce the depth of the expensive recursive CTE-query.
|
||||||
|
|
||||||
|
This has to be explored further.
|
||||||
|
For now, we just keep it in mind and
|
||||||
|
|
||||||
|
### The Mapper is Error-Prone
|
||||||
|
|
||||||
|
Where `org.modelmapper.ModelMapper` reduces bloat-code a lot and has some nice features about recursive data-structure mappings,
|
||||||
|
it often causes strange errors which are hard to fix.
|
||||||
|
E.g. the uuid of the target main object is often taken from an uuid of a sub-subject.
|
||||||
|
(For now, use `StrictMapper` to avoid this, for the case it happens.)
|
||||||
|
|
||||||
|
|
||||||
## How To ...
|
## How To ...
|
||||||
|
|
||||||
### How to Configure .pgpass for the Default PostgreSQL Database?
|
### How to Configure .pgpass for the Default PostgreSQL Database?
|
||||||
|
38
bin/git-pull-and-if-origin-changed-run-tests
Executable file
38
bin/git-pull-and-if-origin-changed-run-tests
Executable file
@ -0,0 +1,38 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# waits for commits on any branch on origin, checks it out and builds it
|
||||||
|
|
||||||
|
. .aliases
|
||||||
|
|
||||||
|
while true; do
|
||||||
|
git fetch origin >/dev/null
|
||||||
|
branch_with_new_commits=`git fetch origin >/dev/null; git for-each-ref --format='%(refname:short) %(upstream:track)' refs/heads | grep '\[behind' | cut -d' ' -f1 | head -n1`
|
||||||
|
|
||||||
|
if [ -n "$branch_with_new_commits" ]; then
|
||||||
|
echo "checking out branch: $branch_with_new_commits"
|
||||||
|
if git show-ref --quiet --heads "$branch_with_new_commits"; then
|
||||||
|
echo "Branch $branch_with_new_commits already exists. Checking it out and pulling latest changes."
|
||||||
|
git checkout "$branch_with_new_commits"
|
||||||
|
git pull origin "$branch_with_new_commits"
|
||||||
|
else
|
||||||
|
echo "Creating and checking out new branch: $branch_with_new_commits"
|
||||||
|
git checkout -b "$branch_with_new_commits" "origin/$branch_with_new_commits"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "building ..."
|
||||||
|
./gradlew gw clean test check -x pitest
|
||||||
|
fi
|
||||||
|
|
||||||
|
# wait 10s with a little animation
|
||||||
|
echo -e -n "\r\033[K waiting for changes (/) ..."
|
||||||
|
sleep 2
|
||||||
|
echo -e -n "\r\033[K waiting for changes (-) ..."
|
||||||
|
sleep 2
|
||||||
|
echo -e -n "\r\033[K waiting for changes (\) ..."
|
||||||
|
sleep 2
|
||||||
|
echo -e -n "\r\033[K waiting for changes (|) ..."
|
||||||
|
sleep 2
|
||||||
|
echo -e -n "\r\033[K waiting for changes ( ) ... "
|
||||||
|
sleep 2
|
||||||
|
echo -e -n "\r\033[K checking for changes"
|
||||||
|
done
|
||||||
|
|
134
build.gradle
134
build.gradle
@ -1,10 +1,10 @@
|
|||||||
plugins {
|
plugins {
|
||||||
id 'java'
|
id 'java'
|
||||||
id 'org.springframework.boot' version '3.2.4'
|
id 'org.springframework.boot' version '3.3.4'
|
||||||
id 'io.spring.dependency-management' version '1.1.4'
|
id 'io.spring.dependency-management' version '1.1.6'
|
||||||
id 'io.openapiprocessor.openapi-processor' version '2023.2'
|
id 'io.openapiprocessor.openapi-processor' version '2023.2'
|
||||||
id 'com.github.jk1.dependency-license-report' version '2.6'
|
id 'com.github.jk1.dependency-license-report' version '2.9'
|
||||||
id "org.owasp.dependencycheck" version "9.0.10"
|
id "org.owasp.dependencycheck" version "10.0.4"
|
||||||
id "com.diffplug.spotless" version "6.25.0"
|
id "com.diffplug.spotless" version "6.25.0"
|
||||||
id 'jacoco'
|
id 'jacoco'
|
||||||
id 'info.solidsoft.pitest' version '1.15.0'
|
id 'info.solidsoft.pitest' version '1.15.0'
|
||||||
@ -58,18 +58,20 @@ dependencies {
|
|||||||
implementation 'org.springframework.boot:spring-boot-starter-jdbc'
|
implementation 'org.springframework.boot:spring-boot-starter-jdbc'
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-web'
|
implementation 'org.springframework.boot:spring-boot-starter-web'
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-validation'
|
implementation 'org.springframework.boot:spring-boot-starter-validation'
|
||||||
implementation 'com.github.gavlyukovskiy:datasource-proxy-spring-boot-starter:1.9.1'
|
implementation 'com.github.gavlyukovskiy:datasource-proxy-spring-boot-starter:1.9.2'
|
||||||
implementation 'org.springdoc:springdoc-openapi:2.4.0'
|
implementation 'org.springdoc:springdoc-openapi:2.6.0'
|
||||||
implementation 'org.postgresql:postgresql:42.7.3'
|
implementation 'org.postgresql:postgresql:42.7.4'
|
||||||
implementation 'org.liquibase:liquibase-core:4.27.0'
|
implementation 'org.liquibase:liquibase-core:4.29.2'
|
||||||
implementation 'io.hypersistence:hypersistence-utils-hibernate-63:3.7.3'
|
implementation 'io.hypersistence:hypersistence-utils-hibernate-63:3.8.3'
|
||||||
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.17.0'
|
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.18.0'
|
||||||
implementation 'org.openapitools:jackson-databind-nullable:0.2.6'
|
implementation 'org.openapitools:jackson-databind-nullable:0.2.6'
|
||||||
implementation 'org.apache.commons:commons-text:1.11.0'
|
implementation 'org.apache.commons:commons-text:1.12.0'
|
||||||
implementation 'org.modelmapper:modelmapper:3.2.0'
|
implementation 'net.java.dev.jna:jna:5.15.0'
|
||||||
implementation 'org.iban4j:iban4j:3.2.7-RELEASE'
|
implementation 'org.modelmapper:modelmapper:3.2.1'
|
||||||
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.4.0'
|
implementation 'org.iban4j:iban4j:3.2.10-RELEASE'
|
||||||
implementation 'org.reflections:reflections:0.9.12'
|
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0'
|
||||||
|
implementation 'org.webjars:swagger-ui:5.17.14'
|
||||||
|
implementation 'org.reflections:reflections:0.10.2'
|
||||||
|
|
||||||
compileOnly 'org.projectlombok:lombok'
|
compileOnly 'org.projectlombok:lombok'
|
||||||
testCompileOnly 'org.projectlombok:lombok'
|
testCompileOnly 'org.projectlombok:lombok'
|
||||||
@ -84,9 +86,9 @@ dependencies {
|
|||||||
testImplementation 'org.testcontainers:junit-jupiter'
|
testImplementation 'org.testcontainers:junit-jupiter'
|
||||||
testImplementation 'org.junit.jupiter:junit-jupiter'
|
testImplementation 'org.junit.jupiter:junit-jupiter'
|
||||||
testImplementation 'org.testcontainers:postgresql'
|
testImplementation 'org.testcontainers:postgresql'
|
||||||
testImplementation 'com.tngtech.archunit:archunit-junit5:1.2.1'
|
testImplementation 'com.tngtech.archunit:archunit-junit5:1.3.0'
|
||||||
testImplementation 'io.rest-assured:spring-mock-mvc'
|
testImplementation 'io.rest-assured:spring-mock-mvc'
|
||||||
testImplementation 'org.hamcrest:hamcrest-core:2.2'
|
testImplementation 'org.hamcrest:hamcrest-core:3.0'
|
||||||
testImplementation 'org.pitest:pitest-junit5-plugin:1.2.1'
|
testImplementation 'org.pitest:pitest-junit5-plugin:1.2.1'
|
||||||
testImplementation 'org.junit.jupiter:junit-jupiter-api'
|
testImplementation 'org.junit.jupiter:junit-jupiter-api'
|
||||||
}
|
}
|
||||||
@ -151,13 +153,22 @@ openapiProcessor {
|
|||||||
}
|
}
|
||||||
springHsBooking {
|
springHsBooking {
|
||||||
processorName 'spring'
|
processorName 'spring'
|
||||||
processor 'io.openapiprocessor:openapi-processor-spring:2024.2'
|
processor 'io.openapiprocessor:openapi-processor-spring:2022.5'
|
||||||
apiPath "$projectDir/src/main/resources/api-definition/hs-booking/hs-booking.yaml"
|
apiPath "$projectDir/src/main/resources/api-definition/hs-booking/hs-booking.yaml"
|
||||||
mapping "$projectDir/src/main/resources/api-definition/hs-booking/api-mappings.yaml"
|
mapping "$projectDir/src/main/resources/api-definition/hs-booking/api-mappings.yaml"
|
||||||
targetDir "$buildDir/generated/sources/openapi-javax"
|
targetDir "$buildDir/generated/sources/openapi-javax"
|
||||||
showWarnings true
|
showWarnings true
|
||||||
openApiNullable true
|
openApiNullable true
|
||||||
}
|
}
|
||||||
|
springHsHosting {
|
||||||
|
processorName 'spring'
|
||||||
|
processor 'io.openapiprocessor:openapi-processor-spring:2022.5'
|
||||||
|
apiPath "$projectDir/src/main/resources/api-definition/hs-hosting/hs-hosting.yaml"
|
||||||
|
mapping "$projectDir/src/main/resources/api-definition/hs-hosting/api-mappings.yaml"
|
||||||
|
targetDir "$buildDir/generated/sources/openapi-javax"
|
||||||
|
showWarnings true
|
||||||
|
openApiNullable true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
sourceSets.main.java.srcDir 'build/generated/sources/openapi'
|
sourceSets.main.java.srcDir 'build/generated/sources/openapi'
|
||||||
abstract class ProcessSpring extends DefaultTask {}
|
abstract class ProcessSpring extends DefaultTask {}
|
||||||
@ -166,7 +177,8 @@ tasks.register('processSpring', ProcessSpring)
|
|||||||
'processSpringRbac',
|
'processSpringRbac',
|
||||||
'processSpringTest',
|
'processSpringTest',
|
||||||
'processSpringHsOffice',
|
'processSpringHsOffice',
|
||||||
'processSpringHsBooking'
|
'processSpringHsBooking',
|
||||||
|
'processSpringHsHosting'
|
||||||
].each {
|
].each {
|
||||||
project.tasks.processSpring.dependsOn it
|
project.tasks.processSpring.dependsOn it
|
||||||
}
|
}
|
||||||
@ -174,7 +186,7 @@ project.tasks.processResources.dependsOn processSpring
|
|||||||
project.tasks.compileJava.dependsOn processSpring
|
project.tasks.compileJava.dependsOn processSpring
|
||||||
|
|
||||||
// Rename javax to jakarta in OpenApi generated java files because
|
// Rename javax to jakarta in OpenApi generated java files because
|
||||||
// io.openapiprocessor.openapi-processor 2022.2 does not yet support the openapiprocessor useSpringBoot3 config option.
|
// io.openapiprocessor.openapi-processor 2022.5 does not yet support the openapiprocessor useSpringBoot3 config option.
|
||||||
// TODO.impl: Upgrade to io.openapiprocessor.openapi-processor >= 2024.2
|
// TODO.impl: Upgrade to io.openapiprocessor.openapi-processor >= 2024.2
|
||||||
// and use either `bean-validation: true` in api-mapping.yaml or `useSpringBoot3 true` (not sure where exactly).
|
// and use either `bean-validation: true` in api-mapping.yaml or `useSpringBoot3 true` (not sure where exactly).
|
||||||
task openApiGenerate(type: Copy) {
|
task openApiGenerate(type: Copy) {
|
||||||
@ -243,7 +255,7 @@ test {
|
|||||||
'net.hostsharing.hsadminng.**.generated.**',
|
'net.hostsharing.hsadminng.**.generated.**',
|
||||||
]
|
]
|
||||||
useJUnitPlatform {
|
useJUnitPlatform {
|
||||||
excludeTags 'import'
|
excludeTags 'importOfficeData', 'importHostingData', 'scenarioTest'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
jacocoTestReport {
|
jacocoTestReport {
|
||||||
@ -265,7 +277,7 @@ jacocoTestCoverageVerification {
|
|||||||
violationRules {
|
violationRules {
|
||||||
rule {
|
rule {
|
||||||
limit {
|
limit {
|
||||||
minimum = 0.92
|
minimum = 0.80 // TODO.test: improve instruction coverage
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -277,15 +289,20 @@ jacocoTestCoverageVerification {
|
|||||||
element = 'CLASS'
|
element = 'CLASS'
|
||||||
excludes = [
|
excludes = [
|
||||||
'net.hostsharing.hsadminng.**.generated.**',
|
'net.hostsharing.hsadminng.**.generated.**',
|
||||||
|
'net.hostsharing.hsadminng.rbac.test.dom.TestDomainEntity',
|
||||||
'net.hostsharing.hsadminng.HsadminNgApplication',
|
'net.hostsharing.hsadminng.HsadminNgApplication',
|
||||||
'net.hostsharing.hsadminng.ping.PingController',
|
'net.hostsharing.hsadminng.ping.PingController',
|
||||||
|
'net.hostsharing.hsadminng.rbac.generator.*',
|
||||||
|
'net.hostsharing.hsadminng.rbac.grant.RbacGrantsDiagramService',
|
||||||
|
'net.hostsharing.hsadminng.rbac.grant.RbacGrantsDiagramService.Node',
|
||||||
|
'net.hostsharing.hsadminng.**.*Repository',
|
||||||
'net.hostsharing.hsadminng.mapper.Mapper'
|
'net.hostsharing.hsadminng.mapper.Mapper'
|
||||||
]
|
]
|
||||||
|
|
||||||
limit {
|
limit {
|
||||||
counter = 'LINE'
|
counter = 'LINE'
|
||||||
value = 'COVEREDRATIO'
|
value = 'COVEREDRATIO'
|
||||||
minimum = 0.98
|
minimum = 0.75 // TODO.test: improve line coverage
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
rule {
|
rule {
|
||||||
@ -299,7 +316,7 @@ jacocoTestCoverageVerification {
|
|||||||
limit {
|
limit {
|
||||||
counter = 'BRANCH'
|
counter = 'BRANCH'
|
||||||
value = 'COVEREDRATIO'
|
value = 'COVEREDRATIO'
|
||||||
minimum = 1.00
|
minimum = 0.00 // TODO.test: improve branch coverage
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -307,7 +324,7 @@ jacocoTestCoverageVerification {
|
|||||||
|
|
||||||
tasks.register('importOfficeData', Test) {
|
tasks.register('importOfficeData', Test) {
|
||||||
useJUnitPlatform {
|
useJUnitPlatform {
|
||||||
includeTags 'import'
|
includeTags 'importOfficeData'
|
||||||
}
|
}
|
||||||
|
|
||||||
group 'verification'
|
group 'verification'
|
||||||
@ -316,20 +333,41 @@ tasks.register('importOfficeData', Test) {
|
|||||||
mustRunAfter spotlessJava
|
mustRunAfter spotlessJava
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tasks.register('importHostingAssets', Test) {
|
||||||
|
useJUnitPlatform {
|
||||||
|
includeTags 'importHostingAssets'
|
||||||
|
}
|
||||||
|
|
||||||
|
group 'verification'
|
||||||
|
description 'run the import jobs as tests'
|
||||||
|
|
||||||
|
mustRunAfter spotlessJava
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.register('scenarioTests', Test) {
|
||||||
|
useJUnitPlatform {
|
||||||
|
includeTags 'scenarioTest'
|
||||||
|
}
|
||||||
|
|
||||||
|
group 'verification'
|
||||||
|
description 'run the import jobs as tests'
|
||||||
|
|
||||||
|
mustRunAfter spotlessJava
|
||||||
|
}
|
||||||
|
|
||||||
// pitest mutation testing
|
// pitest mutation testing
|
||||||
pitest {
|
pitest {
|
||||||
targetClasses = ['net.hostsharing.hsadminng.**']
|
targetClasses = ['net.hostsharing.hsadminng.**']
|
||||||
excludedClasses = [
|
excludedClasses = [
|
||||||
'net.hostsharing.hsadminng.config.**',
|
'net.hostsharing.hsadminng.config.**',
|
||||||
'net.hostsharing.hsadminng.**.*Controller',
|
// 'net.hostsharing.hsadminng.**.*Controller',
|
||||||
'net.hostsharing.hsadminng.**.generated.**'
|
'net.hostsharing.hsadminng.**.generated.**'
|
||||||
]
|
]
|
||||||
|
|
||||||
targetTests = ['net.hostsharing.hsadminng.**.*UnitTest', 'net.hostsharing.hsadminng.**.*RestTest']
|
targetTests = ['net.hostsharing.hsadminng.**.*UnitTest', 'net.hostsharing.hsadminng.**.*RestTest']
|
||||||
excludedTestClasses = ['**AcceptanceTest*', '**IntegrationTest*']
|
excludedTestClasses = ['**AcceptanceTest*', '**IntegrationTest*']
|
||||||
|
|
||||||
pitestVersion = '1.15.3'
|
pitestVersion = '1.17.0'
|
||||||
junit5PluginVersion = '1.1.0'
|
junit5PluginVersion = '1.1.0'
|
||||||
|
|
||||||
threads = 4
|
threads = 4
|
||||||
@ -364,3 +402,45 @@ tasks.named("dependencyUpdates").configure {
|
|||||||
isNonStable(it.candidate.version)
|
isNonStable(it.candidate.version)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Generate HTML from Markdown scenario-test-reports using Pandoc:
|
||||||
|
tasks.register('convertMarkdownToHtml') {
|
||||||
|
description = 'Generates HTML from Markdown scenario-test-reports using Pandoc.'
|
||||||
|
group = 'Conversion'
|
||||||
|
|
||||||
|
// Define the template file and input directory
|
||||||
|
def templateFile = file('doc/scenarios/template.html')
|
||||||
|
|
||||||
|
// Task configuration and execution
|
||||||
|
doFirst {
|
||||||
|
// Check if pandoc is installed
|
||||||
|
try {
|
||||||
|
exec {
|
||||||
|
commandLine 'pandoc', '--version'
|
||||||
|
}
|
||||||
|
} catch (Exception) {
|
||||||
|
throw new GradleException("Pandoc is not installed or not found in the system path.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the template file exists
|
||||||
|
if (!templateFile.exists()) {
|
||||||
|
throw new GradleException("Template file 'doc/scenarios/template.html' not found.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
doLast {
|
||||||
|
// Gather all Markdown files in the current directory
|
||||||
|
fileTree(dir: '.', include: 'doc/scenarios/*.md').each { file ->
|
||||||
|
// Corrected way to create the output file path
|
||||||
|
def outputFile = new File(file.parent, file.name.replaceAll(/\.md$/, '.html'))
|
||||||
|
|
||||||
|
// Execute pandoc for each markdown file
|
||||||
|
exec {
|
||||||
|
commandLine 'pandoc', file.absolutePath, '--template', templateFile.absolutePath, '-o', outputFile.absolutePath
|
||||||
|
}
|
||||||
|
|
||||||
|
println "Converted ${file.name} to ${outputFile.name}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -14,9 +14,9 @@ The core problem here is, that in our RBAC system, determining the permissions o
|
|||||||
|
|
||||||
### Technical Background
|
### Technical Background
|
||||||
|
|
||||||
The session variable `hsadminng.currentUser` contains the accessing (domain-level) user, which is unrelated to the PostgreSQL user).
|
The session variable `hsadminng.currentSubject` contains the accessing (domain-level) user, which is unrelated to the PostgreSQL user).
|
||||||
|
|
||||||
Given is a stored function `isPermissionGrantedToSubject` which detects if the accessing user has a given permission (e.g. 'view').
|
Given is a stored function `isPermissionGrantedToSubject` which detects if the accessing subject has a given permission (e.g. 'view').
|
||||||
|
|
||||||
Given is also a stored function `queryAllPermissionsOfSubjectId` which returns the flattened view to all permissions assigned to the given accessing user.
|
Given is also a stored function `queryAllPermissionsOfSubjectId` which returns the flattened view to all permissions assigned to the given accessing user.
|
||||||
|
|
||||||
@ -38,7 +38,7 @@ In this solution, the database ignores row level visibility and returns all rows
|
|||||||
|
|
||||||
Very flexible access, programmatic, rules could be implemented.
|
Very flexible access, programmatic, rules could be implemented.
|
||||||
|
|
||||||
The role-hierarchy and permissions for currently logged-in users user could be cached in the backend.
|
The role-hierarchy and permissions for current subjects (e.g. logged-in users) could be cached in the backend.
|
||||||
|
|
||||||
The access logic can be tested in pure Java unit tests.
|
The access logic can be tested in pure Java unit tests.
|
||||||
|
|
||||||
@ -74,11 +74,11 @@ For restricted DB-users, which are used by the backend, access to rows is filter
|
|||||||
FOR SELECT
|
FOR SELECT
|
||||||
TO restricted
|
TO restricted
|
||||||
USING (
|
USING (
|
||||||
isPermissionGrantedToSubject(findEffectivePermissionId('customer', id, 'view'), currentUserUuid())
|
rbac.isPermissionGrantedToSubject(rbac.findEffectivePermissionId('customer', id, 'view'), currentSubjectUuid())
|
||||||
);
|
);
|
||||||
|
|
||||||
SET SESSION AUTHORIZATION restricted;
|
SET SESSION AUTHORIZATION restricted;
|
||||||
SET hsadminng.currentUser TO 'alex@example.com';
|
SET hsadminng.currentSubject TO 'alex@example.com';
|
||||||
SELECT * from customer; -- will only return visible rows
|
SELECT * from customer; -- will only return visible rows
|
||||||
|
|
||||||
#### Advantages
|
#### Advantages
|
||||||
@ -101,10 +101,10 @@ We are bound to PostgreSQL, including integration tests and testing the RBAC sys
|
|||||||
CREATE OR REPLACE RULE "_RETURN" AS
|
CREATE OR REPLACE RULE "_RETURN" AS
|
||||||
ON SELECT TO cust_view
|
ON SELECT TO cust_view
|
||||||
DO INSTEAD
|
DO INSTEAD
|
||||||
SELECT * FROM customer WHERE isPermissionGrantedToSubject(findEffectivePermissionId('customer', id, 'view'), currentUserUuid());
|
SELECT * FROM customer WHERE rbac.isPermissionGrantedToSubject(rbac.findEffectivePermissionId('customer', id, 'view'), currentSubjectUuid());
|
||||||
|
|
||||||
SET SESSION AUTHORIZATION restricted;
|
SET SESSION AUTHORIZATION restricted;
|
||||||
SET hsadminng.currentUser TO 'alex@example.com';
|
SET hsadminng.currentSubject TO 'alex@example.com';
|
||||||
SELECT * from customer; -- will only return visible rows
|
SELECT * from customer; -- will only return visible rows
|
||||||
|
|
||||||
#### Advantages
|
#### Advantages
|
||||||
@ -130,12 +130,12 @@ We do not access the tables directly from the backend, but via views which join
|
|||||||
CREATE OR REPLACE VIEW cust_view AS
|
CREATE OR REPLACE VIEW cust_view AS
|
||||||
SELECT c.id, c.reference, c.prefix
|
SELECT c.id, c.reference, c.prefix
|
||||||
FROM customer AS c
|
FROM customer AS c
|
||||||
JOIN queryAllPermissionsOfSubjectId(currentUserUuid()) AS p
|
JOIN queryAllPermissionsOfSubjectId(currentSubjectUuid()) AS p
|
||||||
ON p.tableName='customer' AND p.rowId=c.id AND p.op='view';
|
ON p.tableName='customer' AND p.rowId=c.id AND p.op='view';
|
||||||
GRANT ALL PRIVILEGES ON cust_view TO restricted;
|
GRANT ALL PRIVILEGES ON cust_view TO restricted;
|
||||||
|
|
||||||
SET SESSION SESSION AUTHORIZATION restricted;
|
SET SESSION SESSION AUTHORIZATION restricted;
|
||||||
SET hsadminng.currentUser TO 'alex@example.com';
|
SET hsadminng.currentSubject TO 'alex@example.com';
|
||||||
SELECT * from cust_view; -- will only return visible rows
|
SELECT * from cust_view; -- will only return visible rows
|
||||||
|
|
||||||
Alternatively the JOIN could also be applied in a "ON SELECT DO INSTEAD"-RULE, if there is any advantage for later features.
|
Alternatively the JOIN could also be applied in a "ON SELECT DO INSTEAD"-RULE, if there is any advantage for later features.
|
||||||
|
@ -0,0 +1,119 @@
|
|||||||
|
# Handling Automatic Creation of Hosting Assets for New Booking Items
|
||||||
|
|
||||||
|
**Status:**
|
||||||
|
- [x] proposed by (Michael Hönnig)
|
||||||
|
- [ ] accepted by (Participants)
|
||||||
|
- [ ] rejected by (Participants)
|
||||||
|
- [ ] superseded by (superseding ADR)
|
||||||
|
|
||||||
|
|
||||||
|
## Context and Problem Statement
|
||||||
|
|
||||||
|
When a customer creates a new booking item (e.g., `MANAGED_WEBSPACE`), the system must automatically create the related hosting asset.
|
||||||
|
This process can sometimes fail or require additional data from the user, e.g. installing a DNS verification key, or a hostmaster, e.g. the target server to use.
|
||||||
|
|
||||||
|
The challenge is how to handle this automatic creation process while dealing with missing data, asynchronicity and failures while ensuring system consistency and proper user notification.
|
||||||
|
|
||||||
|
|
||||||
|
### Technical Background
|
||||||
|
|
||||||
|
The creation of hosting assets can occur synchronously (in simple cases) or asynchronously (when additional steps like manual verification are needed).
|
||||||
|
For example, a `DOMAIN_SETUP` hosting asset may require DNS verification from the user, and until this is provided, the related domain cannot be fully set up.
|
||||||
|
|
||||||
|
Additionally, not all data needed for creating the hosting asset is stored in the booking item.
|
||||||
|
It's part of the HTTP request and later stored in the hosting asset, but we also need to store it before the hosting asset can be created asynchronously.
|
||||||
|
|
||||||
|
Current system behavior involves returning HTTP 201 upon booking item creation, but the automatic hosting asset creation might fail due to missing information.
|
||||||
|
The system needs to manage the creation process in a way that ensures valid hosting assets are created and informs the user of any actions required while still returning a 201 HTTP code, not an error code.
|
||||||
|
|
||||||
|
|
||||||
|
## Considered Options
|
||||||
|
|
||||||
|
For storing the data needed for the hosting-asset creation:
|
||||||
|
|
||||||
|
* STORAGE-1: Store temporary asset data in the `BookingItemEntity`, e.g. a JSON column.
|
||||||
|
And delete the value of that column, once the hosting assets got successfully created.
|
||||||
|
* STORAGE-2: Create hosting assets immediately, even if invalid, but mark them as "inactive" until completed and fully validated.
|
||||||
|
* STORAGE-3: Store the asset data in a kind of event- or job-queue, which get deleted once the hosting-asset got successfully created.
|
||||||
|
|
||||||
|
For the user-notification status:
|
||||||
|
|
||||||
|
* STATUS-1: Introduce a status field in the booking-items.
|
||||||
|
* STATUS-2: Store the status in the event-/job-queue entries.
|
||||||
|
|
||||||
|
### STORAGE-1: Temporary Data Storage in `BookingItemEntity`
|
||||||
|
|
||||||
|
Store asset-related data (e.g., domain name) in a temporary column or JSON field in the `BookingItemEntity` until the hosting assets are successfully created.
|
||||||
|
Once assets are created, the temporary data is deleted to avoid inconsistencies.
|
||||||
|
|
||||||
|
#### Advantages
|
||||||
|
- Easy to implement.
|
||||||
|
|
||||||
|
#### Disadvantages
|
||||||
|
- Needs either a separate map of properties in the booking-item.
|
||||||
|
- Or, if stored as a JSON field in the booking-item-resources, these are misused.
|
||||||
|
- Requires additional cleanup logic to remove stale data.
|
||||||
|
|
||||||
|
### STORAGE-2: Inactive Hosting Assets Until Validation
|
||||||
|
|
||||||
|
Create the hosting assets immediately upon booking item creation but mark them as "inactive" until all required information (e.g., verification code) is provided and validation is complete.
|
||||||
|
|
||||||
|
#### Advantages
|
||||||
|
- Avoids temporary external data storage for the hosting-assets.
|
||||||
|
|
||||||
|
#### Disadvantages
|
||||||
|
- Validation becomes more complex as some properties need to be validated, others not.
|
||||||
|
And some properties even need special treatment for new entities, which then becomes vague.
|
||||||
|
- Inactive assets have to be filtered from operational assets.
|
||||||
|
- Potential risk of incomplete or inconsistent assets being created, which may require correction.
|
||||||
|
- Difficult to write tests for all possible combinations of validations.
|
||||||
|
|
||||||
|
### STORAGE-3: Event-Based Approach
|
||||||
|
|
||||||
|
The hosting asset data required for creation us passed to the API and stored in a `BookingItemCreatedEvent`.
|
||||||
|
If hosting asset creation cannot happen synchronously, the event is stored and processed asynchronously in batches, retrying failed asset creation as needed.
|
||||||
|
|
||||||
|
#### Advantages
|
||||||
|
- Clean-data-structure (separation of concerns).
|
||||||
|
- Clear separation between booking item creation and hosting asset creation.
|
||||||
|
- Only valid assets in the database.
|
||||||
|
- Can handle complex asynchronous processes (like waiting for external verification) in a clean and structured manner.
|
||||||
|
- Easier to manage retries and failures in asset creation without complicating the booking item structure.
|
||||||
|
|
||||||
|
#### Disadvantages
|
||||||
|
- At the Spring controller level, the whole JSON is already converted into Java objects,
|
||||||
|
but for storing the asset data in the even, we need JSON again.
|
||||||
|
This could is not just a performance-overhead but could also lead to inconsistencies.
|
||||||
|
|
||||||
|
### STATUS-1: Store hosting-asset-creation-status in the `BookingItemEntity`
|
||||||
|
|
||||||
|
A status field would be added to booking-items to track the creation state of related hosting assets.
|
||||||
|
The users could check their booking-items for the status of the hosting-asset creation, error messages and further instructions.
|
||||||
|
|
||||||
|
#### Advantages
|
||||||
|
- Easy to implement.
|
||||||
|
|
||||||
|
#### Disadvantages
|
||||||
|
- Adds a field to the booking-item which is makes no sense anymore once the related hosting asset is created.
|
||||||
|
|
||||||
|
|
||||||
|
### Status-2: Store hosting-asset-creation-status in the `BookingItemCreateEvent`
|
||||||
|
|
||||||
|
A status field would be added to the booking-item-created event and get updated with the latest messages any time we try to create the hosting-asset.
|
||||||
|
|
||||||
|
#### Advantages
|
||||||
|
- Clean-data-structure (separation of concerns)
|
||||||
|
|
||||||
|
#### Disadvantages
|
||||||
|
- Accessing the status requires querying the event queue.
|
||||||
|
|
||||||
|
|
||||||
|
## Decision Outcome
|
||||||
|
|
||||||
|
**Chosen Option: STORAGE-3 with STATUS-2 (Event-Based Approach with `BookingItemCreatedEvent`)**
|
||||||
|
|
||||||
|
The event-based approach was selected as the best solution for handling automatic hosting asset creation. This option provides a clear separation between booking item creation and hosting asset creation, ensuring that no invalid or incomplete assets are created. The asynchronous nature of the event system allows for retries and external validation steps (such as user-entered verification codes) without disrupting the overall flow.
|
||||||
|
|
||||||
|
By using `BookingItemCreatedEvent` to store the hosting-asset data and the status,
|
||||||
|
we don't need to misuse other data structures for temporary data
|
||||||
|
and therefore hava a clean separation of concerns.
|
218
doc/hs-hosting-asset-type-structure.md
Normal file
218
doc/hs-hosting-asset-type-structure.md
Normal file
@ -0,0 +1,218 @@
|
|||||||
|
## HostingAsset Type Structure
|
||||||
|
|
||||||
|
|
||||||
|
### Server+Webspace
|
||||||
|
|
||||||
|
```plantuml
|
||||||
|
@startuml
|
||||||
|
left to right direction
|
||||||
|
|
||||||
|
package Booking #feb28c {
|
||||||
|
entity BI_PRIVATE_CLOUD
|
||||||
|
entity BI_CLOUD_SERVER
|
||||||
|
entity BI_MANAGED_SERVER
|
||||||
|
entity BI_MANAGED_WEBSPACE
|
||||||
|
entity BI_DOMAIN_SETUP
|
||||||
|
}
|
||||||
|
|
||||||
|
package Hosting #feb28c{
|
||||||
|
package Server #99bcdb {
|
||||||
|
entity HA_CLOUD_SERVER
|
||||||
|
entity HA_MANAGED_SERVER
|
||||||
|
entity HA_IPV4_NUMBER
|
||||||
|
entity HA_IPV6_NUMBER
|
||||||
|
}
|
||||||
|
|
||||||
|
package Webspace #99bcdb {
|
||||||
|
entity HA_MANAGED_WEBSPACE
|
||||||
|
entity HA_UNIX_USER
|
||||||
|
entity HA_EMAIL_ALIAS
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
BI_CLOUD_SERVER *--> BI_PRIVATE_CLOUD
|
||||||
|
BI_MANAGED_SERVER *--> BI_PRIVATE_CLOUD
|
||||||
|
BI_MANAGED_WEBSPACE *--> BI_MANAGED_SERVER
|
||||||
|
|
||||||
|
HA_CLOUD_SERVER *==> BI_CLOUD_SERVER
|
||||||
|
HA_MANAGED_SERVER *==> BI_MANAGED_SERVER
|
||||||
|
HA_MANAGED_WEBSPACE *==> BI_MANAGED_WEBSPACE
|
||||||
|
HA_MANAGED_WEBSPACE o..> HA_MANAGED_SERVER
|
||||||
|
HA_UNIX_USER *==> HA_MANAGED_WEBSPACE
|
||||||
|
HA_EMAIL_ALIAS *==> HA_MANAGED_WEBSPACE
|
||||||
|
HA_IPV4_NUMBER o..> HA_CLOUD_SERVER
|
||||||
|
HA_IPV4_NUMBER o..> HA_MANAGED_SERVER
|
||||||
|
HA_IPV4_NUMBER o..> HA_MANAGED_WEBSPACE
|
||||||
|
HA_IPV6_NUMBER o..> HA_CLOUD_SERVER
|
||||||
|
HA_IPV6_NUMBER o..> HA_MANAGED_SERVER
|
||||||
|
HA_IPV6_NUMBER o..> HA_MANAGED_WEBSPACE
|
||||||
|
|
||||||
|
package Legend #white {
|
||||||
|
SUB_ENTITY1 *--> REQUIRED_PARENT_ENTITY
|
||||||
|
SUB_ENTITY2 *..> OPTIONAL_PARENT_ENTITY
|
||||||
|
ASSIGNED_ENTITY1 o--> REQUIRED_ASSIGNED_TO_ENTITY1
|
||||||
|
ASSIGNED_ENTITY2 o..> OPTIONAL_ASSIGNED_TO_ENTITY2
|
||||||
|
}
|
||||||
|
Booking -down[hidden]->Legend
|
||||||
|
```
|
||||||
|
|
||||||
|
### Domain
|
||||||
|
|
||||||
|
```plantuml
|
||||||
|
@startuml
|
||||||
|
left to right direction
|
||||||
|
|
||||||
|
package Booking #feb28c {
|
||||||
|
entity BI_PRIVATE_CLOUD
|
||||||
|
entity BI_CLOUD_SERVER
|
||||||
|
entity BI_MANAGED_SERVER
|
||||||
|
entity BI_MANAGED_WEBSPACE
|
||||||
|
entity BI_DOMAIN_SETUP
|
||||||
|
}
|
||||||
|
|
||||||
|
package Hosting #feb28c{
|
||||||
|
package Domain #99bcdb {
|
||||||
|
entity HA_DOMAIN_SETUP
|
||||||
|
entity HA_DOMAIN_DNS_SETUP
|
||||||
|
entity HA_DOMAIN_HTTP_SETUP
|
||||||
|
entity HA_DOMAIN_SMTP_SETUP
|
||||||
|
entity HA_DOMAIN_MBOX_SETUP
|
||||||
|
entity HA_EMAIL_ADDRESS
|
||||||
|
}
|
||||||
|
|
||||||
|
package Webspace #99bcdb {
|
||||||
|
entity HA_MANAGED_WEBSPACE
|
||||||
|
entity HA_UNIX_USER
|
||||||
|
entity HA_EMAIL_ALIAS
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
BI_CLOUD_SERVER *--> BI_PRIVATE_CLOUD
|
||||||
|
BI_MANAGED_SERVER *--> BI_PRIVATE_CLOUD
|
||||||
|
BI_MANAGED_WEBSPACE *--> BI_MANAGED_SERVER
|
||||||
|
|
||||||
|
HA_MANAGED_WEBSPACE *==> BI_MANAGED_WEBSPACE
|
||||||
|
HA_UNIX_USER *==> HA_MANAGED_WEBSPACE
|
||||||
|
HA_EMAIL_ALIAS *==> HA_MANAGED_WEBSPACE
|
||||||
|
HA_DOMAIN_SETUP *==> BI_DOMAIN_SETUP
|
||||||
|
HA_DOMAIN_SETUP o..> HA_DOMAIN_SETUP
|
||||||
|
HA_DOMAIN_DNS_SETUP *==> HA_DOMAIN_SETUP
|
||||||
|
HA_DOMAIN_DNS_SETUP o--> HA_MANAGED_WEBSPACE
|
||||||
|
HA_DOMAIN_HTTP_SETUP *==> HA_DOMAIN_SETUP
|
||||||
|
HA_DOMAIN_HTTP_SETUP o--> HA_UNIX_USER
|
||||||
|
HA_DOMAIN_SMTP_SETUP *==> HA_DOMAIN_SETUP
|
||||||
|
HA_DOMAIN_SMTP_SETUP o--> HA_MANAGED_WEBSPACE
|
||||||
|
HA_DOMAIN_MBOX_SETUP *==> HA_DOMAIN_SETUP
|
||||||
|
HA_DOMAIN_MBOX_SETUP o--> HA_MANAGED_WEBSPACE
|
||||||
|
HA_EMAIL_ADDRESS *==> HA_DOMAIN_MBOX_SETUP
|
||||||
|
|
||||||
|
package Legend #white {
|
||||||
|
SUB_ENTITY1 *--> REQUIRED_PARENT_ENTITY
|
||||||
|
SUB_ENTITY2 *..> OPTIONAL_PARENT_ENTITY
|
||||||
|
ASSIGNED_ENTITY1 o--> REQUIRED_ASSIGNED_TO_ENTITY1
|
||||||
|
ASSIGNED_ENTITY2 o..> OPTIONAL_ASSIGNED_TO_ENTITY2
|
||||||
|
}
|
||||||
|
Booking -down[hidden]->Legend
|
||||||
|
```
|
||||||
|
|
||||||
|
### MariaDB
|
||||||
|
|
||||||
|
```plantuml
|
||||||
|
@startuml
|
||||||
|
left to right direction
|
||||||
|
|
||||||
|
package Booking #feb28c {
|
||||||
|
entity BI_PRIVATE_CLOUD
|
||||||
|
entity BI_CLOUD_SERVER
|
||||||
|
entity BI_MANAGED_SERVER
|
||||||
|
entity BI_MANAGED_WEBSPACE
|
||||||
|
entity BI_DOMAIN_SETUP
|
||||||
|
}
|
||||||
|
|
||||||
|
package Hosting #feb28c{
|
||||||
|
package MariaDB #99bcdb {
|
||||||
|
entity HA_MARIADB_INSTANCE
|
||||||
|
entity HA_MARIADB_USER
|
||||||
|
entity HA_MARIADB_DATABASE
|
||||||
|
}
|
||||||
|
|
||||||
|
package Webspace #99bcdb {
|
||||||
|
entity HA_MANAGED_WEBSPACE
|
||||||
|
entity HA_UNIX_USER
|
||||||
|
entity HA_EMAIL_ALIAS
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
BI_CLOUD_SERVER *--> BI_PRIVATE_CLOUD
|
||||||
|
BI_MANAGED_SERVER *--> BI_PRIVATE_CLOUD
|
||||||
|
BI_MANAGED_WEBSPACE *--> BI_MANAGED_SERVER
|
||||||
|
|
||||||
|
HA_MANAGED_WEBSPACE *==> BI_MANAGED_WEBSPACE
|
||||||
|
HA_UNIX_USER *==> HA_MANAGED_WEBSPACE
|
||||||
|
HA_EMAIL_ALIAS *==> HA_MANAGED_WEBSPACE
|
||||||
|
HA_MARIADB_USER *==> HA_MANAGED_WEBSPACE
|
||||||
|
HA_MARIADB_USER o--> HA_MARIADB_INSTANCE
|
||||||
|
HA_MARIADB_DATABASE *==> HA_MARIADB_USER
|
||||||
|
|
||||||
|
package Legend #white {
|
||||||
|
SUB_ENTITY1 *--> REQUIRED_PARENT_ENTITY
|
||||||
|
SUB_ENTITY2 *..> OPTIONAL_PARENT_ENTITY
|
||||||
|
ASSIGNED_ENTITY1 o--> REQUIRED_ASSIGNED_TO_ENTITY1
|
||||||
|
ASSIGNED_ENTITY2 o..> OPTIONAL_ASSIGNED_TO_ENTITY2
|
||||||
|
}
|
||||||
|
Booking -down[hidden]->Legend
|
||||||
|
```
|
||||||
|
|
||||||
|
### PostgreSQL
|
||||||
|
|
||||||
|
```plantuml
|
||||||
|
@startuml
|
||||||
|
left to right direction
|
||||||
|
|
||||||
|
package Booking #feb28c {
|
||||||
|
entity BI_PRIVATE_CLOUD
|
||||||
|
entity BI_CLOUD_SERVER
|
||||||
|
entity BI_MANAGED_SERVER
|
||||||
|
entity BI_MANAGED_WEBSPACE
|
||||||
|
entity BI_DOMAIN_SETUP
|
||||||
|
}
|
||||||
|
|
||||||
|
package Hosting #feb28c{
|
||||||
|
package PostgreSQL #99bcdb {
|
||||||
|
entity HA_PGSQL_INSTANCE
|
||||||
|
entity HA_PGSQL_USER
|
||||||
|
entity HA_PGSQL_DATABASE
|
||||||
|
}
|
||||||
|
|
||||||
|
package Webspace #99bcdb {
|
||||||
|
entity HA_MANAGED_WEBSPACE
|
||||||
|
entity HA_UNIX_USER
|
||||||
|
entity HA_EMAIL_ALIAS
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
BI_CLOUD_SERVER *--> BI_PRIVATE_CLOUD
|
||||||
|
BI_MANAGED_SERVER *--> BI_PRIVATE_CLOUD
|
||||||
|
BI_MANAGED_WEBSPACE *--> BI_MANAGED_SERVER
|
||||||
|
|
||||||
|
HA_MANAGED_WEBSPACE *==> BI_MANAGED_WEBSPACE
|
||||||
|
HA_UNIX_USER *==> HA_MANAGED_WEBSPACE
|
||||||
|
HA_EMAIL_ALIAS *==> HA_MANAGED_WEBSPACE
|
||||||
|
HA_PGSQL_USER *==> HA_MANAGED_WEBSPACE
|
||||||
|
HA_PGSQL_USER o--> HA_PGSQL_INSTANCE
|
||||||
|
HA_PGSQL_DATABASE *==> HA_PGSQL_USER
|
||||||
|
|
||||||
|
package Legend #white {
|
||||||
|
SUB_ENTITY1 *--> REQUIRED_PARENT_ENTITY
|
||||||
|
SUB_ENTITY2 *..> OPTIONAL_PARENT_ENTITY
|
||||||
|
ASSIGNED_ENTITY1 o--> REQUIRED_ASSIGNED_TO_ENTITY1
|
||||||
|
ASSIGNED_ENTITY2 o..> OPTIONAL_ASSIGNED_TO_ENTITY2
|
||||||
|
}
|
||||||
|
Booking -down[hidden]->Legend
|
||||||
|
```
|
||||||
|
|
||||||
|
This code generated was by HsHostingAssetType.main, do not amend manually.
|
288
doc/projects-booking-items-and-hosting-entities.md
Normal file
288
doc/projects-booking-items-and-hosting-entities.md
Normal file
@ -0,0 +1,288 @@
|
|||||||
|
## HSAdmin-NG
|
||||||
|
### Project/BookingItems/HostingEntities
|
||||||
|
|
||||||
|
__ATTENTION__: The notation uses UML clas diagram elements, but partly with different meanings. See Agenda.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
classDiagram
|
||||||
|
direction TD
|
||||||
|
|
||||||
|
Partner o-- "0..n" Membership
|
||||||
|
Partner *-- "1..n" Debitor
|
||||||
|
Debitor *-- "1..n" Project
|
||||||
|
|
||||||
|
Project o-- "0..n" PrivateCloudBI
|
||||||
|
Project o-- "0..n" CloudServerBI
|
||||||
|
Project o-- "0..n" ManagedServerBI
|
||||||
|
Project o-- "0..n" ManagedWebspaceBI
|
||||||
|
|
||||||
|
PrivateCloudBI o-- "0..n" ManagedServerBI
|
||||||
|
PrivateCloudBI o-- "0..n" CloudServerBI
|
||||||
|
|
||||||
|
CloudServerBI *-- CloudServerHE
|
||||||
|
|
||||||
|
ManagedServerBI *-- ManagedServerHE
|
||||||
|
ManagedServerBI o-- "0..n" ManagedWebspaceBI
|
||||||
|
ManagedWebspaceBI *-- ManagedWebspaceHE
|
||||||
|
|
||||||
|
ManagedWebspaceHE *-- "1..n" UnixUserHE
|
||||||
|
ManagedWebspaceHE o-- "0..n" DomainDNSSetupHE
|
||||||
|
ManagedWebspaceHE o-- "0..n" DomainHttpSetupHE
|
||||||
|
ManagedWebspaceHE o-- "0..n" DomainEMailSetupHE
|
||||||
|
ManagedWebspaceHE o-- "0..n" EMailAliasHE
|
||||||
|
DomainEMailSetupHE o-- "0..n" EMailAddressHE
|
||||||
|
ManagedWebspaceHE o-- "0..n" MariaDBUserHE
|
||||||
|
MariaDBUserHE o-- "0..n" MariaDBHE
|
||||||
|
ManagedWebspaceHE o-- "0..n" PostgresDBUserHE
|
||||||
|
PostgresDBUserHE o-- "0..n" PostgresDBHE
|
||||||
|
|
||||||
|
DomainHttpSetupHE --|> UnixUserHE : assignedToAsset
|
||||||
|
|
||||||
|
ManagedWebspaceHE --|> ManagedServerHE
|
||||||
|
|
||||||
|
namespace Office {
|
||||||
|
class Partner {
|
||||||
|
}
|
||||||
|
|
||||||
|
class Membership {
|
||||||
|
}
|
||||||
|
|
||||||
|
class Debitor {
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace Booking {
|
||||||
|
class Project {
|
||||||
|
+caption
|
||||||
|
+create()
|
||||||
|
}
|
||||||
|
class PrivateCloudBI {
|
||||||
|
+caption
|
||||||
|
~resources = [
|
||||||
|
⠀⠀+CPUs
|
||||||
|
⠀⠀+RAM
|
||||||
|
⠀⠀+SSD
|
||||||
|
⠀⠀+HDD
|
||||||
|
⠀⠀+Traffic
|
||||||
|
]
|
||||||
|
|
||||||
|
+book()
|
||||||
|
}
|
||||||
|
class CloudServerBI {
|
||||||
|
+caption
|
||||||
|
~resources = [
|
||||||
|
⠀⠀+CPUs
|
||||||
|
⠀⠀+RAM
|
||||||
|
⠀⠀+SSD
|
||||||
|
⠀⠀+HDD
|
||||||
|
⠀⠀+Traffic
|
||||||
|
]
|
||||||
|
|
||||||
|
+book()
|
||||||
|
}
|
||||||
|
class ManagedServerBI {
|
||||||
|
+caption
|
||||||
|
~respources = [
|
||||||
|
⠀⠀+CPUs
|
||||||
|
⠀⠀+RAM
|
||||||
|
⠀⠀+SSD
|
||||||
|
⠀⠀+HDD
|
||||||
|
⠀⠀+Traffic
|
||||||
|
]
|
||||||
|
|
||||||
|
+book()
|
||||||
|
}
|
||||||
|
class ManagedWebspaceBI {
|
||||||
|
+caption
|
||||||
|
~resources = [
|
||||||
|
⠀⠀+SSD
|
||||||
|
⠀⠀+HDD
|
||||||
|
⠀⠀+Traffic
|
||||||
|
⠀⠀+MultiOptions
|
||||||
|
⠀⠀+Daemons
|
||||||
|
]
|
||||||
|
|
||||||
|
+book()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
style Project stroke:blue,stroke-width:4px
|
||||||
|
style PrivateCloudBI stroke:blue,stroke-width:4px
|
||||||
|
style CloudServerBI stroke:blue,stroke-width:4px
|
||||||
|
style ManagedServerBI stroke:blue,stroke-width:4px
|
||||||
|
style ManagedWebspaceBI stroke:blue,stroke-width:4px
|
||||||
|
|
||||||
|
%% ---------------------------------------------------------
|
||||||
|
|
||||||
|
namespace HostingServers {
|
||||||
|
%% separate (pseudo-) namespace just for better rendering
|
||||||
|
|
||||||
|
class CloudServerHE {
|
||||||
|
-identifier, e.g. "vm1234"
|
||||||
|
-caption := bi.caption?
|
||||||
|
-parentAsset := parentHost
|
||||||
|
-identifier := serverName
|
||||||
|
-create()
|
||||||
|
}
|
||||||
|
class ManagedServerHE {
|
||||||
|
-identifier, e.g. "vm1234"
|
||||||
|
-caption := bi.caption?
|
||||||
|
-parentAsset := parentHost
|
||||||
|
-identifier := serverName
|
||||||
|
~config = [
|
||||||
|
⠀⠀+installed Software
|
||||||
|
]
|
||||||
|
-create()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace Hosting {
|
||||||
|
class ManagedWebspaceHE {
|
||||||
|
-parentAsset := parentManagedServer
|
||||||
|
-identifier : webspaceName
|
||||||
|
+caption
|
||||||
|
|
||||||
|
-create()
|
||||||
|
}
|
||||||
|
|
||||||
|
class UnixUserHE {
|
||||||
|
+identifier ["xyz00-..."]
|
||||||
|
+caption
|
||||||
|
~config = [
|
||||||
|
⠀⠀+SSD Soft Quota
|
||||||
|
⠀⠀+SSD Hard Quota
|
||||||
|
⠀⠀+HDD Soft Quota
|
||||||
|
⠀⠀+HDD Hard Quota
|
||||||
|
⠀⠀#shell
|
||||||
|
⠀⠀#password
|
||||||
|
]
|
||||||
|
|
||||||
|
+create()
|
||||||
|
}
|
||||||
|
class DomainDNSSetupHE {
|
||||||
|
+identifier, e.g. "example.com"
|
||||||
|
+caption
|
||||||
|
|
||||||
|
+create()
|
||||||
|
}
|
||||||
|
class DomainHttpSetupHE {
|
||||||
|
+identifier, e.g. "example.com"
|
||||||
|
+caption
|
||||||
|
|
||||||
|
+create()
|
||||||
|
}
|
||||||
|
class DomainEMailSetupHE {
|
||||||
|
+identifier, e.g. "example.com"
|
||||||
|
+caption
|
||||||
|
|
||||||
|
+create()
|
||||||
|
}
|
||||||
|
class EMailAliasHE {
|
||||||
|
+identifier, e.g "xyz00-..."
|
||||||
|
+caption
|
||||||
|
|
||||||
|
~config = [
|
||||||
|
⠀⠀+target[]
|
||||||
|
]
|
||||||
|
|
||||||
|
+create()
|
||||||
|
}
|
||||||
|
class EMailAddressHE {
|
||||||
|
+identifier, e.g. "test@example.org"
|
||||||
|
+caption
|
||||||
|
~config = [
|
||||||
|
⠀⠀+sub-domain
|
||||||
|
⠀⠀+local-part
|
||||||
|
⠀⠀+target
|
||||||
|
]
|
||||||
|
|
||||||
|
+create()
|
||||||
|
}
|
||||||
|
class MariaDBUserHE {
|
||||||
|
+identifier, e.g. "xyz00_mydb"
|
||||||
|
+caption
|
||||||
|
config = [
|
||||||
|
⠀⠀#password
|
||||||
|
]
|
||||||
|
|
||||||
|
+create()
|
||||||
|
}
|
||||||
|
class MariaDBHE {
|
||||||
|
+identifier, e.g. "xyz00_mydb"
|
||||||
|
+caption
|
||||||
|
~config = [
|
||||||
|
⠀⠀+encoding
|
||||||
|
]
|
||||||
|
|
||||||
|
+create()
|
||||||
|
}
|
||||||
|
class PostgresDBUserHE {
|
||||||
|
+identifier, e.g. "xyz00_mydb"
|
||||||
|
+caption
|
||||||
|
~config = [
|
||||||
|
⠀⠀#password
|
||||||
|
]
|
||||||
|
|
||||||
|
+create()
|
||||||
|
}
|
||||||
|
class PostgresDBHE {
|
||||||
|
+identifier, e.g. "xyz00_mydb"
|
||||||
|
+caption
|
||||||
|
|
||||||
|
~config = [
|
||||||
|
⠀⠀+encoding
|
||||||
|
⠀⠀+extensions
|
||||||
|
]
|
||||||
|
+create()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
style CloudServerHE stroke:orange,stroke-width:4px
|
||||||
|
style ManagedServerHE stroke:orange,stroke-width:4px
|
||||||
|
style ManagedWebspaceHE stroke:orange,stroke-width:4px
|
||||||
|
style UnixUserHE stroke:blue,stroke-width:4px
|
||||||
|
style DomainDNSSetupHE stroke:blue,stroke-width:4px
|
||||||
|
style DomainHttpSetupHE stroke:blue,stroke-width:4px
|
||||||
|
style DomainEMailSetupHE stroke:blue,stroke-width:4px
|
||||||
|
style EMailAliasHE stroke:blue,stroke-width:4px
|
||||||
|
style EMailAddressHE stroke:blue,stroke-width:4px
|
||||||
|
style MariaDBUserHE stroke:blue,stroke-width:4px
|
||||||
|
style MariaDBHE stroke:blue,stroke-width:4px
|
||||||
|
style PostgresDBUserHE stroke:blue,stroke-width:4px
|
||||||
|
style PostgresDBHE stroke:blue,stroke-width:4px
|
||||||
|
|
||||||
|
%% --------------------------------------
|
||||||
|
|
||||||
|
ParentA o-- ChildA : can contain
|
||||||
|
ParentB *-- ChildB : contains
|
||||||
|
|
||||||
|
namespace Agenda {
|
||||||
|
class ParentA {
|
||||||
|
}
|
||||||
|
class ChildA {
|
||||||
|
}
|
||||||
|
class ParentB {
|
||||||
|
}
|
||||||
|
class ChildB {
|
||||||
|
}
|
||||||
|
class CreatedByClient {
|
||||||
|
}
|
||||||
|
class CreatedAutomatically {
|
||||||
|
}
|
||||||
|
class SomeEntity {
|
||||||
|
~patchable = [
|
||||||
|
%% the following indentations uses two U+2800 to have effect in the rendered diagram
|
||||||
|
⠀⠀+first
|
||||||
|
⠀⠀+second
|
||||||
|
]
|
||||||
|
-readOnly for client accounts
|
||||||
|
+readWrite for client accounts
|
||||||
|
#writeOnly
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
style CreatedByClient stroke:blue,stroke-width:4px
|
||||||
|
style CreatedAutomatically stroke:orange,stroke-width:4px
|
||||||
|
end
|
||||||
|
```
|
468
doc/rbac-performance-analysis.md
Normal file
468
doc/rbac-performance-analysis.md
Normal file
@ -0,0 +1,468 @@
|
|||||||
|
# RBAC Performance Analysis
|
||||||
|
|
||||||
|
This describes the analysis of the legacy-data-import which took way too long, which turned out to be a problem in the RBAC-access-rights-check as well as `EntityManager.persist` creating too many SQL queries.
|
||||||
|
|
||||||
|
|
||||||
|
## Our Performance-Problem
|
||||||
|
|
||||||
|
During the legacy data import for hosting assets we noticed massive performance problems. The import of about 2200 hosting-assets (IP-numbers, managed-webspaces, managed- and cloud-servers) as well as the creation of booking-items and booking-projects as well as necessary office-data entities (persons, contacts, partners, debitors, relations) **took 25 minutes**.
|
||||||
|
|
||||||
|
Importing hosting assets up to UnixUsers and EmailAddresses even **took about 100 minutes**.
|
||||||
|
|
||||||
|
(The office data import sometimes, but rarely, took only 10min.
|
||||||
|
We could not find a pattern, why that was the case. The impression that it had to do with too many other parallel processes, e.g. browser with BBB or IntelliJ IDEA was proved wrong, but stopping all unnecessary processes and performing the import again.)
|
||||||
|
|
||||||
|
|
||||||
|
## Preparation
|
||||||
|
|
||||||
|
### Configuring PostgreSQL
|
||||||
|
|
||||||
|
The pg_stat_statements PostgreSQL-Extension can be used to measure how long queries take and how often they are called.
|
||||||
|
|
||||||
|
The module auto_explain can be used to automatically run EXPLAIN on long-running queries.
|
||||||
|
|
||||||
|
To use this extension and module, we extended the PostgreSQL-Docker-image:
|
||||||
|
|
||||||
|
```Dockerfile
|
||||||
|
FROM postgres:15.5-bookworm
|
||||||
|
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y postgresql-contrib && \
|
||||||
|
apt-get clean
|
||||||
|
|
||||||
|
COPY etc/postgresql-log-slow-queries.conf /etc/postgresql/postgresql.conf
|
||||||
|
```
|
||||||
|
|
||||||
|
And create an image from it:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker build -t postgres-with-contrib:15.5-bookworm .
|
||||||
|
```
|
||||||
|
|
||||||
|
Then we created a config file for PostgreSQL in `etc/postgresql-log-slow-queries.conf`:
|
||||||
|
|
||||||
|
```
|
||||||
|
shared_preload_libraries = 'pg_stat_statements,auto_explain'
|
||||||
|
log_min_duration_statement = 1000
|
||||||
|
log_statement = 'all'
|
||||||
|
log_duration = on
|
||||||
|
pg_stat_statements.track = all
|
||||||
|
auto_explain.log_min_duration = '1s' # Logs queries taking longer than 1 second
|
||||||
|
auto_explain.log_analyze = on # Include actual run times
|
||||||
|
auto_explain.log_buffers = on # Include buffer usage statistics
|
||||||
|
auto_explain.log_format = 'json' # Format the log output in JSON
|
||||||
|
listen_addresses = '*'
|
||||||
|
```
|
||||||
|
|
||||||
|
And a Docker-Compose config in 'docker-compose.yml':
|
||||||
|
|
||||||
|
```
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres-with-contrib:15.5-bookworm
|
||||||
|
container_name: custom-postgres
|
||||||
|
environment:
|
||||||
|
POSTGRES_PASSWORD: password
|
||||||
|
volumes:
|
||||||
|
- /home/mi/Projekte/Hostsharing/hsadmin-ng/etc/postgresql-log-slow-queries.conf:/etc/postgresql/postgresql.conf
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
command:
|
||||||
|
- bash
|
||||||
|
- -c
|
||||||
|
- >
|
||||||
|
apt-get update &&
|
||||||
|
apt-get install -y postgresql-contrib &&
|
||||||
|
docker-entrypoint.sh postgres -c config_file=/etc/postgresql/postgresql.conf
|
||||||
|
```
|
||||||
|
|
||||||
|
### Activate the pg_stat_statements Extension
|
||||||
|
|
||||||
|
The pg_stat_statements extension was activated in our Liquibase-scripts:
|
||||||
|
|
||||||
|
```
|
||||||
|
create extension if not exists "pg_stat_statements";
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running the Tweaked PostgreSQL
|
||||||
|
|
||||||
|
Now we can run PostgreSQL with activated slow-query-logging:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running the Import
|
||||||
|
|
||||||
|
Using an environment like this:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
export HSADMINNG_POSTGRES_JDBC_URL=jdbc:postgresql://localhost:5432/postgres
|
||||||
|
export HSADMINNG_POSTGRES_ADMIN_USERNAME=postgres
|
||||||
|
export HSADMINNG_POSTGRES_ADMIN_PASSWORD=password
|
||||||
|
export HSADMINNG_POSTGRES_RESTRICTED_USERNAME=restricted
|
||||||
|
export HSADMINNG_SUPERUSER=superuser-alex@hostsharing.net
|
||||||
|
```
|
||||||
|
|
||||||
|
We can now run the hosting-assets-import:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
time gw-importHostingAssets
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fetch the Query Statistics
|
||||||
|
|
||||||
|
And afterward we can query the statistics in PostgreSQL, e.g.:
|
||||||
|
|
||||||
|
```SQL
|
||||||
|
WITH statements AS (
|
||||||
|
SELECT * FROM pg_stat_statements pss
|
||||||
|
)
|
||||||
|
SELECT calls,
|
||||||
|
total_exec_time::int/(60*1000) as total_mins,
|
||||||
|
mean_exec_time::int as mean_millis,
|
||||||
|
query
|
||||||
|
FROM statements
|
||||||
|
WHERE calls > 100 AND shared_blks_hit > 0
|
||||||
|
ORDER BY total_exec_time DESC
|
||||||
|
LIMIT 16;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reset the Query Statistics
|
||||||
|
|
||||||
|
```SQL
|
||||||
|
SELECT pg_stat_statements_reset();
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## Analysis Result
|
||||||
|
|
||||||
|
### RBAC-Access-Rights Detection query
|
||||||
|
|
||||||
|
This CTE query was run over 4000 times during a single import and takes in total the whole execution time of the import process:
|
||||||
|
|
||||||
|
```SQL
|
||||||
|
WITH RECURSIVE grants AS (
|
||||||
|
SELECT descendantUuid, ascendantUuid, $5 AS level
|
||||||
|
FROM RbacGrants
|
||||||
|
WHERE assumed
|
||||||
|
AND ascendantUuid = any(subjectIds)
|
||||||
|
UNION ALL
|
||||||
|
SELECT g.descendantUuid, g.ascendantUuid, grants.level + $6 AS level
|
||||||
|
FROM RbacGrants g
|
||||||
|
INNER JOIN grants ON grants.descendantUuid = g.ascendantUuid
|
||||||
|
WHERE g.assumed
|
||||||
|
),
|
||||||
|
granted AS (
|
||||||
|
SELECT DISTINCT descendantUuid
|
||||||
|
FROM grants
|
||||||
|
)
|
||||||
|
SELECT DISTINCT perm.objectUuid
|
||||||
|
FROM granted
|
||||||
|
JOIN RbacPermission perm ON granted.descendantUuid = perm.uuid
|
||||||
|
JOIN RbacObject obj ON obj.uuid = perm.objectUuid
|
||||||
|
WHERE (requiredOp = $7 OR perm.op = requiredOp)
|
||||||
|
AND obj.objectTable = forObjectTable
|
||||||
|
LIMIT maxObjects+$8
|
||||||
|
```
|
||||||
|
|
||||||
|
That query is used to determine access rights of the currently active RBAC-subject(s).
|
||||||
|
|
||||||
|
We used `EXPLAIN` with a concrete version (parameters substituted with real values) of that query and got this result:
|
||||||
|
|
||||||
|
```
|
||||||
|
QUERY PLAN
|
||||||
|
Limit (cost=6549.08..6549.35 rows=54 width=16)
|
||||||
|
CTE grants
|
||||||
|
-> Recursive Union (cost=4.32..5845.97 rows=1103 width=36)
|
||||||
|
-> Bitmap Heap Scan on rbacgrants (cost=4.32..15.84 rows=3 width=36)
|
||||||
|
Recheck Cond: (ascendantuuid = ANY ('{ad1133dc-fbb7-43c9-8c20-0da3f89a2388}'::uuid[]))
|
||||||
|
Filter: assumed
|
||||||
|
-> Bitmap Index Scan on rbacgrants_ascendantuuid_idx (cost=0.00..4.32 rows=3 width=0)
|
||||||
|
Index Cond: (ascendantuuid = ANY ('{ad1133dc-fbb7-43c9-8c20-0da3f89a2388}'::uuid[]))
|
||||||
|
-> Nested Loop (cost=0.29..580.81 rows=110 width=36)
|
||||||
|
-> WorkTable Scan on grants grants_1 (cost=0.00..0.60 rows=30 width=20)
|
||||||
|
-> Index Scan using rbacgrants_ascendantuuid_idx on rbacgrants g (cost=0.29..19.29 rows=4 width=32)
|
||||||
|
Index Cond: (ascendantuuid = grants_1.descendantuuid)
|
||||||
|
Filter: assumed
|
||||||
|
-> Unique (cost=703.11..703.38 rows=54 width=16)
|
||||||
|
-> Sort (cost=703.11..703.25 rows=54 width=16)
|
||||||
|
Sort Key: perm.objectuuid
|
||||||
|
-> Nested Loop (cost=31.60..701.56 rows=54 width=16)
|
||||||
|
-> Hash Join (cost=31.32..638.78 rows=200 width=16)
|
||||||
|
Hash Cond: (perm.uuid = grants.descendantuuid)
|
||||||
|
-> Seq Scan on rbacpermission perm (cost=0.00..532.92 rows=28392 width=32)
|
||||||
|
-> Hash (cost=28.82..28.82 rows=200 width=16)
|
||||||
|
-> HashAggregate (cost=24.82..26.82 rows=200 width=16)
|
||||||
|
Group Key: grants.descendantuuid
|
||||||
|
-> CTE Scan on grants (cost=0.00..22.06 rows=1103 width=16)
|
||||||
|
-> Index Only Scan using rbacobject_objecttable_uuid_key on rbacobject obj (cost=0.28..0.31 rows=1 width=16)
|
||||||
|
Index Cond: ((objecttable = 'hs_hosting.asset'::text) AND (uuid = perm.objectuuid))
|
||||||
|
```
|
||||||
|
|
||||||
|
### Office-Relation-Query
|
||||||
|
|
||||||
|
```SQL
|
||||||
|
SELECT hore1_0.uuid,a1_0.uuid,a1_0.familyname,a1_0.givenname,a1_0.persontype,a1_0.salutation,a1_0.title,a1_0.tradename,a1_0.version,c1_0.uuid,c1_0.caption,c1_0.emailaddresses,c1_0.phonenumbers,c1_0.postaladdress,c1_0.version,h1_0.uuid,h1_0.familyname,h1_0.givenname,h1_0.persontype,h1_0.salutation,h1_0.title,h1_0.tradename,h1_0.version,hore1_0.mark,hore1_0.type,hore1_0.version
|
||||||
|
FROM hs_office.relation_rv hore1_0
|
||||||
|
LEFT JOIN hs_office.person_rv a1_0 ON a1_0.uuid=hore1_0.anchoruuid
|
||||||
|
LEFT JOIN hs_office.contact_rv c1_0 ON c1_0.uuid=hore1_0.contactuuid
|
||||||
|
LEFT JOIN hs_office.person_rv h1_0 ON h1_0.uuid=hore1_0.holderuuid
|
||||||
|
WHERE hore1_0.uuid=$1
|
||||||
|
```
|
||||||
|
|
||||||
|
That query on the `hs_office.relation_rv`-table joins the three references anchor-person, holder-person and contact.
|
||||||
|
|
||||||
|
|
||||||
|
### Total-Query-Time > Total-Import-Runtime
|
||||||
|
|
||||||
|
That both queries total up to more than the runtime of the import-process is most likely due to internal parallel query processing.
|
||||||
|
|
||||||
|
|
||||||
|
## Attempts to Mitigate the Problem
|
||||||
|
|
||||||
|
### VACUUM ANALYZE
|
||||||
|
|
||||||
|
In the middle of the import, we updated the PostgreSQL statistics to recalibrate the query optimizer:
|
||||||
|
|
||||||
|
```SQL
|
||||||
|
VACUUM ANALYZE;
|
||||||
|
```
|
||||||
|
|
||||||
|
This did not improve the performance.
|
||||||
|
|
||||||
|
|
||||||
|
### Improving Joins + Indexes
|
||||||
|
|
||||||
|
We were suspicious about the sequential scan over all `rbacpermission` rows which was done by PostgreSQL to execute a HashJoin strategy. Turning off that strategy by
|
||||||
|
|
||||||
|
```SQL
|
||||||
|
ALTER FUNCTION rbac.queryAccessibleObjectUuidsOfSubjectIds SET enable_hashjoin = off;
|
||||||
|
```
|
||||||
|
|
||||||
|
did not improve the performance though. The HashJoin was actually still applied, but no full table scan anymore:
|
||||||
|
|
||||||
|
```
|
||||||
|
[...]
|
||||||
|
QUERY PLAN
|
||||||
|
-> Hash Join (cost=36.02..40.78 rows=1 width=16)
|
||||||
|
Hash Cond: (grants.descendantuuid = perm.uuid)
|
||||||
|
-> HashAggregate (cost=13.32..15.32 rows=200 width=16)
|
||||||
|
Group Key: grants.descendantuuid
|
||||||
|
-> CTE Scan on grants (cost=0.00..11.84 rows=592 width=16)
|
||||||
|
[...]
|
||||||
|
```
|
||||||
|
|
||||||
|
The HashJoin strategy could be great if the hash-map could be kept for multiple invocations. But during an import process, of course, there are always new rows in the underlying table and the hash-map would be outdated immediately.
|
||||||
|
|
||||||
|
Also creating indexes which should suppor the RBAC query, like the following, did not improve performance:
|
||||||
|
|
||||||
|
```SQL
|
||||||
|
create index on RbacPermission (objectUuid, op);
|
||||||
|
create index on RbacPermission (opTableName, op);
|
||||||
|
```
|
||||||
|
|
||||||
|
### LAZY loading for Relation.anchorPerson/.holderPerson/
|
||||||
|
|
||||||
|
At this point, the import took 21mins with these statistics:
|
||||||
|
|
||||||
|
| query | calls | total_m | mean_ms |
|
||||||
|
|-------|-------|---------|---------|
|
||||||
|
| select hore1_0.uuid,a1_0.uuid,a1_0.familyname,a1_0.givenname,a1_0.persontype,a1_0.salutation,a1_0.title,a1_0.tradename,a1_0.version,c1_0.uuid,c1_0.caption,c1_0.emailaddresses,c1_0.phonenumbers,c1_0.postaladdress, c1_0.version,h1_0.uuid,h1_0.familyname,h1_0.givenname,h1_0.persontype,h1_0.salutation,h1_0.title,h1_0.tradename,h1_0.version,hore1_0.mark,hore1_0.type,hore1_0.version from public.hs_office.relation_rv hore1_0 left join public.hs_office.person_rv a1_0 on a1_0.uuid=hore1_0.anchoruuid left join public.hs_office.contact_rv c1_0 on c1_0.uuid=hore1_0.contactuuid left join public.hs_office.person_rv h1_0 on h1_0.uuid=hore1_0.holderuuid where hore1_0.uuid=$1 | 517 | 11 | 1282 |
|
||||||
|
| select hope1_0.uuid,hope1_0.familyname,hope1_0.givenname,hope1_0.persontype,hope1_0.salutation,hope1_0.title,hope1_0.tradename,hope1_0.version from public.hs_office.person_rv hope1_0 where hope1_0.uuid=$1 | 973 | 4 | 254 |
|
||||||
|
| select hoce1_0.uuid,hoce1_0.caption,hoce1_0.emailaddresses,hoce1_0.phonenumbers,hoce1_0.postaladdress,hoce1_0.version from public.hs_office.contact_rv hoce1_0 where hoce1_0.uuid=$1 | 973 | 4 | 253 |
|
||||||
|
| call rbac.grantRoleToRole(roleUuid, superRoleUuid, superRoleDesc.assumed) | 31316 | 0 | 1 |
|
||||||
|
| call buildRbacSystemForHsHostingAsset(NEW) | 2258 | 0 | 7 |
|
||||||
|
| select * from rbac.isGranted(array[granteeId], grantedId) | 44613 | 0 | 0 |
|
||||||
|
| insert into public.hs_hosting.asset_rv (alarmcontactuuid,assignedtoassetuuid,bookingitemuuid,caption,config,identifier,parentassetuuid,type,version,uuid) values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) | 2207 | 0 | 7 |
|
||||||
|
| insert into hs_hosting.asset (alarmcontactuuid, version, bookingitemuuid, type, parentassetuuid, assignedtoassetuuid, config, uuid, identifier, caption) values (new.alarmcontactuuid, new. version, new. bookingitemuuid, new. type, new. parentassetuuid, new. assignedtoassetuuid, new. config, new. uuid, new. identifier, new. caption) returning * | 2207 | 0 | 7 |
|
||||||
|
| insert into public.hs_office.relation_rv (anchoruuid,contactuuid,holderuuid,mark,type,version,uuid) values ($1,$2,$3,$4,$5,$6,$7) | 1261 | 0 | 9 |
|
||||||
|
| insert into hs_office.relation (uuid, version, anchoruuid, holderuuid, contactuuid, type, mark) values (new.uuid, new. version, new. anchoruuid, new. holderuuid, new. contactuuid, new. type, new. mark) returning * | 1261 | 0 | 9 |
|
||||||
|
| call buildRbacSystemForHsOfficeRelation(NEW) | 1276 | 0 | 8 |
|
||||||
|
| with recursive grants as ( select descendantUuid, ascendantUuid from RbacGrants where descendantUuid = grantedId union all select ""grant"".descendantUuid, ""grant"".ascendantUuid from RbacGrants ""grant"" inner join grants recur on recur.ascendantUuid = ""grant"".descendantUuid ) select exists ( select $3 from grants where ascendantUuid = any(granteeIds) ) or grantedId = any(granteeIds) | 47540 | 0 | 0 |
|
||||||
|
| insert into RbacGrants (grantedByTriggerOf, ascendantuuid, descendantUuid, assumed) values (currentTriggerObjectUuid(), superRoleId, subRoleId, doAssume) on conflict do nothing" | 40472 | 0 | 0 |
|
||||||
|
| insert into public.hs_booking.item_rv (caption,parentitemuuid,projectuuid,resources,type,validity,version,uuid) values ($1,$2,$3,$4,$5,$6,$7,$8) | 926 | 0 | 7 |
|
||||||
|
| insert into hs_booking.item (resources, version, projectuuid, type, parentitemuuid, validity, uuid, caption) values (new.resources, new. version, new. projectuuid, new. type, new. parentitemuuid, new. validity, new. uuid, new. caption) returning * | 926 | 0 | 7 |
|
||||||
|
|
||||||
|
|
||||||
|
The slowest query now was fetching Relations joined with Contact, Anchor-Person and Holder-Person, for all tables using the restricted (RBAC) views (_rv).
|
||||||
|
|
||||||
|
We changed these mappings from `EAGER` (default) to `LAZY` to `@ManyToOne(fetch = FetchType.LAZY)` and got this result:
|
||||||
|
|
||||||
|
:::small
|
||||||
|
| query | calls | total (min) | mean (ms) |
|
||||||
|
|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------|-------------|----------|
|
||||||
|
| select hope1_0.uuid,hope1_0.familyname,hope1_0.givenname,hope1_0.persontype,hope1_0.salutation,hope1_0.title,hope1_0.tradename,hope1_0.version from public.hs_office.person_rv hope1_0 where hope1_0.uuid=$1 | 1015 | 4 | 238 |
|
||||||
|
| select hore1_0.uuid,hore1_0.anchoruuid,hore1_0.contactuuid,hore1_0.holderuuid,hore1_0.mark,hore1_0.type,hore1_0.version from public.hs_office.relation_rv hore1_0 where hore1_0.uuid=$1 | 517 | 4 | 439 |
|
||||||
|
| select hoce1_0.uuid,hoce1_0.caption,hoce1_0.emailaddresses,hoce1_0.phonenumbers,hoce1_0.postaladdress,hoce1_0.version from public.hs_office.contact_rv hoce1_0 where hoce1_0.uuid=$1 | 497 | 2 | 213 |
|
||||||
|
| call rbac.grantRoleToRole(roleUuid, superRoleUuid, superRoleDesc.assumed) | 31316 | 0 | 1 |
|
||||||
|
| select * from rbac.isGranted(array[granteeId], grantedId) | 44613 | 0 | 0 |
|
||||||
|
| call buildRbacSystemForHsHostingAsset(NEW) | 2258 | 0 | 7 |
|
||||||
|
| insert into public.hs_hosting.asset_rv (alarmcontactuuid,assignedtoassetuuid,bookingitemuuid,caption,config,identifier,parentassetuuid,type,version,uuid) values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) | 2207 | 0 | 7 |
|
||||||
|
| insert into hs_hosting.asset (alarmcontactuuid, version, bookingitemuuid, type, parentassetuuid, assignedtoassetuuid, config, uuid, identifier, caption) values (new.alarmcontactuuid, new. version, new. bookingitemuuid, new. type, new. parentassetuuid, new. assignedtoassetuuid, new. config, new. uuid, new. identifier, new. caption) returning * | 2207 | 0 | 7 |
|
||||||
|
| with recursive grants as ( select descendantUuid, ascendantUuid from RbacGrants where descendantUuid = grantedId union all select ""grant"".descendantUuid, ""grant"".ascendantUuid from RbacGrants ""grant"" inner join grants recur on recur.ascendantUuid = ""grant"".descendantUuid ) select exists ( select $3 from grants where ascendantUuid = any(granteeIds) ) or grantedId = any(granteeIds) | 47538 | 0 | 0 |
|
||||||
|
insert into public.hs_office.relation_rv (anchoruuid,contactuuid,holderuuid,mark,type,version,uuid) values ($1,$2,$3,$4,$5,$6,$7) | 1261 | 0 | 8 |
|
||||||
|
| insert into hs_office.relation (uuid, version, anchoruuid, holderuuid, contactuuid, type, mark) values (new.uuid, new. version, new. anchoruuid, new. holderuuid, new. contactuuid, new. type, new. mark) returning * | 1261 | 0 | 8 |
|
||||||
|
| call buildRbacSystemForHsOfficeRelation(NEW) | 1276 | 0 | 7 |
|
||||||
|
| insert into public.hs_booking.item_rv (caption,parentitemuuid,projectuuid,resources,type,validity,version,uuid) values ($1,$2,$3,$4,$5,$6,$7,$8) | 926 | 0 | 7 |
|
||||||
|
| insert into hs_booking.item (resources, version, projectuuid, type, parentitemuuid, validity, uuid, caption) values (new.resources, new. version, new. projectuuid, new. type, new. parentitemuuid, new. validity, new. uuid, new. caption) returning * | 926 | 0 | 7 |
|
||||||
|
insert into RbacGrants (grantedByTriggerOf, ascendantuuid, descendantUuid, assumed) values (currentTriggerObjectUuid(), superRoleId, subRoleId, doAssume) on conflict do nothing | 40472 | 0 | 0 |
|
||||||
|
|
||||||
|
Now, finally, the total runtime of the import was down to 12 minutes. This is repeatable, where originally, the import took about 25mins in most cases and just rarely - and for unknown reasons - 10min.
|
||||||
|
|
||||||
|
### Importing UnixUser and EmailAlias Assets
|
||||||
|
|
||||||
|
But once UnixUser and EmailAlias assets got added to the import, the total time went up to about 110min.
|
||||||
|
|
||||||
|
This was not acceptable, especially not, considering that domains, email-addresses and database-assets are almost 10 times that number and thus the import would go up to over 1100min which is 20 hours.
|
||||||
|
|
||||||
|
In a first step, a `HsHostingAssetRawEntity` was created, mapped to the raw table (hs_hosting.asset) not to the RBAC-view (hs_hosting.asset_rv). Unfortunately we did not keep measurements, but that was only part of the problem anyway.
|
||||||
|
|
||||||
|
The main problem was, that there is something strange with persisting (`EntityManager.persist`) for EmailAlias assets. Where importing UnixUsers was mostly slow due to RBAC SELECT-permission checks, persisting EmailAliases suddenly created about a million (in numbers 1.000.000) SQL UPDATE statements after the INSERT, all with the same data, just increased version number (used for optimistic locking). We were not able to figure out why this happened.
|
||||||
|
|
||||||
|
Keep in mind, it's the same table with the same RBAC-triggers, just a different value in the type column.
|
||||||
|
|
||||||
|
Once `EntityManager.persist` was replaced by an explicit SQL INSERT - just for `HsHostingAssetRawEntity`, the total time was down to 17min. Thus importing the UnixUsers and EmailAliases took just 5min, which is an acceptable result. The total import of all HostingAssets is now estimated to about 1 hour (on my developer laptop).
|
||||||
|
|
||||||
|
Now, the longest running queries are these:
|
||||||
|
|
||||||
|
| No.| calls | total_m | mean_ms | query |
|
||||||
|
|---:|---------|--------:|--------:|:-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||||
|
| 1 | 13.093 | 4 | 21 | insert into hs_hosting.asset( uuid, type, bookingitemuuid, parentassetuuid, assignedtoassetuuid, alarmcontactuuid, identifier, caption, config, version) values ( $1, $2, $3, $4, $5, $6, $7, $8, cast($9 as jsonb), $10) |
|
||||||
|
| 2 | 517 | 4 | 502 | select hore1_0.uuid,hore1_0.anchoruuid,hore1_0.contactuuid,hore1_0.holderuuid,hore1_0.mark,hore1_0.type,hore1_0.version from public.hs_office.relation_rv hore1_0 where hore1_0.uuid=$1 |
|
||||||
|
| 3 | 13.144 | 4 | 21 | call buildRbacSystemForHsHostingAsset(NEW) |
|
||||||
|
| 4 | 96.632 | 3 | 2 | call rbac.grantRoleToRole(roleUuid, superRoleUuid, superRoleDesc.assumed) |
|
||||||
|
| 5 | 120.815 | 3 | 2 | select * from rbac.isGranted(array[granteeId], grantedId) |
|
||||||
|
| 6 | 123.740 | 3 | 2 | with recursive grants as ( select descendantUuid, ascendantUuid from RbacGrants where descendantUuid = grantedId union all select "grant".descendantUuid, "grant".ascendantUuid from RbacGrants "grant" inner join grants recur on recur.ascendantUuid = "grant".descendantUuid ) select exists ( select $3 from grants where ascendantUuid = any(granteeIds) ) or grantedId = any(granteeIds) |
|
||||||
|
| 7 | 497 | 2 | 259 | select hoce1_0.uuid,hoce1_0.caption,hoce1_0.emailaddresses,hoce1_0.phonenumbers,hoce1_0.postaladdress,hoce1_0.version from public.hs_office.contact_rv hoce1_0 where hoce1_0.uuid=$1 |
|
||||||
|
| 8 | 497 | 2 | 255 | select hope1_0.uuid,hope1_0.familyname,hope1_0.givenname,hope1_0.persontype,hope1_0.salutation,hope1_0.title,hope1_0.tradename,hope1_0.version from public.hs_office.person_rv hope1_0 where hope1_0.uuid=$1 |
|
||||||
|
| 9 | 13.144 | 1 | 8 | SELECT createRoleWithGrants( hs_hosting.asset_TENANT(NEW), permissions => array[$7], incomingSuperRoles => array[ hs_hosting.asset_AGENT(NEW), hs_office.contact_ADMIN(newAlarmContact)], outgoingSubRoles => array[ hs_booking.item_TENANT(newBookingItem), hs_hosting.asset_TENANT(newParentAsset)] ) |
|
||||||
|
| 10 | 13.144 | 1 | 5 | SELECT createRoleWithGrants( hs_hosting.asset_ADMIN(NEW), permissions => array[$7], incomingSuperRoles => array[ hs_booking.item_AGENT(newBookingItem), hs_hosting.asset_AGENT(newParentAsset), hs_hosting.asset_OWNER(NEW)] ) |
|
||||||
|
|
||||||
|
That the `INSERT into hs_hosting.asset` (No. 1) takes up the most time, seems to be normal, and 21ms for each call is also fine.
|
||||||
|
|
||||||
|
It seems that the trigger effects (eg. No. 3 and No. 4) are included in the measure for the causing INSERT, otherwise summing up the totals would exceed the actual total time of the whole import. And it was to be expected that building the RBAC rules for new business objects takes most of the time.
|
||||||
|
|
||||||
|
In production, the `SELECT ... FROM hs_office.relation_rv` (No. 2) with about 0.5 seconds could still be a problem. But once we apply the improvements from the hosting asset area also to the office area, this should not be a problem for the import anymore.
|
||||||
|
|
||||||
|
|
||||||
|
## Further Options To Explore
|
||||||
|
|
||||||
|
1. Instead of separate SQL INSERT statements, we could try bulk INSERT.
|
||||||
|
2. We could use the SQL INSERT method for all entity-classes, or at least for all which have high row counts.
|
||||||
|
3. For the production code, we could use raw-entities for referenced entities, here usually RBAC SELECT permission is given anyway.
|
||||||
|
|
||||||
|
|
||||||
|
## The Problematically Huge Join
|
||||||
|
|
||||||
|
The origin problem was the expensive RBAC check for many SELECT queries.
|
||||||
|
This consists of two parts:
|
||||||
|
|
||||||
|
1. The recursive CTE query to determine which object's UUIDs are visible for the current subject.
|
||||||
|
This query itself takes currently about 250ms thus is no problem by itself as long as we only need it once per request.
|
||||||
|
2. Joining the result from 1. with the result if a business query.
|
||||||
|
The performance of the business query itself is no problem, for the join see the following explanations.
|
||||||
|
|
||||||
|
Superusers can see all objects (currently already over 90.000)
|
||||||
|
and even high level roles of customers with many hosting assets can see several thousand objects.
|
||||||
|
This is the one side of that problematic join.
|
||||||
|
|
||||||
|
The other side of that problematic is the result of the business query.
|
||||||
|
For example if a user wants to select all of their e-mail-addresses, that might easily half of the visible objects.
|
||||||
|
|
||||||
|
Thus, we would have a join of for example 5.000 x 2.500 rows, which is going to be slow.
|
||||||
|
As there are currently about 84.000 objects are hosting assets and 33.000 e-mail-addresses in our system,
|
||||||
|
for a superuser we would even run into an 84.0000 x 33.0000 join.
|
||||||
|
|
||||||
|
We found some solution approaches:
|
||||||
|
|
||||||
|
1. Getting rid of the `rbacrole` and `rbacpermission` table and only having implicit roles with implicit grants (OWNER->ADMIN->AGENT->TENENT->REFERRER) by comparison of ordered enum values and fixed permission assignments (e.g. OWENER->DELETE, ADMIN->UPDATE etc.). We could also get rid of the table `rbacreferece` if we enter users as business objects.
|
||||||
|
|
||||||
|
This should dramatically reduce the size of the table `rbackgrant` as well as the recusion levels.
|
||||||
|
|
||||||
|
But since we only apply this query once for each business query, that would only improve performance once we have way more objects in our system, but does not help our current problem.
|
||||||
|
|
||||||
|
It's quite some effort to implement even just a prototype, so we did not further explore this idea.
|
||||||
|
|
||||||
|
2. Adding the object type to the table `rbacObject` to reduce the size of the result of the recursive CTE query.
|
||||||
|
|
||||||
|
See chapter below.
|
||||||
|
|
||||||
|
3. Inverting the recursion of the CTE-query, combined with the type condition.
|
||||||
|
|
||||||
|
Instead of starting the recursion with `currentSubjectOrAssumedRolesUuids()`,
|
||||||
|
we could start it with the target table name and row-type,
|
||||||
|
then recurse down to the `currentSubjectOrAssumedRolesUuids()`.
|
||||||
|
|
||||||
|
In the end, we need the object UUIDs, though.
|
||||||
|
But if we start with the join of `rbacObject` with `rbacPermission`,
|
||||||
|
we need to forward the object UUIDs through the whole recursion.
|
||||||
|
|
||||||
|
This idea was not yet further explored.
|
||||||
|
|
||||||
|
|
||||||
|
### Adding The Object Type To The Table `rbacObject`
|
||||||
|
|
||||||
|
This optimization idea came from Michael Hierweck and was promising.
|
||||||
|
The idea is to reduce the size of the result of the recursive CTE query and maybe even speed up that query itself.
|
||||||
|
|
||||||
|
To evaluate this, I added a type column to the `rbacObject` table, initially as an enum hsHostingAssetType. Then I entered the type there for all rows from hs_hosting.asset. This means that 83,886 of 92,545 rows in `rbacobject` have a type set, leaving 8,659 without.
|
||||||
|
|
||||||
|
If we do this for other types (we currently have 1,271 relations and 927 booking items), it gets more complicated because they are different enum types. As varchar(16), we could lose performance again due to the higher storage space requirements.
|
||||||
|
|
||||||
|
But the performance gained is not particularly high anyway.
|
||||||
|
See the average seconds per recursive CTE select as role 'hs_hosting.asset:<DEBITOR>defaultproject:ADMIN',
|
||||||
|
joined with business query for all `'EMAIL_ADDRESSES'`:
|
||||||
|
|
||||||
|
| | D-1000000-hsh | D-1000300-mih |
|
||||||
|
|-----------------------------------------------------|------------------|---------------|
|
||||||
|
| currently (without type comparision in rbacobject): | ~3.30 - ~3.49 | ~0.23 |
|
||||||
|
| optimized (with type comparision in rbacobject): | ~2.99 - ~3.08 | ~0.21 |
|
||||||
|
|
||||||
|
As you can see, the query is no problem at all for normal customers (in the example, yours truly). With Hostsharing (D-1000000-hsh) it is quite slow.
|
||||||
|
|
||||||
|
Luckily this experiment also shows that it's not a big problem, having all hosting assets in the same database table.
|
||||||
|
|
||||||
|
Implementing this approach would be a bit difficult anyway, because we would need to transfer the type query parameter into the definition of the restricted view. We have not even the slightest idea how this could be done.
|
||||||
|
|
||||||
|
See the related queries in [recursive-cte-experiments-for-accessible-uuids.sql](../sql/recursive-cte-experiments-for-accessible-uuids.sql). They might have changed independently since this document was written, but you can still check out the old version from git.
|
||||||
|
|
||||||
|
### Rearranging the Parts of the CTE-Query
|
||||||
|
|
||||||
|
I also moved the function call which determines into its own WITH-section, with no improvement.
|
||||||
|
|
||||||
|
Experimentally I moved the business condition into the CTE SELECT, also with no improvement.
|
||||||
|
|
||||||
|
Such rearrangements seem to be successfully done by the PostgreSQL query optimizer.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
### What we did Achieve?
|
||||||
|
|
||||||
|
In a first step, the total import runtime for office entities was reduced from about 25min to about 10min.
|
||||||
|
|
||||||
|
In a second step, we reduced the import of booking- and hosting-assets from about 100min (not counting the required office entities) to 5min.
|
||||||
|
|
||||||
|
### What did not Help?
|
||||||
|
|
||||||
|
Rearranging the CTE query by extracting parts into WITH-clauses did not improve the performance.
|
||||||
|
|
||||||
|
Surprisingly little performance gain (<10% improvement) came from reducing the result of the CTE query by moving the hosting asset type into RBAC-system and using it in the inner SELECT query instead of in the outer SELECT query of the application side.
|
||||||
|
|
||||||
|
### What did Help?
|
||||||
|
|
||||||
|
Merging the recursive CTE query to determine the RBAC SELECT-permission, made it more clear which business-queries take the time.
|
||||||
|
|
||||||
|
Avoiding EAGER-loading where not necessary, reduced the total runtime of the import to about the half.
|
||||||
|
|
||||||
|
The major improvement came from using direct INSERT statements, which avoided some SELECT statements unnecessarily generated by the EntityManager and also completely bypassed the RBAC SELECT permission checks.
|
||||||
|
|
||||||
|
### What Still Has To Be Done?
|
||||||
|
|
||||||
|
Where this performance analysis was mostly helping the performance of the legacy data import, we still need measures and improvements for the productive code.
|
||||||
|
|
||||||
|
For sure, using more LAZY-loading also helps in the production code. For some more ideas see section _Further Options To Explore_.
|
||||||
|
|
||||||
|
|
56
doc/rbac.md
56
doc/rbac.md
@ -29,7 +29,7 @@ skinparam linetype ortho
|
|||||||
package RBAC {
|
package RBAC {
|
||||||
|
|
||||||
' forward declarations
|
' forward declarations
|
||||||
entity RbacUser
|
entity RbacSubject
|
||||||
|
|
||||||
together {
|
together {
|
||||||
|
|
||||||
@ -37,8 +37,8 @@ package RBAC {
|
|||||||
entity RbacPermission
|
entity RbacPermission
|
||||||
|
|
||||||
|
|
||||||
RbacUser -[hidden]> RbacRole
|
RbacSubject -[hidden]> RbacRole
|
||||||
RbacRole -[hidden]> RbacUser
|
RbacRole -[hidden]> RbacSubject
|
||||||
}
|
}
|
||||||
|
|
||||||
together {
|
together {
|
||||||
@ -57,11 +57,11 @@ package RBAC {
|
|||||||
RbacGrant o-u-> RbacReference
|
RbacGrant o-u-> RbacReference
|
||||||
|
|
||||||
enum RbacReferenceType {
|
enum RbacReferenceType {
|
||||||
RbacUser
|
RbacSubject
|
||||||
RbacRole
|
RbacRole
|
||||||
RbacPermission
|
RbacPermission
|
||||||
}
|
}
|
||||||
RbacReferenceType ..> RbacUser
|
RbacReferenceType ..> RbacSubject
|
||||||
RbacReferenceType ..> RbacRole
|
RbacReferenceType ..> RbacRole
|
||||||
RbacReferenceType ..> RbacPermission
|
RbacReferenceType ..> RbacPermission
|
||||||
|
|
||||||
@ -71,12 +71,12 @@ package RBAC {
|
|||||||
type : RbacReferenceType
|
type : RbacReferenceType
|
||||||
}
|
}
|
||||||
RbacReference o--> RbacReferenceType
|
RbacReference o--> RbacReferenceType
|
||||||
entity RbacUser {
|
entity RbacSubject {
|
||||||
*uuid : uuid <<generated>>
|
*uuid : uuid <<generated>>
|
||||||
--
|
--
|
||||||
name : varchar
|
name : varchar
|
||||||
}
|
}
|
||||||
RbacUser o-- RbacReference
|
RbacSubject o-- RbacReference
|
||||||
|
|
||||||
entity RbacRole {
|
entity RbacRole {
|
||||||
*uuid : uuid(RbacReference)
|
*uuid : uuid(RbacReference)
|
||||||
@ -143,20 +143,20 @@ The primary key of the *RbacReference* and its referred object is always identic
|
|||||||
#### RbacReferenceType
|
#### RbacReferenceType
|
||||||
|
|
||||||
The enum *RbacReferenceType* describes the type of reference.
|
The enum *RbacReferenceType* describes the type of reference.
|
||||||
It's only needed to make it easier to find the referred object in *RbacUser*, *RbacRole* or *RbacPermission*.
|
It's only needed to make it easier to find the referred object in *RbacSubject*, *RbacRole* or *RbacPermission*.
|
||||||
|
|
||||||
#### RbacUser
|
#### RbacSubject
|
||||||
|
|
||||||
An *RbacUser* is a type of RBAC-subject which references a login account outside this system, identified by a name (usually an email-address).
|
An *RbacSubject* is a type of RBAC-subject which references a login account outside this system, identified by a name (usually an email-address).
|
||||||
|
|
||||||
*RbacUser*s can be assigned to multiple *RbacRole*s, through which they can get permissions to *RbacObject*s.
|
*RbacSubject*s can be assigned to multiple *RbacRole*s, through which they can get permissions to *RbacObject*s.
|
||||||
|
|
||||||
The primary key of the *RbacUser* is identical to its related *RbacReference*.
|
The primary key of the *RbacSubject* is identical to its related *RbacReference*.
|
||||||
|
|
||||||
#### RbacRole
|
#### RbacRole
|
||||||
|
|
||||||
An *RbacRole* represents a collection of directly or indirectly assigned *RbacPermission*s.
|
An *RbacRole* represents a collection of directly or indirectly assigned *RbacPermission*s.
|
||||||
Each *RbacRole* can be assigned to *RbacUser*s or to another *RbacRole*.
|
Each *RbacRole* can be assigned to *RbacSubject*s or to another *RbacRole*.
|
||||||
|
|
||||||
Both kinds of assignments are represented via *RbacGrant*.
|
Both kinds of assignments are represented via *RbacGrant*.
|
||||||
|
|
||||||
@ -184,7 +184,7 @@ Only with this rule, the foreign key in *RbacPermission* can be defined as `NOT
|
|||||||
|
|
||||||
#### RbacGrant
|
#### RbacGrant
|
||||||
|
|
||||||
The *RbacGrant* entities represent the access-rights structure from *RbacUser*s via hierarchical *RbacRoles* down to *RbacPermission*s.
|
The *RbacGrant* entities represent the access-rights structure from *RbacSubject*s via hierarchical *RbacRoles* down to *RbacPermission*s.
|
||||||
|
|
||||||
The core SQL queries to determine access rights are all recursive queries on the *RbacGrant* table.
|
The core SQL queries to determine access rights are all recursive queries on the *RbacGrant* table.
|
||||||
|
|
||||||
@ -284,7 +284,7 @@ hide circle
|
|||||||
' use right-angled line routing
|
' use right-angled line routing
|
||||||
' skinparam linetype ortho
|
' skinparam linetype ortho
|
||||||
|
|
||||||
package RbacUsers {
|
package RbacSubjects {
|
||||||
object UserMike
|
object UserMike
|
||||||
object UserSuse
|
object UserSuse
|
||||||
object UserPaul
|
object UserPaul
|
||||||
@ -296,7 +296,7 @@ package RbacRoles {
|
|||||||
object RoleCustXyz_Admin
|
object RoleCustXyz_Admin
|
||||||
object RolePackXyz00_Owner
|
object RolePackXyz00_Owner
|
||||||
}
|
}
|
||||||
RbacUsers -[hidden]> RbacRoles
|
RbacSubjects -[hidden]> RbacRoles
|
||||||
|
|
||||||
package RbacPermissions {
|
package RbacPermissions {
|
||||||
object PermCustXyz_SELECT
|
object PermCustXyz_SELECT
|
||||||
@ -364,10 +364,10 @@ This way, each user can only select the data they have 'SELECT'-permission for,
|
|||||||
|
|
||||||
### Current User
|
### Current User
|
||||||
|
|
||||||
The current use is taken from the session variable `hsadminng.currentUser` which contains the name of the user as stored in the
|
The current use is taken from the session variable `hsadminng.currentSubject` which contains the name of the user as stored in the
|
||||||
*RbacUser*s table. Example:
|
*RbacSubject*s table. Example:
|
||||||
|
|
||||||
SET LOCAL hsadminng.currentUser = 'mike@hostsharing.net';
|
SET LOCAL hsadminng.currentSubject = 'mike@hostsharing.net';
|
||||||
|
|
||||||
That user is also used for historicization and audit log, but which is a different topic.
|
That user is also used for historicization and audit log, but which is a different topic.
|
||||||
|
|
||||||
@ -388,7 +388,7 @@ A full example is shown here:
|
|||||||
|
|
||||||
BEGIN TRANSACTION;
|
BEGIN TRANSACTION;
|
||||||
SET SESSION SESSION AUTHORIZATION restricted;
|
SET SESSION SESSION AUTHORIZATION restricted;
|
||||||
SET LOCAL hsadminng.currentUser = 'mike@hostsharing.net';
|
SET LOCAL hsadminng.currentSubject = 'mike@hostsharing.net';
|
||||||
SET LOCAL hsadminng.assumedRoles = 'customer#aab:admin;customer#aac:admin';
|
SET LOCAL hsadminng.assumedRoles = 'customer#aab:admin;customer#aac:admin';
|
||||||
|
|
||||||
SELECT c.prefix, p.name as "package", ema.localPart || '@' || dom.name as "email-address"
|
SELECT c.prefix, p.name as "package", ema.localPart || '@' || dom.name as "email-address"
|
||||||
@ -605,8 +605,8 @@ Find the SQL script here: `28-hs-tests.sql`.
|
|||||||
We have tested two variants of the query for the restricted view,
|
We have tested two variants of the query for the restricted view,
|
||||||
both utilizing a PostgreSQL function like this:
|
both utilizing a PostgreSQL function like this:
|
||||||
|
|
||||||
FUNCTION queryAccessibleObjectUuidsOfSubjectIds(
|
FUNCTION rbac.queryAccessibleObjectUuidsOfSubjectIds(
|
||||||
requiredOp RbacOp,
|
requiredOp rbac.RbacOp,
|
||||||
forObjectTable varchar,
|
forObjectTable varchar,
|
||||||
subjectIds uuid[],
|
subjectIds uuid[],
|
||||||
maxObjects integer = 16000)
|
maxObjects integer = 16000)
|
||||||
@ -623,8 +623,8 @@ Let's have a look at the two view queries:
|
|||||||
FROM customer AS target
|
FROM customer AS target
|
||||||
WHERE target.uuid IN (
|
WHERE target.uuid IN (
|
||||||
SELECT uuid
|
SELECT uuid
|
||||||
FROM queryAccessibleObjectUuidsOfSubjectIds(
|
FROM rbac.queryAccessibleObjectUuidsOfSubjectIds(
|
||||||
'SELECT, 'customer', currentSubjectsUuids()));
|
'SELECT, 'customer', currentSubjectOrAssumedRolesUuids()));
|
||||||
|
|
||||||
This view should be automatically updatable.
|
This view should be automatically updatable.
|
||||||
Where, for updates, we actually have to check for 'UPDATE' instead of 'SELECT' operation, which makes it a bit more complicated.
|
Where, for updates, we actually have to check for 'UPDATE' instead of 'SELECT' operation, which makes it a bit more complicated.
|
||||||
@ -641,8 +641,8 @@ Looks like the query optimizer needed some statistics to find the best path.
|
|||||||
CREATE OR REPLACE VIEW customer_rv AS
|
CREATE OR REPLACE VIEW customer_rv AS
|
||||||
SELECT DISTINCT target.*
|
SELECT DISTINCT target.*
|
||||||
FROM customer AS target
|
FROM customer AS target
|
||||||
JOIN queryAccessibleObjectUuidsOfSubjectIds(
|
JOIN rbac.queryAccessibleObjectUuidsOfSubjectIds(
|
||||||
'SELECT, 'customer', currentSubjectsUuids()) AS allowedObjId
|
'SELECT, 'customer', currentSubjectOrAssumedRolesUuids()) AS allowedObjId
|
||||||
ON target.uuid = allowedObjId;
|
ON target.uuid = allowedObjId;
|
||||||
|
|
||||||
This view cannot is not updatable automatically,
|
This view cannot is not updatable automatically,
|
||||||
@ -671,9 +671,9 @@ Access Control for business objects checked according to the assigned roles.
|
|||||||
But we decided not to create such roles and permissions for the RBAC-Objects itself.
|
But we decided not to create such roles and permissions for the RBAC-Objects itself.
|
||||||
It would have overcomplicated the system and the necessary information can easily be added to the RBAC-Objects itself, mostly the `RbacGrant`s.
|
It would have overcomplicated the system and the necessary information can easily be added to the RBAC-Objects itself, mostly the `RbacGrant`s.
|
||||||
|
|
||||||
### RbacUser
|
### RbacSubject
|
||||||
|
|
||||||
Users can self-register, thus to create a new RbacUser entity, no login is required.
|
Users can self-register, thus to create a new RbacSubject entity, no login is required.
|
||||||
But such a user has no access-rights except viewing itself.
|
But such a user has no access-rights except viewing itself.
|
||||||
|
|
||||||
Users can view themselves.
|
Users can view themselves.
|
||||||
|
124
doc/scenarios/template.html
Normal file
124
doc/scenarios/template.html
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html $if(lang)$ lang="$lang$" $endif$>
|
||||||
|
<head>
|
||||||
|
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<!--[if lt IE 9]>
|
||||||
|
<script src="http://css3-mediaqueries-js.googlecode.com/svn/trunk/css3-mediaqueries.js"></script>
|
||||||
|
<![endif]-->
|
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
||||||
|
<meta http-equiv="Content-Style-Type" content="text/css" />
|
||||||
|
|
||||||
|
<!-- <link rel="stylesheet" type="text/css" href="template.css" /> -->
|
||||||
|
<link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/gh/diversen/pandoc-bootstrap-adaptive-template@959c3622/template.css" />
|
||||||
|
|
||||||
|
<link href="https://vjs.zencdn.net/5.4.4/video-js.css" rel="stylesheet" />
|
||||||
|
|
||||||
|
<script src="https://code.jquery.com/jquery-2.2.1.min.js"></script>
|
||||||
|
<!-- <script type='text/javascript' src='menu/js/jquery.cookie.js'></script> -->
|
||||||
|
<!-- <script type='text/javascript' src='menu/js/jquery.hoverIntent.minified.js'></script> -->
|
||||||
|
<!-- <script type='text/javascript' src='menu/js/jquery.dcjqaccordion.2.7.min.js'></script> -->
|
||||||
|
|
||||||
|
<!-- <link href="menu/css/skins/blue.css" rel="stylesheet" type="text/css" /> -->
|
||||||
|
<!-- <link href="menu/css/skins/graphite.css" rel="stylesheet" type="text/css" /> -->
|
||||||
|
<!-- <link href="menu/css/skins/grey.css" rel="stylesheet" type="text/css" /> -->
|
||||||
|
|
||||||
|
<!-- <script src="https://cdn.mathjax.org/mathjax/latest/MathJax.js?config=TeX-AMS-MML_HTMLorMML"></script> -->
|
||||||
|
|
||||||
|
|
||||||
|
<!-- <script src="script.js"></script> -->
|
||||||
|
|
||||||
|
<!-- <script src="jquery.sticky-kit.js "></script> -->
|
||||||
|
<script type='text/javascript' src='https://cdn.jsdelivr.net/gh/diversen/pandoc-bootstrap-adaptive-template@959c3622/menu/js/jquery.cookie.js'></script>
|
||||||
|
<script type='text/javascript' src='https://cdn.jsdelivr.net/gh/diversen/pandoc-bootstrap-adaptive-template@959c3622/menu/js/jquery.hoverIntent.minified.js'></script>
|
||||||
|
<script type='text/javascript' src='https://cdn.jsdelivr.net/gh/diversen/pandoc-bootstrap-adaptive-template@959c3622/menu/js/jquery.dcjqaccordion.2.7.min.js'></script>
|
||||||
|
|
||||||
|
<link href="https://cdn.jsdelivr.net/gh/diversen/pandoc-bootstrap-adaptive-template@959c3622/menu/css/skins/blue.css" rel="stylesheet" type="text/css" />
|
||||||
|
<link href="https://cdn.jsdelivr.net/gh/diversen/pandoc-bootstrap-adaptive-template@959c3622/menu/css/skins/graphite.css" rel="stylesheet" type="text/css" />
|
||||||
|
<link href="https://cdn.jsdelivr.net/gh/diversen/pandoc-bootstrap-adaptive-template@959c3622/menu/css/skins/grey.css" rel="stylesheet" type="text/css" />
|
||||||
|
<link href="https://cdn.jsdelivr.net/gh/ryangrose/easy-pandoc-templates@948e28e5/css/elegant_bootstrap.css" rel="stylesheet" type="text/css" />
|
||||||
|
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.4/MathJax.js?config=TeX-AMS-MML_HTMLorMML"></script>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/gh/diversen/pandoc-bootstrap-adaptive-template@959c3622/script.js"></script>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/gh/diversen/pandoc-bootstrap-adaptive-template@959c3622/jquery.sticky-kit.js"></script>
|
||||||
|
<meta name="generator" content="pandoc" />
|
||||||
|
$for(author-meta)$
|
||||||
|
<meta name="author" content="$author-meta$" />
|
||||||
|
$endfor$
|
||||||
|
$if(date-meta)$
|
||||||
|
<meta name="date" content="$date-meta$" />
|
||||||
|
$endif$
|
||||||
|
<title>$if(title-prefix)$$title-prefix$ - $endif$$pagetitle$</title>
|
||||||
|
<style type="text/css">code{white-space: pre;}</style>
|
||||||
|
$if(quotes)$
|
||||||
|
<style type="text/css">q { quotes: "“" "”" "‘" "’"; }</style>
|
||||||
|
$endif$
|
||||||
|
$if(highlighting-css)$
|
||||||
|
<style type="text/css">
|
||||||
|
$highlighting-css$
|
||||||
|
</style>
|
||||||
|
$endif$
|
||||||
|
$for(css)$
|
||||||
|
<link rel="stylesheet" href="$css$" $if(html5)$$else$type="text/css" $endif$/>
|
||||||
|
$endfor$
|
||||||
|
$if(math)$
|
||||||
|
$math$
|
||||||
|
$endif$
|
||||||
|
$for(header-includes)$
|
||||||
|
$header-includes$
|
||||||
|
$endfor$
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
|
||||||
|
$if(title)$
|
||||||
|
<div class="navbar navbar-static-top">
|
||||||
|
<div class="navbar-inner">
|
||||||
|
<div class="container">
|
||||||
|
<span class="doc-title">$title$</span>
|
||||||
|
<ul class="nav pull-right doc-info">
|
||||||
|
$for(author)$
|
||||||
|
<li><p class="navbar-text">$author$</p></li>
|
||||||
|
$endfor$
|
||||||
|
$if(date)$
|
||||||
|
<li><p class="navbar-text">$date$</p></li>
|
||||||
|
$endif$
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
$endif$
|
||||||
|
<div class="container">
|
||||||
|
<div class="row">
|
||||||
|
$if(toc)$
|
||||||
|
<div id="$idprefix$TOC" class="span3">
|
||||||
|
<div class="well toc">
|
||||||
|
|
||||||
|
$toc$
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
$endif$
|
||||||
|
<div class="span$if(toc)$9$else$12$endif$">
|
||||||
|
|
||||||
|
$if(abstract)$
|
||||||
|
<H1>$abstract-title$</H1>
|
||||||
|
$abstract$
|
||||||
|
$endif$
|
||||||
|
|
||||||
|
$for(include-before)$
|
||||||
|
$include-before$
|
||||||
|
$endfor$
|
||||||
|
$body$
|
||||||
|
$for(include-after)$
|
||||||
|
$include-after$
|
||||||
|
$endfor$
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script src="https://vjs.zencdn.net/5.4.4/video.js"></script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
@ -90,6 +90,20 @@ Acceptance-tests, are blackbox-tests and do <u>not</u> count into test-code-cove
|
|||||||
TODO.test: Complete the Acceptance-Tests test concept.
|
TODO.test: Complete the Acceptance-Tests test concept.
|
||||||
|
|
||||||
|
|
||||||
|
#### Scenario-Tests
|
||||||
|
|
||||||
|
Our Scenario-tests are induced by business use-cases.
|
||||||
|
They test from the REST API all the way down to the database.
|
||||||
|
|
||||||
|
Most scenario-tests are positive tests, they test if business scenarios do work.
|
||||||
|
But few might be negative tests, which test if specific forbidden data gets rejected.
|
||||||
|
|
||||||
|
Our scenario tests also generate test-reports which contain the REST-API calls needed for each scenario.
|
||||||
|
These reports can be used as examples for the API usage from a business perspective.
|
||||||
|
|
||||||
|
There is an extra document regarding scenario-test, see [Scenario-Tests README](../src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/README.md).
|
||||||
|
|
||||||
|
|
||||||
#### Performance-Tests
|
#### Performance-Tests
|
||||||
|
|
||||||
Performance-critical scenarios have to be identified and a special performance-test has to be implemented.
|
Performance-critical scenarios have to be identified and a special performance-test has to be implemented.
|
||||||
|
@ -1,8 +1,10 @@
|
|||||||
{
|
{
|
||||||
"allowedLicenses": [
|
"allowedLicenses": [
|
||||||
{ "moduleLicense": "Apache 2.0" },
|
|
||||||
{ "moduleLicense": "Apache 2" },
|
{ "moduleLicense": "Apache 2" },
|
||||||
|
{ "moduleLicense": "Apache 2.0" },
|
||||||
|
{ "moduleLicense": "Apache-2.0" },
|
||||||
{ "moduleLicense": "Apache License 2.0" },
|
{ "moduleLicense": "Apache License 2.0" },
|
||||||
|
{ "moduleLicense": "Apache License v2.0" },
|
||||||
{ "moduleLicense": "Apache License, Version 2.0" },
|
{ "moduleLicense": "Apache License, Version 2.0" },
|
||||||
{ "moduleLicense": "The Apache Software License, Version 2.0" },
|
{ "moduleLicense": "The Apache Software License, Version 2.0" },
|
||||||
|
|
||||||
@ -11,6 +13,8 @@
|
|||||||
{ "moduleLicense": "BSD-3-Clause" },
|
{ "moduleLicense": "BSD-3-Clause" },
|
||||||
{ "moduleLicense": "The BSD License" },
|
{ "moduleLicense": "The BSD License" },
|
||||||
|
|
||||||
|
{ "moduleLicense": "The New BSD License" },
|
||||||
|
|
||||||
{ "moduleLicense": "CDDL 1.1" },
|
{ "moduleLicense": "CDDL 1.1" },
|
||||||
{ "moduleLicense": "CDDL/GPLv2+CE" },
|
{ "moduleLicense": "CDDL/GPLv2+CE" },
|
||||||
{ "moduleLicense": "COMMON DEVELOPMENT AND DISTRIBUTION LICENSE (CDDL) Version 1.0" },
|
{ "moduleLicense": "COMMON DEVELOPMENT AND DISTRIBUTION LICENSE (CDDL) Version 1.0" },
|
||||||
@ -29,11 +33,22 @@
|
|||||||
{ "moduleLicense": "GNU General Public License, version 2 with the GNU Classpath Exception" },
|
{ "moduleLicense": "GNU General Public License, version 2 with the GNU Classpath Exception" },
|
||||||
{ "moduleLicense": "GPL2 w/ CPE" },
|
{ "moduleLicense": "GPL2 w/ CPE" },
|
||||||
|
|
||||||
|
{ "moduleLicense": "LGPL, version 2.1"},
|
||||||
|
{ "moduleLicense": "LGPL-2.1-or-later"},
|
||||||
|
|
||||||
{ "moduleLicense": "MIT License" },
|
{ "moduleLicense": "MIT License" },
|
||||||
{ "moduleLicense": "MIT" },
|
{ "moduleLicense": "MIT" },
|
||||||
{ "moduleLicense": "The MIT License (MIT)" },
|
{ "moduleLicense": "The MIT License (MIT)" },
|
||||||
{ "moduleLicense": "The MIT License" },
|
{ "moduleLicense": "The MIT License" },
|
||||||
|
|
||||||
{ "moduleName": "org.springdoc:springdoc-openapi" }
|
{ "moduleLicense": "WTFPL" },
|
||||||
|
|
||||||
|
{
|
||||||
|
"moduleLicense": null,
|
||||||
|
"#moduleLicense": "Apache License 2.0, see https://github.com/springdoc/springdoc-openapi/blob/main/LICENSE",
|
||||||
|
"moduleVersion": "2.4.0",
|
||||||
|
"moduleName": "org.springdoc:springdoc-openapi"
|
||||||
|
}
|
||||||
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
27
etc/docker-compose.yml
Normal file
27
etc/docker-compose.yml
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres-with-contrib:15.5-bookworm
|
||||||
|
container_name: custom-postgres
|
||||||
|
environment:
|
||||||
|
POSTGRES_PASSWORD: password
|
||||||
|
volumes:
|
||||||
|
- ./postgresql-log-slow-queries.conf:/etc/postgresql/postgresql.conf
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
command:
|
||||||
|
- bash
|
||||||
|
- -c
|
||||||
|
- >
|
||||||
|
apt-get update &&
|
||||||
|
apt-get install -y postgresql-contrib &&
|
||||||
|
docker-entrypoint.sh postgres -c config_file=/etc/postgresql/postgresql.conf
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpus: '2'
|
||||||
|
memory: 8G
|
||||||
|
reservations:
|
||||||
|
cpus: '1'
|
||||||
|
memory: 2G
|
6
etc/jenkinsAgent.Dockerfile
Normal file
6
etc/jenkinsAgent.Dockerfile
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
FROM eclipse-temurin:21-jdk
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y bind9-utils pandoc && \
|
||||||
|
apt-get clean && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
@ -1,12 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<suppressions xmlns="https://jeremylong.github.io/DependencyCheck/dependency-suppression.1.3.xsd">
|
<suppressions xmlns="https://jeremylong.github.io/DependencyCheck/dependency-suppression.1.3.xsd">
|
||||||
<suppress>
|
|
||||||
<notes><![CDATA[
|
|
||||||
Cyclic references are not possible if file comes in JSON text format.
|
|
||||||
]]></notes>
|
|
||||||
<packageUrl regex="true">^pkg:maven/com\.fasterxml\.jackson\.core/jackson\-databind@.*$</packageUrl>
|
|
||||||
<cpe>cpe:/a:fasterxml:jackson-databind</cpe>
|
|
||||||
</suppress>
|
|
||||||
<suppress>
|
<suppress>
|
||||||
<notes><![CDATA[
|
<notes><![CDATA[
|
||||||
Internal tooling, not exposed to the Internet.
|
Internal tooling, not exposed to the Internet.
|
||||||
@ -14,4 +7,10 @@
|
|||||||
<packageUrl regex="true">^pkg:maven/org\.pitest/pitest\-command\-line@.*$</packageUrl>
|
<packageUrl regex="true">^pkg:maven/org\.pitest/pitest\-command\-line@.*$</packageUrl>
|
||||||
<cpe>cpe:/a:line:line</cpe>
|
<cpe>cpe:/a:line:line</cpe>
|
||||||
</suppress>
|
</suppress>
|
||||||
|
<suppress>
|
||||||
|
<notes><![CDATA[
|
||||||
|
Malicious HTTP redirect in JAXB on a REST-endpoint is not that dangerous.
|
||||||
|
]]></notes>
|
||||||
|
<cve>CVE-2024-9329</cve>
|
||||||
|
</suppress>
|
||||||
</suppressions>
|
</suppressions>
|
||||||
|
10
etc/postgresql-log-slow-queries.conf
Normal file
10
etc/postgresql-log-slow-queries.conf
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
shared_preload_libraries = 'pg_stat_statements,auto_explain'
|
||||||
|
log_min_duration_statement = 1000
|
||||||
|
log_statement = 'all'
|
||||||
|
log_duration = on
|
||||||
|
pg_stat_statements.track = all
|
||||||
|
auto_explain.log_min_duration = '1s' # Logs queries taking longer than 1 second
|
||||||
|
auto_explain.log_analyze = on # Include actual run times
|
||||||
|
auto_explain.log_buffers = on # Include buffer usage statistics
|
||||||
|
auto_explain.log_format = 'json' # Format the log output in JSON
|
||||||
|
listen_addresses = '*'
|
@ -1,53 +0,0 @@
|
|||||||
-- ========================================================
|
|
||||||
-- First Example Entity with History
|
|
||||||
-- --------------------------------------------------------
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS customer (
|
|
||||||
"id" SERIAL PRIMARY KEY,
|
|
||||||
"reference" int not null unique, -- 10000-99999
|
|
||||||
"prefix" character(3) unique
|
|
||||||
);
|
|
||||||
|
|
||||||
CALL create_historicization('customer');
|
|
||||||
|
|
||||||
|
|
||||||
-- ========================================================
|
|
||||||
-- Second Example Entity with History
|
|
||||||
-- --------------------------------------------------------
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS package_type (
|
|
||||||
"id" serial PRIMARY KEY,
|
|
||||||
"name" character varying(8)
|
|
||||||
);
|
|
||||||
|
|
||||||
CALL create_historicization('package_type');
|
|
||||||
|
|
||||||
-- ========================================================
|
|
||||||
-- Third Example Entity with History
|
|
||||||
-- --------------------------------------------------------
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS package (
|
|
||||||
"id" serial PRIMARY KEY,
|
|
||||||
"name" character varying(5),
|
|
||||||
"customer_id" INTEGER REFERENCES customer(id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CALL create_historicization('package');
|
|
||||||
|
|
||||||
|
|
||||||
-- ========================================================
|
|
||||||
-- query historical data
|
|
||||||
-- --------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
ABORT;
|
|
||||||
BEGIN TRANSACTION;
|
|
||||||
SET LOCAL hsadminng.currentUser TO 'mih42_customer_aaa';
|
|
||||||
SET LOCAL hsadminng.currentTask TO 'adding customer_aaa';
|
|
||||||
INSERT INTO package (customer_id, name) VALUES (10000, 'aaa00');
|
|
||||||
COMMIT;
|
|
||||||
-- Usage:
|
|
||||||
|
|
||||||
SET hsadminng.timestamp TO '2022-07-12 08:53:27.723315';
|
|
||||||
SET hsadminng.timestamp TO '2022-07-12 11:38:27.723315';
|
|
||||||
SELECT * FROM customer_hv p WHERE prefix = 'aaa';
|
|
@ -1,166 +1,39 @@
|
|||||||
|
|
||||||
-- ========================================================
|
-- ========================================================
|
||||||
-- Historization
|
-- Historization twiddle
|
||||||
-- --------------------------------------------------------
|
-- --------------------------------------------------------
|
||||||
|
|
||||||
CREATE TABLE "tx_history" (
|
rollback;
|
||||||
"tx_id" BIGINT NOT NULL UNIQUE,
|
begin transaction;
|
||||||
"tx_timestamp" TIMESTAMP NOT NULL,
|
call defineContext('historization testing', null, 'superuser-alex@hostsharing.net',
|
||||||
"user" VARCHAR(64) NOT NULL, -- references postgres user
|
-- 'hs_booking.project#D-1000000-hshdefaultproject:ADMIN'); -- prod+test
|
||||||
"task" VARCHAR NOT NULL
|
'hs_booking.project#D-1000313-D-1000313defaultproject:ADMIN'); -- prod+test
|
||||||
);
|
-- 'hs_booking.project#D-1000300-mihdefaultproject:ADMIN'); -- prod
|
||||||
|
-- 'hs_booking.project#D-1000300-mimdefaultproject:ADMIN'); -- test
|
||||||
|
-- update hs_hosting.asset set caption='lug00 b' where identifier = 'lug00' and type = 'MANAGED_WEBSPACE'; -- prod
|
||||||
|
-- update hs_hosting.asset set caption='hsh00 A ' || now()::text where identifier = 'hsh00' and type = 'MANAGED_WEBSPACE'; -- test
|
||||||
|
-- update hs_hosting.asset set caption='hsh00 B ' || now()::text where identifier = 'hsh00' and type = 'MANAGED_WEBSPACE'; -- test
|
||||||
|
|
||||||
CREATE TYPE "operation" AS ENUM ('INSERT', 'UPDATE', 'DELETE', 'TRUNCATE');
|
-- insert into hs_hosting.asset
|
||||||
|
-- (uuid, bookingitemuuid, type, parentassetuuid, assignedtoassetuuid, identifier, caption, config, alarmcontactuuid)
|
||||||
|
-- values
|
||||||
|
-- (uuid_generate_v4(), null, 'EMAIL_ADDRESS', 'bbda5895-0569-4e20-bb4c-34f3a38f3f63'::uuid, null,
|
||||||
|
-- 'new@thi.example.org', 'some new E-Mail-Address', '{}'::jsonb, null);
|
||||||
|
|
||||||
-- see https://www.postgresql.org/docs/current/plpgsql-trigger.html
|
delete from hs_hosting.asset where uuid='5aea68d2-3b55-464f-8362-b05c76c5a681'::uuid;
|
||||||
|
commit;
|
||||||
|
|
||||||
CREATE OR REPLACE FUNCTION historicize()
|
-- single version at point in time
|
||||||
RETURNS trigger
|
-- set hsadminng.tx_history_txid to (select max(txid) from base.tx_context where txtimestamp<='2024-08-27 12:13:13.450821');
|
||||||
LANGUAGE plpgsql STRICT AS $$
|
set hsadminng.tx_history_txid to '';
|
||||||
DECLARE
|
set hsadminng.tx_history_timestamp to '2024-08-29 12:42';
|
||||||
currentUser VARCHAR(63);
|
-- all versions
|
||||||
currentTask VARCHAR(127);
|
select base.tx_history_txid(), txc.txtimestamp, txc.currentSubject, txc.currentTask, haex.*
|
||||||
"row" RECORD;
|
from hs_hosting.asset_ex haex
|
||||||
"alive" BOOLEAN;
|
join base.tx_context txc on haex.txid=txc.txid
|
||||||
"sql" varchar;
|
where haex.identifier = 'test@thi.example.org';
|
||||||
BEGIN
|
|
||||||
-- determine user_id
|
|
||||||
BEGIN
|
|
||||||
currentUser := current_setting('hsadminng.currentUser');
|
|
||||||
EXCEPTION WHEN OTHERS THEN
|
|
||||||
currentUser := NULL;
|
|
||||||
END;
|
|
||||||
IF (currentUser IS NULL OR currentUser = '') THEN
|
|
||||||
RAISE EXCEPTION 'hsadminng.currentUser must be defined, please use "SET LOCAL ...;"';
|
|
||||||
END IF;
|
|
||||||
RAISE NOTICE 'currentUser: %', currentUser;
|
|
||||||
|
|
||||||
-- determine task
|
select uuid, version, type, identifier, caption from hs_hosting.asset_hv p where identifier = 'test@thi.example.org';
|
||||||
currentTask = current_setting('hsadminng.currentTask');
|
|
||||||
assert currentTask IS NOT NULL AND length(currentTask) >= 12,
|
|
||||||
format('hsadminng.currentTask (%s) must be defined and min 12 characters long, please use "SET LOCAL ...;"', currentTask);
|
|
||||||
assert length(currentTask) <= 127,
|
|
||||||
format('hsadminng.currentTask (%s) must not be longer than 127 characters"', currentTask);
|
|
||||||
|
|
||||||
IF (TG_OP = 'INSERT') OR (TG_OP = 'UPDATE') THEN
|
select pg_current_xact_id();
|
||||||
"row" := NEW;
|
|
||||||
"alive" := TRUE;
|
|
||||||
ELSE -- DELETE or TRUNCATE
|
|
||||||
"row" := OLD;
|
|
||||||
"alive" := FALSE;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
sql := format('INSERT INTO tx_history VALUES (txid_current(), now(), %1L, %2L) ON CONFLICT DO NOTHING', currentUser, currentTask);
|
|
||||||
RAISE NOTICE 'sql: %', sql;
|
|
||||||
EXECUTE sql;
|
|
||||||
sql := format('INSERT INTO %3$I_versions VALUES (DEFAULT, txid_current(), %1$L, %2$L, $1.*)', TG_OP, alive, TG_TABLE_NAME);
|
|
||||||
RAISE NOTICE 'sql: %', sql;
|
|
||||||
EXECUTE sql USING "row";
|
|
||||||
|
|
||||||
RETURN "row";
|
|
||||||
END; $$;
|
|
||||||
|
|
||||||
CREATE OR REPLACE PROCEDURE create_historical_view(baseTable varchar)
|
|
||||||
LANGUAGE plpgsql AS $$
|
|
||||||
DECLARE
|
|
||||||
createTriggerSQL varchar;
|
|
||||||
viewName varchar;
|
|
||||||
versionsTable varchar;
|
|
||||||
createViewSQL varchar;
|
|
||||||
baseCols varchar;
|
|
||||||
BEGIN
|
|
||||||
|
|
||||||
viewName = quote_ident(format('%s_hv', baseTable));
|
|
||||||
versionsTable = quote_ident(format('%s_versions', baseTable));
|
|
||||||
baseCols = (SELECT string_agg(quote_ident(column_name), ', ')
|
|
||||||
FROM information_schema.columns
|
|
||||||
WHERE table_schema = 'public' AND table_name = baseTable);
|
|
||||||
|
|
||||||
createViewSQL = format(
|
|
||||||
'CREATE OR REPLACE VIEW %1$s AS' ||
|
|
||||||
'(' ||
|
|
||||||
' SELECT %2$s' ||
|
|
||||||
' FROM %3$s' ||
|
|
||||||
' WHERE alive = TRUE' ||
|
|
||||||
' AND version_id IN' ||
|
|
||||||
' (' ||
|
|
||||||
' SELECT max(vt.version_id) AS history_id' ||
|
|
||||||
' FROM %3$s AS vt' ||
|
|
||||||
' JOIN tx_history as txh ON vt.tx_id = txh.tx_id' ||
|
|
||||||
' WHERE txh.tx_timestamp <= current_setting(''hsadminng.timestamp'')::timestamp' ||
|
|
||||||
' GROUP BY id' ||
|
|
||||||
' )' ||
|
|
||||||
')',
|
|
||||||
viewName, baseCols, versionsTable
|
|
||||||
);
|
|
||||||
RAISE NOTICE 'sql: %', createViewSQL;
|
|
||||||
EXECUTE createViewSQL;
|
|
||||||
|
|
||||||
createTriggerSQL = 'CREATE TRIGGER ' || baseTable || '_historicize' ||
|
|
||||||
' AFTER INSERT OR DELETE OR UPDATE ON ' || baseTable ||
|
|
||||||
' FOR EACH ROW EXECUTE PROCEDURE historicize()';
|
|
||||||
RAISE NOTICE 'sql: %', createTriggerSQL;
|
|
||||||
EXECUTE createTriggerSQL;
|
|
||||||
|
|
||||||
END; $$;
|
|
||||||
|
|
||||||
CREATE OR REPLACE PROCEDURE create_historicization(baseTable varchar)
|
|
||||||
LANGUAGE plpgsql AS $$
|
|
||||||
DECLARE
|
|
||||||
createHistTableSql varchar;
|
|
||||||
createTriggerSQL varchar;
|
|
||||||
viewName varchar;
|
|
||||||
versionsTable varchar;
|
|
||||||
createViewSQL varchar;
|
|
||||||
baseCols varchar;
|
|
||||||
BEGIN
|
|
||||||
|
|
||||||
-- create the history table
|
|
||||||
createHistTableSql = '' ||
|
|
||||||
'CREATE TABLE ' || baseTable || '_versions (' ||
|
|
||||||
' version_id serial PRIMARY KEY,' ||
|
|
||||||
' tx_id bigint NOT NULL REFERENCES tx_history(tx_id),' ||
|
|
||||||
' trigger_op operation NOT NULL,' ||
|
|
||||||
' alive boolean not null,' ||
|
|
||||||
|
|
||||||
' LIKE ' || baseTable ||
|
|
||||||
' EXCLUDING CONSTRAINTS' ||
|
|
||||||
' EXCLUDING STATISTICS' ||
|
|
||||||
')';
|
|
||||||
RAISE NOTICE 'sql: %', createHistTableSql;
|
|
||||||
EXECUTE createHistTableSql;
|
|
||||||
|
|
||||||
-- create the historical view
|
|
||||||
viewName = quote_ident(format('%s_hv', baseTable));
|
|
||||||
versionsTable = quote_ident(format('%s_versions', baseTable));
|
|
||||||
baseCols = (SELECT string_agg(quote_ident(column_name), ', ')
|
|
||||||
FROM information_schema.columns
|
|
||||||
WHERE table_schema = 'public' AND table_name = baseTable);
|
|
||||||
|
|
||||||
createViewSQL = format(
|
|
||||||
'CREATE OR REPLACE VIEW %1$s AS' ||
|
|
||||||
'(' ||
|
|
||||||
' SELECT %2$s' ||
|
|
||||||
' FROM %3$s' ||
|
|
||||||
' WHERE alive = TRUE' ||
|
|
||||||
' AND version_id IN' ||
|
|
||||||
' (' ||
|
|
||||||
' SELECT max(vt.version_id) AS history_id' ||
|
|
||||||
' FROM %3$s AS vt' ||
|
|
||||||
' JOIN tx_history as txh ON vt.tx_id = txh.tx_id' ||
|
|
||||||
' WHERE txh.tx_timestamp <= current_setting(''hsadminng.timestamp'')::timestamp' ||
|
|
||||||
' GROUP BY id' ||
|
|
||||||
' )' ||
|
|
||||||
')',
|
|
||||||
viewName, baseCols, versionsTable
|
|
||||||
);
|
|
||||||
RAISE NOTICE 'sql: %', createViewSQL;
|
|
||||||
EXECUTE createViewSQL;
|
|
||||||
|
|
||||||
createTriggerSQL = 'CREATE TRIGGER ' || baseTable || '_historicize' ||
|
|
||||||
' AFTER INSERT OR DELETE OR UPDATE ON ' || baseTable ||
|
|
||||||
' FOR EACH ROW EXECUTE PROCEDURE historicize()';
|
|
||||||
RAISE NOTICE 'sql: %', createTriggerSQL;
|
|
||||||
EXECUTE createTriggerSQL;
|
|
||||||
|
|
||||||
END; $$;
|
|
||||||
|
@ -3,28 +3,28 @@
|
|||||||
-- --------------------------------------------------------
|
-- --------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
select isGranted(findRoleId('administrators'), findRoleId('test_package#aaa00:OWNER'));
|
select rbac.isGranted(rbac.findRoleId('administrators'), rbac.findRoleId('rbactest.package#aaa00:OWNER'));
|
||||||
select isGranted(findRoleId('test_package#aaa00:OWNER'), findRoleId('administrators'));
|
select rbac.isGranted(rbac.findRoleId('rbactest.package#aaa00:OWNER'), rbac.findRoleId('administrators'));
|
||||||
-- call grantRoleToRole(findRoleId('test_package#aaa00:OWNER'), findRoleId('administrators'));
|
-- call rbac.grantRoleToRole(findRoleId('rbactest.package#aaa00:OWNER'), findRoleId('administrators'));
|
||||||
-- call grantRoleToRole(findRoleId('administrators'), findRoleId('test_package#aaa00:OWNER'));
|
-- call rbac.grantRoleToRole(findRoleId('administrators'), findRoleId('rbactest.package#aaa00:OWNER'));
|
||||||
|
|
||||||
select count(*)
|
select count(*)
|
||||||
FROM queryAllPermissionsOfSubjectIdForObjectUuids(findRbacUser('superuser-fran@hostsharing.net'),
|
FROM rbac.queryAllPermissionsOfSubjectIdForObjectUuids(rbac.findRbacSubject('superuser-fran@hostsharing.net'),
|
||||||
ARRAY(select uuid from customer where reference < 1100000));
|
ARRAY(select uuid from rbactest.customer where reference < 1100000));
|
||||||
select count(*)
|
select count(*)
|
||||||
FROM queryAllPermissionsOfSubjectId(findRbacUser('superuser-fran@hostsharing.net'));
|
FROM rbac.queryAllPermissionsOfSubjectId(findRbacSubject('superuser-fran@hostsharing.net'));
|
||||||
select *
|
select *
|
||||||
FROM queryAllPermissionsOfSubjectId(findRbacUser('alex@example.com'));
|
FROM rbac.queryAllPermissionsOfSubjectId(findRbacSubject('alex@example.com'));
|
||||||
select *
|
select *
|
||||||
FROM queryAllPermissionsOfSubjectId(findRbacUser('rosa@example.com'));
|
FROM rbac.queryAllPermissionsOfSubjectId(findRbacSubject('rosa@example.com'));
|
||||||
|
|
||||||
select *
|
select *
|
||||||
FROM queryAllRbacUsersWithPermissionsFor(findEffectivePermissionId('customer',
|
FROM rbac.queryAllRbacSubjectsWithPermissionsFor(rbac.findEffectivePermissionId('customer',
|
||||||
(SELECT uuid FROM RbacObject WHERE objectTable = 'customer' LIMIT 1),
|
(SELECT uuid FROM rbac.RbacObject WHERE objectTable = 'customer' LIMIT 1),
|
||||||
'add-package'));
|
'add-package'));
|
||||||
select *
|
select *
|
||||||
FROM queryAllRbacUsersWithPermissionsFor(findEffectivePermissionId('package',
|
FROM rbac.queryAllRbacSubjectsWithPermissionsFor(rbac.findEffectivePermissionId('package',
|
||||||
(SELECT uuid FROM RbacObject WHERE objectTable = 'package' LIMIT 1),
|
(SELECT uuid FROM rbac.RbacObject WHERE objectTable = 'package' LIMIT 1),
|
||||||
'DELETE'));
|
'DELETE'));
|
||||||
|
|
||||||
DO LANGUAGE plpgsql
|
DO LANGUAGE plpgsql
|
||||||
@ -33,13 +33,13 @@ $$
|
|||||||
userId uuid;
|
userId uuid;
|
||||||
result bool;
|
result bool;
|
||||||
BEGIN
|
BEGIN
|
||||||
userId = findRbacUser('superuser-alex@hostsharing.net');
|
userId = rbac.findRbacSubject('superuser-alex@hostsharing.net');
|
||||||
result = (SELECT * FROM isPermissionGrantedToSubject(findPermissionId('package', 94928, 'add-package'), userId));
|
result = (SELECT * FROM rbac.isPermissionGrantedToSubject(rbac.findPermissionId('package', 94928, 'add-package'), userId));
|
||||||
IF (result) THEN
|
IF (result) THEN
|
||||||
RAISE EXCEPTION 'expected permission NOT to be granted, but it is';
|
RAISE EXCEPTION 'expected permission NOT to be granted, but it is';
|
||||||
end if;
|
end if;
|
||||||
|
|
||||||
result = (SELECT * FROM isPermissionGrantedToSubject(findPermissionId('package', 94928, 'SELECT'), userId));
|
result = (SELECT * FROM rbac.isPermissionGrantedToSubject(rbac.findPermissionId('package', 94928, 'SELECT'), userId));
|
||||||
IF (NOT result) THEN
|
IF (NOT result) THEN
|
||||||
RAISE EXCEPTION 'expected permission to be granted, but it is NOT';
|
RAISE EXCEPTION 'expected permission to be granted, but it is NOT';
|
||||||
end if;
|
end if;
|
||||||
|
@ -20,43 +20,43 @@ CREATE POLICY customer_policy ON customer
|
|||||||
TO restricted
|
TO restricted
|
||||||
USING (
|
USING (
|
||||||
-- id=1000
|
-- id=1000
|
||||||
isPermissionGrantedToSubject(findEffectivePermissionId('test_customer', id, 'SELECT'), currentUserUuid())
|
rbac.isPermissionGrantedToSubject(rbac.findEffectivePermissionId('rbactest.customer', id, 'SELECT'), rbac.currentSubjectUuid())
|
||||||
);
|
);
|
||||||
|
|
||||||
SET SESSION AUTHORIZATION restricted;
|
SET SESSION AUTHORIZATION restricted;
|
||||||
SET hsadminng.currentUser TO 'alex@example.com';
|
SET hsadminng.currentSubject TO 'alex@example.com';
|
||||||
SELECT * from customer;
|
SELECT * from customer;
|
||||||
|
|
||||||
-- access control via view-rule and isPermissionGrantedToSubject - way too slow (35 s 580 ms for 1 million rows)
|
-- access control via view-rule and isPermissionGrantedToSubject - way too slow (35 s 580 ms for 1 million rows)
|
||||||
SET SESSION SESSION AUTHORIZATION DEFAULT;
|
SET SESSION SESSION AUTHORIZATION DEFAULT;
|
||||||
DROP VIEW cust_view;
|
DROP VIEW cust_view;
|
||||||
CREATE VIEW cust_view AS
|
CREATE VIEW cust_view AS
|
||||||
SELECT * FROM customer;
|
SELECT * FROM rbactest.customer;
|
||||||
CREATE OR REPLACE RULE "_RETURN" AS
|
CREATE OR REPLACE RULE "_RETURN" AS
|
||||||
ON SELECT TO cust_view
|
ON SELECT TO cust_view
|
||||||
DO INSTEAD
|
DO INSTEAD
|
||||||
SELECT * FROM customer WHERE isPermissionGrantedToSubject(findEffectivePermissionId('test_customer', id, 'SELECT'), currentUserUuid());
|
SELECT * FROM rbactest.customer WHERE rbac.isPermissionGrantedToSubject(rbac.findEffectivePermissionId('rbactest.customer', id, 'SELECT'), rbac.currentSubjectUuid());
|
||||||
SELECT * from cust_view LIMIT 10;
|
SELECT * from cust_view LIMIT 10;
|
||||||
|
|
||||||
select queryAllPermissionsOfSubjectId(findRbacUser('superuser-alex@hostsharing.net'));
|
select rbac.queryAllPermissionsOfSubjectId(findRbacSubject('superuser-alex@hostsharing.net'));
|
||||||
|
|
||||||
-- access control via view-rule with join to recursive permissions - really fast (38ms for 1 million rows)
|
-- access control via view-rule with join to recursive permissions - really fast (38ms for 1 million rows)
|
||||||
SET SESSION SESSION AUTHORIZATION DEFAULT;
|
SET SESSION SESSION AUTHORIZATION DEFAULT;
|
||||||
ALTER TABLE customer ENABLE ROW LEVEL SECURITY;
|
ALTER TABLE rbactest.customer ENABLE ROW LEVEL SECURITY;
|
||||||
DROP VIEW IF EXISTS cust_view;
|
DROP VIEW IF EXISTS cust_view;
|
||||||
CREATE OR REPLACE VIEW cust_view AS
|
CREATE OR REPLACE VIEW cust_view AS
|
||||||
SELECT *
|
SELECT *
|
||||||
FROM customer;
|
FROM rbactest.customer;
|
||||||
CREATE OR REPLACE RULE "_RETURN" AS
|
CREATE OR REPLACE RULE "_RETURN" AS
|
||||||
ON SELECT TO cust_view
|
ON SELECT TO cust_view
|
||||||
DO INSTEAD
|
DO INSTEAD
|
||||||
SELECT c.uuid, c.reference, c.prefix FROM customer AS c
|
SELECT c.uuid, c.reference, c.prefix FROM rbactest.customer AS c
|
||||||
JOIN queryAllPermissionsOfSubjectId(currentUserUuid()) AS p
|
JOIN rbac.queryAllPermissionsOfSubjectId(rbac.currentSubjectUuid()) AS p
|
||||||
ON p.objectTable='test_customer' AND p.objectUuid=c.uuid;
|
ON p.objectTable='rbactest.customer' AND p.objectUuid=c.uuid;
|
||||||
GRANT ALL PRIVILEGES ON cust_view TO restricted;
|
GRANT ALL PRIVILEGES ON cust_view TO restricted;
|
||||||
|
|
||||||
SET SESSION SESSION AUTHORIZATION restricted;
|
SET SESSION SESSION AUTHORIZATION restricted;
|
||||||
SET hsadminng.currentUser TO 'alex@example.com';
|
SET hsadminng.currentSubject TO 'alex@example.com';
|
||||||
SELECT * from cust_view;
|
SELECT * from cust_view;
|
||||||
|
|
||||||
|
|
||||||
@ -67,23 +67,23 @@ DROP VIEW IF EXISTS cust_view;
|
|||||||
CREATE OR REPLACE VIEW cust_view AS
|
CREATE OR REPLACE VIEW cust_view AS
|
||||||
SELECT c.uuid, c.reference, c.prefix
|
SELECT c.uuid, c.reference, c.prefix
|
||||||
FROM customer AS c
|
FROM customer AS c
|
||||||
JOIN queryAllPermissionsOfSubjectId(currentUserUuid()) AS p
|
JOIN queryAllPermissionsOfSubjectId(rbac.currentSubjectUuid()) AS p
|
||||||
ON p.objectUuid=c.uuid;
|
ON p.objectUuid=c.uuid;
|
||||||
GRANT ALL PRIVILEGES ON cust_view TO restricted;
|
GRANT ALL PRIVILEGES ON cust_view TO restricted;
|
||||||
|
|
||||||
SET SESSION SESSION AUTHORIZATION restricted;
|
SET SESSION SESSION AUTHORIZATION restricted;
|
||||||
-- SET hsadminng.currentUser TO 'alex@example.com';
|
-- SET hsadminng.currentSubject TO 'alex@example.com';
|
||||||
SET hsadminng.currentUser TO 'superuser-alex@hostsharing.net';
|
SET hsadminng.currentSubject TO 'superuser-alex@hostsharing.net';
|
||||||
-- SET hsadminng.currentUser TO 'aaaaouq@example.com';
|
-- SET hsadminng.currentSubject TO 'aaaaouq@example.com';
|
||||||
SELECT * from cust_view where reference=1144150;
|
SELECT * from cust_view where reference=1144150;
|
||||||
|
|
||||||
select rr.uuid, rr.type from RbacGrants g
|
select rr.uuid, rr.type from rbac.RbacGrants g
|
||||||
join RbacReference RR on g.ascendantUuid = RR.uuid
|
join rbac.RbacReference RR on g.ascendantUuid = RR.uuid
|
||||||
where g.descendantUuid in (
|
where g.descendantUuid in (
|
||||||
select uuid from queryAllPermissionsOfSubjectId(findRbacUser('alex@example.com'))
|
select uuid from rbac.queryAllPermissionsOfSubjectId(findRbacSubject('alex@example.com'))
|
||||||
where objectTable='test_customer');
|
where objectTable='rbactest.customer');
|
||||||
|
|
||||||
call grantRoleToUser(findRoleId('test_customer#aaa:ADMIN'), findRbacUser('aaaaouq@example.com'));
|
call rbac.grantRoleToUser(rbac.findRoleId('rbactest.customer#aaa:ADMIN'), rbac.findRbacSubject('aaaaouq@example.com'));
|
||||||
|
|
||||||
select queryAllPermissionsOfSubjectId(findRbacUser('aaaaouq@example.com'));
|
select rbac.queryAllPermissionsOfSubjectId(findRbacSubject('aaaaouq@example.com'));
|
||||||
|
|
||||||
|
175
sql/recursive-cte-experiments-for-accessible-uuids.sql
Normal file
175
sql/recursive-cte-experiments-for-accessible-uuids.sql
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
-- just a permanent playground to explore optimization of the central recursive CTE query for RBAC
|
||||||
|
|
||||||
|
select * from hs_statistics_v;
|
||||||
|
|
||||||
|
-- ========================================================
|
||||||
|
|
||||||
|
-- This is the extracted recursive CTE query to determine the visible object UUIDs of a single table
|
||||||
|
-- (and optionally the hosting-asset-type) as a separate VIEW.
|
||||||
|
-- In the generated code this is part of the hs_hosting.asset_rv VIEW.
|
||||||
|
|
||||||
|
drop view if exists hs_hosting.asset_example_gv;
|
||||||
|
create view hs_hosting.asset_example_gv as
|
||||||
|
with recursive
|
||||||
|
recursive_grants as (
|
||||||
|
select distinct rbacgrants.descendantuuid,
|
||||||
|
rbacgrants.ascendantuuid,
|
||||||
|
1 as level,
|
||||||
|
true
|
||||||
|
from rbacgrants
|
||||||
|
where (rbacgrants.ascendantuuid = any (rbac.currentSubjectOrAssumedRolesUuids()))
|
||||||
|
and rbacgrants.assumed
|
||||||
|
union all
|
||||||
|
select distinct g.descendantuuid,
|
||||||
|
g.ascendantuuid,
|
||||||
|
grants.level + 1 as level,
|
||||||
|
assertTrue(grants.level < 22, 'too many grant-levels: ' || grants.level)
|
||||||
|
from rbacgrants g
|
||||||
|
join recursive_grants grants on grants.descendantuuid = g.ascendantuuid
|
||||||
|
where g.assumed
|
||||||
|
),
|
||||||
|
grant_count as (
|
||||||
|
select count(*) as grant_count from recursive_grants
|
||||||
|
),
|
||||||
|
count_check as (
|
||||||
|
select assertTrue((select grant_count from grant_count) < 600000,
|
||||||
|
'too many grants for current subjects: ' || (select grant_count from grant_count)) as valid
|
||||||
|
)
|
||||||
|
select distinct perm.objectuuid
|
||||||
|
from recursive_grants
|
||||||
|
join rbacpermission perm on recursive_grants.descendantuuid = perm.uuid
|
||||||
|
join rbacobject obj on obj.uuid = perm.objectuuid
|
||||||
|
join count_check cc on cc.valid
|
||||||
|
where obj.objecttable::text = 'hs_hosting.asset'::text
|
||||||
|
-- with/without this type condition
|
||||||
|
-- and obj.type = 'EMAIL_ADDRESS'::hshostingassettype
|
||||||
|
and obj.type = 'EMAIL_ADDRESS'::hshostingassettype
|
||||||
|
;
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- A query just on the above view, only determining visible objects, no JOIN with business data:
|
||||||
|
|
||||||
|
rollback transaction;
|
||||||
|
begin transaction;
|
||||||
|
CALL defineContext('performance testing', null, 'superuser-alex@hostsharing.net',
|
||||||
|
'hs_booking.project#D-1000000-hshdefaultproject:ADMIN');
|
||||||
|
-- 'hs_booking.project#D-1000300-mihdefaultproject:ADMIN');
|
||||||
|
SET TRANSACTION READ ONLY;
|
||||||
|
EXPLAIN ANALYZE select * from hs_hosting.asset_example_gv;
|
||||||
|
end transaction ;
|
||||||
|
|
||||||
|
-- ========================================================
|
||||||
|
|
||||||
|
-- An example for a restricted view (_rv) similar to the one generated by our RBAC system,
|
||||||
|
-- but using the above separate VIEW to determine the visible objects.
|
||||||
|
|
||||||
|
drop view if exists hs_hosting.asset_example_rv;
|
||||||
|
create view hs_hosting.asset_example_rv as
|
||||||
|
with accessible_hs_hosting.asset_uuids as (
|
||||||
|
select * from hs_hosting.asset_example_gv
|
||||||
|
)
|
||||||
|
select target.*
|
||||||
|
from hs_hosting.asset target
|
||||||
|
where (target.uuid in (select accessible_hs_hosting.asset_uuids.objectuuid
|
||||||
|
from accessible_hs_hosting.asset_uuids));
|
||||||
|
|
||||||
|
-- -------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- performing several queries on the above view to determine average performance:
|
||||||
|
|
||||||
|
rollback transaction;
|
||||||
|
DO language plpgsql $$
|
||||||
|
DECLARE
|
||||||
|
start_time timestamp;
|
||||||
|
end_time timestamp;
|
||||||
|
total_time interval;
|
||||||
|
letter char(1);
|
||||||
|
BEGIN
|
||||||
|
start_time := clock_timestamp();
|
||||||
|
|
||||||
|
CALL defineContext('performance testing', null, 'superuser-alex@hostsharing.net',
|
||||||
|
'hs_booking.project#D-1000000-hshdefaultproject:ADMIN');
|
||||||
|
-- 'hs_booking.project#D-1000300-mihdefaultproject:ADMIN');
|
||||||
|
SET TRANSACTION READ ONLY;
|
||||||
|
|
||||||
|
FOR i IN 0..25 LOOP
|
||||||
|
letter := chr(i+ascii('a'));
|
||||||
|
PERFORM count(*) from (
|
||||||
|
|
||||||
|
-- An example for a business query based on the view:
|
||||||
|
select type, uuid, identifier, caption
|
||||||
|
from hs_hosting.asset_example_rv
|
||||||
|
where type = 'EMAIL_ADDRESS'
|
||||||
|
and identifier like letter || '%'
|
||||||
|
-- end of the business query example.
|
||||||
|
|
||||||
|
) AS timed;
|
||||||
|
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
end_time := clock_timestamp();
|
||||||
|
total_time := end_time - start_time;
|
||||||
|
|
||||||
|
RAISE NOTICE 'average execution time: %', total_time/26;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- average seconds per recursive CTE select as role 'hs_hosting.asset:<DEBITOR>defaultproject:ADMIN'
|
||||||
|
-- joined with business query for all 'EMAIL_ADDRESSES':
|
||||||
|
-- D-1000000-hsh D-1000300-mih
|
||||||
|
-- - without type comparison in rbacobject: ~3.30 - ~3.49 ~0.23
|
||||||
|
-- - with type comparison in rbacobject: ~2.99 - ~3.08 ~0.21
|
||||||
|
|
||||||
|
-- -------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- and a single query, so EXPLAIN can be used
|
||||||
|
|
||||||
|
rollback transaction;
|
||||||
|
begin transaction;
|
||||||
|
CALL defineContext('performance testing', null, 'superuser-alex@hostsharing.net',
|
||||||
|
'hs_booking.project#D-1000000-hshdefaultproject:ADMIN');
|
||||||
|
-- 'hs_booking.project#D-1000300-mihdefaultproject:ADMIN');
|
||||||
|
SET TRANSACTION READ ONLY;
|
||||||
|
|
||||||
|
EXPLAIN SELECT * from (
|
||||||
|
|
||||||
|
-- An example for a business query based on the view:
|
||||||
|
select type, uuid, identifier, caption
|
||||||
|
from hs_hosting.asset_example_rv
|
||||||
|
where type = 'EMAIL_ADDRESS'
|
||||||
|
-- and identifier like 'b%'
|
||||||
|
-- end of the business query example.
|
||||||
|
|
||||||
|
) ha;
|
||||||
|
|
||||||
|
end transaction;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- extending the rbacobject table:
|
||||||
|
|
||||||
|
alter table rbacobject
|
||||||
|
-- just for performance testing, we would need a joined enum or a varchar(16) which would make it slow
|
||||||
|
add column type hs_hosting.AssetType;
|
||||||
|
|
||||||
|
-- and fill the type column with hs_hosting.asset types:
|
||||||
|
|
||||||
|
rollback transaction;
|
||||||
|
begin transaction;
|
||||||
|
call defineContext('setting rbacobject.type from hs_hosting.asset.type', null, 'superuser-alex@hostsharing.net');
|
||||||
|
|
||||||
|
UPDATE rbacobject
|
||||||
|
SET type = hs.type
|
||||||
|
FROM hs_hosting.asset hs
|
||||||
|
WHERE rbacobject.uuid = hs.uuid;
|
||||||
|
|
||||||
|
end transaction;
|
||||||
|
|
||||||
|
-- check the result:
|
||||||
|
|
||||||
|
select
|
||||||
|
(select count(*) as "total" from rbacobject),
|
||||||
|
(select count(*) as "not null" from rbacobject where type is not null),
|
||||||
|
(select count(*) as "null" from rbacobject where type is null);
|
||||||
|
|
@ -17,7 +17,7 @@ public class JsonObjectMapperConfiguration {
|
|||||||
public Jackson2ObjectMapperBuilder customObjectMapper() {
|
public Jackson2ObjectMapperBuilder customObjectMapper() {
|
||||||
return new Jackson2ObjectMapperBuilder()
|
return new Jackson2ObjectMapperBuilder()
|
||||||
.modules(new JsonNullableModule(), new JavaTimeModule())
|
.modules(new JsonNullableModule(), new JavaTimeModule())
|
||||||
.featuresToEnable(JsonParser.Feature.ALLOW_COMMENTS)
|
.featuresToEnable(JsonParser.Feature.ALLOW_COMMENTS, JsonParser.Feature.ALLOW_COMMENTS)
|
||||||
.featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
|
.featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -38,53 +38,53 @@ public class Context {
|
|||||||
private HttpServletRequest request;
|
private HttpServletRequest request;
|
||||||
|
|
||||||
@Transactional(propagation = MANDATORY)
|
@Transactional(propagation = MANDATORY)
|
||||||
public void define(final String currentUser) {
|
public void define(final String currentSubject) {
|
||||||
define(currentUser, null);
|
define(currentSubject, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(propagation = MANDATORY)
|
@Transactional(propagation = MANDATORY)
|
||||||
public void define(final String currentUser, final String assumedRoles) {
|
public void define(final String currentSubject, final String assumedRoles) {
|
||||||
define(toTask(request), toCurl(request), currentUser, assumedRoles);
|
define(toTask(request), toCurl(request), currentSubject, assumedRoles);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(propagation = MANDATORY)
|
@Transactional(propagation = MANDATORY)
|
||||||
public void define(
|
public void define(
|
||||||
final String currentTask,
|
final String currentTask,
|
||||||
final String currentRequest,
|
final String currentRequest,
|
||||||
final String currentUser,
|
final String currentSubject,
|
||||||
final String assumedRoles) {
|
final String assumedRoles) {
|
||||||
final var query = em.createNativeQuery("""
|
final var query = em.createNativeQuery("""
|
||||||
call defineContext(
|
call base.defineContext(
|
||||||
cast(:currentTask as varchar(127)),
|
cast(:currentTask as varchar(127)),
|
||||||
cast(:currentRequest as text),
|
cast(:currentRequest as text),
|
||||||
cast(:currentUser as varchar(63)),
|
cast(:currentSubject as varchar(63)),
|
||||||
cast(:assumedRoles as varchar(1023)));
|
cast(:assumedRoles as varchar(1023)));
|
||||||
""");
|
""");
|
||||||
query.setParameter("currentTask", shortenToMaxLength(currentTask, 127));
|
query.setParameter("currentTask", shortenToMaxLength(currentTask, 127));
|
||||||
query.setParameter("currentRequest", currentRequest);
|
query.setParameter("currentRequest", currentRequest);
|
||||||
query.setParameter("currentUser", currentUser);
|
query.setParameter("currentSubject", currentSubject);
|
||||||
query.setParameter("assumedRoles", assumedRoles != null ? assumedRoles : "");
|
query.setParameter("assumedRoles", assumedRoles != null ? assumedRoles : "");
|
||||||
query.executeUpdate();
|
query.executeUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getCurrentTask() {
|
public String fetchCurrentTask() {
|
||||||
return (String) em.createNativeQuery("select current_setting('hsadminng.currentTask');").getSingleResult();
|
return (String) em.createNativeQuery("select current_setting('hsadminng.currentTask');").getSingleResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getCurrentUser() {
|
public String fetchCurrentSubject() {
|
||||||
return String.valueOf(em.createNativeQuery("select currentUser()").getSingleResult());
|
return String.valueOf(em.createNativeQuery("select base.currentSubject()").getSingleResult());
|
||||||
}
|
}
|
||||||
|
|
||||||
public UUID getCurrentUserUUid() {
|
public UUID fetchCurrentSubjectUuid() {
|
||||||
return (UUID) em.createNativeQuery("select currentUserUUid()", UUID.class).getSingleResult();
|
return (UUID) em.createNativeQuery("select rbac.currentSubjectUuid()", UUID.class).getSingleResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
public String[] getAssumedRoles() {
|
public String[] fetchAssumedRoles() {
|
||||||
return (String[]) em.createNativeQuery("select assumedRoles() as roles", String[].class).getSingleResult();
|
return (String[]) em.createNativeQuery("select base.assumedRoles() as roles", String[].class).getSingleResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
public UUID[] currentSubjectsUuids() {
|
public UUID[] fetchCurrentSubjectOrAssumedRolesUuids() {
|
||||||
return (UUID[]) em.createNativeQuery("select currentSubjectsUuids() as uuids", UUID[].class).getSingleResult();
|
return (UUID[]) em.createNativeQuery("select rbac.currentSubjectOrAssumedRolesUuids() as uuids", UUID[].class).getSingleResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static String getCallerMethodNameFromStackFrame(final int skipFrames) {
|
public static String getCallerMethodNameFromStackFrame(final int skipFrames) {
|
||||||
|
@ -9,7 +9,7 @@ import org.springframework.web.context.request.WebRequest;
|
|||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
@Getter
|
@Getter
|
||||||
class CustomErrorResponse {
|
public class CustomErrorResponse {
|
||||||
|
|
||||||
static ResponseEntity<CustomErrorResponse> errorResponse(
|
static ResponseEntity<CustomErrorResponse> errorResponse(
|
||||||
final WebRequest request,
|
final WebRequest request,
|
||||||
@ -46,6 +46,6 @@ class CustomErrorResponse {
|
|||||||
this.path = path;
|
this.path = path;
|
||||||
this.statusCode = status.value();
|
this.statusCode = status.value();
|
||||||
this.statusPhrase = status.getReasonPhrase();
|
this.statusPhrase = status.getReasonPhrase();
|
||||||
this.message = message;
|
this.message = message.startsWith("ERROR: [") ? message : "ERROR: [" + statusCode + "] " + message;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,34 @@
|
|||||||
|
package net.hostsharing.hsadminng.errors;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import java.lang.annotation.ElementType;
|
||||||
|
import java.lang.annotation.Retention;
|
||||||
|
import java.lang.annotation.RetentionPolicy;
|
||||||
|
import java.lang.annotation.Target;
|
||||||
|
|
||||||
|
@Target(ElementType.TYPE)
|
||||||
|
@Retention(RetentionPolicy.RUNTIME)
|
||||||
|
public @interface DisplayAs {
|
||||||
|
|
||||||
|
class DisplayName {
|
||||||
|
|
||||||
|
public static String of(final Class<?> clazz) {
|
||||||
|
final var displayNameAnnot = getDisplayNameAnnotation(clazz);
|
||||||
|
return displayNameAnnot != null ? displayNameAnnot.value() : clazz.getSimpleName();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String of(@NotNull final Object instance) {
|
||||||
|
return of(instance.getClass());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DisplayAs getDisplayNameAnnotation(final Class<?> clazz) {
|
||||||
|
if (clazz == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final var annot = clazz.getAnnotation(DisplayAs.class);
|
||||||
|
return annot != null ? annot : getDisplayNameAnnotation(clazz.getSuperclass());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String value() default "";
|
||||||
|
}
|
@ -1,12 +0,0 @@
|
|||||||
package net.hostsharing.hsadminng.errors;
|
|
||||||
|
|
||||||
import java.lang.annotation.ElementType;
|
|
||||||
import java.lang.annotation.Retention;
|
|
||||||
import java.lang.annotation.RetentionPolicy;
|
|
||||||
import java.lang.annotation.Target;
|
|
||||||
|
|
||||||
@Target(ElementType.TYPE)
|
|
||||||
@Retention(RetentionPolicy.RUNTIME)
|
|
||||||
public @interface DisplayName {
|
|
||||||
String value() default "";
|
|
||||||
}
|
|
@ -0,0 +1,23 @@
|
|||||||
|
package net.hostsharing.hsadminng.errors;
|
||||||
|
|
||||||
|
import jakarta.validation.ValidationException;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static java.lang.String.join;
|
||||||
|
|
||||||
|
public class MultiValidationException extends ValidationException {
|
||||||
|
|
||||||
|
private MultiValidationException(final List<String> violations) {
|
||||||
|
super(
|
||||||
|
violations.size() > 1
|
||||||
|
? "[\n" + join(",\n", violations) + "\n]"
|
||||||
|
: "[" + join(",\n", violations) + "]"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void throwIfNotEmpty(final List<String> violations) {
|
||||||
|
if (!violations.isEmpty()) {
|
||||||
|
throw new MultiValidationException(violations);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -73,9 +73,10 @@ public class RestResponseEntityExceptionHandler
|
|||||||
}
|
}
|
||||||
|
|
||||||
@ExceptionHandler({ Iban4jException.class, ValidationException.class })
|
@ExceptionHandler({ Iban4jException.class, ValidationException.class })
|
||||||
protected ResponseEntity<CustomErrorResponse> handleIbanAndBicExceptions(
|
protected ResponseEntity<CustomErrorResponse> handleValidationExceptions(
|
||||||
final Throwable exc, final WebRequest request) {
|
final Throwable exc, final WebRequest request) {
|
||||||
final var message = line(NestedExceptionUtils.getMostSpecificCause(exc).getMessage(), 0);
|
final String fullMessage = NestedExceptionUtils.getMostSpecificCause(exc).getMessage();
|
||||||
|
final var message = exc instanceof MultiValidationException ? fullMessage : line(fullMessage, 0);
|
||||||
return errorResponse(request, HttpStatus.BAD_REQUEST, message);
|
return errorResponse(request, HttpStatus.BAD_REQUEST, message);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -151,8 +152,8 @@ public class RestResponseEntityExceptionHandler
|
|||||||
final var entityName = matcher.group(1);
|
final var entityName = matcher.group(1);
|
||||||
final var entityClass = resolveClass(entityName);
|
final var entityClass = resolveClass(entityName);
|
||||||
if (entityClass.isPresent()) {
|
if (entityClass.isPresent()) {
|
||||||
return (entityClass.get().isAnnotationPresent(DisplayName.class)
|
return (entityClass.get().isAnnotationPresent(DisplayAs.class)
|
||||||
? exceptionMessage.replace(entityName, entityClass.get().getAnnotation(DisplayName.class).value())
|
? exceptionMessage.replace(entityName, entityClass.get().getAnnotation(DisplayAs.class).value())
|
||||||
: exceptionMessage.replace(entityName, entityClass.get().getSimpleName()))
|
: exceptionMessage.replace(entityName, entityClass.get().getSimpleName()))
|
||||||
.replace(" with id ", " with uuid ");
|
.replace(" with id ", " with uuid ");
|
||||||
}
|
}
|
||||||
|
130
src/main/java/net/hostsharing/hsadminng/hash/HashGenerator.java
Normal file
130
src/main/java/net/hostsharing/hsadminng/hash/HashGenerator.java
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
package net.hostsharing.hsadminng.hash;
|
||||||
|
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.PriorityQueue;
|
||||||
|
import java.util.Queue;
|
||||||
|
import java.util.function.BiFunction;
|
||||||
|
import java.util.random.RandomGenerator;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Usage-example to generate hash:
|
||||||
|
* HashGenerator.using(LINUX_SHA512).withRandomSalt().hash("plaintext password");
|
||||||
|
*
|
||||||
|
* Usage-example to verify hash:
|
||||||
|
* HashGenerator.fromHash("hashed password).verify("plaintext password");
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
public final class HashGenerator {
|
||||||
|
|
||||||
|
private static final RandomGenerator random = new SecureRandom();
|
||||||
|
private static final Queue<String> predefinedSalts = new PriorityQueue<>();
|
||||||
|
|
||||||
|
public static final int RANDOM_SALT_LENGTH = 16;
|
||||||
|
private static final String RANDOM_SALT_CHARACTERS =
|
||||||
|
"abcdefghijklmnopqrstuvwxyz" +
|
||||||
|
"ABCDEFGHIJKLMNOPQRSTUVWXYZ" +
|
||||||
|
"0123456789/.";
|
||||||
|
private static boolean couldBeHashEnabled; // TODO.legacy: remove after legacy data is migrated
|
||||||
|
|
||||||
|
public enum Algorithm {
|
||||||
|
LINUX_SHA512(LinuxEtcShadowHashGenerator::hash, "6"),
|
||||||
|
LINUX_YESCRYPT(LinuxEtcShadowHashGenerator::hash, "y", "j9T$") {
|
||||||
|
@Override
|
||||||
|
String enrichedSalt(final String salt) {
|
||||||
|
return prefix + "$" + (salt.startsWith(optionalParam) ? salt : optionalParam + salt);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
MYSQL_NATIVE(MySQLNativePasswordHashGenerator::hash, "*"),
|
||||||
|
SCRAM_SHA256(PostgreSQLScramSHA256::hash, "SCRAM-SHA-256");
|
||||||
|
|
||||||
|
final BiFunction<HashGenerator, String, String> implementation;
|
||||||
|
final String prefix;
|
||||||
|
final String optionalParam;
|
||||||
|
|
||||||
|
Algorithm(BiFunction<HashGenerator, String, String> implementation, final String prefix, final String optionalParam) {
|
||||||
|
this.implementation = implementation;
|
||||||
|
this.prefix = prefix;
|
||||||
|
this.optionalParam = optionalParam;
|
||||||
|
}
|
||||||
|
|
||||||
|
Algorithm(BiFunction<HashGenerator, String, String> implementation, final String prefix) {
|
||||||
|
this(implementation, prefix, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Algorithm byPrefix(final String prefix) {
|
||||||
|
return Arrays.stream(Algorithm.values()).filter(a -> a.prefix.equals(prefix)).findAny()
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("unknown hash algorithm: '" + prefix + "'"));
|
||||||
|
}
|
||||||
|
|
||||||
|
String enrichedSalt(final String salt) {
|
||||||
|
return prefix + "$" + salt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final Algorithm algorithm;
|
||||||
|
private String salt;
|
||||||
|
|
||||||
|
public static HashGenerator using(final Algorithm algorithm) {
|
||||||
|
return new HashGenerator(algorithm);
|
||||||
|
}
|
||||||
|
|
||||||
|
private HashGenerator(final Algorithm algorithm) {
|
||||||
|
this.algorithm = algorithm;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void enableCouldBeHash(final boolean enable) {
|
||||||
|
couldBeHashEnabled = enable;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean couldBeHash(final String value) {
|
||||||
|
return couldBeHashEnabled && value.startsWith(algorithm.prefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String hash(final String plaintextPassword) {
|
||||||
|
if (plaintextPassword == null) {
|
||||||
|
throw new IllegalStateException("no password given");
|
||||||
|
}
|
||||||
|
|
||||||
|
final var hash = algorithm.implementation.apply(this, plaintextPassword);
|
||||||
|
if (hash.length() < plaintextPassword.length()) {
|
||||||
|
throw new AssertionError("generated hash too short: " + hash);
|
||||||
|
}
|
||||||
|
return hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String hashIfNotYetHashed(final String plaintextPasswordOrHash) {
|
||||||
|
return couldBeHash(plaintextPasswordOrHash)
|
||||||
|
? plaintextPasswordOrHash
|
||||||
|
: hash(plaintextPasswordOrHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void nextSalt(final String salt) {
|
||||||
|
predefinedSalts.add(salt);
|
||||||
|
}
|
||||||
|
|
||||||
|
public HashGenerator withSalt(final String salt) {
|
||||||
|
this.salt = salt;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public HashGenerator withRandomSalt() {
|
||||||
|
if (!predefinedSalts.isEmpty()) {
|
||||||
|
return withSalt(predefinedSalts.poll());
|
||||||
|
}
|
||||||
|
final var stringBuilder = new StringBuilder(RANDOM_SALT_LENGTH);
|
||||||
|
for (int i = 0; i < RANDOM_SALT_LENGTH; ++i) {
|
||||||
|
int randomIndex = random.nextInt(RANDOM_SALT_CHARACTERS.length());
|
||||||
|
stringBuilder.append(RANDOM_SALT_CHARACTERS.charAt(randomIndex));
|
||||||
|
}
|
||||||
|
return withSalt(stringBuilder.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
System.out.println(
|
||||||
|
HashGenerator.using(Algorithm.LINUX_YESCRYPT).withRandomSalt().hash("my plaintext domain transfer passphrase")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,36 @@
|
|||||||
|
package net.hostsharing.hsadminng.hash;
|
||||||
|
|
||||||
|
import com.sun.jna.Library;
|
||||||
|
import com.sun.jna.Native;
|
||||||
|
|
||||||
|
public class LinuxEtcShadowHashGenerator {
|
||||||
|
|
||||||
|
public static String hash(final HashGenerator generator, final String payload) {
|
||||||
|
if (generator.getSalt() == null) {
|
||||||
|
throw new IllegalStateException("no salt given");
|
||||||
|
}
|
||||||
|
|
||||||
|
return NativeCryptLibrary.INSTANCE.crypt(payload, "$" + generator.getAlgorithm().enrichedSalt(generator.getSalt()));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void verify(final String givenHash, final String payload) {
|
||||||
|
|
||||||
|
final var parts = givenHash.split("\\$");
|
||||||
|
if (parts.length < 3 || parts.length > 5) {
|
||||||
|
throw new IllegalArgumentException("hash with unknown hash method: " + givenHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
final var algorithm = HashGenerator.Algorithm.byPrefix(parts[1]);
|
||||||
|
final var salt = parts.length == 4 ? parts[2] : parts[2] + "$" + parts[3];
|
||||||
|
final var calculatedHash = HashGenerator.using(algorithm).withSalt(salt).hash(payload);
|
||||||
|
if (!calculatedHash.equals(givenHash)) {
|
||||||
|
throw new IllegalArgumentException("invalid password");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface NativeCryptLibrary extends Library {
|
||||||
|
NativeCryptLibrary INSTANCE = Native.load("crypt", NativeCryptLibrary.class);
|
||||||
|
|
||||||
|
String crypt(String password, String salt);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,35 @@
|
|||||||
|
package net.hostsharing.hsadminng.hash;
|
||||||
|
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
|
||||||
|
public class MySQLNativePasswordHashGenerator {
|
||||||
|
|
||||||
|
public static String hash(final HashGenerator generator, final String password) {
|
||||||
|
// TODO.impl: if a random salt is generated or not should be part of the algorithm definition
|
||||||
|
// if (generator.getSalt() != null) {
|
||||||
|
// throw new IllegalStateException("salt not supported");
|
||||||
|
// }
|
||||||
|
|
||||||
|
try {
|
||||||
|
final var sha1 = MessageDigest.getInstance("SHA-1");
|
||||||
|
final var firstHash = sha1.digest(password.getBytes());
|
||||||
|
final var secondHash = sha1.digest(firstHash);
|
||||||
|
return "*" + bytesToHex(secondHash).toUpperCase();
|
||||||
|
} catch (NoSuchAlgorithmException e) {
|
||||||
|
throw new RuntimeException("SHA-1 algorithm not found", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String bytesToHex(byte[] bytes) {
|
||||||
|
final var hexString = new StringBuilder();
|
||||||
|
for (byte b : bytes) {
|
||||||
|
final var hex = Integer.toHexString(0xff & b);
|
||||||
|
if (hex.length() == 1) {
|
||||||
|
hexString.append('0');
|
||||||
|
}
|
||||||
|
hexString.append(hex);
|
||||||
|
}
|
||||||
|
return hexString.toString();
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,61 @@
|
|||||||
|
package net.hostsharing.hsadminng.hash;
|
||||||
|
|
||||||
|
import lombok.SneakyThrows;
|
||||||
|
|
||||||
|
import javax.crypto.Mac;
|
||||||
|
import javax.crypto.SecretKeyFactory;
|
||||||
|
import javax.crypto.spec.PBEKeySpec;
|
||||||
|
import javax.crypto.spec.SecretKeySpec;
|
||||||
|
import java.nio.charset.Charset;
|
||||||
|
import java.security.InvalidKeyException;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.security.spec.InvalidKeySpecException;
|
||||||
|
import java.util.Base64;
|
||||||
|
|
||||||
|
public class PostgreSQLScramSHA256 {
|
||||||
|
|
||||||
|
private static final String PBKDF_2_WITH_HMAC_SHA256 = "PBKDF2WithHmacSHA256";
|
||||||
|
private static final String HMAC_SHA256 = "HmacSHA256";
|
||||||
|
private static final String SHA256 = "SHA-256";
|
||||||
|
private static final int ITERATIONS = 4096;
|
||||||
|
public static final int KEY_LENGTH_IN_BITS = 256;
|
||||||
|
|
||||||
|
private static final PostgreSQLScramSHA256 scram = new PostgreSQLScramSHA256();
|
||||||
|
|
||||||
|
@SneakyThrows
|
||||||
|
public static String hash(final HashGenerator generator, final String password) {
|
||||||
|
if (generator.getSalt() == null) {
|
||||||
|
throw new IllegalStateException("no salt given");
|
||||||
|
}
|
||||||
|
|
||||||
|
final byte[] salt = generator.getSalt().getBytes(Charset.forName("latin1")); // Base64.getEncoder().encode(generator.getSalt().getBytes());
|
||||||
|
final byte[] saltedPassword = scram.generateSaltedPassword(password, salt);
|
||||||
|
final byte[] clientKey = scram.hmacSHA256(saltedPassword, "Client Key".getBytes());
|
||||||
|
final byte[] storedKey = MessageDigest.getInstance(SHA256).digest(clientKey);
|
||||||
|
final byte[] serverKey = scram.hmacSHA256(saltedPassword, "Server Key".getBytes());
|
||||||
|
|
||||||
|
return "SCRAM-SHA-256${iterations}:{base64EncodedSalt}${base64EncodedStoredKey}:{base64EncodedServerKey}"
|
||||||
|
.replace("{iterations}", Integer.toString(ITERATIONS))
|
||||||
|
.replace("{base64EncodedSalt}", base64(salt))
|
||||||
|
.replace("{base64EncodedStoredKey}", base64(storedKey))
|
||||||
|
.replace("{base64EncodedServerKey}", base64(serverKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String base64(final byte[] salt) {
|
||||||
|
return Base64.getEncoder().encodeToString(salt);
|
||||||
|
}
|
||||||
|
|
||||||
|
private byte[] generateSaltedPassword(String password, byte[] salt) throws NoSuchAlgorithmException, InvalidKeySpecException {
|
||||||
|
final var spec = new PBEKeySpec(password.toCharArray(), salt, ITERATIONS, KEY_LENGTH_IN_BITS);
|
||||||
|
return SecretKeyFactory.getInstance(PBKDF_2_WITH_HMAC_SHA256).generateSecret(spec).getEncoded();
|
||||||
|
}
|
||||||
|
|
||||||
|
private byte[] hmacSHA256(byte[] key, byte[] message)
|
||||||
|
throws NoSuchAlgorithmException, InvalidKeyException {
|
||||||
|
final var mac = Mac.getInstance(HMAC_SHA256);
|
||||||
|
mac.init(new SecretKeySpec(key, HMAC_SHA256));
|
||||||
|
return mac.doFinal(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,55 @@
|
|||||||
|
package net.hostsharing.hsadminng.hs.booking.debitor;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import net.hostsharing.hsadminng.errors.DisplayAs;
|
||||||
|
import net.hostsharing.hsadminng.stringify.Stringify;
|
||||||
|
import net.hostsharing.hsadminng.stringify.Stringifyable;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static net.hostsharing.hsadminng.stringify.Stringify.stringify;
|
||||||
|
|
||||||
|
// a partial HsOfficeDebitorEntity to reduce the number of SQL queries to load the entity
|
||||||
|
@Entity
|
||||||
|
@Table(schema = "hs_booking", name = "debitor_xv")
|
||||||
|
@Getter
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@DisplayAs("BookingDebitor")
|
||||||
|
public class HsBookingDebitorEntity implements Stringifyable {
|
||||||
|
|
||||||
|
public static final String DEBITOR_NUMBER_TAG = "D-";
|
||||||
|
|
||||||
|
private static Stringify<HsBookingDebitorEntity> stringify =
|
||||||
|
stringify(HsBookingDebitorEntity.class, "booking-debitor")
|
||||||
|
.withIdProp(HsBookingDebitorEntity::toShortString)
|
||||||
|
.withProp(HsBookingDebitorEntity::getDefaultPrefix)
|
||||||
|
.quotedValues(false);
|
||||||
|
|
||||||
|
@Id
|
||||||
|
private UUID uuid;
|
||||||
|
|
||||||
|
@Column(name = "debitornumber")
|
||||||
|
private Integer debitorNumber;
|
||||||
|
|
||||||
|
@Column(name = "defaultprefix", columnDefinition = "char(3) not null")
|
||||||
|
private String defaultPrefix;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return stringify.apply(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toShortString() {
|
||||||
|
return DEBITOR_NUMBER_TAG + debitorNumber;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,14 @@
|
|||||||
|
package net.hostsharing.hsadminng.hs.booking.debitor;
|
||||||
|
|
||||||
|
import org.springframework.data.repository.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public interface HsBookingDebitorRepository extends Repository<HsBookingDebitorEntity, UUID> {
|
||||||
|
|
||||||
|
Optional<HsBookingDebitorEntity> findByUuid(UUID id);
|
||||||
|
|
||||||
|
List<HsBookingDebitorEntity> findByDebitorNumber(int debitorNumber);
|
||||||
|
}
|
@ -0,0 +1,20 @@
|
|||||||
|
package net.hostsharing.hsadminng.hs.booking.item;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
import org.springframework.context.ApplicationEvent;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
public class BookingItemCreatedAppEvent extends ApplicationEvent {
|
||||||
|
|
||||||
|
private BookingItemCreatedEventEntity entity;
|
||||||
|
|
||||||
|
public BookingItemCreatedAppEvent(
|
||||||
|
@NotNull final Object source,
|
||||||
|
@NotNull final HsBookingItemRealEntity newBookingItem,
|
||||||
|
final String assetJson) {
|
||||||
|
super(source);
|
||||||
|
this.entity = new BookingItemCreatedEventEntity(newBookingItem, assetJson);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,55 @@
|
|||||||
|
package net.hostsharing.hsadminng.hs.booking.item;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import lombok.Setter;
|
||||||
|
import lombok.ToString;
|
||||||
|
import lombok.experimental.SuperBuilder;
|
||||||
|
import net.hostsharing.hsadminng.persistence.BaseEntity;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.JoinColumn;
|
||||||
|
import jakarta.persistence.ManyToOne;
|
||||||
|
import jakarta.persistence.MapsId;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import jakarta.persistence.Version;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(schema = "hs_booking", name = "item_created_event")
|
||||||
|
@SuperBuilder(toBuilder = true)
|
||||||
|
@Getter
|
||||||
|
@ToString
|
||||||
|
@NoArgsConstructor
|
||||||
|
public class BookingItemCreatedEventEntity implements BaseEntity {
|
||||||
|
@Id
|
||||||
|
@Column(name="bookingitemuuid")
|
||||||
|
private UUID uuid;
|
||||||
|
|
||||||
|
@MapsId
|
||||||
|
@ManyToOne(optional = false)
|
||||||
|
@JoinColumn(name = "bookingitemuuid", nullable = false)
|
||||||
|
private HsBookingItemRealEntity bookingItem;
|
||||||
|
|
||||||
|
@Version
|
||||||
|
private int version;
|
||||||
|
|
||||||
|
@Column(name = "assetjson")
|
||||||
|
private String assetJson;
|
||||||
|
|
||||||
|
@Setter
|
||||||
|
@Column(name = "statusmessage")
|
||||||
|
private String statusMessage;
|
||||||
|
|
||||||
|
public BookingItemCreatedEventEntity(
|
||||||
|
@NotNull final HsBookingItemRealEntity newBookingItem,
|
||||||
|
final String assetJson) {
|
||||||
|
this.bookingItem = newBookingItem;
|
||||||
|
this.assetJson = assetJson;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,12 @@
|
|||||||
|
package net.hostsharing.hsadminng.hs.booking.item;
|
||||||
|
|
||||||
|
import org.springframework.data.repository.Repository;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public interface BookingItemCreatedEventRepository extends Repository<BookingItemCreatedEventEntity, UUID> {
|
||||||
|
|
||||||
|
BookingItemCreatedEventEntity save(HsBookingItemRealEntity current);
|
||||||
|
|
||||||
|
BookingItemCreatedEventEntity findByBookingItem(HsBookingItemRealEntity newBookingItem);
|
||||||
|
}
|
@ -0,0 +1,172 @@
|
|||||||
|
package net.hostsharing.hsadminng.hs.booking.item;
|
||||||
|
|
||||||
|
import io.hypersistence.utils.hibernate.type.json.JsonType;
|
||||||
|
import io.hypersistence.utils.hibernate.type.range.PostgreSQLRangeType;
|
||||||
|
import io.hypersistence.utils.hibernate.type.range.Range;
|
||||||
|
import lombok.AccessLevel;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import lombok.Setter;
|
||||||
|
import lombok.experimental.SuperBuilder;
|
||||||
|
import net.hostsharing.hsadminng.hs.booking.project.HsBookingProject;
|
||||||
|
import net.hostsharing.hsadminng.hs.booking.project.HsBookingProjectRealEntity;
|
||||||
|
import net.hostsharing.hsadminng.hs.validation.PropertiesProvider;
|
||||||
|
import net.hostsharing.hsadminng.mapper.PatchableMapWrapper;
|
||||||
|
import net.hostsharing.hsadminng.persistence.BaseEntity;
|
||||||
|
import net.hostsharing.hsadminng.stringify.Stringify;
|
||||||
|
import net.hostsharing.hsadminng.stringify.Stringifyable;
|
||||||
|
import org.hibernate.annotations.Type;
|
||||||
|
|
||||||
|
import jakarta.persistence.CascadeType;
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.EnumType;
|
||||||
|
import jakarta.persistence.Enumerated;
|
||||||
|
import jakarta.persistence.FetchType;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.JoinColumn;
|
||||||
|
import jakarta.persistence.ManyToOne;
|
||||||
|
import jakarta.persistence.MappedSuperclass;
|
||||||
|
import jakarta.persistence.OneToMany;
|
||||||
|
import jakarta.persistence.PostLoad;
|
||||||
|
import jakarta.persistence.Transient;
|
||||||
|
import jakarta.persistence.Version;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static java.util.Collections.emptyMap;
|
||||||
|
import static java.util.Optional.ofNullable;
|
||||||
|
import static net.hostsharing.hsadminng.mapper.PostgresDateRange.lowerInclusiveFromPostgresDateRange;
|
||||||
|
import static net.hostsharing.hsadminng.mapper.PostgresDateRange.toPostgresDateRange;
|
||||||
|
import static net.hostsharing.hsadminng.mapper.PostgresDateRange.upperInclusiveFromPostgresDateRange;
|
||||||
|
import static net.hostsharing.hsadminng.stringify.Stringify.stringify;
|
||||||
|
|
||||||
|
@MappedSuperclass
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
||||||
|
@AllArgsConstructor(access = AccessLevel.PROTECTED)
|
||||||
|
@SuperBuilder(builderMethodName = "baseBuilder", toBuilder = true)
|
||||||
|
public abstract class HsBookingItem implements Stringifyable, BaseEntity<HsBookingItem>, PropertiesProvider {
|
||||||
|
|
||||||
|
private static Stringify<HsBookingItem> stringify = stringify(HsBookingItem.class)
|
||||||
|
.withProp(HsBookingItem::getType)
|
||||||
|
.withProp(HsBookingItem::getCaption)
|
||||||
|
.withProp(HsBookingItem::getProject)
|
||||||
|
.withProp(e -> e.getValidity().asString())
|
||||||
|
.withProp(HsBookingItem::getResources)
|
||||||
|
.quotedValues(false);
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue
|
||||||
|
private UUID uuid;
|
||||||
|
|
||||||
|
@Version
|
||||||
|
private int version;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "projectuuid")
|
||||||
|
private HsBookingProjectRealEntity project;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "parentitemuuid")
|
||||||
|
private HsBookingItemRealEntity parentItem;
|
||||||
|
|
||||||
|
@NotNull
|
||||||
|
@Column(name = "type")
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
private HsBookingItemType type;
|
||||||
|
|
||||||
|
@Builder.Default
|
||||||
|
@Type(PostgreSQLRangeType.class)
|
||||||
|
@Column(name = "validity", columnDefinition = "daterange")
|
||||||
|
private Range<LocalDate> validity = Range.closedInfinite(LocalDate.now());
|
||||||
|
|
||||||
|
@Column(name = "caption")
|
||||||
|
private String caption;
|
||||||
|
|
||||||
|
@Builder.Default
|
||||||
|
@Setter(AccessLevel.NONE)
|
||||||
|
@Type(JsonType.class)
|
||||||
|
@Column(columnDefinition = "resources")
|
||||||
|
private Map<String, Object> resources = new HashMap<>();
|
||||||
|
|
||||||
|
@OneToMany(cascade = CascadeType.REFRESH, orphanRemoval = true)
|
||||||
|
@JoinColumn(name = "parentitemuuid", referencedColumnName = "uuid")
|
||||||
|
private List<HsBookingItemRealEntity> subBookingItems;
|
||||||
|
|
||||||
|
@Transient
|
||||||
|
private PatchableMapWrapper<Object> resourcesWrapper;
|
||||||
|
|
||||||
|
@Transient
|
||||||
|
private boolean isLoaded;
|
||||||
|
|
||||||
|
@PostLoad
|
||||||
|
public void markAsLoaded() {
|
||||||
|
this.isLoaded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public PatchableMapWrapper<Object> getResources() {
|
||||||
|
return PatchableMapWrapper.of(resourcesWrapper, (newWrapper) -> {resourcesWrapper = newWrapper;}, resources);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void putResources(Map<String, Object> newResources) {
|
||||||
|
getResources().assign(newResources);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setValidFrom(final LocalDate validFrom) {
|
||||||
|
setValidity(toPostgresDateRange(validFrom, getValidTo()));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setValidTo(final LocalDate validTo) {
|
||||||
|
setValidity(toPostgresDateRange(getValidFrom(), validTo));
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDate getValidFrom() {
|
||||||
|
return lowerInclusiveFromPostgresDateRange(getValidity());
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDate getValidTo() {
|
||||||
|
return upperInclusiveFromPostgresDateRange(getValidity());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public PatchableMapWrapper<Object> directProps() {
|
||||||
|
return getResources();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Object getContextValue(final String propName) {
|
||||||
|
final var v = resources.get(propName);
|
||||||
|
if (v != null) {
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
if (parentItem != null) {
|
||||||
|
return parentItem.getResources().get(propName);
|
||||||
|
}
|
||||||
|
return emptyMap();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return stringify.apply(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toShortString() {
|
||||||
|
return ofNullable(getRelatedProject()).map(HsBookingProject::toShortString).orElse("D-???????-?") +
|
||||||
|
":" + caption;
|
||||||
|
}
|
||||||
|
|
||||||
|
public HsBookingProject getRelatedProject() {
|
||||||
|
return project != null ? project
|
||||||
|
: parentItem != null ? parentItem.getRelatedProject()
|
||||||
|
: null; // can be the case for technical assets like IP-numbers
|
||||||
|
}
|
||||||
|
}
|
@ -1,22 +1,31 @@
|
|||||||
package net.hostsharing.hsadminng.hs.booking.item;
|
package net.hostsharing.hsadminng.hs.booking.item;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import net.hostsharing.hsadminng.context.Context;
|
import net.hostsharing.hsadminng.context.Context;
|
||||||
import net.hostsharing.hsadminng.hs.booking.generated.api.v1.api.HsBookingItemsApi;
|
import net.hostsharing.hsadminng.hs.booking.generated.api.v1.api.HsBookingItemsApi;
|
||||||
import net.hostsharing.hsadminng.hs.booking.generated.api.v1.model.HsBookingItemInsertResource;
|
import net.hostsharing.hsadminng.hs.booking.generated.api.v1.model.HsBookingItemInsertResource;
|
||||||
import net.hostsharing.hsadminng.hs.booking.generated.api.v1.model.HsBookingItemPatchResource;
|
import net.hostsharing.hsadminng.hs.booking.generated.api.v1.model.HsBookingItemPatchResource;
|
||||||
import net.hostsharing.hsadminng.hs.booking.generated.api.v1.model.HsBookingItemResource;
|
import net.hostsharing.hsadminng.hs.booking.generated.api.v1.model.HsBookingItemResource;
|
||||||
|
import net.hostsharing.hsadminng.hs.booking.item.validators.BookingItemEntitySaveProcessor;
|
||||||
|
import net.hostsharing.hsadminng.hs.booking.item.validators.HsBookingItemEntityValidatorRegistry;
|
||||||
|
import net.hostsharing.hsadminng.hs.booking.project.HsBookingProjectRealEntity;
|
||||||
import net.hostsharing.hsadminng.mapper.KeyValueMap;
|
import net.hostsharing.hsadminng.mapper.KeyValueMap;
|
||||||
import net.hostsharing.hsadminng.mapper.Mapper;
|
import net.hostsharing.hsadminng.mapper.StrictMapper;
|
||||||
|
import net.hostsharing.hsadminng.persistence.EntityManagerWrapper;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.context.ApplicationEventPublisher;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
import org.springframework.web.servlet.mvc.method.annotation.MvcUriComponentsBuilder;
|
import org.springframework.web.servlet.mvc.method.annotation.MvcUriComponentsBuilder;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.function.BiConsumer;
|
import java.util.function.BiConsumer;
|
||||||
|
|
||||||
|
import static java.util.Optional.ofNullable;
|
||||||
import static net.hostsharing.hsadminng.mapper.PostgresDateRange.toPostgresDateRange;
|
import static net.hostsharing.hsadminng.mapper.PostgresDateRange.toPostgresDateRange;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@ -26,70 +35,87 @@ public class HsBookingItemController implements HsBookingItemsApi {
|
|||||||
private Context context;
|
private Context context;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private Mapper mapper;
|
private StrictMapper mapper;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private HsBookingItemRepository bookingItemRepo;
|
private ApplicationEventPublisher applicationEventPublisher;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private HsBookingItemRbacRepository bookingItemRepo;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ObjectMapper jsonMapper;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private EntityManagerWrapper em;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public ResponseEntity<List<HsBookingItemResource>> listBookingItemsByDebitorUuid(
|
public ResponseEntity<List<HsBookingItemResource>> listBookingItemsByProjectUuid(
|
||||||
final String currentUser,
|
final String currentSubject,
|
||||||
final String assumedRoles,
|
final String assumedRoles,
|
||||||
final UUID debitorUuid) {
|
final UUID projectUuid) {
|
||||||
context.define(currentUser, assumedRoles);
|
context.define(currentSubject, assumedRoles);
|
||||||
|
|
||||||
final var entities = bookingItemRepo.findAllByDebitorUuid(debitorUuid);
|
final var entities = bookingItemRepo.findAllByProjectUuid(projectUuid);
|
||||||
|
|
||||||
final var resources = mapper.mapList(entities, HsBookingItemResource.class, ENTITY_TO_RESOURCE_POSTMAPPER);
|
final var resources = mapper.mapList(entities, HsBookingItemResource.class, RBAC_ENTITY_TO_RESOURCE_POSTMAPPER);
|
||||||
return ResponseEntity.ok(resources);
|
return ResponseEntity.ok(resources);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Transactional
|
@Transactional
|
||||||
public ResponseEntity<HsBookingItemResource> addBookingItem(
|
public ResponseEntity<HsBookingItemResource> addBookingItem(
|
||||||
final String currentUser,
|
final String currentSubject,
|
||||||
final String assumedRoles,
|
final String assumedRoles,
|
||||||
final HsBookingItemInsertResource body) {
|
final HsBookingItemInsertResource body) {
|
||||||
|
|
||||||
context.define(currentUser, assumedRoles);
|
context.define(currentSubject, assumedRoles);
|
||||||
|
|
||||||
final var entityToSave = mapper.map(body, HsBookingItemEntity.class, RESOURCE_TO_ENTITY_POSTMAPPER);
|
final var entityToSave = mapper.map(body, HsBookingItemRbacEntity.class, RESOURCE_TO_ENTITY_POSTMAPPER);
|
||||||
|
final var saveProcessor = new BookingItemEntitySaveProcessor(em, entityToSave);
|
||||||
final var saved = bookingItemRepo.save(entityToSave);
|
final var mapped = saveProcessor
|
||||||
|
.preprocessEntity()
|
||||||
|
.validateEntity()
|
||||||
|
.prepareForSave()
|
||||||
|
.save()
|
||||||
|
.validateContext()
|
||||||
|
.mapUsing(e -> mapper.map(e, HsBookingItemResource.class, ITEM_TO_RESOURCE_POSTMAPPER))
|
||||||
|
.revampProperties();
|
||||||
|
publishSavedEvent(saveProcessor, body);
|
||||||
|
|
||||||
final var uri =
|
final var uri =
|
||||||
MvcUriComponentsBuilder.fromController(getClass())
|
MvcUriComponentsBuilder.fromController(getClass())
|
||||||
.path("/api/hs/booking/items/{id}")
|
.path("/api/hs/booking/items/{id}")
|
||||||
.buildAndExpand(saved.getUuid())
|
.buildAndExpand(mapped.getUuid())
|
||||||
.toUri();
|
.toUri();
|
||||||
final var mapped = mapper.map(saved, HsBookingItemResource.class, ENTITY_TO_RESOURCE_POSTMAPPER);
|
|
||||||
return ResponseEntity.created(uri).body(mapped);
|
return ResponseEntity.created(uri).body(mapped);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public ResponseEntity<HsBookingItemResource> getBookingItemByUuid(
|
public ResponseEntity<HsBookingItemResource> getBookingItemByUuid(
|
||||||
final String currentUser,
|
final String currentSubject,
|
||||||
final String assumedRoles,
|
final String assumedRoles,
|
||||||
final UUID bookingItemUuid) {
|
final UUID bookingItemUuid) {
|
||||||
|
|
||||||
context.define(currentUser, assumedRoles);
|
context.define(currentSubject, assumedRoles);
|
||||||
|
|
||||||
final var result = bookingItemRepo.findByUuid(bookingItemUuid);
|
final var result = bookingItemRepo.findByUuid(bookingItemUuid);
|
||||||
|
result.ifPresent(entity -> em.detach(entity)); // prevent further LAZY-loading
|
||||||
return result
|
return result
|
||||||
.map(bookingItemEntity -> ResponseEntity.ok(
|
.map(bookingItemEntity -> ResponseEntity.ok(
|
||||||
mapper.map(bookingItemEntity, HsBookingItemResource.class, ENTITY_TO_RESOURCE_POSTMAPPER)))
|
mapper.map(bookingItemEntity, HsBookingItemResource.class, RBAC_ENTITY_TO_RESOURCE_POSTMAPPER)))
|
||||||
.orElseGet(() -> ResponseEntity.notFound().build());
|
.orElseGet(() -> ResponseEntity.notFound().build());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Transactional
|
@Transactional
|
||||||
public ResponseEntity<Void> deleteBookingIemByUuid(
|
public ResponseEntity<Void> deleteBookingIemByUuid(
|
||||||
final String currentUser,
|
final String currentSubject,
|
||||||
final String assumedRoles,
|
final String assumedRoles,
|
||||||
final UUID bookingItemUuid) {
|
final UUID bookingItemUuid) {
|
||||||
context.define(currentUser, assumedRoles);
|
context.define(currentSubject, assumedRoles);
|
||||||
|
|
||||||
final var result = bookingItemRepo.deleteByUuid(bookingItemUuid);
|
final var result = bookingItemRepo.deleteByUuid(bookingItemUuid);
|
||||||
return result == 0
|
return result == 0
|
||||||
@ -100,32 +126,47 @@ public class HsBookingItemController implements HsBookingItemsApi {
|
|||||||
@Override
|
@Override
|
||||||
@Transactional
|
@Transactional
|
||||||
public ResponseEntity<HsBookingItemResource> patchBookingItem(
|
public ResponseEntity<HsBookingItemResource> patchBookingItem(
|
||||||
final String currentUser,
|
final String currentSubject,
|
||||||
final String assumedRoles,
|
final String assumedRoles,
|
||||||
final UUID bookingItemUuid,
|
final UUID bookingItemUuid,
|
||||||
final HsBookingItemPatchResource body) {
|
final HsBookingItemPatchResource body) {
|
||||||
|
|
||||||
context.define(currentUser, assumedRoles);
|
context.define(currentSubject, assumedRoles);
|
||||||
|
|
||||||
final var current = bookingItemRepo.findByUuid(bookingItemUuid).orElseThrow();
|
final var current = bookingItemRepo.findByUuid(bookingItemUuid).orElseThrow();
|
||||||
|
|
||||||
new HsBookingItemEntityPatcher(current).apply(body);
|
new HsBookingItemEntityPatcher(current).apply(body);
|
||||||
|
|
||||||
final var saved = bookingItemRepo.save(current);
|
final var saved = bookingItemRepo.save(HsBookingItemEntityValidatorRegistry.validated(em, current));
|
||||||
final var mapped = mapper.map(saved, HsBookingItemResource.class, ENTITY_TO_RESOURCE_POSTMAPPER);
|
final var mapped = mapper.map(saved, HsBookingItemResource.class, RBAC_ENTITY_TO_RESOURCE_POSTMAPPER);
|
||||||
return ResponseEntity.ok(mapped);
|
return ResponseEntity.ok(mapped);
|
||||||
}
|
}
|
||||||
|
|
||||||
final BiConsumer<HsBookingItemEntity, HsBookingItemResource> ENTITY_TO_RESOURCE_POSTMAPPER = (entity, resource) -> {
|
private void publishSavedEvent(final BookingItemEntitySaveProcessor saveProcessor, final HsBookingItemInsertResource body) {
|
||||||
|
try {
|
||||||
|
final var bookingItemRealEntity = em.getReference(HsBookingItemRealEntity.class, saveProcessor.getEntity().getUuid());
|
||||||
|
applicationEventPublisher.publishEvent(new BookingItemCreatedAppEvent(
|
||||||
|
this, bookingItemRealEntity, jsonMapper.writeValueAsString(body.getHostingAsset())));
|
||||||
|
} catch (JsonProcessingException e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final BiConsumer<HsBookingItem, HsBookingItemResource> ITEM_TO_RESOURCE_POSTMAPPER = (entity, resource) -> {
|
||||||
resource.setValidFrom(entity.getValidity().lower());
|
resource.setValidFrom(entity.getValidity().lower());
|
||||||
if (entity.getValidity().hasUpperBound()) {
|
if (entity.getValidity().hasUpperBound()) {
|
||||||
resource.setValidTo(entity.getValidity().upper().minusDays(1));
|
resource.setValidTo(entity.getValidity().upper().minusDays(1));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
final BiConsumer<HsBookingItemRbacEntity, HsBookingItemResource> RBAC_ENTITY_TO_RESOURCE_POSTMAPPER = ITEM_TO_RESOURCE_POSTMAPPER::accept;
|
||||||
final BiConsumer<HsBookingItemInsertResource, HsBookingItemEntity> RESOURCE_TO_ENTITY_POSTMAPPER = (resource, entity) -> {
|
|
||||||
entity.setValidity(toPostgresDateRange(resource.getValidFrom(), resource.getValidTo()));
|
final BiConsumer<HsBookingItemInsertResource, HsBookingItemRbacEntity> RESOURCE_TO_ENTITY_POSTMAPPER = (resource, entity) -> {
|
||||||
|
entity.setProject(em.find(HsBookingProjectRealEntity.class, resource.getProjectUuid()));
|
||||||
|
ofNullable(resource.getParentItemUuid())
|
||||||
|
.map(parentItemUuid -> em.find(HsBookingItemRealEntity.class, parentItemUuid))
|
||||||
|
.ifPresent(entity::setParentItem);
|
||||||
|
entity.setValidity(toPostgresDateRange(LocalDate.now(), resource.getValidTo()));
|
||||||
entity.putResources(KeyValueMap.from(resource.getResources()));
|
entity.putResources(KeyValueMap.from(resource.getResources()));
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -1,182 +0,0 @@
|
|||||||
package net.hostsharing.hsadminng.hs.booking.item;
|
|
||||||
|
|
||||||
import io.hypersistence.utils.hibernate.type.json.JsonType;
|
|
||||||
import io.hypersistence.utils.hibernate.type.range.PostgreSQLRangeType;
|
|
||||||
import io.hypersistence.utils.hibernate.type.range.Range;
|
|
||||||
import lombok.AccessLevel;
|
|
||||||
import lombok.AllArgsConstructor;
|
|
||||||
import lombok.Builder;
|
|
||||||
import lombok.Getter;
|
|
||||||
import lombok.NoArgsConstructor;
|
|
||||||
import lombok.Setter;
|
|
||||||
import net.hostsharing.hsadminng.hs.office.debitor.HsOfficeDebitorEntity;
|
|
||||||
import net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationEntity;
|
|
||||||
import net.hostsharing.hsadminng.mapper.PatchableMapWrapper;
|
|
||||||
import net.hostsharing.hsadminng.rbac.rbacdef.RbacView;
|
|
||||||
import net.hostsharing.hsadminng.rbac.rbacdef.RbacView.SQL;
|
|
||||||
import net.hostsharing.hsadminng.rbac.rbacobject.RbacObject;
|
|
||||||
import net.hostsharing.hsadminng.stringify.Stringify;
|
|
||||||
import net.hostsharing.hsadminng.stringify.Stringifyable;
|
|
||||||
import org.hibernate.annotations.Type;
|
|
||||||
|
|
||||||
import jakarta.persistence.Column;
|
|
||||||
import jakarta.persistence.Entity;
|
|
||||||
import jakarta.persistence.GeneratedValue;
|
|
||||||
import jakarta.persistence.Id;
|
|
||||||
import jakarta.persistence.JoinColumn;
|
|
||||||
import jakarta.persistence.ManyToOne;
|
|
||||||
import jakarta.persistence.Table;
|
|
||||||
import jakarta.persistence.Transient;
|
|
||||||
import jakarta.persistence.Version;
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
import static java.util.Optional.ofNullable;
|
|
||||||
import static net.hostsharing.hsadminng.mapper.PostgresDateRange.lowerInclusiveFromPostgresDateRange;
|
|
||||||
import static net.hostsharing.hsadminng.mapper.PostgresDateRange.toPostgresDateRange;
|
|
||||||
import static net.hostsharing.hsadminng.mapper.PostgresDateRange.upperInclusiveFromPostgresDateRange;
|
|
||||||
import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Column.dependsOnColumn;
|
|
||||||
import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Nullable.NOT_NULL;
|
|
||||||
import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Permission.DELETE;
|
|
||||||
import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Permission.INSERT;
|
|
||||||
import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Permission.SELECT;
|
|
||||||
import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Permission.UPDATE;
|
|
||||||
import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Role.ADMIN;
|
|
||||||
import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Role.AGENT;
|
|
||||||
import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Role.OWNER;
|
|
||||||
import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Role.TENANT;
|
|
||||||
import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.SQL.directlyFetchedByDependsOnColumn;
|
|
||||||
import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.SQL.fetchedBySql;
|
|
||||||
import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.rbacViewFor;
|
|
||||||
import static net.hostsharing.hsadminng.stringify.Stringify.stringify;
|
|
||||||
|
|
||||||
@Builder
|
|
||||||
@Entity
|
|
||||||
@Table(name = "hs_booking_item_rv")
|
|
||||||
@Getter
|
|
||||||
@Setter
|
|
||||||
@NoArgsConstructor
|
|
||||||
@AllArgsConstructor
|
|
||||||
public class HsBookingItemEntity implements Stringifyable, RbacObject {
|
|
||||||
|
|
||||||
private static Stringify<HsBookingItemEntity> stringify = stringify(HsBookingItemEntity.class)
|
|
||||||
.withProp(e -> e.getDebitor().toShortString())
|
|
||||||
.withProp(e -> e.getValidity().asString())
|
|
||||||
.withProp(HsBookingItemEntity::getCaption)
|
|
||||||
.withProp(HsBookingItemEntity::getResources)
|
|
||||||
.quotedValues(false);
|
|
||||||
|
|
||||||
@Id
|
|
||||||
@GeneratedValue
|
|
||||||
private UUID uuid;
|
|
||||||
|
|
||||||
@Version
|
|
||||||
private int version;
|
|
||||||
|
|
||||||
@ManyToOne(optional = false)
|
|
||||||
@JoinColumn(name = "debitoruuid")
|
|
||||||
private HsOfficeDebitorEntity debitor;
|
|
||||||
|
|
||||||
@Builder.Default
|
|
||||||
@Type(PostgreSQLRangeType.class)
|
|
||||||
@Column(name = "validity", columnDefinition = "daterange")
|
|
||||||
private Range<LocalDate> validity = Range.emptyRange(LocalDate.class);
|
|
||||||
|
|
||||||
@Column(name = "caption")
|
|
||||||
private String caption;
|
|
||||||
|
|
||||||
@Builder.Default
|
|
||||||
@Setter(AccessLevel.NONE)
|
|
||||||
@Type(JsonType.class)
|
|
||||||
@Column(columnDefinition = "resources")
|
|
||||||
private Map<String, Object> resources = new HashMap<>();
|
|
||||||
|
|
||||||
@Transient
|
|
||||||
private PatchableMapWrapper resourcesWrapper;
|
|
||||||
|
|
||||||
public void setValidFrom(final LocalDate validFrom) {
|
|
||||||
setValidity(toPostgresDateRange(validFrom, getValidTo()));
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setValidTo(final LocalDate validTo) {
|
|
||||||
setValidity(toPostgresDateRange(getValidFrom(), validTo));
|
|
||||||
}
|
|
||||||
|
|
||||||
public LocalDate getValidFrom() {
|
|
||||||
return lowerInclusiveFromPostgresDateRange(getValidity());
|
|
||||||
}
|
|
||||||
|
|
||||||
public LocalDate getValidTo() {
|
|
||||||
return upperInclusiveFromPostgresDateRange(getValidity());
|
|
||||||
}
|
|
||||||
|
|
||||||
public PatchableMapWrapper getResources() {
|
|
||||||
if ( resourcesWrapper == null ) {
|
|
||||||
resourcesWrapper = new PatchableMapWrapper(resources);
|
|
||||||
}
|
|
||||||
return resourcesWrapper;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void putResources(Map<String, Object> entries) {
|
|
||||||
if ( resourcesWrapper == null ) {
|
|
||||||
resourcesWrapper = new PatchableMapWrapper(resources);
|
|
||||||
}
|
|
||||||
resourcesWrapper.assign(entries);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public String toString() {
|
|
||||||
return stringify.apply(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public String toShortString() {
|
|
||||||
return ofNullable(debitor).map(HsOfficeDebitorEntity::toShortString).orElse("D-???????") +
|
|
||||||
":" + caption;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static RbacView rbac() {
|
|
||||||
return rbacViewFor("bookingItem", HsBookingItemEntity.class)
|
|
||||||
.withIdentityView(SQL.query("""
|
|
||||||
SELECT i.uuid as uuid, d.idName || ':' || i.caption as idName
|
|
||||||
FROM hs_booking_item i
|
|
||||||
JOIN hs_office_debitor_iv d ON d.uuid = i.debitorUuid
|
|
||||||
"""))
|
|
||||||
.withRestrictedViewOrderBy(SQL.expression("validity"))
|
|
||||||
.withUpdatableColumns("version", "validity", "resources")
|
|
||||||
|
|
||||||
.importEntityAlias("debitor", HsOfficeDebitorEntity.class,
|
|
||||||
dependsOnColumn("debitorUuid"),
|
|
||||||
directlyFetchedByDependsOnColumn(),
|
|
||||||
NOT_NULL)
|
|
||||||
|
|
||||||
.importEntityAlias("debitorRel", HsOfficeRelationEntity.class,
|
|
||||||
dependsOnColumn("debitorUuid"),
|
|
||||||
fetchedBySql("""
|
|
||||||
SELECT ${columns}
|
|
||||||
FROM hs_office_relation debitorRel
|
|
||||||
JOIN hs_office_debitor debitor ON debitor.debitorRelUuid = debitorRel.uuid
|
|
||||||
WHERE debitor.uuid = ${REF}.debitorUuid
|
|
||||||
"""),
|
|
||||||
NOT_NULL)
|
|
||||||
.toRole("debitorRel", ADMIN).grantPermission(INSERT)
|
|
||||||
.toRole("global", ADMIN).grantPermission(DELETE)
|
|
||||||
|
|
||||||
.createRole(OWNER, (with) -> {
|
|
||||||
with.incomingSuperRole("debitorRel", AGENT);
|
|
||||||
with.permission(UPDATE);
|
|
||||||
})
|
|
||||||
.createSubRole(ADMIN)
|
|
||||||
.createSubRole(TENANT, (with) -> {
|
|
||||||
with.outgoingSubRole("debitorRel", TENANT);
|
|
||||||
with.permission(SELECT);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void main(String[] args) throws IOException {
|
|
||||||
rbac().generateWithBaseFileName("6-hs-booking/601-booking-item/6013-hs-booking-item-rbac");
|
|
||||||
}
|
|
||||||
}
|
|
@ -10,9 +10,9 @@ import java.util.Optional;
|
|||||||
|
|
||||||
public class HsBookingItemEntityPatcher implements EntityPatcher<HsBookingItemPatchResource> {
|
public class HsBookingItemEntityPatcher implements EntityPatcher<HsBookingItemPatchResource> {
|
||||||
|
|
||||||
private final HsBookingItemEntity entity;
|
private final HsBookingItem entity;
|
||||||
|
|
||||||
public HsBookingItemEntityPatcher(final HsBookingItemEntity entity) {
|
public HsBookingItemEntityPatcher(final HsBookingItem entity) {
|
||||||
this.entity = entity;
|
this.entity = entity;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -0,0 +1,84 @@
|
|||||||
|
package net.hostsharing.hsadminng.hs.booking.item;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import lombok.Setter;
|
||||||
|
import lombok.experimental.SuperBuilder;
|
||||||
|
import net.hostsharing.hsadminng.hs.booking.project.HsBookingProjectRbacEntity;
|
||||||
|
import net.hostsharing.hsadminng.rbac.generator.RbacView;
|
||||||
|
import net.hostsharing.hsadminng.rbac.generator.RbacView.SQL;
|
||||||
|
|
||||||
|
import jakarta.persistence.AttributeOverride;
|
||||||
|
import jakarta.persistence.AttributeOverrides;
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
import static net.hostsharing.hsadminng.rbac.generator.RbacView.Column.dependsOnColumn;
|
||||||
|
import static net.hostsharing.hsadminng.rbac.generator.RbacView.ColumnValue.usingDefaultCase;
|
||||||
|
import static net.hostsharing.hsadminng.rbac.generator.RbacView.GLOBAL;
|
||||||
|
import static net.hostsharing.hsadminng.rbac.generator.RbacView.Nullable.NULLABLE;
|
||||||
|
import static net.hostsharing.hsadminng.rbac.generator.RbacView.Permission.DELETE;
|
||||||
|
import static net.hostsharing.hsadminng.rbac.generator.RbacView.Permission.INSERT;
|
||||||
|
import static net.hostsharing.hsadminng.rbac.generator.RbacView.Permission.SELECT;
|
||||||
|
import static net.hostsharing.hsadminng.rbac.generator.RbacView.Permission.UPDATE;
|
||||||
|
import static net.hostsharing.hsadminng.rbac.generator.RbacView.Role.ADMIN;
|
||||||
|
import static net.hostsharing.hsadminng.rbac.generator.RbacView.Role.AGENT;
|
||||||
|
import static net.hostsharing.hsadminng.rbac.generator.RbacView.Role.OWNER;
|
||||||
|
import static net.hostsharing.hsadminng.rbac.generator.RbacView.Role.TENANT;
|
||||||
|
import static net.hostsharing.hsadminng.rbac.generator.RbacView.SQL.directlyFetchedByDependsOnColumn;
|
||||||
|
import static net.hostsharing.hsadminng.rbac.generator.RbacView.rbacViewFor;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(schema = "hs_booking", name = "item_rv")
|
||||||
|
@SuperBuilder(toBuilder = true)
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AttributeOverrides({
|
||||||
|
@AttributeOverride(name = "uuid", column = @Column(name = "uuid"))
|
||||||
|
})
|
||||||
|
public class HsBookingItemRbacEntity extends HsBookingItem {
|
||||||
|
|
||||||
|
public static RbacView rbac() {
|
||||||
|
return rbacViewFor("bookingItem", HsBookingItemRbacEntity.class)
|
||||||
|
.withIdentityView(SQL.projection("caption"))
|
||||||
|
.withRestrictedViewOrderBy(SQL.expression("validity"))
|
||||||
|
.withUpdatableColumns("version", "caption", "validity", "resources")
|
||||||
|
.toRole(GLOBAL, ADMIN).grantPermission(INSERT) // TODO.impl: Why is this necessary to insert test data?
|
||||||
|
.toRole(GLOBAL, ADMIN).grantPermission(DELETE)
|
||||||
|
|
||||||
|
.importEntityAlias("project", HsBookingProjectRbacEntity.class, usingDefaultCase(),
|
||||||
|
dependsOnColumn("projectUuid"),
|
||||||
|
directlyFetchedByDependsOnColumn(),
|
||||||
|
NULLABLE)
|
||||||
|
.toRole("project", ADMIN).grantPermission(INSERT)
|
||||||
|
|
||||||
|
.importEntityAlias("parentItem", HsBookingItemRbacEntity.class, usingDefaultCase(),
|
||||||
|
dependsOnColumn("parentItemUuid"),
|
||||||
|
directlyFetchedByDependsOnColumn(),
|
||||||
|
NULLABLE)
|
||||||
|
.toRole("parentItem", ADMIN).grantPermission(INSERT)
|
||||||
|
|
||||||
|
.createRole(OWNER, (with) -> {
|
||||||
|
with.incomingSuperRole("project", AGENT);
|
||||||
|
with.incomingSuperRole("parentItem", AGENT);
|
||||||
|
})
|
||||||
|
.createSubRole(ADMIN, (with) -> {
|
||||||
|
with.permission(UPDATE);
|
||||||
|
})
|
||||||
|
.createSubRole(AGENT)
|
||||||
|
.createSubRole(TENANT, (with) -> {
|
||||||
|
with.outgoingSubRole("project", TENANT);
|
||||||
|
with.outgoingSubRole("parentItem", TENANT);
|
||||||
|
with.permission(SELECT);
|
||||||
|
})
|
||||||
|
|
||||||
|
.limitDiagramTo("bookingItem", "project", "rbac.global");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void main(String[] args) throws IOException {
|
||||||
|
rbac().generateWithBaseFileName("6-hs-booking/630-booking-item/6303-hs-booking-item-rbac");
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,23 @@
|
|||||||
|
package net.hostsharing.hsadminng.hs.booking.item;
|
||||||
|
|
||||||
|
import org.springframework.data.repository.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public interface HsBookingItemRbacRepository extends HsBookingItemRepository<HsBookingItemRbacEntity>,
|
||||||
|
Repository<HsBookingItemRbacEntity, UUID> {
|
||||||
|
|
||||||
|
Optional<HsBookingItemRbacEntity> findByUuid(final UUID bookingItemUuid);
|
||||||
|
|
||||||
|
List<HsBookingItemRbacEntity> findByCaption(String bookingItemCaption);
|
||||||
|
|
||||||
|
List<HsBookingItemRbacEntity> findAllByProjectUuid(final UUID projectItemUuid);
|
||||||
|
|
||||||
|
HsBookingItemRbacEntity save(HsBookingItemRbacEntity current);
|
||||||
|
|
||||||
|
int deleteByUuid(final UUID uuid);
|
||||||
|
|
||||||
|
long count();
|
||||||
|
}
|
@ -0,0 +1,24 @@
|
|||||||
|
package net.hostsharing.hsadminng.hs.booking.item;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import lombok.Setter;
|
||||||
|
import lombok.experimental.SuperBuilder;
|
||||||
|
|
||||||
|
import jakarta.persistence.AttributeOverride;
|
||||||
|
import jakarta.persistence.AttributeOverrides;
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(schema = "hs_booking", name = "item")
|
||||||
|
@SuperBuilder(toBuilder = true)
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AttributeOverrides({
|
||||||
|
@AttributeOverride(name = "uuid", column = @Column(name = "uuid"))
|
||||||
|
})public class HsBookingItemRealEntity extends HsBookingItem {
|
||||||
|
}
|
@ -0,0 +1,23 @@
|
|||||||
|
package net.hostsharing.hsadminng.hs.booking.item;
|
||||||
|
|
||||||
|
import org.springframework.data.repository.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public interface HsBookingItemRealRepository extends HsBookingItemRepository<HsBookingItemRealEntity>,
|
||||||
|
Repository<HsBookingItemRealEntity, UUID> {
|
||||||
|
|
||||||
|
Optional<HsBookingItemRealEntity> findByUuid(final UUID bookingItemUuid);
|
||||||
|
|
||||||
|
List<HsBookingItemRealEntity> findByCaption(String bookingItemCaption);
|
||||||
|
|
||||||
|
List<HsBookingItemRealEntity> findAllByProjectUuid(final UUID projectItemUuid);
|
||||||
|
|
||||||
|
HsBookingItemRealEntity save(HsBookingItemRealEntity current);
|
||||||
|
|
||||||
|
int deleteByUuid(final UUID uuid);
|
||||||
|
|
||||||
|
long count();
|
||||||
|
}
|
@ -1,19 +1,18 @@
|
|||||||
package net.hostsharing.hsadminng.hs.booking.item;
|
package net.hostsharing.hsadminng.hs.booking.item;
|
||||||
|
|
||||||
import org.springframework.data.repository.Repository;
|
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
public interface HsBookingItemRepository extends Repository<HsBookingItemEntity, UUID> {
|
public interface HsBookingItemRepository<E extends HsBookingItem> {
|
||||||
|
|
||||||
List<HsBookingItemEntity> findAll();
|
Optional<E> findByUuid(final UUID bookingItemUuid);
|
||||||
Optional<HsBookingItemEntity> findByUuid(final UUID bookingItemUuid);
|
|
||||||
|
|
||||||
List<HsBookingItemEntity> findAllByDebitorUuid(final UUID bookingItemUuid);
|
List<E> findByCaption(String bookingItemCaption);
|
||||||
|
|
||||||
HsBookingItemEntity save(HsBookingItemEntity current);
|
List<E> findAllByProjectUuid(final UUID projectItemUuid);
|
||||||
|
|
||||||
|
E save(E current);
|
||||||
|
|
||||||
int deleteByUuid(final UUID uuid);
|
int deleteByUuid(final UUID uuid);
|
||||||
|
|
||||||
|
@ -0,0 +1,42 @@
|
|||||||
|
package net.hostsharing.hsadminng.hs.booking.item;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
import static java.util.Optional.ofNullable;
|
||||||
|
|
||||||
|
public enum HsBookingItemType implements Node {
|
||||||
|
PRIVATE_CLOUD,
|
||||||
|
CLOUD_SERVER(PRIVATE_CLOUD),
|
||||||
|
MANAGED_SERVER(PRIVATE_CLOUD),
|
||||||
|
MANAGED_WEBSPACE(MANAGED_SERVER),
|
||||||
|
DOMAIN_SETUP;
|
||||||
|
|
||||||
|
private final HsBookingItemType parentItemType;
|
||||||
|
|
||||||
|
HsBookingItemType() {
|
||||||
|
this.parentItemType = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
HsBookingItemType(final HsBookingItemType parentItemType) {
|
||||||
|
this.parentItemType = parentItemType;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<String> edges(final Set<String> inGroups) {
|
||||||
|
return ofNullable(parentItemType)
|
||||||
|
.map(p -> (nodeName() + " *--> " + p.nodeName()))
|
||||||
|
.stream().toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean belongsToAny(final Set<String> groups) {
|
||||||
|
return true; // we currently do not filter booking item types
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String nodeName() {
|
||||||
|
return "BI_" + name();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,11 @@
|
|||||||
|
package net.hostsharing.hsadminng.hs.booking.item;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
public interface Node {
|
||||||
|
|
||||||
|
String nodeName();
|
||||||
|
boolean belongsToAny(Set<String> groups);
|
||||||
|
List<String> edges(final Set<String> inGroup);
|
||||||
|
}
|
@ -0,0 +1,136 @@
|
|||||||
|
package net.hostsharing.hsadminng.hs.booking.item.validators;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
import net.hostsharing.hsadminng.errors.MultiValidationException;
|
||||||
|
import net.hostsharing.hsadminng.hs.booking.generated.api.v1.model.HsBookingItemResource;
|
||||||
|
import net.hostsharing.hsadminng.hs.booking.item.HsBookingItem;
|
||||||
|
import net.hostsharing.hsadminng.hs.validation.HsEntityValidator;
|
||||||
|
|
||||||
|
import jakarta.persistence.EntityManager;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.function.Function;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
// TODO.refa: introduce common base class with HsHostingAssetEntitySaveProcessor
|
||||||
|
/**
|
||||||
|
* Wraps the steps of the pararation, validation, mapping and revamp around saving of a HsBookingItem into a readable API.
|
||||||
|
*/
|
||||||
|
public class BookingItemEntitySaveProcessor {
|
||||||
|
|
||||||
|
private final HsEntityValidator<HsBookingItem> validator;
|
||||||
|
private String expectedStep = "preprocessEntity";
|
||||||
|
private final EntityManager em;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
private HsBookingItem entity;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
private HsBookingItemResource resource;
|
||||||
|
|
||||||
|
public BookingItemEntitySaveProcessor(final EntityManager em, final HsBookingItem entity) {
|
||||||
|
this.em = em;
|
||||||
|
this.entity = entity;
|
||||||
|
this.validator = HsBookingItemEntityValidatorRegistry.forType(entity.getType());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// initial step allowing to set default values before any validations
|
||||||
|
public BookingItemEntitySaveProcessor preprocessEntity() {
|
||||||
|
step("preprocessEntity", "validateEntity");
|
||||||
|
validator.preprocessEntity(entity);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// validates the entity itself including its properties
|
||||||
|
public BookingItemEntitySaveProcessor validateEntity() {
|
||||||
|
step("validateEntity", "prepareForSave");
|
||||||
|
MultiValidationException.throwIfNotEmpty(validator.validateEntity(entity));
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO.legacy: remove once the migration of legacy data is done
|
||||||
|
/// validates the entity itself including its properties, but ignoring some error messages for import of legacy data
|
||||||
|
public BookingItemEntitySaveProcessor validateEntityIgnoring(final String... ignoreRegExp) {
|
||||||
|
step("validateEntity", "prepareForSave");
|
||||||
|
final var ignoreRegExpPatterns = Arrays.stream(ignoreRegExp).map(Pattern::compile).toList();
|
||||||
|
MultiValidationException.throwIfNotEmpty(
|
||||||
|
validator.validateEntity(entity).stream()
|
||||||
|
.filter(error -> ignoreRegExpPatterns.stream().noneMatch(p -> p.matcher(error).matches() ))
|
||||||
|
.toList()
|
||||||
|
);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// hashing passwords etc.
|
||||||
|
public BookingItemEntitySaveProcessor prepareForSave() {
|
||||||
|
step("prepareForSave", "save");
|
||||||
|
validator.prepareProperties(em, entity);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves the entity using the given `saveFunction`.
|
||||||
|
*
|
||||||
|
* <p>`validator.postPersist(em, entity)` is NOT called.
|
||||||
|
* If any postprocessing is necessary, the saveFunction has to implement this.</p>
|
||||||
|
* @param saveFunction
|
||||||
|
* @return this
|
||||||
|
*/
|
||||||
|
public BookingItemEntitySaveProcessor saveUsing(final Function<HsBookingItem, HsBookingItem> saveFunction) {
|
||||||
|
step("save", "validateContext");
|
||||||
|
entity = saveFunction.apply(entity);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves the using the `EntityManager`, but does NOT ever merge the entity.
|
||||||
|
*
|
||||||
|
* <p>`validator.postPersist(em, entity)` is called afterwards with the entity guaranteed to be flushed to the database.</p>
|
||||||
|
* @return this
|
||||||
|
*/
|
||||||
|
public BookingItemEntitySaveProcessor save() {
|
||||||
|
return saveUsing(e -> {
|
||||||
|
if (!em.contains(entity)) {
|
||||||
|
em.persist(entity);
|
||||||
|
}
|
||||||
|
em.flush(); // makes RbacEntity available as RealEntity if needed
|
||||||
|
validator.postPersist(em, entity);
|
||||||
|
return entity;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// validates the entity within it's parent and child hierarchy (e.g. totals validators and other limits)
|
||||||
|
public BookingItemEntitySaveProcessor validateContext() {
|
||||||
|
step("validateContext", "mapUsing");
|
||||||
|
return HsEntityValidator.doWithEntityManager(em, () -> {
|
||||||
|
MultiValidationException.throwIfNotEmpty(validator.validateContext(entity));
|
||||||
|
return this;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// maps entity to JSON resource representation
|
||||||
|
public BookingItemEntitySaveProcessor mapUsing(
|
||||||
|
final Function<HsBookingItem, HsBookingItemResource> mapFunction) {
|
||||||
|
step("mapUsing", "revampProperties");
|
||||||
|
resource = mapFunction.apply(entity);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// removes write-only-properties and ads computed-properties
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public HsBookingItemResource revampProperties() {
|
||||||
|
step("revampProperties", null);
|
||||||
|
final var revampedProps = validator.revampProperties(em, entity, (Map<String, Object>) resource.getResources());
|
||||||
|
resource.setResources(revampedProps);
|
||||||
|
return resource;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Makes sure that the steps are called in the correct order.
|
||||||
|
// Could also be implemented using an interface per method, but that seems exaggerated.
|
||||||
|
private void step(final String current, final String next) {
|
||||||
|
if (!expectedStep.equals(current)) {
|
||||||
|
throw new IllegalStateException("expected " + expectedStep + " but got " + current);
|
||||||
|
}
|
||||||
|
expectedStep = next;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,88 @@
|
|||||||
|
package net.hostsharing.hsadminng.hs.booking.item.validators;
|
||||||
|
|
||||||
|
import net.hostsharing.hsadminng.hs.booking.item.HsBookingItem;
|
||||||
|
import net.hostsharing.hsadminng.hs.validation.HsEntityValidator;
|
||||||
|
import net.hostsharing.hsadminng.hs.validation.ValidatableProperty;
|
||||||
|
import org.apache.commons.lang3.BooleanUtils;
|
||||||
|
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
|
import static java.util.Arrays.stream;
|
||||||
|
import static java.util.Collections.emptyList;
|
||||||
|
import static java.util.Optional.ofNullable;
|
||||||
|
|
||||||
|
public class HsBookingItemEntityValidator extends HsEntityValidator<HsBookingItem> {
|
||||||
|
|
||||||
|
public HsBookingItemEntityValidator(final ValidatableProperty<?, ?>... properties) {
|
||||||
|
super(properties);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<String> validateEntity(final HsBookingItem bookingItem) {
|
||||||
|
// TODO.impl: HsBookingItemType could do this similar to HsHostingAssetType
|
||||||
|
if ( bookingItem.getParentItem() == null && bookingItem.getProject() == null) {
|
||||||
|
return List.of(bookingItem + ".'parentItem' or .'project' expected to be set, but both are null");
|
||||||
|
}
|
||||||
|
return enrich(prefix(bookingItem.toShortString(), "resources"), super.validateProperties(bookingItem));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<String> validateContext(final HsBookingItem bookingItem) {
|
||||||
|
return sequentiallyValidate(
|
||||||
|
() -> optionallyValidate(bookingItem.getParentItem()),
|
||||||
|
() -> validateAgainstSubEntities(bookingItem)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<String> optionallyValidate(final HsBookingItem bookingItem) {
|
||||||
|
return bookingItem != null
|
||||||
|
? enrich(prefix(bookingItem.toShortString(), ""),
|
||||||
|
HsBookingItemEntityValidatorRegistry.forType(bookingItem.getType()).validateContext(bookingItem))
|
||||||
|
: emptyList();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected List<String> validateAgainstSubEntities(final HsBookingItem bookingItem) {
|
||||||
|
return enrich(prefix(bookingItem.toShortString(), "resources"),
|
||||||
|
Stream.concat(
|
||||||
|
stream(propertyValidators)
|
||||||
|
.map(propDef -> propDef.validateTotals(bookingItem))
|
||||||
|
.flatMap(Collection::stream),
|
||||||
|
stream(propertyValidators)
|
||||||
|
.filter(ValidatableProperty::isTotalsValidator)
|
||||||
|
.map(prop -> validateMaxTotalValue(bookingItem, prop))
|
||||||
|
).filter(Objects::nonNull).toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO.refa: convert into generic shape like multi-options validator
|
||||||
|
private static String validateMaxTotalValue(
|
||||||
|
final HsBookingItem bookingItem,
|
||||||
|
final ValidatableProperty<?, ?> propDef) {
|
||||||
|
final var propName = propDef.propertyName();
|
||||||
|
final var propUnit = ofNullable(propDef.unit()).map(u -> " " + u).orElse("");
|
||||||
|
final var totalValue = ofNullable(bookingItem.getSubBookingItems()).orElse(emptyList())
|
||||||
|
.stream()
|
||||||
|
.map(subItem -> propDef.getValue(subItem.getResources()))
|
||||||
|
.map(HsBookingItemEntityValidator::convertBooleanToInteger)
|
||||||
|
.map(HsBookingItemEntityValidator::toIntegerWithDefault0)
|
||||||
|
.reduce(0, Integer::sum);
|
||||||
|
final var maxValue = getIntegerValueWithDefault0(propDef, bookingItem.getResources());
|
||||||
|
if (propDef.thresholdPercentage() != null ) {
|
||||||
|
return totalValue > (maxValue * propDef.thresholdPercentage() / 100)
|
||||||
|
? "%s' maximum total is %d%s, but actual total %s is %d%s, which exceeds threshold of %d%%"
|
||||||
|
.formatted(propName, maxValue, propUnit, propName, totalValue, propUnit, propDef.thresholdPercentage())
|
||||||
|
: null;
|
||||||
|
} else {
|
||||||
|
return totalValue > maxValue
|
||||||
|
? "%s' maximum total is %d%s, but actual total %s is %d%s"
|
||||||
|
.formatted(propName, maxValue, propUnit, propName, totalValue, propUnit)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Object convertBooleanToInteger(final Object value) {
|
||||||
|
return value instanceof Boolean ? BooleanUtils.toInteger((Boolean)value) : value;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,63 @@
|
|||||||
|
package net.hostsharing.hsadminng.hs.booking.item.validators;
|
||||||
|
|
||||||
|
import net.hostsharing.hsadminng.hs.booking.item.HsBookingItem;
|
||||||
|
import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType;
|
||||||
|
import net.hostsharing.hsadminng.hs.validation.HsEntityValidator;
|
||||||
|
import net.hostsharing.hsadminng.errors.MultiValidationException;
|
||||||
|
|
||||||
|
import jakarta.persistence.EntityManager;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
import static java.util.Arrays.stream;
|
||||||
|
import static net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType.CLOUD_SERVER;
|
||||||
|
import static net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType.DOMAIN_SETUP;
|
||||||
|
import static net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType.MANAGED_SERVER;
|
||||||
|
import static net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType.MANAGED_WEBSPACE;
|
||||||
|
import static net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType.PRIVATE_CLOUD;
|
||||||
|
|
||||||
|
public class HsBookingItemEntityValidatorRegistry {
|
||||||
|
|
||||||
|
private static final Map<Enum<HsBookingItemType>, HsEntityValidator<HsBookingItem>> validators = new HashMap<>();
|
||||||
|
static {
|
||||||
|
register(PRIVATE_CLOUD, new HsPrivateCloudBookingItemValidator());
|
||||||
|
register(CLOUD_SERVER, new HsCloudServerBookingItemValidator());
|
||||||
|
register(MANAGED_SERVER, new HsManagedServerBookingItemValidator());
|
||||||
|
register(MANAGED_WEBSPACE, new HsManagedWebspaceBookingItemValidator());
|
||||||
|
register(DOMAIN_SETUP, new HsDomainSetupBookingItemValidator());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void register(final Enum<HsBookingItemType> type, final HsEntityValidator<HsBookingItem> validator) {
|
||||||
|
stream(validator.propertyValidators).forEach( entry -> {
|
||||||
|
entry.verifyConsistency(Map.entry(type, validator));
|
||||||
|
});
|
||||||
|
validators.put(type, validator);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static HsEntityValidator<HsBookingItem> forType(final Enum<HsBookingItemType> type) {
|
||||||
|
if ( validators.containsKey(type)) {
|
||||||
|
return validators.get(type);
|
||||||
|
}
|
||||||
|
throw new IllegalArgumentException("no validator found for type " + type);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Set<Enum<HsBookingItemType>> types() {
|
||||||
|
return validators.keySet();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<String> doValidate(final EntityManager em, final HsBookingItem bookingItem) {
|
||||||
|
final var bookingItemValidator = HsBookingItemEntityValidatorRegistry.forType(bookingItem.getType());
|
||||||
|
return HsEntityValidator.doWithEntityManager(em, () ->
|
||||||
|
HsEntityValidator.sequentiallyValidate(
|
||||||
|
() -> bookingItemValidator.validateEntity(bookingItem),
|
||||||
|
() -> bookingItemValidator.validateContext(bookingItem))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static <E extends HsBookingItem> E validated(final EntityManager em, final E entityToSave) {
|
||||||
|
MultiValidationException.throwIfNotEmpty(doValidate(em, entityToSave));
|
||||||
|
return entityToSave;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,29 @@
|
|||||||
|
package net.hostsharing.hsadminng.hs.booking.item.validators;
|
||||||
|
|
||||||
|
import static net.hostsharing.hsadminng.hs.validation.BooleanProperty.booleanProperty;
|
||||||
|
import static net.hostsharing.hsadminng.hs.validation.EnumerationProperty.enumerationProperty;
|
||||||
|
import static net.hostsharing.hsadminng.hs.validation.IntegerProperty.integerProperty;
|
||||||
|
|
||||||
|
class HsCloudServerBookingItemValidator extends HsBookingItemEntityValidator {
|
||||||
|
|
||||||
|
HsCloudServerBookingItemValidator() {
|
||||||
|
super(
|
||||||
|
// @formatter:off
|
||||||
|
booleanProperty("active") .withDefault(true),
|
||||||
|
|
||||||
|
integerProperty("CPU") .min( 1) .max( 32) .required(),
|
||||||
|
integerProperty("RAM").unit("GB") .min( 1) .max( 8192) .required(),
|
||||||
|
integerProperty("SSD").unit("GB") .min( 25) .max( 1000) .step(25).requiresAtLeastOneOf("SDD", "HDD"),
|
||||||
|
integerProperty("HDD").unit("GB") .min(250) .max( 4000) .step(250).requiresAtLeastOneOf("SSD", "HDD"),
|
||||||
|
integerProperty("Traffic").unit("GB") .min(250) .max(10000) .step(250).requiresAtMaxOneOf("Bandwidth", "Traffic"),
|
||||||
|
integerProperty("Bandwidth").unit("GB") .min(250) .max(10000) .step(250).requiresAtMaxOneOf("Bandwidth", "Traffic"), // TODO.spec
|
||||||
|
|
||||||
|
enumerationProperty("SLA-Infrastructure").values("BASIC", "EXT8H", "EXT4H", "EXT2H").optional()
|
||||||
|
// @formatter:on
|
||||||
|
);
|
||||||
|
|
||||||
|
// (q) We do have pre-existing CloudServers without SSD, just HDD, thus SSD starts with min=0.
|
||||||
|
// TODO.impl: Validation that SSD+HDD is at minimum 25 GB is missing.
|
||||||
|
// e.g. validationGroup("SSD", "HDD").min(0);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,69 @@
|
|||||||
|
|
||||||
|
package net.hostsharing.hsadminng.hs.booking.item.validators;
|
||||||
|
|
||||||
|
import net.hostsharing.hsadminng.hs.booking.item.HsBookingItem;
|
||||||
|
import net.hostsharing.hsadminng.hs.validation.PropertiesProvider;
|
||||||
|
|
||||||
|
import jakarta.persistence.EntityManager;
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static net.hostsharing.hsadminng.hs.hosting.asset.validators.Dns.REGISTRAR_LEVEL_DOMAINS;
|
||||||
|
import static net.hostsharing.hsadminng.hs.validation.StringProperty.stringProperty;
|
||||||
|
|
||||||
|
class HsDomainSetupBookingItemValidator extends HsBookingItemEntityValidator {
|
||||||
|
public static final String DOMAIN_NAME_PROPERTY_NAME = "domainName";
|
||||||
|
|
||||||
|
public static final String FQDN_REGEX = "^((?!-)[A-Za-z0-9-]{1,63}(?<!-)\\.)+[A-Za-z]{2,12}";
|
||||||
|
public static final String WEBSPACE_NAME_REGEX = "[a-z][a-z0-9]{2}[0-9]{2}";
|
||||||
|
public static final String TARGET_UNIX_USER_NAME_REGEX = "^"+WEBSPACE_NAME_REGEX+"$|^"+WEBSPACE_NAME_REGEX+"-[a-z0-9\\._-]+$";
|
||||||
|
public static final String VERIFICATION_CODE_PROPERTY_NAME = "verificationCode";
|
||||||
|
|
||||||
|
HsDomainSetupBookingItemValidator() {
|
||||||
|
super(
|
||||||
|
// TODO.spec: feels wrong
|
||||||
|
stringProperty(DOMAIN_NAME_PROPERTY_NAME).writeOnce()
|
||||||
|
.maxLength(253)
|
||||||
|
.matchesRegEx(FQDN_REGEX).describedAs("is not a (non-top-level) fully qualified domain name")
|
||||||
|
.notMatchesRegEx(REGISTRAR_LEVEL_DOMAINS).describedAs("is a forbidden registrar-level domain name")
|
||||||
|
.required(),
|
||||||
|
stringProperty(VERIFICATION_CODE_PROPERTY_NAME)
|
||||||
|
.minLength(12)
|
||||||
|
.maxLength(64)
|
||||||
|
.initializedBy(HsDomainSetupBookingItemValidator::generateVerificationCode)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<String> validateEntity(final HsBookingItem bookingItem) {
|
||||||
|
final var violations = new ArrayList<String>();
|
||||||
|
final var domainName = bookingItem.getDirectValue(DOMAIN_NAME_PROPERTY_NAME, String.class);
|
||||||
|
if (!bookingItem.isLoaded() &&
|
||||||
|
domainName.matches("hostsharing.(com|net|org|coop|de)")) {
|
||||||
|
violations.add("'" + bookingItem.toShortString() + ".resources." + DOMAIN_NAME_PROPERTY_NAME + "' = '" + domainName
|
||||||
|
+ "' is a forbidden Hostsharing domain name");
|
||||||
|
}
|
||||||
|
violations.addAll(super.validateEntity(bookingItem));
|
||||||
|
return violations;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String generateVerificationCode(final EntityManager em, final PropertiesProvider propertiesProvider) {
|
||||||
|
final var userDefinedVerificationCode = propertiesProvider.getDirectValue(VERIFICATION_CODE_PROPERTY_NAME, String.class);
|
||||||
|
if (userDefinedVerificationCode != null) {
|
||||||
|
return userDefinedVerificationCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
final var alphaNumeric = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||||
|
final var secureRandom = new SecureRandom();
|
||||||
|
final var sb = new StringBuilder();
|
||||||
|
for (int i = 0; i < 40; ++i) {
|
||||||
|
if ( i > 0 && i % 4 == 0 ) {
|
||||||
|
sb.append("-");
|
||||||
|
}
|
||||||
|
sb.append(alphaNumeric.charAt(secureRandom.nextInt(alphaNumeric.length())));
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,27 @@
|
|||||||
|
package net.hostsharing.hsadminng.hs.booking.item.validators;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
import static net.hostsharing.hsadminng.hs.validation.BooleanProperty.booleanProperty;
|
||||||
|
import static net.hostsharing.hsadminng.hs.validation.EnumerationProperty.enumerationProperty;
|
||||||
|
import static net.hostsharing.hsadminng.hs.validation.IntegerProperty.integerProperty;
|
||||||
|
|
||||||
|
class HsManagedServerBookingItemValidator extends HsBookingItemEntityValidator {
|
||||||
|
|
||||||
|
HsManagedServerBookingItemValidator() {
|
||||||
|
super(
|
||||||
|
integerProperty("CPU").min(1).max(32).required(),
|
||||||
|
integerProperty("RAM").unit("GB").min(1).max(128).required(),
|
||||||
|
integerProperty("SSD").unit("GB").min(25).max(2000).step(25).requiresAtLeastOneOf("SSD", "HDD").asTotalLimit().withThreshold(200),
|
||||||
|
integerProperty("HDD").unit("GB").min(250).max(10000).step(250).requiresAtLeastOneOf("SSD", "HDD").asTotalLimit().withThreshold(200),
|
||||||
|
integerProperty("Traffic").unit("GB").min(250).max(64000).step(250).requiresAtMaxOneOf("Bandwidth", "Traffic").asTotalLimit().withThreshold(200),
|
||||||
|
integerProperty("Bandwidth").unit("GB").min(250).max(64000).step(250).requiresAtMaxOneOf("Bandwidth", "Traffic").asTotalLimit().withThreshold(200), // TODO.spec
|
||||||
|
enumerationProperty("SLA-Platform").values("BASIC", "EXT8H", "EXT4H", "EXT2H").withDefault("BASIC"),
|
||||||
|
booleanProperty("SLA-EMail").falseIf("SLA-Platform", "BASIC").withDefault(false),
|
||||||
|
booleanProperty("SLA-Maria").falseIf("SLA-Platform", "BASIC").optional(),
|
||||||
|
booleanProperty("SLA-PgSQL").falseIf("SLA-Platform", "BASIC").optional(),
|
||||||
|
booleanProperty("SLA-Office").falseIf("SLA-Platform", "BASIC").optional(),
|
||||||
|
booleanProperty("SLA-Web").falseIf("SLA-Platform", "BASIC").optional()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,115 @@
|
|||||||
|
package net.hostsharing.hsadminng.hs.booking.item.validators;
|
||||||
|
|
||||||
|
import net.hostsharing.hsadminng.hs.booking.item.HsBookingItem;
|
||||||
|
import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetRealEntity;
|
||||||
|
import net.hostsharing.hsadminng.hs.validation.HsEntityValidator;
|
||||||
|
import net.hostsharing.hsadminng.hs.validation.IntegerProperty;
|
||||||
|
import org.apache.commons.lang3.function.TriFunction;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import static java.util.Collections.emptyList;
|
||||||
|
import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.DOMAIN_MBOX_SETUP;
|
||||||
|
import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.EMAIL_ADDRESS;
|
||||||
|
import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.MARIADB_DATABASE;
|
||||||
|
import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.MARIADB_USER;
|
||||||
|
import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.PGSQL_DATABASE;
|
||||||
|
import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.PGSQL_USER;
|
||||||
|
import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.UNIX_USER;
|
||||||
|
import static net.hostsharing.hsadminng.hs.validation.BooleanProperty.booleanProperty;
|
||||||
|
import static net.hostsharing.hsadminng.hs.validation.EnumerationProperty.enumerationProperty;
|
||||||
|
import static net.hostsharing.hsadminng.hs.validation.IntegerProperty.integerProperty;
|
||||||
|
|
||||||
|
class HsManagedWebspaceBookingItemValidator extends HsBookingItemEntityValidator {
|
||||||
|
|
||||||
|
public HsManagedWebspaceBookingItemValidator() {
|
||||||
|
super(
|
||||||
|
integerProperty("SSD").unit("GB").min(1).max(2000).step(1).required(),
|
||||||
|
integerProperty("HDD").unit("GB").min(0).max(10000).step(10).optional(),
|
||||||
|
integerProperty("Traffic").unit("GB").min(10).max(64000).step(10).requiresAtMaxOneOf("Bandwidth", "Traffic"),
|
||||||
|
integerProperty("Bandwidth").unit("GB").min(10).max(1000).step(10).requiresAtMaxOneOf("Bandwidth", "Traffic"), // TODO.spec
|
||||||
|
integerProperty("Multi").min(1).max(100).step(1).withDefault(1)
|
||||||
|
.eachComprising( 25, unixUsers())
|
||||||
|
.eachComprising( 5, databaseUsers())
|
||||||
|
.eachComprising( 5, databases())
|
||||||
|
.eachComprising(250, eMailAddresses()),
|
||||||
|
integerProperty("Daemons").min(0).max(16).withDefault(0),
|
||||||
|
booleanProperty("Online Office Server").optional(), // TODO.impl: shorten to "Office"
|
||||||
|
enumerationProperty("SLA-Platform").values("BASIC", "EXT24H").withDefault("BASIC")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TriFunction<HsBookingItem, IntegerProperty<?>, Integer, List<String>> unixUsers() {
|
||||||
|
return (final HsBookingItem entity, final IntegerProperty<?> prop, final Integer factor) -> {
|
||||||
|
final var unixUserCount = fetchRelatedBookingItem(entity)
|
||||||
|
.map(ha -> ha.getSubHostingAssets().stream()
|
||||||
|
.filter(subAsset -> subAsset.getType() == UNIX_USER)
|
||||||
|
.count())
|
||||||
|
.orElse(0L);
|
||||||
|
final long limitingValue = prop.getValue(entity.getResources());
|
||||||
|
if (unixUserCount > factor*limitingValue) {
|
||||||
|
return List.of(prop.propertyName() + "=" + limitingValue + " allows at maximum " + limitingValue*factor + " unix users, but " + unixUserCount + " found");
|
||||||
|
}
|
||||||
|
return emptyList();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TriFunction<HsBookingItem, IntegerProperty<?>, Integer, List<String>> databaseUsers() {
|
||||||
|
return (final HsBookingItem entity, final IntegerProperty<?> prop, final Integer factor) -> {
|
||||||
|
final var dbUserCount = fetchRelatedBookingItem(entity)
|
||||||
|
.map(ha -> ha.getSubHostingAssets().stream()
|
||||||
|
.filter(bi -> bi.getType() == PGSQL_USER || bi.getType() == MARIADB_USER )
|
||||||
|
.count())
|
||||||
|
.orElse(0L);
|
||||||
|
final long limitingValue = prop.getValue(entity.getResources());
|
||||||
|
if (dbUserCount > factor*limitingValue) {
|
||||||
|
return List.of(prop.propertyName() + "=" + limitingValue + " allows at maximum " + limitingValue*factor + " database users, but " + dbUserCount + " found");
|
||||||
|
}
|
||||||
|
return emptyList();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TriFunction<HsBookingItem, IntegerProperty<?>, Integer, List<String>> databases() {
|
||||||
|
return (final HsBookingItem entity, final IntegerProperty<?> prop, final Integer factor) -> {
|
||||||
|
final var unixUserCount = fetchRelatedBookingItem(entity)
|
||||||
|
.map(ha -> ha.getSubHostingAssets().stream()
|
||||||
|
.filter(bi -> bi.getType()==PGSQL_USER || bi.getType()==MARIADB_USER )
|
||||||
|
.flatMap(domainEMailSetup -> domainEMailSetup.getSubHostingAssets().stream()
|
||||||
|
.filter(subAsset -> subAsset.getType()==PGSQL_DATABASE || subAsset.getType()==MARIADB_DATABASE))
|
||||||
|
.count())
|
||||||
|
.orElse(0L);
|
||||||
|
final long limitingValue = prop.getValue(entity.getResources());
|
||||||
|
if (unixUserCount > factor*limitingValue) {
|
||||||
|
return List.of(prop.propertyName() + "=" + limitingValue + " allows at maximum " + limitingValue*factor + " databases, but " + unixUserCount + " found");
|
||||||
|
}
|
||||||
|
return emptyList();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TriFunction<HsBookingItem, IntegerProperty<?>, Integer, List<String>> eMailAddresses() {
|
||||||
|
return (final HsBookingItem entity, final IntegerProperty<?> prop, final Integer factor) -> {
|
||||||
|
final var unixUserCount = fetchRelatedBookingItem(entity)
|
||||||
|
.map(ha -> ha.getSubHostingAssets().stream()
|
||||||
|
.filter(bi -> bi.getType() == DOMAIN_MBOX_SETUP)
|
||||||
|
.flatMap(domainEMailSetup -> domainEMailSetup.getSubHostingAssets().stream()
|
||||||
|
.filter(subAsset -> subAsset.getType()==EMAIL_ADDRESS))
|
||||||
|
.count())
|
||||||
|
.orElse(0L);
|
||||||
|
final long limitingValue = prop.getValue(entity.getResources());
|
||||||
|
if (unixUserCount > factor*limitingValue) {
|
||||||
|
return List.of(prop.propertyName() + "=" + limitingValue + " allows at maximum " + limitingValue*factor + " databases, but " + unixUserCount + " found");
|
||||||
|
}
|
||||||
|
return emptyList();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Optional<HsHostingAssetRealEntity> fetchRelatedBookingItem(final HsBookingItem entity) {
|
||||||
|
// TODO.perf: maybe we need to cache the result at least for a single valiationrun
|
||||||
|
return HsEntityValidator.localEntityManager.get().createQuery(
|
||||||
|
"SELECT asset FROM HsHostingAssetRealEntity asset WHERE asset.bookingItem.uuid=:bookingItemUuid",
|
||||||
|
HsHostingAssetRealEntity.class)
|
||||||
|
.setParameter("bookingItemUuid", entity.getUuid())
|
||||||
|
.getResultStream().findFirst(); // there are 0 or 1, never more
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,41 @@
|
|||||||
|
package net.hostsharing.hsadminng.hs.booking.item.validators;
|
||||||
|
|
||||||
|
import static net.hostsharing.hsadminng.hs.validation.IntegerProperty.integerProperty;
|
||||||
|
|
||||||
|
class HsPrivateCloudBookingItemValidator extends HsBookingItemEntityValidator {
|
||||||
|
|
||||||
|
HsPrivateCloudBookingItemValidator() {
|
||||||
|
super(
|
||||||
|
// @formatter:off
|
||||||
|
integerProperty("CPU") .min( 1).max( 128).required().asTotalLimit(),
|
||||||
|
integerProperty("RAM").unit("GB") .min( 1).max( 512).required().asTotalLimit(),
|
||||||
|
integerProperty("SSD").unit("GB") .min( 25).max( 4000).step(25).requiresAtLeastOneOf("SSD", "HDD").asTotalLimit(),
|
||||||
|
integerProperty("HDD").unit("GB") .min(250).max(16000).step(250).requiresAtLeastOneOf("SSD", "HDD").asTotalLimit(),
|
||||||
|
integerProperty("Traffic").unit("GB") .min(250).max(64000).step(250).requiresAtMaxOneOf("Bandwidth", "Traffic").asTotalLimit(),
|
||||||
|
integerProperty("Bandwidth").unit("GB") .min(250).max(64000).step(250).requiresAtMaxOneOf("Bandwidth", "Traffic").asTotalLimit(), // TODO.spec
|
||||||
|
|
||||||
|
// Alternatively we could specify it similarly to "Multi" option but exclusively counting:
|
||||||
|
// integerProperty("Resource-Points") .min(4).max(100).required()
|
||||||
|
// .each("CPU").countsAs(64)
|
||||||
|
// .each("RAM").countsAs(64)
|
||||||
|
// .each("SSD").countsAs(18)
|
||||||
|
// .each("HDD").countsAs(2)
|
||||||
|
// .each("Traffic").countsAs(1),
|
||||||
|
|
||||||
|
integerProperty("SLA-Infrastructure EXT8H") .min( 0).max( 20).withDefault(0).asTotalLimitFor("SLA-Infrastructure", "EXT8H"),
|
||||||
|
integerProperty("SLA-Infrastructure EXT4H") .min( 0).max( 20).withDefault(0).asTotalLimitFor("SLA-Infrastructure", "EXT4H"),
|
||||||
|
integerProperty("SLA-Infrastructure EXT2H") .min( 0).max( 20).withDefault(0).asTotalLimitFor("SLA-Infrastructure", "EXT2H"),
|
||||||
|
|
||||||
|
integerProperty("SLA-Platform EXT8H") .min( 0).max( 20).withDefault(0).asTotalLimitFor("SLA-Platform", "EXT8H"),
|
||||||
|
integerProperty("SLA-Platform EXT4H") .min( 0).max( 20).withDefault(0).asTotalLimitFor("SLA-Platform", "EXT4H"),
|
||||||
|
integerProperty("SLA-Platform EXT2H") .min( 0).max( 20).withDefault(0).asTotalLimitFor("SLA-Platform", "EXT2H"),
|
||||||
|
|
||||||
|
integerProperty("SLA-EMail") .min( 0).max( 20).withDefault(0).asTotalLimit(),
|
||||||
|
integerProperty("SLA-Maria") .min( 0).max( 20).withDefault(0).asTotalLimit(),
|
||||||
|
integerProperty("SLA-PgSQL") .min( 0).max( 20).withDefault(0).asTotalLimit(),
|
||||||
|
integerProperty("SLA-Office") .min( 0).max( 20).withDefault(0).asTotalLimit(),
|
||||||
|
integerProperty("SLA-Web") .min( 0).max( 20).withDefault(0).asTotalLimit()
|
||||||
|
// @formatter:on
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,53 @@
|
|||||||
|
package net.hostsharing.hsadminng.hs.booking.project;
|
||||||
|
|
||||||
|
import lombok.*;
|
||||||
|
import lombok.experimental.SuperBuilder;
|
||||||
|
import net.hostsharing.hsadminng.hs.booking.debitor.HsBookingDebitorEntity;
|
||||||
|
import net.hostsharing.hsadminng.persistence.BaseEntity;
|
||||||
|
import net.hostsharing.hsadminng.stringify.Stringify;
|
||||||
|
import net.hostsharing.hsadminng.stringify.Stringifyable;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static java.util.Optional.ofNullable;
|
||||||
|
import static net.hostsharing.hsadminng.stringify.Stringify.stringify;
|
||||||
|
|
||||||
|
@MappedSuperclass
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
||||||
|
@AllArgsConstructor(access = AccessLevel.PROTECTED)
|
||||||
|
@SuperBuilder(builderMethodName = "baseBuilder", toBuilder = true)
|
||||||
|
public abstract class HsBookingProject implements Stringifyable, BaseEntity<HsBookingProject> {
|
||||||
|
|
||||||
|
private static Stringify<HsBookingProject> stringify = stringify(HsBookingProject.class)
|
||||||
|
.withProp(HsBookingProject::getDebitor)
|
||||||
|
.withProp(HsBookingProject::getCaption)
|
||||||
|
.quotedValues(false);
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue
|
||||||
|
private UUID uuid;
|
||||||
|
|
||||||
|
@Version
|
||||||
|
private int version;
|
||||||
|
|
||||||
|
@ManyToOne(optional = false)
|
||||||
|
@JoinColumn(name = "debitoruuid")
|
||||||
|
private HsBookingDebitorEntity debitor;
|
||||||
|
|
||||||
|
@Column(name = "caption")
|
||||||
|
private String caption;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return stringify.apply(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toShortString() {
|
||||||
|
return ofNullable(debitor).map(HsBookingDebitorEntity::toShortString).orElse("D-???????") +
|
||||||
|
":" + caption;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,128 @@
|
|||||||
|
package net.hostsharing.hsadminng.hs.booking.project;
|
||||||
|
|
||||||
|
import net.hostsharing.hsadminng.context.Context;
|
||||||
|
import net.hostsharing.hsadminng.hs.booking.debitor.HsBookingDebitorRepository;
|
||||||
|
import net.hostsharing.hsadminng.hs.booking.generated.api.v1.api.HsBookingProjectsApi;
|
||||||
|
import net.hostsharing.hsadminng.hs.booking.generated.api.v1.model.HsBookingProjectInsertResource;
|
||||||
|
import net.hostsharing.hsadminng.hs.booking.generated.api.v1.model.HsBookingProjectPatchResource;
|
||||||
|
import net.hostsharing.hsadminng.hs.booking.generated.api.v1.model.HsBookingProjectResource;
|
||||||
|
import net.hostsharing.hsadminng.mapper.StandardMapper;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.servlet.mvc.method.annotation.MvcUriComponentsBuilder;
|
||||||
|
|
||||||
|
import jakarta.persistence.EntityNotFoundException;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.function.BiConsumer;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
public class HsBookingProjectController implements HsBookingProjectsApi {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private Context context;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private StandardMapper mapper;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private HsBookingProjectRbacRepository bookingProjectRepo;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private HsBookingDebitorRepository debitorRepo;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public ResponseEntity<List<HsBookingProjectResource>> listBookingProjectsByDebitorUuid(
|
||||||
|
final String currentSubject,
|
||||||
|
final String assumedRoles,
|
||||||
|
final UUID debitorUuid) {
|
||||||
|
context.define(currentSubject, assumedRoles);
|
||||||
|
|
||||||
|
final var entities = bookingProjectRepo.findAllByDebitorUuid(debitorUuid);
|
||||||
|
|
||||||
|
final var resources = mapper.mapList(entities, HsBookingProjectResource.class);
|
||||||
|
return ResponseEntity.ok(resources);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public ResponseEntity<HsBookingProjectResource> addBookingProject(
|
||||||
|
final String currentSubject,
|
||||||
|
final String assumedRoles,
|
||||||
|
final HsBookingProjectInsertResource body) {
|
||||||
|
|
||||||
|
context.define(currentSubject, assumedRoles);
|
||||||
|
|
||||||
|
final var entityToSave = mapper.map(body, HsBookingProjectRbacEntity.class, RESOURCE_TO_ENTITY_POSTMAPPER);
|
||||||
|
|
||||||
|
final var saved = bookingProjectRepo.save(entityToSave);
|
||||||
|
|
||||||
|
final var uri =
|
||||||
|
MvcUriComponentsBuilder.fromController(getClass())
|
||||||
|
.path("/api/hs/booking/projects/{id}")
|
||||||
|
.buildAndExpand(saved.getUuid())
|
||||||
|
.toUri();
|
||||||
|
final var mapped = mapper.map(saved, HsBookingProjectResource.class);
|
||||||
|
return ResponseEntity.created(uri).body(mapped);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public ResponseEntity<HsBookingProjectResource> getBookingProjectByUuid(
|
||||||
|
final String currentSubject,
|
||||||
|
final String assumedRoles,
|
||||||
|
final UUID bookingProjectUuid) {
|
||||||
|
|
||||||
|
context.define(currentSubject, assumedRoles);
|
||||||
|
|
||||||
|
final var result = bookingProjectRepo.findByUuid(bookingProjectUuid);
|
||||||
|
return result
|
||||||
|
.map(bookingProjectEntity -> ResponseEntity.ok(
|
||||||
|
mapper.map(bookingProjectEntity, HsBookingProjectResource.class)))
|
||||||
|
.orElseGet(() -> ResponseEntity.notFound().build());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public ResponseEntity<Void> deleteBookingIemByUuid(
|
||||||
|
final String currentSubject,
|
||||||
|
final String assumedRoles,
|
||||||
|
final UUID bookingProjectUuid) {
|
||||||
|
context.define(currentSubject, assumedRoles);
|
||||||
|
|
||||||
|
final var result = bookingProjectRepo.deleteByUuid(bookingProjectUuid);
|
||||||
|
return result == 0
|
||||||
|
? ResponseEntity.notFound().build()
|
||||||
|
: ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public ResponseEntity<HsBookingProjectResource> patchBookingProject(
|
||||||
|
final String currentSubject,
|
||||||
|
final String assumedRoles,
|
||||||
|
final UUID bookingProjectUuid,
|
||||||
|
final HsBookingProjectPatchResource body) {
|
||||||
|
|
||||||
|
context.define(currentSubject, assumedRoles);
|
||||||
|
|
||||||
|
final var current = bookingProjectRepo.findByUuid(bookingProjectUuid).orElseThrow();
|
||||||
|
|
||||||
|
new HsBookingProjectEntityPatcher(current).apply(body);
|
||||||
|
|
||||||
|
final var saved = bookingProjectRepo.save(current);
|
||||||
|
final var mapped = mapper.map(saved, HsBookingProjectResource.class);
|
||||||
|
return ResponseEntity.ok(mapped);
|
||||||
|
}
|
||||||
|
|
||||||
|
final BiConsumer<HsBookingProjectInsertResource, HsBookingProjectRbacEntity> RESOURCE_TO_ENTITY_POSTMAPPER = (resource, entity) -> {
|
||||||
|
if (resource.getDebitorUuid() != null) {
|
||||||
|
entity.setDebitor(debitorRepo.findByUuid(resource.getDebitorUuid())
|
||||||
|
.orElseThrow(() -> new EntityNotFoundException("ERROR: [400] debitorUuid %s not found".formatted(
|
||||||
|
resource.getDebitorUuid()))));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
@ -0,0 +1,22 @@
|
|||||||
|
package net.hostsharing.hsadminng.hs.booking.project;
|
||||||
|
|
||||||
|
import net.hostsharing.hsadminng.hs.booking.generated.api.v1.model.HsBookingProjectPatchResource;
|
||||||
|
import net.hostsharing.hsadminng.mapper.EntityPatcher;
|
||||||
|
import net.hostsharing.hsadminng.mapper.OptionalFromJson;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
public class HsBookingProjectEntityPatcher implements EntityPatcher<HsBookingProjectPatchResource> {
|
||||||
|
|
||||||
|
private final HsBookingProject entity;
|
||||||
|
|
||||||
|
public HsBookingProjectEntityPatcher(final HsBookingProject entity) {
|
||||||
|
this.entity = entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void apply(final HsBookingProjectPatchResource resource) {
|
||||||
|
OptionalFromJson.of(resource.getCaption())
|
||||||
|
.ifPresent(entity::setCaption);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,87 @@
|
|||||||
|
package net.hostsharing.hsadminng.hs.booking.project;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import lombok.Setter;
|
||||||
|
import lombok.experimental.SuperBuilder;
|
||||||
|
import net.hostsharing.hsadminng.hs.office.debitor.HsOfficeDebitorEntity;
|
||||||
|
import net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationRbacEntity;
|
||||||
|
import net.hostsharing.hsadminng.rbac.generator.RbacView;
|
||||||
|
import net.hostsharing.hsadminng.rbac.generator.RbacView.SQL;
|
||||||
|
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
import static net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationType.DEBITOR;
|
||||||
|
import static net.hostsharing.hsadminng.rbac.generator.RbacView.Column.dependsOnColumn;
|
||||||
|
import static net.hostsharing.hsadminng.rbac.generator.RbacView.ColumnValue.usingCase;
|
||||||
|
import static net.hostsharing.hsadminng.rbac.generator.RbacView.ColumnValue.usingDefaultCase;
|
||||||
|
import static net.hostsharing.hsadminng.rbac.generator.RbacView.GLOBAL;
|
||||||
|
import static net.hostsharing.hsadminng.rbac.generator.RbacView.Nullable.NOT_NULL;
|
||||||
|
import static net.hostsharing.hsadminng.rbac.generator.RbacView.Permission.DELETE;
|
||||||
|
import static net.hostsharing.hsadminng.rbac.generator.RbacView.Permission.INSERT;
|
||||||
|
import static net.hostsharing.hsadminng.rbac.generator.RbacView.Permission.SELECT;
|
||||||
|
import static net.hostsharing.hsadminng.rbac.generator.RbacView.Permission.UPDATE;
|
||||||
|
import static net.hostsharing.hsadminng.rbac.generator.RbacView.Role.ADMIN;
|
||||||
|
import static net.hostsharing.hsadminng.rbac.generator.RbacView.Role.AGENT;
|
||||||
|
import static net.hostsharing.hsadminng.rbac.generator.RbacView.Role.OWNER;
|
||||||
|
import static net.hostsharing.hsadminng.rbac.generator.RbacView.Role.TENANT;
|
||||||
|
import static net.hostsharing.hsadminng.rbac.generator.RbacView.SQL.directlyFetchedByDependsOnColumn;
|
||||||
|
import static net.hostsharing.hsadminng.rbac.generator.RbacView.SQL.fetchedBySql;
|
||||||
|
import static net.hostsharing.hsadminng.rbac.generator.RbacView.rbacViewFor;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(schema = "hs_booking", name = "project_rv")
|
||||||
|
@SuperBuilder(toBuilder = true)
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@NoArgsConstructor
|
||||||
|
public class HsBookingProjectRbacEntity extends HsBookingProject {
|
||||||
|
|
||||||
|
public static RbacView rbac() {
|
||||||
|
return rbacViewFor("project", HsBookingProjectRbacEntity.class)
|
||||||
|
.withIdentityView(SQL.query("""
|
||||||
|
SELECT bookingProject.uuid as uuid, debitorIV.idName || '-' || base.cleanIdentifier(bookingProject.caption) as idName
|
||||||
|
FROM hs_booking.project bookingProject
|
||||||
|
JOIN hs_office.debitor_iv debitorIV ON debitorIV.uuid = bookingProject.debitorUuid
|
||||||
|
"""))
|
||||||
|
.withRestrictedViewOrderBy(SQL.expression("caption"))
|
||||||
|
.withUpdatableColumns("version", "caption")
|
||||||
|
|
||||||
|
.importEntityAlias("debitor", HsOfficeDebitorEntity.class, usingDefaultCase(),
|
||||||
|
dependsOnColumn("debitorUuid"),
|
||||||
|
directlyFetchedByDependsOnColumn(),
|
||||||
|
NOT_NULL)
|
||||||
|
|
||||||
|
.importEntityAlias("debitorRel", HsOfficeRelationRbacEntity.class, usingCase(DEBITOR),
|
||||||
|
dependsOnColumn("debitorUuid"),
|
||||||
|
fetchedBySql("""
|
||||||
|
SELECT ${columns}
|
||||||
|
FROM hs_office.relation debitorRel
|
||||||
|
JOIN hs_office.debitor debitor ON debitor.debitorRelUuid = debitorRel.uuid
|
||||||
|
WHERE debitor.uuid = ${REF}.debitorUuid
|
||||||
|
"""),
|
||||||
|
NOT_NULL)
|
||||||
|
.toRole("debitorRel", ADMIN).grantPermission(INSERT)
|
||||||
|
.toRole(GLOBAL, ADMIN).grantPermission(DELETE)
|
||||||
|
|
||||||
|
.createRole(OWNER, (with) -> {
|
||||||
|
with.incomingSuperRole("debitorRel", AGENT).unassumed();
|
||||||
|
})
|
||||||
|
.createSubRole(ADMIN, (with) -> {
|
||||||
|
with.permission(UPDATE);
|
||||||
|
})
|
||||||
|
.createSubRole(AGENT)
|
||||||
|
.createSubRole(TENANT, (with) -> {
|
||||||
|
with.outgoingSubRole("debitorRel", TENANT);
|
||||||
|
with.permission(SELECT);
|
||||||
|
})
|
||||||
|
|
||||||
|
.limitDiagramTo("project", "debitorRel", "rbac.global");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void main(String[] args) throws IOException {
|
||||||
|
rbac().generateWithBaseFileName("6-hs-booking/620-booking-project/6203-hs-booking-project-rbac");
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,22 @@
|
|||||||
|
package net.hostsharing.hsadminng.hs.booking.project;
|
||||||
|
|
||||||
|
import org.springframework.data.repository.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public interface HsBookingProjectRbacRepository extends HsBookingProjectRepository<HsBookingProjectRbacEntity>,
|
||||||
|
Repository<HsBookingProjectRbacEntity, UUID> {
|
||||||
|
|
||||||
|
Optional<HsBookingProjectRbacEntity> findByUuid(final UUID bookingProjectUuid);
|
||||||
|
List<HsBookingProjectRbacEntity> findByCaption(final String projectCaption);
|
||||||
|
|
||||||
|
List<HsBookingProjectRbacEntity> findAllByDebitorUuid(final UUID bookingProjectUuid);
|
||||||
|
|
||||||
|
HsBookingProjectRbacEntity save(HsBookingProjectRbacEntity current);
|
||||||
|
|
||||||
|
int deleteByUuid(final UUID uuid);
|
||||||
|
|
||||||
|
long count();
|
||||||
|
}
|
@ -0,0 +1,19 @@
|
|||||||
|
package net.hostsharing.hsadminng.hs.booking.project;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import lombok.Setter;
|
||||||
|
import lombok.experimental.SuperBuilder;
|
||||||
|
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(schema = "hs_booking", name = "project")
|
||||||
|
@SuperBuilder(toBuilder = true)
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@NoArgsConstructor
|
||||||
|
public class HsBookingProjectRealEntity extends HsBookingProject {
|
||||||
|
}
|
@ -0,0 +1,22 @@
|
|||||||
|
package net.hostsharing.hsadminng.hs.booking.project;
|
||||||
|
|
||||||
|
import org.springframework.data.repository.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public interface HsBookingProjectRealRepository extends HsBookingProjectRepository<HsBookingProjectRealEntity>,
|
||||||
|
Repository<HsBookingProjectRealEntity, UUID> {
|
||||||
|
|
||||||
|
Optional<HsBookingProjectRealEntity> findByUuid(final UUID bookingProjectUuid);
|
||||||
|
List<HsBookingProjectRealEntity> findByCaption(final String projectCaption);
|
||||||
|
|
||||||
|
List<HsBookingProjectRealEntity> findAllByDebitorUuid(final UUID bookingProjectUuid);
|
||||||
|
|
||||||
|
HsBookingProjectRealEntity save(HsBookingProjectRealEntity current);
|
||||||
|
|
||||||
|
int deleteByUuid(final UUID uuid);
|
||||||
|
|
||||||
|
long count();
|
||||||
|
}
|
@ -0,0 +1,19 @@
|
|||||||
|
package net.hostsharing.hsadminng.hs.booking.project;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public interface HsBookingProjectRepository<E extends HsBookingProject> {
|
||||||
|
|
||||||
|
Optional<E> findByUuid(final UUID bookingProjectUuid);
|
||||||
|
List<E> findByCaption(final String projectCaption);
|
||||||
|
|
||||||
|
List<E> findAllByDebitorUuid(final UUID bookingProjectUuid);
|
||||||
|
|
||||||
|
E save(E current);
|
||||||
|
|
||||||
|
int deleteByUuid(final UUID uuid);
|
||||||
|
|
||||||
|
long count();
|
||||||
|
}
|
@ -0,0 +1,172 @@
|
|||||||
|
package net.hostsharing.hsadminng.hs.hosting.asset;
|
||||||
|
|
||||||
|
import io.hypersistence.utils.hibernate.type.json.JsonType;
|
||||||
|
import lombok.AccessLevel;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import lombok.Setter;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.experimental.SuperBuilder;
|
||||||
|
import net.hostsharing.hsadminng.hs.booking.item.HsBookingItem;
|
||||||
|
import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemRealEntity;
|
||||||
|
import net.hostsharing.hsadminng.hs.booking.project.HsBookingProject;
|
||||||
|
import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRealEntity;
|
||||||
|
import net.hostsharing.hsadminng.hs.validation.PropertiesProvider;
|
||||||
|
import net.hostsharing.hsadminng.mapper.PatchableMapWrapper;
|
||||||
|
import net.hostsharing.hsadminng.persistence.BaseEntity;
|
||||||
|
import net.hostsharing.hsadminng.stringify.Stringify;
|
||||||
|
import net.hostsharing.hsadminng.stringify.Stringifyable;
|
||||||
|
import org.hibernate.annotations.Type;
|
||||||
|
|
||||||
|
import jakarta.persistence.CascadeType;
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.EnumType;
|
||||||
|
import jakarta.persistence.Enumerated;
|
||||||
|
import jakarta.persistence.FetchType;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.JoinColumn;
|
||||||
|
import jakarta.persistence.ManyToOne;
|
||||||
|
import jakarta.persistence.MappedSuperclass;
|
||||||
|
import jakarta.persistence.OneToMany;
|
||||||
|
import jakarta.persistence.OneToOne;
|
||||||
|
import jakarta.persistence.PostLoad;
|
||||||
|
import jakarta.persistence.Transient;
|
||||||
|
import jakarta.persistence.Version;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static java.util.Collections.emptyMap;
|
||||||
|
import static net.hostsharing.hsadminng.stringify.Stringify.stringify;
|
||||||
|
|
||||||
|
@MappedSuperclass
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
||||||
|
@AllArgsConstructor(access = AccessLevel.PROTECTED)
|
||||||
|
@SuperBuilder(builderMethodName = "baseBuilder", toBuilder = true)
|
||||||
|
public abstract class HsHostingAsset implements Stringifyable, BaseEntity<HsHostingAsset>, PropertiesProvider {
|
||||||
|
|
||||||
|
static Stringify<HsHostingAsset> stringify = stringify(HsHostingAsset.class)
|
||||||
|
.withProp(HsHostingAsset::getType)
|
||||||
|
.withProp(HsHostingAsset::getIdentifier)
|
||||||
|
.withProp(HsHostingAsset::getCaption)
|
||||||
|
.withProp(HsHostingAsset::getParentAsset)
|
||||||
|
.withProp(HsHostingAsset::getAssignedToAsset)
|
||||||
|
.withProp(HsHostingAsset::getBookingItem)
|
||||||
|
.withProp(HsHostingAsset::getConfig)
|
||||||
|
.quotedValues(false);
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue
|
||||||
|
private UUID uuid;
|
||||||
|
|
||||||
|
@Version
|
||||||
|
private int version;
|
||||||
|
|
||||||
|
@OneToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "bookingitemuuid")
|
||||||
|
private HsBookingItemRealEntity bookingItem;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "parentassetuuid")
|
||||||
|
private HsHostingAssetRealEntity parentAsset;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "assignedtoassetuuid")
|
||||||
|
private HsHostingAssetRealEntity assignedToAsset;
|
||||||
|
|
||||||
|
@Column(name = "type")
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
private HsHostingAssetType type;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "alarmcontactuuid")
|
||||||
|
private HsOfficeContactRealEntity alarmContact;
|
||||||
|
|
||||||
|
@OneToMany(cascade = CascadeType.REFRESH, orphanRemoval = true, fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "parentassetuuid", referencedColumnName = "uuid")
|
||||||
|
private List<HsHostingAssetRealEntity> subHostingAssets;
|
||||||
|
|
||||||
|
@Column(name = "identifier")
|
||||||
|
private String identifier; // e.g. vm1234, xyz00, example.org, xyz00_abc
|
||||||
|
|
||||||
|
@Column(name = "caption")
|
||||||
|
private String caption;
|
||||||
|
|
||||||
|
@Builder.Default
|
||||||
|
@Setter(AccessLevel.NONE)
|
||||||
|
@Type(JsonType.class)
|
||||||
|
@Column(columnDefinition = "config")
|
||||||
|
private Map<String, Object> config = new HashMap<>();
|
||||||
|
|
||||||
|
@Transient
|
||||||
|
private PatchableMapWrapper<Object> configWrapper;
|
||||||
|
|
||||||
|
@Transient
|
||||||
|
private boolean isLoaded;
|
||||||
|
|
||||||
|
@PostLoad
|
||||||
|
public void markAsLoaded() {
|
||||||
|
this.isLoaded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public PatchableMapWrapper<Object> getConfig() {
|
||||||
|
return PatchableMapWrapper.of(configWrapper, (newWrapper) -> {configWrapper = newWrapper;}, config);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void putConfig(Map<String, Object> newConfig) {
|
||||||
|
PatchableMapWrapper.of(configWrapper, (newWrapper) -> {configWrapper = newWrapper;}, config).assign(newConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<HsHostingAssetRealEntity> getSubHostingAssets() {
|
||||||
|
if (subHostingAssets == null) {
|
||||||
|
subHostingAssets = new ArrayList<>();
|
||||||
|
}
|
||||||
|
return subHostingAssets;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public PatchableMapWrapper<Object> directProps() {
|
||||||
|
return getConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
public HsBookingProject getRelatedProject() {
|
||||||
|
return Optional.ofNullable(getBookingItem())
|
||||||
|
.map(HsBookingItem::getRelatedProject)
|
||||||
|
.orElseGet(() -> Optional.ofNullable(getParentAsset())
|
||||||
|
.map(HsHostingAsset::getRelatedProject)
|
||||||
|
.orElse(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Object getContextValue(final String propName) {
|
||||||
|
final var v = directProps().get(propName);
|
||||||
|
if (v != null) {
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (getBookingItem() != null) {
|
||||||
|
return getBookingItem().getResources().get(propName);
|
||||||
|
}
|
||||||
|
if (getParentAsset() != null && getParentAsset().getBookingItem() != null) {
|
||||||
|
return getParentAsset().getBookingItem().getResources().get(propName);
|
||||||
|
}
|
||||||
|
return emptyMap();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toShortString() {
|
||||||
|
return getType() + ":" + getIdentifier();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return stringify.apply(this);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,168 @@
|
|||||||
|
package net.hostsharing.hsadminng.hs.hosting.asset;
|
||||||
|
|
||||||
|
import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemRealRepository;
|
||||||
|
import net.hostsharing.hsadminng.hs.hosting.asset.validators.HostingAssetEntitySaveProcessor;
|
||||||
|
import net.hostsharing.hsadminng.hs.hosting.asset.validators.HostingAssetEntityValidatorRegistry;
|
||||||
|
import net.hostsharing.hsadminng.hs.hosting.generated.api.v1.api.HsHostingAssetsApi;
|
||||||
|
|
||||||
|
import net.hostsharing.hsadminng.context.Context;
|
||||||
|
import net.hostsharing.hsadminng.hs.hosting.generated.api.v1.model.HsHostingAssetInsertResource;
|
||||||
|
import net.hostsharing.hsadminng.hs.hosting.generated.api.v1.model.HsHostingAssetPatchResource;
|
||||||
|
import net.hostsharing.hsadminng.hs.hosting.generated.api.v1.model.HsHostingAssetResource;
|
||||||
|
import net.hostsharing.hsadminng.hs.hosting.generated.api.v1.model.HsHostingAssetTypeResource;
|
||||||
|
import net.hostsharing.hsadminng.mapper.KeyValueMap;
|
||||||
|
import net.hostsharing.hsadminng.mapper.StandardMapper;
|
||||||
|
import net.hostsharing.hsadminng.persistence.EntityManagerWrapper;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.servlet.mvc.method.annotation.MvcUriComponentsBuilder;
|
||||||
|
|
||||||
|
import jakarta.persistence.EntityNotFoundException;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.function.BiConsumer;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
public class HsHostingAssetController implements HsHostingAssetsApi {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private EntityManagerWrapper emw;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private Context context;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private StandardMapper mapper;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private HsHostingAssetRbacRepository rbacAssetRepo;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private HsHostingAssetRealRepository realAssetRepo;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private HsBookingItemRealRepository realBookingItemRepo;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public ResponseEntity<List<HsHostingAssetResource>> listAssets(
|
||||||
|
final String currentSubject,
|
||||||
|
final String assumedRoles,
|
||||||
|
final UUID debitorUuid,
|
||||||
|
final UUID parentAssetUuid,
|
||||||
|
final HsHostingAssetTypeResource type) {
|
||||||
|
context.define(currentSubject, assumedRoles);
|
||||||
|
|
||||||
|
final var entities = rbacAssetRepo.findAllByCriteria(debitorUuid, parentAssetUuid, HsHostingAssetType.of(type));
|
||||||
|
|
||||||
|
final var resources = mapper.mapList(entities, HsHostingAssetResource.class, ENTITY_TO_RESOURCE_POSTMAPPER);
|
||||||
|
return ResponseEntity.ok(resources);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public ResponseEntity<HsHostingAssetResource> addAsset(
|
||||||
|
final String currentSubject,
|
||||||
|
final String assumedRoles,
|
||||||
|
final HsHostingAssetInsertResource body) {
|
||||||
|
|
||||||
|
context.define(currentSubject, assumedRoles);
|
||||||
|
|
||||||
|
final var entity = mapper.map(body, HsHostingAssetRbacEntity.class, RESOURCE_TO_ENTITY_POSTMAPPER);
|
||||||
|
|
||||||
|
final var mapped = new HostingAssetEntitySaveProcessor(emw, entity)
|
||||||
|
.preprocessEntity()
|
||||||
|
.validateEntity()
|
||||||
|
.prepareForSave()
|
||||||
|
.save()
|
||||||
|
.validateContext()
|
||||||
|
.mapUsing(e -> mapper.map(e, HsHostingAssetResource.class))
|
||||||
|
.revampProperties();
|
||||||
|
|
||||||
|
final var uri =
|
||||||
|
MvcUriComponentsBuilder.fromController(getClass())
|
||||||
|
.path("/api/hs/hosting/assets/{id}")
|
||||||
|
.buildAndExpand(mapped.getUuid())
|
||||||
|
.toUri();
|
||||||
|
return ResponseEntity.created(uri).body(mapped);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public ResponseEntity<HsHostingAssetResource> getAssetByUuid(
|
||||||
|
final String currentSubject,
|
||||||
|
final String assumedRoles,
|
||||||
|
final UUID assetUuid) {
|
||||||
|
|
||||||
|
context.define(currentSubject, assumedRoles);
|
||||||
|
|
||||||
|
final var result = rbacAssetRepo.findByUuid(assetUuid);
|
||||||
|
return result
|
||||||
|
.map(assetEntity -> ResponseEntity.ok(
|
||||||
|
mapper.map(assetEntity, HsHostingAssetResource.class, ENTITY_TO_RESOURCE_POSTMAPPER)))
|
||||||
|
.orElseGet(() -> ResponseEntity.notFound().build());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public ResponseEntity<Void> deleteAssetUuid(
|
||||||
|
final String currentSubject,
|
||||||
|
final String assumedRoles,
|
||||||
|
final UUID assetUuid) {
|
||||||
|
context.define(currentSubject, assumedRoles);
|
||||||
|
|
||||||
|
final var result = rbacAssetRepo.deleteByUuid(assetUuid);
|
||||||
|
return result == 0
|
||||||
|
? ResponseEntity.notFound().build()
|
||||||
|
: ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public ResponseEntity<HsHostingAssetResource> patchAsset(
|
||||||
|
final String currentSubject,
|
||||||
|
final String assumedRoles,
|
||||||
|
final UUID assetUuid,
|
||||||
|
final HsHostingAssetPatchResource body) {
|
||||||
|
|
||||||
|
context.define(currentSubject, assumedRoles);
|
||||||
|
|
||||||
|
final var entity = rbacAssetRepo.findByUuid(assetUuid).orElseThrow();
|
||||||
|
|
||||||
|
new HsHostingAssetEntityPatcher(emw, entity).apply(body);
|
||||||
|
|
||||||
|
final var mapped = new HostingAssetEntitySaveProcessor(emw, entity)
|
||||||
|
.preprocessEntity()
|
||||||
|
.validateEntity()
|
||||||
|
.prepareForSave()
|
||||||
|
.save()
|
||||||
|
.validateContext()
|
||||||
|
.mapUsing(e -> mapper.map(e, HsHostingAssetResource.class))
|
||||||
|
.revampProperties();
|
||||||
|
|
||||||
|
return ResponseEntity.ok(mapped);
|
||||||
|
}
|
||||||
|
|
||||||
|
final BiConsumer<HsHostingAssetInsertResource, HsHostingAssetRbacEntity> RESOURCE_TO_ENTITY_POSTMAPPER = (resource, entity) -> {
|
||||||
|
entity.putConfig(KeyValueMap.from(resource.getConfig()));
|
||||||
|
if (resource.getBookingItemUuid() != null) {
|
||||||
|
entity.setBookingItem(realBookingItemRepo.findByUuid(resource.getBookingItemUuid())
|
||||||
|
.orElseThrow(() -> new EntityNotFoundException("ERROR: [400] bookingItemUuid %s not found".formatted(
|
||||||
|
resource.getBookingItemUuid()))));
|
||||||
|
}
|
||||||
|
if (resource.getParentAssetUuid() != null) {
|
||||||
|
entity.setParentAsset(realAssetRepo.findByUuid(resource.getParentAssetUuid())
|
||||||
|
.orElseThrow(() -> new EntityNotFoundException("ERROR: [400] parentAssetUuid %s not found".formatted(
|
||||||
|
resource.getParentAssetUuid()))));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
final BiConsumer<HsHostingAssetRbacEntity, HsHostingAssetResource> ENTITY_TO_RESOURCE_POSTMAPPER = (entity, resource)
|
||||||
|
-> resource.setConfig(HostingAssetEntityValidatorRegistry.forType(entity.getType())
|
||||||
|
.revampProperties(emw, entity, (Map<String, Object>) resource.getConfig()));
|
||||||
|
}
|
@ -0,0 +1,35 @@
|
|||||||
|
package net.hostsharing.hsadminng.hs.hosting.asset;
|
||||||
|
|
||||||
|
import net.hostsharing.hsadminng.hs.hosting.generated.api.v1.model.HsHostingAssetPatchResource;
|
||||||
|
import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRealEntity;
|
||||||
|
import net.hostsharing.hsadminng.mapper.EntityPatcher;
|
||||||
|
import net.hostsharing.hsadminng.mapper.KeyValueMap;
|
||||||
|
import net.hostsharing.hsadminng.mapper.OptionalFromJson;
|
||||||
|
|
||||||
|
import jakarta.persistence.EntityManager;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public class HsHostingAssetEntityPatcher implements EntityPatcher<HsHostingAssetPatchResource> {
|
||||||
|
|
||||||
|
private final EntityManager em;
|
||||||
|
private final HsHostingAssetRbacEntity entity;
|
||||||
|
|
||||||
|
public HsHostingAssetEntityPatcher(final EntityManager em, final HsHostingAssetRbacEntity entity) {
|
||||||
|
this.em = em;
|
||||||
|
this.entity = entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void apply(final HsHostingAssetPatchResource resource) {
|
||||||
|
OptionalFromJson.of(resource.getCaption())
|
||||||
|
.ifPresent(entity::setCaption);
|
||||||
|
Optional.ofNullable(resource.getConfig())
|
||||||
|
.ifPresent(r -> entity.getConfig().patch(KeyValueMap.from(resource.getConfig())));
|
||||||
|
OptionalFromJson.of(resource.getAlarmContactUuid())
|
||||||
|
// HOWTO: patch nullable JSON resource uuid to an ntity reference
|
||||||
|
.ifPresent(newValue -> entity.setAlarmContact(
|
||||||
|
Optional.ofNullable(newValue)
|
||||||
|
.map(uuid -> em.getReference(HsOfficeContactRealEntity.class, newValue))
|
||||||
|
.orElse(null)));
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,40 @@
|
|||||||
|
package net.hostsharing.hsadminng.hs.hosting.asset;
|
||||||
|
|
||||||
|
import net.hostsharing.hsadminng.hs.hosting.asset.validators.HostingAssetEntityValidatorRegistry;
|
||||||
|
import net.hostsharing.hsadminng.hs.hosting.generated.api.v1.api.HsHostingAssetPropsApi;
|
||||||
|
import net.hostsharing.hsadminng.hs.hosting.generated.api.v1.model.HsHostingAssetTypeResource;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
public class HsHostingAssetPropsController implements HsHostingAssetPropsApi {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ResponseEntity<List<String>> listAssetTypes() {
|
||||||
|
final var resource = HostingAssetEntityValidatorRegistry.types().stream()
|
||||||
|
.map(Enum::name)
|
||||||
|
.toList();
|
||||||
|
return ResponseEntity.ok(resource);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ResponseEntity<List<Object>> listAssetTypeProps(
|
||||||
|
final HsHostingAssetTypeResource assetType) {
|
||||||
|
|
||||||
|
final Enum<HsHostingAssetType> type = HsHostingAssetType.of(assetType);
|
||||||
|
final var propValidators = HostingAssetEntityValidatorRegistry.forType(type);
|
||||||
|
final List<Map<String, Object>> resource = propValidators.properties();
|
||||||
|
return ResponseEntity.ok(toListOfObjects(resource));
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Object> toListOfObjects(final List<Map<String, Object>> resource) {
|
||||||
|
// OpenApi ony generates List<Object> not List<Map<String, Object>> for the Java interface.
|
||||||
|
// But Spring properly converts the List of Maps, thus we can simply cast the type:
|
||||||
|
//noinspection rawtypes,unchecked
|
||||||
|
return (List) resource;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,115 @@
|
|||||||
|
package net.hostsharing.hsadminng.hs.hosting.asset;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import lombok.Setter;
|
||||||
|
import lombok.experimental.SuperBuilder;
|
||||||
|
import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemRbacEntity;
|
||||||
|
import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRbacEntity;
|
||||||
|
import net.hostsharing.hsadminng.rbac.generator.RbacView;
|
||||||
|
import net.hostsharing.hsadminng.rbac.generator.RbacView.SQL;
|
||||||
|
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
import static net.hostsharing.hsadminng.rbac.generator.RbacView.CaseDef.inCaseOf;
|
||||||
|
import static net.hostsharing.hsadminng.rbac.generator.RbacView.Column.dependsOnColumn;
|
||||||
|
import static net.hostsharing.hsadminng.rbac.generator.RbacView.ColumnValue.usingDefaultCase;
|
||||||
|
import static net.hostsharing.hsadminng.rbac.generator.RbacView.GLOBAL;
|
||||||
|
import static net.hostsharing.hsadminng.rbac.generator.RbacView.Nullable.NULLABLE;
|
||||||
|
import static net.hostsharing.hsadminng.rbac.generator.RbacView.Permission.DELETE;
|
||||||
|
import static net.hostsharing.hsadminng.rbac.generator.RbacView.Permission.INSERT;
|
||||||
|
import static net.hostsharing.hsadminng.rbac.generator.RbacView.Permission.SELECT;
|
||||||
|
import static net.hostsharing.hsadminng.rbac.generator.RbacView.Permission.UPDATE;
|
||||||
|
import static net.hostsharing.hsadminng.rbac.generator.RbacView.RbacSubjectReference.UserRole.CREATOR;
|
||||||
|
import static net.hostsharing.hsadminng.rbac.generator.RbacView.Role.ADMIN;
|
||||||
|
import static net.hostsharing.hsadminng.rbac.generator.RbacView.Role.AGENT;
|
||||||
|
import static net.hostsharing.hsadminng.rbac.generator.RbacView.Role.GUEST;
|
||||||
|
import static net.hostsharing.hsadminng.rbac.generator.RbacView.Role.OWNER;
|
||||||
|
import static net.hostsharing.hsadminng.rbac.generator.RbacView.Role.REFERRER;
|
||||||
|
import static net.hostsharing.hsadminng.rbac.generator.RbacView.Role.TENANT;
|
||||||
|
import static net.hostsharing.hsadminng.rbac.generator.RbacView.SQL.directlyFetchedByDependsOnColumn;
|
||||||
|
import static net.hostsharing.hsadminng.rbac.generator.RbacView.rbacViewFor;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(schema = "hs_hosting", name = "asset_rv")
|
||||||
|
@SuperBuilder(toBuilder = true)
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@NoArgsConstructor
|
||||||
|
public class HsHostingAssetRbacEntity extends HsHostingAsset {
|
||||||
|
|
||||||
|
public static RbacView rbac() {
|
||||||
|
return rbacViewFor("asset", HsHostingAssetRbacEntity.class)
|
||||||
|
.withIdentityView(SQL.projection("identifier"))
|
||||||
|
.withRestrictedViewOrderBy(SQL.expression("identifier"))
|
||||||
|
.withUpdatableColumns("version", "caption", "config", "assignedToAssetUuid", "alarmContactUuid")
|
||||||
|
.toRole(GLOBAL, ADMIN).grantPermission(INSERT) // TODO.impl: Why is this necessary to insert test data?
|
||||||
|
|
||||||
|
.importEntityAlias("bookingItem", HsBookingItemRbacEntity.class, usingDefaultCase(),
|
||||||
|
dependsOnColumn("bookingItemUuid"),
|
||||||
|
directlyFetchedByDependsOnColumn(),
|
||||||
|
NULLABLE)
|
||||||
|
|
||||||
|
.importEntityAlias("parentAsset", HsHostingAssetRbacEntity.class, usingDefaultCase(),
|
||||||
|
dependsOnColumn("parentAssetUuid"),
|
||||||
|
directlyFetchedByDependsOnColumn(),
|
||||||
|
NULLABLE)
|
||||||
|
.toRole("parentAsset", ADMIN).grantPermission(INSERT)
|
||||||
|
|
||||||
|
.importEntityAlias("assignedToAsset", HsHostingAssetRbacEntity.class, usingDefaultCase(),
|
||||||
|
dependsOnColumn("assignedToAssetUuid"),
|
||||||
|
directlyFetchedByDependsOnColumn(),
|
||||||
|
NULLABLE)
|
||||||
|
|
||||||
|
.importEntityAlias("alarmContact", HsOfficeContactRbacEntity.class, usingDefaultCase(),
|
||||||
|
dependsOnColumn("alarmContactUuid"),
|
||||||
|
directlyFetchedByDependsOnColumn(),
|
||||||
|
NULLABLE)
|
||||||
|
|
||||||
|
.switchOnColumn(
|
||||||
|
"type",
|
||||||
|
inCaseOf("DOMAIN_SETUP", then -> {
|
||||||
|
then.toRole(GLOBAL, GUEST).grantPermission(INSERT);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
.createRole(OWNER, (with) -> {
|
||||||
|
with.owningUser(CREATOR);
|
||||||
|
with.incomingSuperRole(GLOBAL, ADMIN).unassumed(); // TODO.spec: replace by a better solution
|
||||||
|
with.incomingSuperRole("bookingItem", ADMIN);
|
||||||
|
with.incomingSuperRole("parentAsset", ADMIN);
|
||||||
|
with.permission(DELETE);
|
||||||
|
})
|
||||||
|
.createSubRole(ADMIN, (with) -> {
|
||||||
|
with.incomingSuperRole("bookingItem", AGENT);
|
||||||
|
with.incomingSuperRole("parentAsset", AGENT);
|
||||||
|
with.permission(UPDATE);
|
||||||
|
})
|
||||||
|
.createSubRole(AGENT, (with) -> {
|
||||||
|
with.incomingSuperRole("assignedToAsset", AGENT); // TODO.spec: or ADMIN?
|
||||||
|
with.outgoingSubRole("assignedToAsset", TENANT);
|
||||||
|
with.outgoingSubRole("alarmContact", REFERRER);
|
||||||
|
})
|
||||||
|
.createSubRole(TENANT, (with) -> {
|
||||||
|
with.outgoingSubRole("bookingItem", TENANT);
|
||||||
|
with.outgoingSubRole("parentAsset", TENANT);
|
||||||
|
with.incomingSuperRole("alarmContact", ADMIN);
|
||||||
|
with.permission(SELECT);
|
||||||
|
})
|
||||||
|
|
||||||
|
.limitDiagramTo(
|
||||||
|
"asset",
|
||||||
|
"bookingItem",
|
||||||
|
"bookingItem.debitorRel",
|
||||||
|
"parentAsset",
|
||||||
|
"assignedToAsset",
|
||||||
|
"alarmContact",
|
||||||
|
"rbac.global");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void main(String[] args) throws IOException {
|
||||||
|
rbac().generateWithBaseFileName("7-hs-hosting/701-hosting-asset/7013-hs-hosting-asset-rbac");
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,47 @@
|
|||||||
|
package net.hostsharing.hsadminng.hs.hosting.asset;
|
||||||
|
|
||||||
|
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 HsHostingAssetRbacRepository extends HsHostingAssetRepository<HsHostingAssetRbacEntity>, Repository<HsHostingAssetRbacEntity, UUID> {
|
||||||
|
|
||||||
|
Optional<HsHostingAssetRbacEntity> findByUuid(final UUID serverUuid);
|
||||||
|
|
||||||
|
List<HsHostingAssetRbacEntity> findByIdentifier(String assetIdentifier);
|
||||||
|
|
||||||
|
@Query(value = """
|
||||||
|
select ha.uuid,
|
||||||
|
ha.alarmcontactuuid,
|
||||||
|
ha.assignedtoassetuuid,
|
||||||
|
ha.bookingitemuuid,
|
||||||
|
ha.caption,
|
||||||
|
ha.config,
|
||||||
|
ha.identifier,
|
||||||
|
ha.parentassetuuid,
|
||||||
|
ha.type,
|
||||||
|
ha.version
|
||||||
|
from hs_hosting.asset_rv ha
|
||||||
|
left join hs_booking.item bi on bi.uuid = ha.bookingitemuuid
|
||||||
|
left join hs_hosting.asset pha on pha.uuid = ha.parentassetuuid
|
||||||
|
where (:projectUuid is null or bi.projectuuid=:projectUuid)
|
||||||
|
and (:parentAssetUuid is null or pha.uuid=:parentAssetUuid)
|
||||||
|
and (:type is null or :type=cast(ha.type as text))
|
||||||
|
""", nativeQuery = true)
|
||||||
|
// The JPQL query did not generate "left join" but just "join".
|
||||||
|
// I also optimized the query by not using the _rv for hs_booking.item and hs_hosting.asset, only for hs_hosting.asset_rv.
|
||||||
|
List<HsHostingAssetRbacEntity> findAllByCriteriaImpl(UUID projectUuid, UUID parentAssetUuid, String type);
|
||||||
|
default List<HsHostingAssetRbacEntity> findAllByCriteria(final UUID projectUuid, final UUID parentAssetUuid, final HsHostingAssetType type) {
|
||||||
|
return findAllByCriteriaImpl(projectUuid, parentAssetUuid, HsHostingAssetType.asString(type));
|
||||||
|
}
|
||||||
|
|
||||||
|
HsHostingAssetRbacEntity save(HsHostingAsset current);
|
||||||
|
|
||||||
|
int deleteByUuid(final UUID uuid);
|
||||||
|
|
||||||
|
long count();
|
||||||
|
}
|
@ -0,0 +1,24 @@
|
|||||||
|
package net.hostsharing.hsadminng.hs.hosting.asset;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import lombok.Setter;
|
||||||
|
import lombok.experimental.SuperBuilder;
|
||||||
|
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(schema = "hs_hosting", name = "asset")
|
||||||
|
@SuperBuilder(builderMethodName = "genericBuilder", toBuilder = true)
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@NoArgsConstructor
|
||||||
|
public class HsHostingAssetRealEntity extends HsHostingAsset {
|
||||||
|
|
||||||
|
// without this wrapper method, the builder returns a generic entity which cannot resolved in a generic context
|
||||||
|
public static HsHostingAssetRealEntityBuilder<HsHostingAssetRealEntity, ?> builder() {
|
||||||
|
//noinspection unchecked
|
||||||
|
return (HsHostingAssetRealEntityBuilder<HsHostingAssetRealEntity, ?>) genericBuilder();
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,59 @@
|
|||||||
|
package net.hostsharing.hsadminng.hs.hosting.asset;
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
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 HsHostingAssetRealRepository extends HsHostingAssetRepository<HsHostingAssetRealEntity>, Repository<HsHostingAssetRealEntity, UUID> {
|
||||||
|
|
||||||
|
Optional<HsHostingAssetRealEntity> findByUuid(final UUID serverUuid);
|
||||||
|
|
||||||
|
List<HsHostingAssetRealEntity> findByIdentifier(String assetIdentifier);
|
||||||
|
|
||||||
|
default List<HsHostingAssetRealEntity> findByTypeAndIdentifier(@NotNull HsHostingAssetType type, @NotNull String identifier) {
|
||||||
|
return findByTypeAndIdentifierImpl(type.name(), identifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Query("""
|
||||||
|
select ha
|
||||||
|
from HsHostingAssetRealEntity ha
|
||||||
|
where cast(ha.type as String) = :type
|
||||||
|
and ha.identifier = :identifier
|
||||||
|
""")
|
||||||
|
List<HsHostingAssetRealEntity> findByTypeAndIdentifierImpl(@NotNull String type, @NotNull String identifier);
|
||||||
|
|
||||||
|
@Query(value = """
|
||||||
|
select ha.uuid,
|
||||||
|
ha.alarmcontactuuid,
|
||||||
|
ha.assignedtoassetuuid,
|
||||||
|
ha.bookingitemuuid,
|
||||||
|
ha.caption,
|
||||||
|
ha.config,
|
||||||
|
ha.identifier,
|
||||||
|
ha.parentassetuuid,
|
||||||
|
ha.type,
|
||||||
|
ha.version
|
||||||
|
from hs_hosting.asset_rv ha
|
||||||
|
left join hs_booking.item bi on bi.uuid = ha.bookingitemuuid
|
||||||
|
left join hs_hosting.asset pha on pha.uuid = ha.parentassetuuid
|
||||||
|
where (:projectUuid is null or bi.projectuuid=:projectUuid)
|
||||||
|
and (:parentAssetUuid is null or pha.uuid=:parentAssetUuid)
|
||||||
|
and (:type is null or :type=cast(ha.type as text))
|
||||||
|
""", nativeQuery = true)
|
||||||
|
// The JPQL query did not generate "left join" but just "join".
|
||||||
|
// I also optimized the query by not using the _rv for hs_booking.item and hs_hosting.asset, only for hs_hosting.asset_rv.
|
||||||
|
List<HsHostingAssetRealEntity> findAllByCriteriaImpl(UUID projectUuid, UUID parentAssetUuid, String type);
|
||||||
|
default List<HsHostingAssetRealEntity> findAllByCriteria(final UUID projectUuid, final UUID parentAssetUuid, final HsHostingAssetType type) {
|
||||||
|
return findAllByCriteriaImpl(projectUuid, parentAssetUuid, HsHostingAssetType.asString(type));
|
||||||
|
}
|
||||||
|
|
||||||
|
HsHostingAssetRealEntity save(HsHostingAssetRealEntity current);
|
||||||
|
|
||||||
|
int deleteByUuid(final UUID uuid);
|
||||||
|
|
||||||
|
long count();
|
||||||
|
}
|
@ -0,0 +1,24 @@
|
|||||||
|
package net.hostsharing.hsadminng.hs.hosting.asset;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public interface HsHostingAssetRepository<E extends HsHostingAsset> {
|
||||||
|
|
||||||
|
Optional<E> findByUuid(final UUID serverUuid);
|
||||||
|
|
||||||
|
List<E> findByIdentifier(String assetIdentifier);
|
||||||
|
|
||||||
|
List<E> findAllByCriteriaImpl(UUID projectUuid, UUID parentAssetUuid, String type);
|
||||||
|
|
||||||
|
default List<E> findAllByCriteria(final UUID projectUuid, final UUID parentAssetUuid, final HsHostingAssetType type) {
|
||||||
|
return findAllByCriteriaImpl(projectUuid, parentAssetUuid, HsHostingAssetType.asString(type));
|
||||||
|
}
|
||||||
|
|
||||||
|
E save(HsHostingAsset current);
|
||||||
|
|
||||||
|
int deleteByUuid(final UUID uuid);
|
||||||
|
|
||||||
|
long count();
|
||||||
|
}
|
@ -0,0 +1,444 @@
|
|||||||
|
package net.hostsharing.hsadminng.hs.hosting.asset;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import net.hostsharing.hsadminng.hs.booking.item.HsBookingItem;
|
||||||
|
import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType;
|
||||||
|
import net.hostsharing.hsadminng.hs.booking.item.Node;
|
||||||
|
|
||||||
|
import javax.naming.NamingException;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.StandardOpenOption;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.function.Function;
|
||||||
|
|
||||||
|
import static java.util.Arrays.stream;
|
||||||
|
import static java.util.stream.Collectors.joining;
|
||||||
|
import static java.util.stream.Collectors.toSet;
|
||||||
|
import static net.hostsharing.hsadminng.hs.hosting.asset.EntityTypeRelation.assignedTo;
|
||||||
|
import static net.hostsharing.hsadminng.hs.hosting.asset.EntityTypeRelation.optionalParent;
|
||||||
|
import static net.hostsharing.hsadminng.hs.hosting.asset.EntityTypeRelation.optionallyAssignedTo;
|
||||||
|
import static net.hostsharing.hsadminng.hs.hosting.asset.EntityTypeRelation.requiredParent;
|
||||||
|
import static net.hostsharing.hsadminng.hs.hosting.asset.EntityTypeRelation.requires;
|
||||||
|
import static net.hostsharing.hsadminng.hs.hosting.asset.EntityTypeRelation.terminatory;
|
||||||
|
import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.RelationPolicy.OPTIONAL;
|
||||||
|
import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.RelationPolicy.REQUIRED;
|
||||||
|
import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.RelationPolicy.TERMINATORY;
|
||||||
|
import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.RelationType.ASSIGNED_TO_ASSET;
|
||||||
|
import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.RelationType.BOOKING_ITEM;
|
||||||
|
import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.RelationType.PARENT_ASSET;
|
||||||
|
|
||||||
|
public enum HsHostingAssetType implements Node {
|
||||||
|
SAME_TYPE, // pseudo-type for recursive references
|
||||||
|
|
||||||
|
CLOUD_SERVER( // named e.g. vm1234
|
||||||
|
inGroup("Server"),
|
||||||
|
requires(HsBookingItemType.CLOUD_SERVER)),
|
||||||
|
|
||||||
|
MANAGED_SERVER( // named e.g. vm1234
|
||||||
|
inGroup("Server"),
|
||||||
|
requires(HsBookingItemType.MANAGED_SERVER)),
|
||||||
|
|
||||||
|
MANAGED_WEBSPACE( // named eg. xyz00
|
||||||
|
inGroup("Webspace"),
|
||||||
|
requires(HsBookingItemType.MANAGED_WEBSPACE),
|
||||||
|
optionalParent(MANAGED_SERVER)),
|
||||||
|
|
||||||
|
UNIX_USER( // named e.g. xyz00-abc
|
||||||
|
inGroup("Webspace"),
|
||||||
|
requiredParent(MANAGED_WEBSPACE)),
|
||||||
|
|
||||||
|
// TODO.spec: do we really want to keep email aliases or migrate to unix users with .forward?
|
||||||
|
EMAIL_ALIAS( // named e.g. xyz00-abc
|
||||||
|
inGroup("Webspace"),
|
||||||
|
requiredParent(MANAGED_WEBSPACE)),
|
||||||
|
|
||||||
|
DOMAIN_SETUP( // named e.g. example.org
|
||||||
|
inGroup("Domain"),
|
||||||
|
terminatory(HsBookingItemType.DOMAIN_SETUP),
|
||||||
|
optionalParent(SAME_TYPE)
|
||||||
|
),
|
||||||
|
|
||||||
|
DOMAIN_DNS_SETUP( // named e.g. example.org
|
||||||
|
inGroup("Domain"),
|
||||||
|
requiredParent(DOMAIN_SETUP),
|
||||||
|
assignedTo(MANAGED_WEBSPACE)),
|
||||||
|
|
||||||
|
DOMAIN_HTTP_SETUP( // named e.g. example.org
|
||||||
|
inGroup("Domain"),
|
||||||
|
requiredParent(DOMAIN_SETUP),
|
||||||
|
assignedTo(UNIX_USER)),
|
||||||
|
|
||||||
|
DOMAIN_SMTP_SETUP( // named e.g. example.org
|
||||||
|
inGroup("Domain"),
|
||||||
|
requiredParent(DOMAIN_SETUP),
|
||||||
|
assignedTo(MANAGED_WEBSPACE)),
|
||||||
|
|
||||||
|
DOMAIN_MBOX_SETUP( // named e.g. example.org
|
||||||
|
inGroup("Domain"),
|
||||||
|
requiredParent(DOMAIN_SETUP),
|
||||||
|
assignedTo(MANAGED_WEBSPACE)),
|
||||||
|
|
||||||
|
// TODO.spec: SECURE_MX
|
||||||
|
|
||||||
|
EMAIL_ADDRESS( // named e.g. sample@example.org
|
||||||
|
inGroup("Domain"),
|
||||||
|
requiredParent(DOMAIN_MBOX_SETUP)),
|
||||||
|
|
||||||
|
PGSQL_INSTANCE( // TODO.spec: identifier to be specified
|
||||||
|
inGroup("PostgreSQL"),
|
||||||
|
requiredParent(MANAGED_SERVER)), // TODO.spec: or MANAGED_WEBSPACE?
|
||||||
|
|
||||||
|
PGSQL_USER( // named e.g. xyz00_abc
|
||||||
|
inGroup("PostgreSQL"),
|
||||||
|
requiredParent(MANAGED_WEBSPACE), // thus, the MANAGED_WEBSPACE:Agent becomes RBAC owner
|
||||||
|
assignedTo(PGSQL_INSTANCE)), // keep in mind: no RBAC grants implied
|
||||||
|
|
||||||
|
PGSQL_DATABASE( // named e.g. xyz00_abc
|
||||||
|
inGroup("PostgreSQL"),
|
||||||
|
requiredParent(PGSQL_USER)), // thus, the PGSQL_USER_USER:Agent becomes RBAC owner
|
||||||
|
|
||||||
|
MARIADB_INSTANCE( // TODO.spec: identifier to be specified
|
||||||
|
inGroup("MariaDB"),
|
||||||
|
requiredParent(MANAGED_SERVER)), // TODO.spec: or MANAGED_WEBSPACE?
|
||||||
|
|
||||||
|
MARIADB_USER( // named e.g. xyz00_abc
|
||||||
|
inGroup("MariaDB"),
|
||||||
|
requiredParent(MANAGED_WEBSPACE), // thus, the MANAGED_WEBSPACE:Agent becomes RBAC owner
|
||||||
|
assignedTo(MARIADB_INSTANCE)),
|
||||||
|
|
||||||
|
MARIADB_DATABASE( // named e.g. xyz00_abc
|
||||||
|
inGroup("MariaDB"),
|
||||||
|
requiredParent(MARIADB_USER)), // thus, the MARIADB_USER:Agent becomes RBAC owner
|
||||||
|
|
||||||
|
IPV4_NUMBER(
|
||||||
|
inGroup("Server"),
|
||||||
|
optionallyAssignedTo(CLOUD_SERVER).or(MANAGED_SERVER).or(MANAGED_WEBSPACE)
|
||||||
|
),
|
||||||
|
|
||||||
|
IPV6_NUMBER(
|
||||||
|
inGroup("Server"),
|
||||||
|
optionallyAssignedTo(CLOUD_SERVER).or(MANAGED_SERVER).or(MANAGED_WEBSPACE)
|
||||||
|
);
|
||||||
|
|
||||||
|
private final String groupName;
|
||||||
|
private final EntityTypeRelation<?, ?>[] relations;
|
||||||
|
|
||||||
|
HsHostingAssetType(
|
||||||
|
final String groupName,
|
||||||
|
final EntityTypeRelation<?, ?>... relations
|
||||||
|
) {
|
||||||
|
this.groupName = groupName;
|
||||||
|
this.relations = relations;
|
||||||
|
}
|
||||||
|
|
||||||
|
HsHostingAssetType() {
|
||||||
|
this.groupName = null;
|
||||||
|
this.relations = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// just syntactic sugar
|
||||||
|
private static String inGroup(final String groupName) {
|
||||||
|
return groupName;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO.refa: try to get rid of the following similar methods:
|
||||||
|
|
||||||
|
public RelationPolicy bookingItemPolicy() {
|
||||||
|
return stream(relations)
|
||||||
|
.filter(r -> r.relationType == BOOKING_ITEM)
|
||||||
|
.map(r -> r.relationPolicy)
|
||||||
|
.reduce(HsHostingAssetType::onlyASingleElementExpectedException)
|
||||||
|
.orElse(RelationPolicy.FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Set<HsBookingItemType> bookingItemTypes() {
|
||||||
|
return stream(relations)
|
||||||
|
.filter(r -> r.relationType == BOOKING_ITEM)
|
||||||
|
.reduce(HsHostingAssetType::onlyASingleElementExpectedException)
|
||||||
|
.map(r -> r.relatedTypes(this))
|
||||||
|
.stream().flatMap(Set::stream)
|
||||||
|
.map(r -> (HsBookingItemType) r)
|
||||||
|
.collect(toSet());
|
||||||
|
}
|
||||||
|
|
||||||
|
public RelationPolicy parentAssetPolicy() {
|
||||||
|
return stream(relations)
|
||||||
|
.filter(r -> r.relationType == PARENT_ASSET)
|
||||||
|
.reduce(HsHostingAssetType::onlyASingleElementExpectedException)
|
||||||
|
.map(r -> r.relationPolicy)
|
||||||
|
.orElse(RelationPolicy.FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Set<HsHostingAssetType> parentAssetTypes() {
|
||||||
|
return stream(relations)
|
||||||
|
.filter(r -> r.relationType == PARENT_ASSET)
|
||||||
|
.reduce(HsHostingAssetType::onlyASingleElementExpectedException)
|
||||||
|
.map(r -> r.relatedTypes(this))
|
||||||
|
.stream().flatMap(Set::stream)
|
||||||
|
.map(r -> (HsHostingAssetType) r)
|
||||||
|
.collect(toSet());
|
||||||
|
}
|
||||||
|
|
||||||
|
public RelationPolicy assignedToAssetPolicy() {
|
||||||
|
return stream(relations)
|
||||||
|
.filter(r -> r.relationType == ASSIGNED_TO_ASSET)
|
||||||
|
.reduce(HsHostingAssetType::onlyASingleElementExpectedException)
|
||||||
|
.map(r -> r.relationPolicy)
|
||||||
|
.orElse(RelationPolicy.FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Set<HsHostingAssetType> assignedToAssetTypes() {
|
||||||
|
return stream(relations)
|
||||||
|
.filter(r -> r.relationType == ASSIGNED_TO_ASSET)
|
||||||
|
.reduce(HsHostingAssetType::onlyASingleElementExpectedException)
|
||||||
|
.map(r -> r.relatedTypes(this))
|
||||||
|
.stream().flatMap(Set::stream)
|
||||||
|
.map(r -> (HsHostingAssetType) r)
|
||||||
|
.collect(toSet());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static <X> X onlyASingleElementExpectedException(Object a, Object b) {
|
||||||
|
throw new IllegalStateException("Only a single element expected to match criteria.");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<String> edges(final Set<String> inGroups) {
|
||||||
|
return stream(relations)
|
||||||
|
.map(r -> r.relatedTypes(this).stream()
|
||||||
|
.filter(x -> x.belongsToAny(inGroups))
|
||||||
|
.map(x -> nodeName() + r.edge + x.nodeName())
|
||||||
|
.toList())
|
||||||
|
.flatMap(List::stream)
|
||||||
|
.sorted()
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean belongsToAny(final Set<String> groups) {
|
||||||
|
return groups.contains(this.groupName);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String nodeName() {
|
||||||
|
return "HA_" + name();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static <T extends Enum<?>> HsHostingAssetType of(final T value) {
|
||||||
|
return value == null ? null : valueOf(value.name());
|
||||||
|
}
|
||||||
|
|
||||||
|
static String asString(final HsHostingAssetType type) {
|
||||||
|
return type == null ? null : type.name();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String renderAsPlantUML(final String caption, final Set<String> includedHostingGroups) {
|
||||||
|
final String bookingNodes = stream(HsBookingItemType.values())
|
||||||
|
.map(t -> " entity " + t.nodeName())
|
||||||
|
.collect(joining("\n"));
|
||||||
|
final String hostingGroups = includedHostingGroups.stream().sorted()
|
||||||
|
.map(HsHostingAssetType::generateGroup)
|
||||||
|
.collect(joining("\n"));
|
||||||
|
final String hostingAssetNodes = stream(HsHostingAssetType.values())
|
||||||
|
.filter(t -> t.isInGroups(includedHostingGroups))
|
||||||
|
.map(t -> "entity " + t.nodeName())
|
||||||
|
.collect(joining("\n"));
|
||||||
|
final String bookingItemEdges = stream(HsBookingItemType.values())
|
||||||
|
.map(t -> t.edges(includedHostingGroups))
|
||||||
|
.flatMap(Collection::stream)
|
||||||
|
.collect(joining("\n"));
|
||||||
|
final String hostingAssetEdges = stream(HsHostingAssetType.values())
|
||||||
|
.filter(t -> t.isInGroups(includedHostingGroups))
|
||||||
|
.map(t -> t.edges(includedHostingGroups))
|
||||||
|
.flatMap(Collection::stream)
|
||||||
|
.collect(joining("\n"));
|
||||||
|
return """
|
||||||
|
|
||||||
|
### %{caption}
|
||||||
|
|
||||||
|
```plantuml
|
||||||
|
@startuml
|
||||||
|
left to right direction
|
||||||
|
|
||||||
|
package Booking #feb28c {
|
||||||
|
%{bookingNodes}
|
||||||
|
}
|
||||||
|
|
||||||
|
package Hosting #feb28c{
|
||||||
|
%{hostingGroups}
|
||||||
|
}
|
||||||
|
|
||||||
|
%{bookingItemEdges}
|
||||||
|
|
||||||
|
%{hostingAssetEdges}
|
||||||
|
|
||||||
|
package Legend #white {
|
||||||
|
SUB_ENTITY1 *--> REQUIRED_PARENT_ENTITY
|
||||||
|
SUB_ENTITY2 *..> OPTIONAL_PARENT_ENTITY
|
||||||
|
ASSIGNED_ENTITY1 o--> REQUIRED_ASSIGNED_TO_ENTITY1
|
||||||
|
ASSIGNED_ENTITY2 o..> OPTIONAL_ASSIGNED_TO_ENTITY2
|
||||||
|
}
|
||||||
|
Booking -down[hidden]->Legend
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
.replace("%{caption}", caption)
|
||||||
|
.replace("%{bookingNodes}", bookingNodes)
|
||||||
|
.replace("%{hostingGroups}", hostingGroups)
|
||||||
|
.replace("%{hostingAssetNodeStyles}", hostingAssetNodes)
|
||||||
|
.replace("%{bookingItemEdges}", bookingItemEdges)
|
||||||
|
.replace("%{hostingAssetEdges}", hostingAssetEdges);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isInGroups(final Set<String> assetGroups) {
|
||||||
|
return groupName != null && assetGroups.contains(groupName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String generateGroup(final String group) {
|
||||||
|
return " package " + group + " #99bcdb {\n"
|
||||||
|
+ stream(HsHostingAssetType.values())
|
||||||
|
.filter(t -> group.equals(t.groupName))
|
||||||
|
.map(t -> " entity " + t.nodeName())
|
||||||
|
.collect(joining("\n"))
|
||||||
|
+ "\n }\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
static String renderAsEmbeddedPlantUml() {
|
||||||
|
|
||||||
|
final var markdown = new StringBuilder("""
|
||||||
|
## HostingAsset Type Structure
|
||||||
|
|
||||||
|
""");
|
||||||
|
|
||||||
|
// rendering all types in a single diagram is currently ignored
|
||||||
|
renderAsPlantUML("Domain", stream(HsHostingAssetType.values())
|
||||||
|
.filter(t -> t.groupName != null)
|
||||||
|
.map(t -> t.groupName)
|
||||||
|
.collect(toSet()));
|
||||||
|
|
||||||
|
markdown
|
||||||
|
.append(renderAsPlantUML("Server+Webspace", Set.of("Server", "Webspace")))
|
||||||
|
.append(renderAsPlantUML("Domain", Set.of("Domain", "Webspace")))
|
||||||
|
.append(renderAsPlantUML("MariaDB", Set.of("MariaDB", "Webspace")))
|
||||||
|
.append(renderAsPlantUML("PostgreSQL", Set.of("PostgreSQL", "Webspace")));
|
||||||
|
|
||||||
|
markdown.append("""
|
||||||
|
|
||||||
|
This code generated was by %{this}.main, do not amend manually.
|
||||||
|
"""
|
||||||
|
.replace("%{this}", HsHostingAssetType.class.getSimpleName()));
|
||||||
|
|
||||||
|
return markdown.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void main(final String[] args) throws IOException, NamingException {
|
||||||
|
Files.writeString(
|
||||||
|
Path.of("doc/hs-hosting-asset-type-structure.md"),
|
||||||
|
renderAsEmbeddedPlantUml(),
|
||||||
|
StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum RelationPolicy {
|
||||||
|
FORBIDDEN, OPTIONAL, TERMINATORY, REQUIRED
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum RelationType {
|
||||||
|
BOOKING_ITEM,
|
||||||
|
PARENT_ASSET,
|
||||||
|
ASSIGNED_TO_ASSET
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@AllArgsConstructor
|
||||||
|
class EntityTypeRelation<E, T extends Node> {
|
||||||
|
|
||||||
|
final HsHostingAssetType.RelationPolicy relationPolicy;
|
||||||
|
final HsHostingAssetType.RelationType relationType;
|
||||||
|
final Function<HsHostingAssetRbacEntity, E> getter;
|
||||||
|
private final List<T> acceptedRelatedTypes;
|
||||||
|
final String edge;
|
||||||
|
|
||||||
|
private EntityTypeRelation(
|
||||||
|
final HsHostingAssetType.RelationPolicy relationPolicy,
|
||||||
|
final HsHostingAssetType.RelationType relationType,
|
||||||
|
final Function<HsHostingAssetRbacEntity, E> getter,
|
||||||
|
final T acceptedRelatedType,
|
||||||
|
final String edge
|
||||||
|
) {
|
||||||
|
this(relationPolicy, relationType, getter, modifiyableListOf(acceptedRelatedType), edge);
|
||||||
|
}
|
||||||
|
|
||||||
|
public <R extends Node> Set<R> relatedTypes(final HsHostingAssetType referringType) {
|
||||||
|
final Set<Node> result = acceptedRelatedTypes.stream()
|
||||||
|
.map(t -> t == HsHostingAssetType.SAME_TYPE ? referringType : t)
|
||||||
|
.collect(toSet());
|
||||||
|
//noinspection unchecked
|
||||||
|
return (Set<R>) result;
|
||||||
|
}
|
||||||
|
|
||||||
|
static EntityTypeRelation<HsBookingItem, HsBookingItemType> terminatory(final HsBookingItemType bookingItemType) {
|
||||||
|
return new EntityTypeRelation<>(
|
||||||
|
TERMINATORY,
|
||||||
|
BOOKING_ITEM,
|
||||||
|
HsHostingAssetRbacEntity::getBookingItem,
|
||||||
|
bookingItemType,
|
||||||
|
" *..> ");
|
||||||
|
}
|
||||||
|
|
||||||
|
static EntityTypeRelation<HsBookingItem, HsBookingItemType> requires(final HsBookingItemType bookingItemType) {
|
||||||
|
return new EntityTypeRelation<>(
|
||||||
|
REQUIRED,
|
||||||
|
BOOKING_ITEM,
|
||||||
|
HsHostingAssetRbacEntity::getBookingItem,
|
||||||
|
bookingItemType,
|
||||||
|
" *==> ");
|
||||||
|
}
|
||||||
|
|
||||||
|
static EntityTypeRelation<HsHostingAsset, HsHostingAssetType> optionalParent(final HsHostingAssetType hostingAssetType) {
|
||||||
|
return new EntityTypeRelation<>(
|
||||||
|
OPTIONAL,
|
||||||
|
PARENT_ASSET,
|
||||||
|
HsHostingAsset::getParentAsset,
|
||||||
|
hostingAssetType,
|
||||||
|
" o..> ");
|
||||||
|
}
|
||||||
|
|
||||||
|
static EntityTypeRelation<HsHostingAsset, HsHostingAssetType> requiredParent(final HsHostingAssetType hostingAssetType) {
|
||||||
|
return new EntityTypeRelation<>(
|
||||||
|
REQUIRED,
|
||||||
|
PARENT_ASSET,
|
||||||
|
HsHostingAsset::getParentAsset,
|
||||||
|
hostingAssetType,
|
||||||
|
" *==> ");
|
||||||
|
}
|
||||||
|
|
||||||
|
static EntityTypeRelation<HsHostingAsset, HsHostingAssetType> assignedTo(final HsHostingAssetType hostingAssetType) {
|
||||||
|
return new EntityTypeRelation<>(
|
||||||
|
REQUIRED,
|
||||||
|
ASSIGNED_TO_ASSET,
|
||||||
|
HsHostingAsset::getAssignedToAsset,
|
||||||
|
hostingAssetType,
|
||||||
|
" o--> ");
|
||||||
|
}
|
||||||
|
|
||||||
|
EntityTypeRelation<E, T> or(final T alternativeHostingAssetType) {
|
||||||
|
acceptedRelatedTypes.add(alternativeHostingAssetType);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
static EntityTypeRelation<HsHostingAsset, HsHostingAssetType> optionallyAssignedTo(final HsHostingAssetType hostingAssetType) {
|
||||||
|
return new EntityTypeRelation<>(
|
||||||
|
OPTIONAL,
|
||||||
|
ASSIGNED_TO_ASSET,
|
||||||
|
HsHostingAsset::getAssignedToAsset,
|
||||||
|
hostingAssetType,
|
||||||
|
" o..> ");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static <T extends Node> ArrayList<T> modifiyableListOf(final T acceptedRelatedType) {
|
||||||
|
return new ArrayList<>(List.of(acceptedRelatedType));
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,159 @@
|
|||||||
|
package net.hostsharing.hsadminng.hs.hosting.asset.factories;
|
||||||
|
|
||||||
|
import net.hostsharing.hsadminng.hs.booking.generated.api.v1.model.HsHostingAssetAutoInsertResource;
|
||||||
|
import net.hostsharing.hsadminng.hs.booking.generated.api.v1.model.HsHostingAssetSubInsertResource;
|
||||||
|
import net.hostsharing.hsadminng.hs.booking.generated.api.v1.model.HsHostingAssetTypeResource;
|
||||||
|
import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemRealEntity;
|
||||||
|
import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAsset;
|
||||||
|
import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetRealEntity;
|
||||||
|
import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType;
|
||||||
|
import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRealEntity;
|
||||||
|
import net.hostsharing.hsadminng.lambda.Reducer;
|
||||||
|
import net.hostsharing.hsadminng.mapper.StandardMapper;
|
||||||
|
import net.hostsharing.hsadminng.mapper.ToStringConverter;
|
||||||
|
import net.hostsharing.hsadminng.persistence.EntityManagerWrapper;
|
||||||
|
|
||||||
|
import jakarta.validation.ValidationException;
|
||||||
|
import java.net.IDN;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.function.Function;
|
||||||
|
|
||||||
|
import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.DOMAIN_DNS_SETUP;
|
||||||
|
import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.DOMAIN_HTTP_SETUP;
|
||||||
|
import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.DOMAIN_MBOX_SETUP;
|
||||||
|
import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.DOMAIN_SMTP_SETUP;
|
||||||
|
|
||||||
|
public class DomainSetupHostingAssetFactory extends HostingAssetFactory {
|
||||||
|
|
||||||
|
public DomainSetupHostingAssetFactory(
|
||||||
|
final EntityManagerWrapper emw,
|
||||||
|
final HsBookingItemRealEntity newBookingItemRealEntity,
|
||||||
|
final HsHostingAssetAutoInsertResource asset,
|
||||||
|
final StandardMapper standardMapper) {
|
||||||
|
super(emw, newBookingItemRealEntity, asset, standardMapper);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected HsHostingAsset create() {
|
||||||
|
final var domainSetupAsset = createDomainSetupAsset(getDomainName());
|
||||||
|
final var subHostingAssets = domainSetupAsset.getSubHostingAssets();
|
||||||
|
|
||||||
|
// TODO.legacy: as long as we need to be compatible, we always do all technical domain-setups
|
||||||
|
|
||||||
|
final var domainHttpSetupAssetResource = findSubHostingAssetResource(HsHostingAssetTypeResource.DOMAIN_HTTP_SETUP);
|
||||||
|
final var assignedToUnixUserAssetEntity = domainHttpSetupAssetResource
|
||||||
|
.map(HsHostingAssetSubInsertResource::getAssignedToAssetUuid)
|
||||||
|
.map(uuid -> emw.find(HsHostingAssetRealEntity.class, uuid))
|
||||||
|
.orElseThrow(() -> new ValidationException("DOMAIN_HTTP_SETUP subAsset with assignedToAssetUuid required in compatibility mode"));
|
||||||
|
|
||||||
|
subHostingAssets.add(
|
||||||
|
createDomainSubSetupAssetEntity(
|
||||||
|
domainSetupAsset,
|
||||||
|
DOMAIN_HTTP_SETUP,
|
||||||
|
builder -> builder
|
||||||
|
.assignedToAsset(assignedToUnixUserAssetEntity)
|
||||||
|
.identifier(getDomainName() + "|HTTP")
|
||||||
|
.caption("HTTP-Setup für " + IDN.toUnicode(getDomainName())))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Do not add to subHostingAssets in compatibility mode, in this case, DNS setup works via file system.
|
||||||
|
// The entity is created just for validation purposes.
|
||||||
|
createDomainSubSetupAssetEntity(
|
||||||
|
domainSetupAsset,
|
||||||
|
DOMAIN_DNS_SETUP,
|
||||||
|
builder -> builder
|
||||||
|
.assignedToAsset(assignedToUnixUserAssetEntity.getParentAsset())
|
||||||
|
.identifier(getDomainName() + "|DNS")
|
||||||
|
.caption("DNS-Setup für " + IDN.toUnicode(getDomainName())));
|
||||||
|
|
||||||
|
subHostingAssets.add(
|
||||||
|
createDomainSubSetupAssetEntity(
|
||||||
|
domainSetupAsset,
|
||||||
|
DOMAIN_MBOX_SETUP,
|
||||||
|
builder -> builder
|
||||||
|
.assignedToAsset(assignedToUnixUserAssetEntity.getParentAsset())
|
||||||
|
.identifier(getDomainName() + "|MBOX")
|
||||||
|
.caption("MBOX-Setup für " + IDN.toUnicode(getDomainName())))
|
||||||
|
);
|
||||||
|
|
||||||
|
subHostingAssets.add(
|
||||||
|
createDomainSubSetupAssetEntity(
|
||||||
|
domainSetupAsset,
|
||||||
|
DOMAIN_SMTP_SETUP,
|
||||||
|
builder -> builder
|
||||||
|
.assignedToAsset(assignedToUnixUserAssetEntity.getParentAsset())
|
||||||
|
.identifier(getDomainName() + "|SMTP")
|
||||||
|
.caption("SMTP-Setup für " + IDN.toUnicode(getDomainName())))
|
||||||
|
);
|
||||||
|
|
||||||
|
return domainSetupAsset;
|
||||||
|
}
|
||||||
|
|
||||||
|
private HsHostingAssetRealEntity createDomainSetupAsset(final String domainName) {
|
||||||
|
return HsHostingAssetRealEntity.builder()
|
||||||
|
.bookingItem(fromBookingItem)
|
||||||
|
.type(HsHostingAssetType.DOMAIN_SETUP)
|
||||||
|
.identifier(domainName)
|
||||||
|
.caption(asset.getCaption() != null ? asset.getCaption() : domainName)
|
||||||
|
.alarmContact(ref(HsOfficeContactRealEntity.class, asset.getAlarmContactUuid()))
|
||||||
|
// the sub-hosting-assets get added later
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private HsHostingAssetRealEntity createDomainSubSetupAssetEntity(
|
||||||
|
final HsHostingAssetRealEntity domainSetupAsset,
|
||||||
|
final HsHostingAssetType subAssetType,
|
||||||
|
final Function<HsHostingAssetRealEntity.HsHostingAssetRealEntityBuilder<?, ?>, HsHostingAssetRealEntity.HsHostingAssetRealEntityBuilder<?, ?>> builderTransformer) {
|
||||||
|
final var resourceType = HsHostingAssetTypeResource.valueOf(subAssetType.name());
|
||||||
|
|
||||||
|
final var subAssetResourceOptional = findSubHostingAssetResource(resourceType);
|
||||||
|
|
||||||
|
subAssetResourceOptional.ifPresentOrElse(
|
||||||
|
this::verifyNotOverspecified,
|
||||||
|
() -> { throw new ValidationException("sub-asset of type " + resourceType.name() + " required in legacy mode, but missing"); }
|
||||||
|
);
|
||||||
|
|
||||||
|
return builderTransformer.apply(
|
||||||
|
HsHostingAssetRealEntity.builder()
|
||||||
|
.type(subAssetType)
|
||||||
|
.parentAsset(domainSetupAsset))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Optional<HsHostingAssetSubInsertResource> findSubHostingAssetResource(final HsHostingAssetTypeResource resourceType) {
|
||||||
|
return getSubHostingAssetResources().stream()
|
||||||
|
.filter(ha -> ha.getType() == resourceType)
|
||||||
|
.reduce(Reducer::toSingleElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO.legacy: while we need to stay compatible, only default values can be used, thus only the type can be specified
|
||||||
|
private void verifyNotOverspecified(final HsHostingAssetSubInsertResource givenSubAssetResource) {
|
||||||
|
final var convert = new ToStringConverter().ignoring("assignedToAssetUuid");
|
||||||
|
final var expectedSubAssetResource = new HsHostingAssetSubInsertResource();
|
||||||
|
expectedSubAssetResource.setType(givenSubAssetResource.getType());
|
||||||
|
if ( !convert.from(givenSubAssetResource).equals(convert.from(expectedSubAssetResource)) ) {
|
||||||
|
throw new ValidationException("sub asset " + givenSubAssetResource.getType() + " is over-specified, in compatibility mode, only default values allowed");
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getDomainName() {
|
||||||
|
return asset.getIdentifier();
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<HsHostingAssetSubInsertResource> getSubHostingAssetResources() {
|
||||||
|
return asset.getSubHostingAssets();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void persist(final HsHostingAsset newHostingAsset) {
|
||||||
|
super.persist(newHostingAsset);
|
||||||
|
newHostingAsset.getSubHostingAssets().forEach(super::persist);
|
||||||
|
}
|
||||||
|
|
||||||
|
private <T> T ref(final Class<T> entityClass, final UUID uuid) {
|
||||||
|
return uuid != null ? emw.getReference(entityClass, uuid) : null;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,41 @@
|
|||||||
|
package net.hostsharing.hsadminng.hs.hosting.asset.factories;
|
||||||
|
|
||||||
|
import jakarta.validation.ValidationException;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import net.hostsharing.hsadminng.hs.booking.generated.api.v1.model.HsHostingAssetAutoInsertResource;
|
||||||
|
import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemRealEntity;
|
||||||
|
import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAsset;
|
||||||
|
import net.hostsharing.hsadminng.hs.hosting.asset.validators.HostingAssetEntitySaveProcessor;
|
||||||
|
import net.hostsharing.hsadminng.mapper.StandardMapper;
|
||||||
|
import net.hostsharing.hsadminng.persistence.EntityManagerWrapper;
|
||||||
|
|
||||||
|
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
abstract class HostingAssetFactory {
|
||||||
|
|
||||||
|
final EntityManagerWrapper emw;
|
||||||
|
final HsBookingItemRealEntity fromBookingItem;
|
||||||
|
final HsHostingAssetAutoInsertResource asset;
|
||||||
|
final StandardMapper standardMapper;
|
||||||
|
|
||||||
|
protected abstract HsHostingAsset create();
|
||||||
|
|
||||||
|
public String createAndPersist() {
|
||||||
|
try {
|
||||||
|
final HsHostingAsset newHostingAsset = create();
|
||||||
|
persist(newHostingAsset);
|
||||||
|
return null;
|
||||||
|
} catch (final ValidationException exc) {
|
||||||
|
return exc.getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected void persist(final HsHostingAsset newHostingAsset) {
|
||||||
|
new HostingAssetEntitySaveProcessor(emw, newHostingAsset)
|
||||||
|
.preprocessEntity()
|
||||||
|
.validateEntity()
|
||||||
|
.prepareForSave()
|
||||||
|
.save()
|
||||||
|
.validateContext();
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,76 @@
|
|||||||
|
package net.hostsharing.hsadminng.hs.hosting.asset.factories;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import jakarta.validation.ValidationException;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import lombok.SneakyThrows;
|
||||||
|
import net.hostsharing.hsadminng.hs.booking.generated.api.v1.model.HsHostingAssetAutoInsertResource;
|
||||||
|
import net.hostsharing.hsadminng.hs.booking.item.BookingItemCreatedAppEvent;
|
||||||
|
import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemRealEntity;
|
||||||
|
import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAsset;
|
||||||
|
import net.hostsharing.hsadminng.mapper.StandardMapper;
|
||||||
|
import net.hostsharing.hsadminng.persistence.EntityManagerWrapper;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.context.ApplicationListener;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class HsBookingItemCreatedListener implements ApplicationListener<BookingItemCreatedAppEvent> {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private EntityManagerWrapper emw;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ObjectMapper jsonMapper;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private StandardMapper standardMapper;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@SneakyThrows
|
||||||
|
public void onApplicationEvent(@NotNull BookingItemCreatedAppEvent bookingItemCreatedAppEvent) {
|
||||||
|
if (containsAssetJson(bookingItemCreatedAppEvent)) {
|
||||||
|
createRelatedHostingAsset(bookingItemCreatedAppEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean containsAssetJson(final BookingItemCreatedAppEvent bookingItemCreatedAppEvent) {
|
||||||
|
return bookingItemCreatedAppEvent.getEntity().getAssetJson() != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void createRelatedHostingAsset(final BookingItemCreatedAppEvent event) throws JsonProcessingException {
|
||||||
|
final var newBookingItemRealEntity = event.getEntity().getBookingItem();
|
||||||
|
final var asset = jsonMapper.readValue(event.getEntity().getAssetJson(), HsHostingAssetAutoInsertResource.class);
|
||||||
|
final var factory = switch (newBookingItemRealEntity.getType()) {
|
||||||
|
case PRIVATE_CLOUD, CLOUD_SERVER, MANAGED_SERVER ->
|
||||||
|
forNowNoAutomaticHostingAssetCreationPossible(emw, newBookingItemRealEntity, asset, standardMapper);
|
||||||
|
case MANAGED_WEBSPACE -> new ManagedWebspaceHostingAssetFactory(emw, newBookingItemRealEntity, asset, standardMapper);
|
||||||
|
case DOMAIN_SETUP -> new DomainSetupHostingAssetFactory(emw, newBookingItemRealEntity, asset, standardMapper);
|
||||||
|
};
|
||||||
|
if (factory != null) {
|
||||||
|
final var statusMessage = factory.createAndPersist();
|
||||||
|
// TODO.impl: once we implement retry, we need to amend this code (persist/merge/delete)
|
||||||
|
if (statusMessage != null) {
|
||||||
|
event.getEntity().setStatusMessage(statusMessage);
|
||||||
|
emw.persist(event.getEntity());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private HostingAssetFactory forNowNoAutomaticHostingAssetCreationPossible(
|
||||||
|
final EntityManagerWrapper emw,
|
||||||
|
final HsBookingItemRealEntity fromBookingItem,
|
||||||
|
final HsHostingAssetAutoInsertResource asset,
|
||||||
|
final StandardMapper standardMapper
|
||||||
|
) {
|
||||||
|
return new HostingAssetFactory(emw, fromBookingItem, asset, standardMapper) {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected HsHostingAsset create() {
|
||||||
|
// TODO.impl: we should validate the asset JSON, but some violations are un-avoidable at that stage
|
||||||
|
throw new ValidationException("waiting for manual setup of hosting asset for booking item of type " + fromBookingItem.getType());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,51 @@
|
|||||||
|
package net.hostsharing.hsadminng.hs.hosting.asset.factories;
|
||||||
|
|
||||||
|
import net.hostsharing.hsadminng.hs.booking.generated.api.v1.model.HsHostingAssetAutoInsertResource;
|
||||||
|
import net.hostsharing.hsadminng.hs.booking.generated.api.v1.model.HsHostingAssetTypeResource;
|
||||||
|
import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemRealEntity;
|
||||||
|
import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAsset;
|
||||||
|
import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetRealEntity;
|
||||||
|
import net.hostsharing.hsadminng.mapper.StandardMapper;
|
||||||
|
import net.hostsharing.hsadminng.persistence.EntityManagerWrapper;
|
||||||
|
|
||||||
|
import jakarta.validation.ValidationException;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
|
||||||
|
public class ManagedWebspaceHostingAssetFactory extends HostingAssetFactory {
|
||||||
|
|
||||||
|
public ManagedWebspaceHostingAssetFactory(
|
||||||
|
final EntityManagerWrapper emw,
|
||||||
|
final HsBookingItemRealEntity newBookingItemRealEntity,
|
||||||
|
final HsHostingAssetAutoInsertResource asset,
|
||||||
|
final StandardMapper standardMapper) {
|
||||||
|
super(emw, newBookingItemRealEntity, asset, standardMapper);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected HsHostingAsset create() {
|
||||||
|
if (asset.getType() != HsHostingAssetTypeResource.MANAGED_WEBSPACE) {
|
||||||
|
throw new ValidationException("requires MANAGED_WEBSPACE hosting asset, but got " +
|
||||||
|
Optional.of(asset)
|
||||||
|
.map(HsHostingAssetAutoInsertResource::getType)
|
||||||
|
.map(Enum::name)
|
||||||
|
.orElse(null));
|
||||||
|
}
|
||||||
|
final var managedWebspaceHostingAsset = standardMapper.map(asset, HsHostingAssetRealEntity.class);
|
||||||
|
managedWebspaceHostingAsset.setBookingItem(fromBookingItem);
|
||||||
|
emw.createQuery(
|
||||||
|
"SELECT asset FROM HsHostingAssetRealEntity asset WHERE asset.bookingItem.uuid=:bookingItemUuid",
|
||||||
|
HsHostingAssetRealEntity.class)
|
||||||
|
.setParameter("bookingItemUuid", fromBookingItem.getParentItem().getUuid())
|
||||||
|
.getResultStream().findFirst()
|
||||||
|
.ifPresent(managedWebspaceHostingAsset::setParentAsset);
|
||||||
|
|
||||||
|
return managedWebspaceHostingAsset;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void persist(final HsHostingAsset newManagedWebspaceHostingAsset) {
|
||||||
|
super.persist(newManagedWebspaceHostingAsset);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,134 @@
|
|||||||
|
package net.hostsharing.hsadminng.hs.hosting.asset.validators;
|
||||||
|
|
||||||
|
import net.hostsharing.hsadminng.mapper.Array;
|
||||||
|
import org.apache.commons.collections4.EnumerationUtils;
|
||||||
|
|
||||||
|
import javax.naming.InvalidNameException;
|
||||||
|
import javax.naming.NameNotFoundException;
|
||||||
|
import javax.naming.NamingEnumeration;
|
||||||
|
import javax.naming.NamingException;
|
||||||
|
import javax.naming.ServiceUnavailableException;
|
||||||
|
import javax.naming.directory.Attribute;
|
||||||
|
import javax.naming.directory.InitialDirContext;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Hashtable;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
import static java.util.Arrays.stream;
|
||||||
|
import static java.util.Collections.emptyList;
|
||||||
|
|
||||||
|
public class Dns {
|
||||||
|
|
||||||
|
public static final String[] REGISTRAR_LEVEL_DOMAINS = Array.of(
|
||||||
|
"[^.]+", // top-level-domains
|
||||||
|
"(co|org|gov|ac|sch)\\.uk",
|
||||||
|
"(com|net|org|edu|gov|asn|id)\\.au",
|
||||||
|
"(co|ne|or|ac|go)\\.jp",
|
||||||
|
"(com|net|org|gov|edu|ac)\\.cn",
|
||||||
|
"(com|net|org|gov|edu|mil|art)\\.br",
|
||||||
|
"(co|net|org|gen|firm|ind)\\.in",
|
||||||
|
"(com|net|org|gob|edu)\\.mx",
|
||||||
|
"(gov|edu)\\.it",
|
||||||
|
"(co|net|org|govt|ac|school|geek|kiwi)\\.nz",
|
||||||
|
"(co|ne|or|go|re|pe)\\.kr"
|
||||||
|
);
|
||||||
|
public static final Pattern[] REGISTRAR_LEVEL_DOMAIN_PATTERN = stream(REGISTRAR_LEVEL_DOMAINS)
|
||||||
|
.map(Pattern::compile)
|
||||||
|
.toArray(Pattern[]::new);
|
||||||
|
|
||||||
|
private final static Map<String, Result> fakeResults = new HashMap<>();
|
||||||
|
|
||||||
|
public static Optional<String> superDomain(final String domainName) {
|
||||||
|
final var parts = domainName.split("\\.", 2);
|
||||||
|
if (parts.length == 2) {
|
||||||
|
return Optional.of(parts[1]);
|
||||||
|
}
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean isRegistrarLevelDomain(final String domainName) {
|
||||||
|
return stream(REGISTRAR_LEVEL_DOMAIN_PATTERN)
|
||||||
|
.anyMatch(p -> p.matcher(domainName).matches());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param domainName a fully qualified domain name
|
||||||
|
* @return true if `domainName` can be registered at a registrar, false if it's a subdomain of such or a registrar-level domain itself
|
||||||
|
*/
|
||||||
|
public static boolean isRegistrableDomain(final String domainName) {
|
||||||
|
return !isRegistrarLevelDomain(domainName) &&
|
||||||
|
superDomain(domainName).map(Dns::isRegistrarLevelDomain).orElse(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void fakeResultForDomain(final String domainName, final Result fakeResult) {
|
||||||
|
fakeResults.put(domainName, fakeResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void resetFakeResults() {
|
||||||
|
fakeResults.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum Status {
|
||||||
|
SUCCESS,
|
||||||
|
NAME_NOT_FOUND,
|
||||||
|
INVALID_NAME,
|
||||||
|
SERVICE_UNAVAILABLE,
|
||||||
|
UNKNOWN_FAILURE
|
||||||
|
}
|
||||||
|
|
||||||
|
public record Result(Status status, List<String> records, NamingException exception) {
|
||||||
|
|
||||||
|
|
||||||
|
public static Result fromRecords(final NamingEnumeration<?> recordEnumeration) {
|
||||||
|
final List<String> records = recordEnumeration == null
|
||||||
|
? emptyList()
|
||||||
|
: EnumerationUtils.toList(recordEnumeration).stream().map(Object::toString).toList();
|
||||||
|
return new Result(Status.SUCCESS, records, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Result fromRecords(final String... records) {
|
||||||
|
return new Result(Status.SUCCESS, stream(records).toList(), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Result fromException(final NamingException exception) {
|
||||||
|
return switch (exception) {
|
||||||
|
case ServiceUnavailableException exc -> new Result(Status.SERVICE_UNAVAILABLE, emptyList(), exc);
|
||||||
|
case NameNotFoundException exc -> new Result(Status.NAME_NOT_FOUND, emptyList(), exc);
|
||||||
|
case InvalidNameException exc -> new Result(Status.INVALID_NAME, emptyList(), exc);
|
||||||
|
case NamingException exc -> new Result(Status.UNKNOWN_FAILURE, emptyList(), exc);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final String domainName;
|
||||||
|
|
||||||
|
public Dns(final String domainName) {
|
||||||
|
this.domainName = domainName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Result fetchRecordsOfType(final String recordType) {
|
||||||
|
if (fakeResults.containsKey(domainName)) {
|
||||||
|
return fakeResults.get(domainName);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final var env = new Hashtable<>();
|
||||||
|
env.put("java.naming.factory.initial", "com.sun.jndi.dns.DnsContextFactory");
|
||||||
|
final Attribute records = new InitialDirContext(env)
|
||||||
|
.getAttributes(domainName, new String[] { recordType })
|
||||||
|
.get(recordType);
|
||||||
|
return Result.fromRecords(records != null ? records.getAll() : null);
|
||||||
|
} catch (final NamingException exception) {
|
||||||
|
return Result.fromException(exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
final var result = new Dns("example.org").fetchRecordsOfType("TXT");
|
||||||
|
System.out.println(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,131 @@
|
|||||||
|
package net.hostsharing.hsadminng.hs.hosting.asset.validators;
|
||||||
|
|
||||||
|
import net.hostsharing.hsadminng.errors.MultiValidationException;
|
||||||
|
import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAsset;
|
||||||
|
import net.hostsharing.hsadminng.hs.hosting.generated.api.v1.model.HsHostingAssetResource;
|
||||||
|
import net.hostsharing.hsadminng.hs.validation.HsEntityValidator;
|
||||||
|
|
||||||
|
import jakarta.persistence.EntityManager;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.function.Function;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps the steps of the pararation, validation, mapping and revamp around saving of a HsHostingAsset into a readable API.
|
||||||
|
*/
|
||||||
|
public class HostingAssetEntitySaveProcessor {
|
||||||
|
|
||||||
|
private final HsEntityValidator<HsHostingAsset> validator;
|
||||||
|
private String expectedStep = "preprocessEntity";
|
||||||
|
private final EntityManager em;
|
||||||
|
private HsHostingAsset entity;
|
||||||
|
private HsHostingAssetResource resource;
|
||||||
|
|
||||||
|
public HostingAssetEntitySaveProcessor(final EntityManager em, final HsHostingAsset entity) {
|
||||||
|
this.em = em;
|
||||||
|
this.entity = entity;
|
||||||
|
this.validator = HostingAssetEntityValidatorRegistry.forType(entity.getType());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// initial step allowing to set default values before any validations
|
||||||
|
public HostingAssetEntitySaveProcessor preprocessEntity() {
|
||||||
|
step("preprocessEntity", "validateEntity");
|
||||||
|
validator.preprocessEntity(entity);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// validates the entity itself including its properties
|
||||||
|
public HostingAssetEntitySaveProcessor validateEntity() {
|
||||||
|
step("validateEntity", "prepareForSave");
|
||||||
|
MultiValidationException.throwIfNotEmpty(validator.validateEntity(entity));
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO.legacy: remove once the migration of legacy data is done
|
||||||
|
/// validates the entity itself including its properties, but ignoring some error messages for import of legacy data
|
||||||
|
public HostingAssetEntitySaveProcessor validateEntityIgnoring(final String... ignoreRegExp) {
|
||||||
|
step("validateEntity", "prepareForSave");
|
||||||
|
final var ignoreRegExpPatterns = Arrays.stream(ignoreRegExp).map(Pattern::compile).toList();
|
||||||
|
MultiValidationException.throwIfNotEmpty(
|
||||||
|
validator.validateEntity(entity).stream()
|
||||||
|
.filter(error -> ignoreRegExpPatterns.stream().noneMatch(p -> p.matcher(error).matches() ))
|
||||||
|
.toList()
|
||||||
|
);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// hashing passwords etc.
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public HostingAssetEntitySaveProcessor prepareForSave() {
|
||||||
|
step("prepareForSave", "save");
|
||||||
|
validator.prepareProperties(em, entity);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves the entity using the given `saveFunction`.
|
||||||
|
*
|
||||||
|
* <p>`validator.postPersist(em, entity)` is NOT called.
|
||||||
|
* If any postprocessing is necessary, the saveFunction has to implement this.</p>
|
||||||
|
* @param saveFunction
|
||||||
|
* @return
|
||||||
|
*/
|
||||||
|
public HostingAssetEntitySaveProcessor saveUsing(final Function<HsHostingAsset, HsHostingAsset> saveFunction) {
|
||||||
|
step("save", "validateContext");
|
||||||
|
entity = saveFunction.apply(entity);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves the using the `EntityManager`, but does NOT ever merge the entity.
|
||||||
|
*
|
||||||
|
* <p>`validator.postPersist(em, entity)` is called afterwards with the entity guaranteed to be flushed to the database.</p>
|
||||||
|
* @return
|
||||||
|
*/
|
||||||
|
public HostingAssetEntitySaveProcessor save() {
|
||||||
|
return saveUsing(e -> {
|
||||||
|
if (!em.contains(entity)) {
|
||||||
|
em.persist(entity);
|
||||||
|
}
|
||||||
|
em.flush(); // makes RbacEntity available as RealEntity if needed
|
||||||
|
validator.postPersist(em, entity);
|
||||||
|
return entity;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// validates the entity within it's parent and child hierarchy (e.g. totals validators and other limits)
|
||||||
|
public HostingAssetEntitySaveProcessor validateContext() {
|
||||||
|
step("validateContext", "mapUsing");
|
||||||
|
return HsEntityValidator.doWithEntityManager(em, () -> {
|
||||||
|
MultiValidationException.throwIfNotEmpty(validator.validateContext(entity));
|
||||||
|
return this;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// maps entity to JSON resource representation
|
||||||
|
public HostingAssetEntitySaveProcessor mapUsing(
|
||||||
|
final Function<HsHostingAsset, HsHostingAssetResource> mapFunction) {
|
||||||
|
step("mapUsing", "revampProperties");
|
||||||
|
resource = mapFunction.apply(entity);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// removes write-only-properties and ads computed-properties
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public HsHostingAssetResource revampProperties() {
|
||||||
|
step("revampProperties", null);
|
||||||
|
final var revampedProps = validator.revampProperties(em, entity, (Map<String, Object>) resource.getConfig());
|
||||||
|
resource.setConfig(revampedProps);
|
||||||
|
return resource;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Makes sure that the steps are called in the correct order.
|
||||||
|
// Could also be implemented using an interface per method, but that seems exaggerated.
|
||||||
|
private void step(final String current, final String next) {
|
||||||
|
if (!expectedStep.equals(current)) {
|
||||||
|
throw new IllegalStateException("expected " + expectedStep + " but got " + current);
|
||||||
|
}
|
||||||
|
expectedStep = next;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,238 @@
|
|||||||
|
package net.hostsharing.hsadminng.hs.hosting.asset.validators;
|
||||||
|
|
||||||
|
import net.hostsharing.hsadminng.hs.booking.item.HsBookingItem;
|
||||||
|
import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType;
|
||||||
|
import net.hostsharing.hsadminng.hs.booking.item.validators.HsBookingItemEntityValidatorRegistry;
|
||||||
|
import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAsset;
|
||||||
|
import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType;
|
||||||
|
import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRealEntity;
|
||||||
|
import net.hostsharing.hsadminng.hs.validation.HsEntityValidator;
|
||||||
|
import net.hostsharing.hsadminng.hs.validation.ValidatableProperty;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.function.BiFunction;
|
||||||
|
import java.util.function.Function;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
|
import static java.util.Arrays.stream;
|
||||||
|
import static java.util.Collections.emptyList;
|
||||||
|
import static java.util.Optional.ofNullable;
|
||||||
|
|
||||||
|
public abstract class HostingAssetEntityValidator extends HsEntityValidator<HsHostingAsset> {
|
||||||
|
|
||||||
|
static final ValidatableProperty<?, ?>[] NO_EXTRA_PROPERTIES = new ValidatableProperty<?, ?>[0];
|
||||||
|
|
||||||
|
private final ReferenceValidator<HsBookingItem, HsBookingItemType> bookingItemReferenceValidation;
|
||||||
|
private final ReferenceValidator<HsHostingAsset, HsHostingAssetType> parentAssetReferenceValidation;
|
||||||
|
private final ReferenceValidator<HsHostingAsset, HsHostingAssetType> assignedToAssetReferenceValidation;
|
||||||
|
private final HostingAssetEntityValidator.AlarmContact alarmContactValidation;
|
||||||
|
|
||||||
|
HostingAssetEntityValidator(
|
||||||
|
final HsHostingAssetType assetType,
|
||||||
|
final AlarmContact alarmContactValidation, // hostmaster alert address is implicitly added where needed
|
||||||
|
final ValidatableProperty<?, ?>... properties) {
|
||||||
|
super(properties);
|
||||||
|
this.bookingItemReferenceValidation = new ReferenceValidator<>(
|
||||||
|
assetType.bookingItemPolicy(),
|
||||||
|
assetType.bookingItemTypes(),
|
||||||
|
HsHostingAsset::getBookingItem,
|
||||||
|
HsBookingItem::getType);
|
||||||
|
this.parentAssetReferenceValidation = new ReferenceValidator<>(
|
||||||
|
assetType.parentAssetPolicy(),
|
||||||
|
assetType.parentAssetTypes(),
|
||||||
|
HsHostingAsset::getParentAsset,
|
||||||
|
HsHostingAsset::getType);
|
||||||
|
this.assignedToAssetReferenceValidation = new ReferenceValidator<>(
|
||||||
|
assetType.assignedToAssetPolicy(),
|
||||||
|
assetType.assignedToAssetTypes(),
|
||||||
|
HsHostingAsset::getAssignedToAsset,
|
||||||
|
HsHostingAsset::getType);
|
||||||
|
this.alarmContactValidation = alarmContactValidation;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<String> validateEntity(final HsHostingAsset assetEntity) {
|
||||||
|
return sequentiallyValidate(
|
||||||
|
() -> validateEntityReferencesAndProperties(assetEntity),
|
||||||
|
() -> validateIdentifierPattern(assetEntity)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<String> validateContext(final HsHostingAsset assetEntity) {
|
||||||
|
return sequentiallyValidate(
|
||||||
|
() -> optionallyValidate(assetEntity.getBookingItem()),
|
||||||
|
() -> optionallyValidate(assetEntity.getParentAsset()),
|
||||||
|
() -> validateAgainstSubEntities(assetEntity)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> validateEntityReferencesAndProperties(final HsHostingAsset assetEntity) {
|
||||||
|
return Stream.of(
|
||||||
|
validateReferencedEntity(assetEntity, "bookingItem", bookingItemReferenceValidation::validate),
|
||||||
|
validateReferencedEntity(assetEntity, "parentAsset", parentAssetReferenceValidation::validate),
|
||||||
|
validateReferencedEntity(assetEntity, "assignedToAsset", assignedToAssetReferenceValidation::validate),
|
||||||
|
validateReferencedEntity(assetEntity, "alarmContact", alarmContactValidation::validate),
|
||||||
|
validateProperties(assetEntity))
|
||||||
|
.filter(Objects::nonNull)
|
||||||
|
.flatMap(List::stream)
|
||||||
|
.filter(Objects::nonNull)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> validateReferencedEntity(
|
||||||
|
final HsHostingAsset assetEntity,
|
||||||
|
final String referenceFieldName,
|
||||||
|
final BiFunction<HsHostingAsset, String, List<String>> validator) {
|
||||||
|
return enrich(prefix(assetEntity.toShortString()), validator.apply(assetEntity, referenceFieldName));
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> validateProperties(final HsHostingAsset assetEntity) {
|
||||||
|
return enrich(prefix(assetEntity.toShortString(), "config"), super.validateProperties(assetEntity));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<String> optionallyValidate(final HsHostingAsset assetEntity) {
|
||||||
|
return assetEntity != null
|
||||||
|
? enrich(
|
||||||
|
prefix(assetEntity.toShortString(), "parentAsset"),
|
||||||
|
HostingAssetEntityValidatorRegistry.forType(assetEntity.getType()).validateContext(assetEntity))
|
||||||
|
: emptyList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<String> optionallyValidate(final HsBookingItem bookingItem) {
|
||||||
|
return bookingItem != null
|
||||||
|
? enrich(
|
||||||
|
prefix(bookingItem.toShortString(), "bookingItem"),
|
||||||
|
HsBookingItemEntityValidatorRegistry.forType(bookingItem.getType()).validateContext(bookingItem))
|
||||||
|
: emptyList();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected List<String> validateAgainstSubEntities(final HsHostingAsset assetEntity) {
|
||||||
|
return enrich(
|
||||||
|
prefix(assetEntity.toShortString(), "config"),
|
||||||
|
stream(propertyValidators)
|
||||||
|
.filter(ValidatableProperty::isTotalsValidator)
|
||||||
|
.map(prop -> validateMaxTotalValue(assetEntity, prop))
|
||||||
|
.filter(Objects::nonNull)
|
||||||
|
.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO.test: check, if there are any hosting assets which need this validation at all
|
||||||
|
private String validateMaxTotalValue(
|
||||||
|
final HsHostingAsset hostingAsset,
|
||||||
|
final ValidatableProperty<?, ?> propDef) {
|
||||||
|
final var propName = propDef.propertyName();
|
||||||
|
final var propUnit = ofNullable(propDef.unit()).map(u -> " " + u).orElse("");
|
||||||
|
final var totalValue = ofNullable(hostingAsset.getSubHostingAssets()).orElse(emptyList())
|
||||||
|
.stream()
|
||||||
|
.map(subItem -> propDef.getValue(subItem.getConfig()))
|
||||||
|
.map(HsEntityValidator::toIntegerWithDefault0)
|
||||||
|
.reduce(0, Integer::sum);
|
||||||
|
final var maxValue = getIntegerValueWithDefault0(propDef, hostingAsset.getConfig());
|
||||||
|
return totalValue > maxValue
|
||||||
|
? "%s' maximum total is %d%s, but actual total %s is %d%s".formatted(
|
||||||
|
propName, maxValue, propUnit, propName, totalValue, propUnit)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> validateIdentifierPattern(final HsHostingAsset assetEntity) {
|
||||||
|
final var expectedIdentifierPattern = identifierPattern(assetEntity);
|
||||||
|
if (assetEntity.getIdentifier() == null ||
|
||||||
|
!expectedIdentifierPattern.matcher(assetEntity.getIdentifier()).matches()) {
|
||||||
|
return List.of(
|
||||||
|
"'identifier' expected to match '" + expectedIdentifierPattern + "', but is '" + assetEntity.getIdentifier()
|
||||||
|
+ "'");
|
||||||
|
}
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected abstract Pattern identifierPattern(HsHostingAsset assetEntity);
|
||||||
|
|
||||||
|
static class ReferenceValidator<S, T> {
|
||||||
|
|
||||||
|
private final HsHostingAssetType.RelationPolicy policy;
|
||||||
|
private final Set<T> referencedEntityTypes;
|
||||||
|
private final Function<HsHostingAsset, S> referencedEntityGetter;
|
||||||
|
private final Function<S, T> referencedEntityTypeGetter;
|
||||||
|
|
||||||
|
public ReferenceValidator(
|
||||||
|
final HsHostingAssetType.RelationPolicy policy,
|
||||||
|
final Set<T> referencedEntityTypes,
|
||||||
|
final Function<HsHostingAsset, S> referencedEntityGetter,
|
||||||
|
final Function<S, T> referencedEntityTypeGetter) {
|
||||||
|
this.policy = policy;
|
||||||
|
this.referencedEntityTypes = referencedEntityTypes;
|
||||||
|
this.referencedEntityGetter = referencedEntityGetter;
|
||||||
|
this.referencedEntityTypeGetter = referencedEntityTypeGetter;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ReferenceValidator(
|
||||||
|
final HsHostingAssetType.RelationPolicy policy,
|
||||||
|
final Function<HsHostingAsset, S> referencedEntityGetter) {
|
||||||
|
this.policy = policy;
|
||||||
|
this.referencedEntityTypes = Set.of();
|
||||||
|
this.referencedEntityGetter = referencedEntityGetter;
|
||||||
|
this.referencedEntityTypeGetter = e -> null;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> validate(final HsHostingAsset assetEntity, final String referenceFieldName) {
|
||||||
|
|
||||||
|
final var referencedEntity = referencedEntityGetter.apply(assetEntity);
|
||||||
|
final var referencedEntityType = referencedEntity != null ? referencedEntityTypeGetter.apply(referencedEntity) : null;
|
||||||
|
|
||||||
|
switch (policy) {
|
||||||
|
case REQUIRED:
|
||||||
|
if (!referencedEntityTypes.contains(referencedEntityType)) {
|
||||||
|
return List.of(referencedEntityType == null
|
||||||
|
? referenceFieldName + "' must be of type " + toDisplay(referencedEntityTypes) + " but is null"
|
||||||
|
: referenceFieldName + "' must be of type " + toDisplay(referencedEntityTypes) + " but is of type " + referencedEntityType);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case TERMINATORY:
|
||||||
|
if (assetEntity.getParentAsset() != null && assetEntity.getBookingItem() != null) {
|
||||||
|
return List.of(referenceFieldName + "' or parentItem must be null but is of type " + referencedEntityType);
|
||||||
|
}
|
||||||
|
if (assetEntity.getParentAsset() == null && !referencedEntityTypes.contains(referencedEntityType)) {
|
||||||
|
return List.of(referencedEntityType == null
|
||||||
|
? referenceFieldName + "' must be of type " + toDisplay(referencedEntityTypes) + " but is null"
|
||||||
|
: referenceFieldName + "' must be of type " + toDisplay(referencedEntityTypes) + " but is of type " + referencedEntityType);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case OPTIONAL:
|
||||||
|
if (referencedEntityType != null && !referencedEntityTypes.contains(referencedEntityType)) {
|
||||||
|
return List.of(referenceFieldName + "' must be null or of type " + toDisplay(referencedEntityTypes) + " but is of type "
|
||||||
|
+ referencedEntityType);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case FORBIDDEN:
|
||||||
|
if (referencedEntityType != null) {
|
||||||
|
return List.of(referenceFieldName + "' must be null but is of type " + referencedEntityType);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return emptyList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String toDisplay(final Set<T> referencedEntityTypes) {
|
||||||
|
return referencedEntityTypes.stream().sorted().map(Object::toString).collect(Collectors.joining(" or "));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static class AlarmContact extends ReferenceValidator<HsOfficeContactRealEntity, Enum<?>> {
|
||||||
|
|
||||||
|
AlarmContact(final HsHostingAssetType.RelationPolicy policy) {
|
||||||
|
super(policy, HsHostingAsset::getAlarmContact);
|
||||||
|
}
|
||||||
|
|
||||||
|
// hostmaster alert address is implicitly added where neccessary
|
||||||
|
static AlarmContact isOptional() {
|
||||||
|
return new AlarmContact(HsHostingAssetType.RelationPolicy.OPTIONAL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,56 @@
|
|||||||
|
package net.hostsharing.hsadminng.hs.hosting.asset.validators;
|
||||||
|
|
||||||
|
import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAsset;
|
||||||
|
import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAsset;
|
||||||
|
import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType;
|
||||||
|
import net.hostsharing.hsadminng.hs.validation.HsEntityValidator;
|
||||||
|
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
import static java.util.Arrays.stream;
|
||||||
|
import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.*;
|
||||||
|
|
||||||
|
public class HostingAssetEntityValidatorRegistry {
|
||||||
|
|
||||||
|
private static final Map<Enum<HsHostingAssetType>, HsEntityValidator<HsHostingAsset>> validators = new HashMap<>();
|
||||||
|
static {
|
||||||
|
// HOWTO: add (register) new HsHostingAssetType-specific validators
|
||||||
|
register(CLOUD_SERVER, new HsCloudServerHostingAssetValidator());
|
||||||
|
register(MANAGED_SERVER, new HsManagedServerHostingAssetValidator());
|
||||||
|
register(MANAGED_WEBSPACE, new HsManagedWebspaceHostingAssetValidator());
|
||||||
|
register(UNIX_USER, new HsUnixUserHostingAssetValidator());
|
||||||
|
register(EMAIL_ALIAS, new HsEMailAliasHostingAssetValidator());
|
||||||
|
register(DOMAIN_SETUP, new HsDomainSetupHostingAssetValidator());
|
||||||
|
register(DOMAIN_DNS_SETUP, new HsDomainDnsSetupHostingAssetValidator());
|
||||||
|
register(DOMAIN_HTTP_SETUP, new HsDomainHttpSetupHostingAssetValidator());
|
||||||
|
register(DOMAIN_SMTP_SETUP, new HsDomainSmtpSetupHostingAssetValidator());
|
||||||
|
register(DOMAIN_MBOX_SETUP, new HsDomainMboxSetupHostingAssetValidator());
|
||||||
|
register(EMAIL_ADDRESS, new HsEMailAddressHostingAssetValidator());
|
||||||
|
register(MARIADB_INSTANCE, new HsMariaDbInstanceHostingAssetValidator());
|
||||||
|
register(MARIADB_USER, new HsMariaDbUserHostingAssetValidator());
|
||||||
|
register(MARIADB_DATABASE, new HsMariaDbDatabaseHostingAssetValidator());
|
||||||
|
register(PGSQL_INSTANCE, new HsPostgreSqlDbInstanceHostingAssetValidator());
|
||||||
|
register(PGSQL_USER, new HsPostgreSqlUserHostingAssetValidator());
|
||||||
|
register(PGSQL_DATABASE, new HsPostgreSqlDatabaseHostingAssetValidator());
|
||||||
|
register(IPV4_NUMBER, new HsIPv4NumberHostingAssetValidator());
|
||||||
|
register(IPV6_NUMBER, new HsIPv6NumberHostingAssetValidator());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void register(final Enum<HsHostingAssetType> type, final HsEntityValidator<HsHostingAsset> validator) {
|
||||||
|
stream(validator.propertyValidators).forEach( entry -> {
|
||||||
|
entry.verifyConsistency(Map.entry(type, validator));
|
||||||
|
});
|
||||||
|
validators.put(type, validator);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static HsEntityValidator<HsHostingAsset> forType(final Enum<HsHostingAssetType> type) {
|
||||||
|
if ( validators.containsKey(type)) {
|
||||||
|
return validators.get(type);
|
||||||
|
}
|
||||||
|
throw new IllegalArgumentException("no validator found for type " + type);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Set<Enum<HsHostingAssetType>> types() {
|
||||||
|
return validators.keySet();
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,22 @@
|
|||||||
|
package net.hostsharing.hsadminng.hs.hosting.asset.validators;
|
||||||
|
|
||||||
|
import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAsset;
|
||||||
|
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.CLOUD_SERVER;
|
||||||
|
|
||||||
|
class HsCloudServerHostingAssetValidator extends HostingAssetEntityValidator {
|
||||||
|
|
||||||
|
HsCloudServerHostingAssetValidator() {
|
||||||
|
super(
|
||||||
|
CLOUD_SERVER,
|
||||||
|
AlarmContact.isOptional(),
|
||||||
|
NO_EXTRA_PROPERTIES);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected Pattern identifierPattern(final HsHostingAsset assetEntity) {
|
||||||
|
return Pattern.compile("^vm[0-9][0-9][0-9][0-9]$");
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,185 @@
|
|||||||
|
package net.hostsharing.hsadminng.hs.hosting.asset.validators;
|
||||||
|
|
||||||
|
import lombok.SneakyThrows;
|
||||||
|
import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAsset;
|
||||||
|
import net.hostsharing.hsadminng.system.SystemProcess;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
import static java.util.Arrays.stream;
|
||||||
|
import static java.util.Optional.ofNullable;
|
||||||
|
import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.DOMAIN_DNS_SETUP;
|
||||||
|
import static net.hostsharing.hsadminng.hs.validation.ArrayProperty.arrayOf;
|
||||||
|
import static net.hostsharing.hsadminng.hs.validation.BooleanProperty.booleanProperty;
|
||||||
|
import static net.hostsharing.hsadminng.hs.validation.IntegerProperty.integerProperty;
|
||||||
|
import static net.hostsharing.hsadminng.hs.validation.StringProperty.stringProperty;
|
||||||
|
|
||||||
|
// TODO.legacy: make package private once we've migrated the legacy data
|
||||||
|
public class HsDomainDnsSetupHostingAssetValidator extends HostingAssetEntityValidator {
|
||||||
|
|
||||||
|
// according to RFC 1035 (section 5) and RFC 1034
|
||||||
|
static final String RR_REGEX_NAME = "(\\*\\.)?([a-zA-Z0-9\\._-]+|@)[ \t]+";
|
||||||
|
static final String RR_REGEX_TTL = "(([1-9][0-9]*[mMhHdDwW]?)+[ \t]+)?";
|
||||||
|
static final String RR_REGEX_IN = "[iI][nN][ \t]+"; // record class IN for Internet
|
||||||
|
static final String RR_RECORD_TYPE = "[a-zA-Z]+[ \t]+";
|
||||||
|
static final String RR_RECORD_DATA = "(([^;]+)|(\".*\")|(\\(.*\\)))[ \t]*";
|
||||||
|
static final String RR_COMMENT = "(;.*)?";
|
||||||
|
|
||||||
|
static final String RR_REGEX_TTL_IN =
|
||||||
|
RR_REGEX_NAME + RR_REGEX_TTL + RR_REGEX_IN + RR_RECORD_TYPE + RR_RECORD_DATA + RR_COMMENT;
|
||||||
|
|
||||||
|
static final String RR_REGEX_IN_TTL =
|
||||||
|
RR_REGEX_NAME + RR_REGEX_IN + RR_REGEX_TTL + RR_RECORD_TYPE + RR_RECORD_DATA + RR_COMMENT;
|
||||||
|
public static final String IDENTIFIER_SUFFIX = "|DNS";
|
||||||
|
|
||||||
|
private static List<String> zoneFileErrors = null; // TODO.legacy: remove once legacy data is migrated
|
||||||
|
|
||||||
|
HsDomainDnsSetupHostingAssetValidator() {
|
||||||
|
super(
|
||||||
|
DOMAIN_DNS_SETUP,
|
||||||
|
AlarmContact.isOptional(),
|
||||||
|
|
||||||
|
integerProperty("TTL").min(0).withDefault(21600),
|
||||||
|
booleanProperty("auto-SOA").withDefault(true),
|
||||||
|
booleanProperty("auto-NS-RR").withDefault(true),
|
||||||
|
booleanProperty("auto-MX-RR").withDefault(true),
|
||||||
|
booleanProperty("auto-A-RR").withDefault(true),
|
||||||
|
booleanProperty("auto-AAAA-RR").withDefault(true),
|
||||||
|
booleanProperty("auto-MAILSERVICES-RR").withDefault(true),
|
||||||
|
booleanProperty("auto-AUTOCONFIG-RR").withDefault(true),
|
||||||
|
booleanProperty("auto-AUTODISCOVER-RR").withDefault(true),
|
||||||
|
booleanProperty("auto-DKIM-RR").withDefault(true),
|
||||||
|
booleanProperty("auto-SPF-RR").withDefault(true),
|
||||||
|
booleanProperty("auto-WILDCARD-MX-RR").withDefault(true),
|
||||||
|
booleanProperty("auto-WILDCARD-A-RR").withDefault(true),
|
||||||
|
booleanProperty("auto-WILDCARD-AAAA-RR").withDefault(true),
|
||||||
|
booleanProperty("auto-WILDCARD-SPF-RR").withDefault(true),
|
||||||
|
arrayOf(
|
||||||
|
stringProperty("user-RR").matchesRegEx(RR_REGEX_TTL_IN, RR_REGEX_IN_TTL).required()
|
||||||
|
).optional());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected Pattern identifierPattern(final HsHostingAsset assetEntity) {
|
||||||
|
return Pattern.compile("^" + Pattern.quote(assetEntity.getParentAsset().getIdentifier() + IDENTIFIER_SUFFIX) + "$");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void preprocessEntity(final HsHostingAsset entity) {
|
||||||
|
super.preprocessEntity(entity);
|
||||||
|
if (entity.getIdentifier() == null) {
|
||||||
|
ofNullable(entity.getParentAsset()).ifPresent(pa -> entity.setIdentifier(pa.getIdentifier() + IDENTIFIER_SUFFIX));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@SneakyThrows
|
||||||
|
public List<String> validateContext(final HsHostingAsset assetEntity) {
|
||||||
|
final var result = super.validateContext(assetEntity);
|
||||||
|
|
||||||
|
// TODO.spec: define which checks should get raised to error level
|
||||||
|
final var namedCheckZone = new SystemProcess("named-checkzone", fqdn(assetEntity));
|
||||||
|
final var zonefileString = toZonefileString(assetEntity);
|
||||||
|
final var zoneFileErrorResult = zoneFileErrors != null ? zoneFileErrors : result;
|
||||||
|
if (namedCheckZone.execute(zonefileString) != 0) {
|
||||||
|
// yes, named-checkzone writes error messages to stdout, not stderr
|
||||||
|
stream(namedCheckZone.getStdOut().split("\n"))
|
||||||
|
.map(line -> line.replaceAll(" stream-0x[0-9a-f]+:", "line "))
|
||||||
|
.map(line -> "[" + assetEntity.getIdentifier() + "] " + line)
|
||||||
|
.forEach(zoneFileErrorResult::add);
|
||||||
|
if (!namedCheckZone.getStdErr().isEmpty()) {
|
||||||
|
result.add("unexpected stderr output for " + namedCheckZone.getCommand() + ": " + namedCheckZone.getStdErr());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
String toZonefileString(final HsHostingAsset assetEntity) {
|
||||||
|
// TODO.spec: we need to expand the templates (auto-...) in the same way as in Saltstack, with proper IP-numbers etc.
|
||||||
|
// TODO.impl: auto-AUTOCONFIG-RR auto-AUTODISCOVER-RR missing
|
||||||
|
return """
|
||||||
|
$TTL {ttl}
|
||||||
|
|
||||||
|
{auto-SOA}
|
||||||
|
{auto-NS-RR}
|
||||||
|
{auto-MX-RR}
|
||||||
|
{auto-A-RR}
|
||||||
|
{auto-AAAA-RR}
|
||||||
|
{auto-DKIM-RR}
|
||||||
|
{auto-SPF-RR}
|
||||||
|
|
||||||
|
{auto-WILDCARD-MX-RR}
|
||||||
|
{auto-WILDCARD-A-RR}
|
||||||
|
{auto-WILDCARD-AAAA-RR}
|
||||||
|
{auto-WILDCARD-SPF-RR}
|
||||||
|
|
||||||
|
{userRRs}
|
||||||
|
"""
|
||||||
|
.replace("{ttl}", assetEntity.getDirectValue("TTL", Integer.class, 43200).toString())
|
||||||
|
.replace("{auto-SOA}", assetEntity.getDirectValue("auto-SOA", Boolean.class, false).equals(true)
|
||||||
|
? """
|
||||||
|
{domain}. IN SOA h00.hostsharing.net. hostmaster.hostsharing.net. (
|
||||||
|
1303649373 ; serial secs since Jan 1 1970
|
||||||
|
6H ; refresh (>=10000)
|
||||||
|
1H ; retry (>=1800)
|
||||||
|
1W ; expire
|
||||||
|
1H ; minimum
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
: "; no auto-SOA"
|
||||||
|
)
|
||||||
|
.replace("{auto-NS-RR}", assetEntity.getDirectValue("auto-NS-RR", Boolean.class, true)
|
||||||
|
? """
|
||||||
|
{domain}. IN NS dns1.hostsharing.net.
|
||||||
|
{domain}. IN NS dns2.hostsharing.net.
|
||||||
|
{domain}. IN NS dns3.hostsharing.net.
|
||||||
|
"""
|
||||||
|
: "; no auto-NS-RR")
|
||||||
|
.replace("{auto-MX-RR}", assetEntity.getDirectValue("auto-MX-RR", Boolean.class, true)
|
||||||
|
? """
|
||||||
|
{domain}. IN MX 30 mailin1.hostsharing.net.
|
||||||
|
{domain}. IN MX 30 mailin2.hostsharing.net.
|
||||||
|
{domain}. IN MX 30 mailin3.hostsharing.net.
|
||||||
|
"""
|
||||||
|
: "; no auto-MX-RR")
|
||||||
|
.replace("{auto-A-RR}", assetEntity.getDirectValue("auto-A-RR", Boolean.class, true)
|
||||||
|
? "{domain}. IN A 83.223.95.160" // arbitrary IP-number
|
||||||
|
: "; no auto-A-RR")
|
||||||
|
.replace("{auto-AAAA-RR}", assetEntity.getDirectValue("auto-AAA-RR", Boolean.class, true)
|
||||||
|
? "{domain}. IN AAAA 2a01:37:1000::53df:5fa0:0" // arbitrary IP-number
|
||||||
|
: "; no auto-AAAA-RR")
|
||||||
|
.replace("{auto-DKIM-RR}", assetEntity.getDirectValue("auto-DKIM-RR", Boolean.class, true)
|
||||||
|
? "default._domainkey 21600 IN TXT \"v=DKIM1; h=sha256; k=rsa; s=email; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCmdM9d15bqe94zbHVcKKpUF875XoCWHKRap/sG3NJZ9xZ/BjfGXmqoEYeFNpX3CB7pOXhH5naq4N+6gTjArTviAiVThHXyebhrxaf1dVS4IUC6raTEyQrWPZUf7ZxXmcCYvOdV4jIQ8GRfxwxqibIJcmMiufXTLIgRUif5uaTgFwIDAQAB\""
|
||||||
|
: "; no auto-DKIM-RR")
|
||||||
|
.replace("{auto-SPF-RR}", assetEntity.getDirectValue("auto-SPF-RR", Boolean.class, true)
|
||||||
|
? "{domain}. IN TXT \"v=spf1 include:spf.hostsharing.net ?all\""
|
||||||
|
: "; no auto-SPF-RR")
|
||||||
|
.replace("{auto-WILDCARD-MX-RR}", assetEntity.getDirectValue("auto-SPF-RR", Boolean.class, true)
|
||||||
|
? """
|
||||||
|
*.{domain}. IN MX 30 mailin1.hostsharing.net.
|
||||||
|
*.{domain}. IN MX 30 mailin1.hostsharing.net.
|
||||||
|
*.{domain}. IN MX 30 mailin1.hostsharing.net.
|
||||||
|
"""
|
||||||
|
: "; no auto-WILDCARD-MX-RR")
|
||||||
|
.replace("{auto-WILDCARD-A-RR}", assetEntity.getDirectValue("auto-WILDCARD-A-RR", Boolean.class, true)
|
||||||
|
? "*.{domain}. IN A 83.223.95.160" // arbitrary IP-number
|
||||||
|
: "; no auto-WILDCARD-A-RR")
|
||||||
|
.replace("{auto-WILDCARD-AAAA-RR}", assetEntity.getDirectValue("auto-WILDCARD-AAAA-RR", Boolean.class, true)
|
||||||
|
? "*.{domain}. IN AAAA 2a01:37:1000::53df:5fa0:0" // arbitrary IP-number
|
||||||
|
: "; no auto-WILDCARD-AAAA-RR")
|
||||||
|
.replace("{auto-WILDCARD-SPF-RR}", assetEntity.getDirectValue("auto-WILDCARD-SPF-RR", Boolean.class, true)
|
||||||
|
? "*.{domain}. IN TXT \"v=spf1 include:spf.hostsharing.net ?all\""
|
||||||
|
: "; no auto-WILDCARD-SPF-RR")
|
||||||
|
.replace("{domain}", fqdn(assetEntity))
|
||||||
|
.replace("{userRRs}", getPropertyValues(assetEntity, "user-RR"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String fqdn(final HsHostingAsset assetEntity) {
|
||||||
|
return assetEntity.getIdentifier().substring(0, assetEntity.getIdentifier().length() - IDENTIFIER_SUFFIX.length());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void addZonefileErrorsTo(final List<String> zoneFileErrors) {
|
||||||
|
HsDomainDnsSetupHostingAssetValidator.zoneFileErrors = zoneFileErrors;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,56 @@
|
|||||||
|
package net.hostsharing.hsadminng.hs.hosting.asset.validators;
|
||||||
|
|
||||||
|
import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAsset;
|
||||||
|
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
import static java.util.Optional.ofNullable;
|
||||||
|
import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.DOMAIN_HTTP_SETUP;
|
||||||
|
import static net.hostsharing.hsadminng.hs.validation.ArrayProperty.arrayOf;
|
||||||
|
import static net.hostsharing.hsadminng.hs.validation.BooleanProperty.booleanProperty;
|
||||||
|
import static net.hostsharing.hsadminng.hs.validation.StringProperty.stringProperty;
|
||||||
|
|
||||||
|
class HsDomainHttpSetupHostingAssetValidator extends HostingAssetEntityValidator {
|
||||||
|
|
||||||
|
public static final String IDENTIFIER_SUFFIX = "|HTTP";
|
||||||
|
public static final String FILESYSTEM_PATH = "^/.*";
|
||||||
|
public static final String SUBDOMAIN_NAME_REGEX = "(\\*|(?!-)[A-Za-z0-9-]{1,63}(?<!-))";
|
||||||
|
|
||||||
|
HsDomainHttpSetupHostingAssetValidator() {
|
||||||
|
super(
|
||||||
|
DOMAIN_HTTP_SETUP,
|
||||||
|
AlarmContact.isOptional(),
|
||||||
|
|
||||||
|
booleanProperty("htdocsfallback").withDefault(true),
|
||||||
|
booleanProperty("indexes").withDefault(true),
|
||||||
|
booleanProperty("cgi").withDefault(true),
|
||||||
|
booleanProperty("passenger").withDefault(true),
|
||||||
|
booleanProperty("passenger-errorpage").withDefault(false),
|
||||||
|
booleanProperty("fastcgi").withDefault(true),
|
||||||
|
booleanProperty("autoconfig").withDefault(true),
|
||||||
|
booleanProperty("greylisting").withDefault(true),
|
||||||
|
booleanProperty("includes").withDefault(true),
|
||||||
|
booleanProperty("letsencrypt").withDefault(true),
|
||||||
|
booleanProperty("multiviews").withDefault(true),
|
||||||
|
stringProperty("fcgi-php-bin").matchesRegEx(FILESYSTEM_PATH).provided("/usr/lib/cgi-bin/php").withDefault("/usr/lib/cgi-bin/php"),
|
||||||
|
stringProperty("passenger-nodejs").matchesRegEx(FILESYSTEM_PATH).provided("/usr/bin/node").withDefault("/usr/bin/node"),
|
||||||
|
stringProperty("passenger-python").matchesRegEx(FILESYSTEM_PATH).provided("/usr/bin/python3").withDefault("/usr/bin/python3"),
|
||||||
|
stringProperty("passenger-ruby").matchesRegEx(FILESYSTEM_PATH).provided("/usr/bin/ruby").withDefault("/usr/bin/ruby"),
|
||||||
|
arrayOf(
|
||||||
|
stringProperty("subdomains").matchesRegEx(SUBDOMAIN_NAME_REGEX).required()
|
||||||
|
).optional());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected Pattern identifierPattern(final HsHostingAsset assetEntity) {
|
||||||
|
return Pattern.compile("^" + Pattern.quote(assetEntity.getParentAsset().getIdentifier() + IDENTIFIER_SUFFIX) + "$");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void preprocessEntity(final HsHostingAsset entity) {
|
||||||
|
super.preprocessEntity(entity);
|
||||||
|
if (entity.getIdentifier() == null) {
|
||||||
|
ofNullable(entity.getParentAsset()).ifPresent(pa -> entity.setIdentifier(pa.getIdentifier() + IDENTIFIER_SUFFIX));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,34 @@
|
|||||||
|
package net.hostsharing.hsadminng.hs.hosting.asset.validators;
|
||||||
|
|
||||||
|
import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAsset;
|
||||||
|
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
import static java.util.Optional.ofNullable;
|
||||||
|
import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.DOMAIN_MBOX_SETUP;
|
||||||
|
|
||||||
|
class HsDomainMboxSetupHostingAssetValidator extends HostingAssetEntityValidator {
|
||||||
|
|
||||||
|
public static final String IDENTIFIER_SUFFIX = "|MBOX";
|
||||||
|
|
||||||
|
HsDomainMboxSetupHostingAssetValidator() {
|
||||||
|
super(
|
||||||
|
DOMAIN_MBOX_SETUP,
|
||||||
|
AlarmContact.isOptional(),
|
||||||
|
|
||||||
|
NO_EXTRA_PROPERTIES);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected Pattern identifierPattern(final HsHostingAsset assetEntity) {
|
||||||
|
return Pattern.compile("^" + Pattern.quote(assetEntity.getParentAsset().getIdentifier() + IDENTIFIER_SUFFIX) + "$");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void preprocessEntity(final HsHostingAsset entity) {
|
||||||
|
super.preprocessEntity(entity);
|
||||||
|
if (entity.getIdentifier() == null) {
|
||||||
|
ofNullable(entity.getParentAsset()).ifPresent(pa -> entity.setIdentifier(pa.getIdentifier() + IDENTIFIER_SUFFIX));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,128 @@
|
|||||||
|
package net.hostsharing.hsadminng.hs.hosting.asset.validators;
|
||||||
|
|
||||||
|
import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAsset;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.DOMAIN_SETUP;
|
||||||
|
import static net.hostsharing.hsadminng.hs.hosting.asset.validators.Dns.superDomain;
|
||||||
|
import static net.hostsharing.hsadminng.hs.hosting.asset.validators.HsDomainHttpSetupHostingAssetValidator.SUBDOMAIN_NAME_REGEX;
|
||||||
|
|
||||||
|
class HsDomainSetupHostingAssetValidator extends HostingAssetEntityValidator {
|
||||||
|
|
||||||
|
public static final String DOMAIN_NAME_PROPERTY_NAME = "domainName";
|
||||||
|
|
||||||
|
HsDomainSetupHostingAssetValidator() {
|
||||||
|
super(
|
||||||
|
DOMAIN_SETUP,
|
||||||
|
AlarmContact.isOptional(),
|
||||||
|
|
||||||
|
NO_EXTRA_PROPERTIES);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<String> validateEntity(final HsHostingAsset assetEntity) {
|
||||||
|
final var violations = super.validateEntity(assetEntity);
|
||||||
|
if (!violations.isEmpty() || assetEntity.isLoaded()) {
|
||||||
|
// it makes no sense to do DNS-based validation
|
||||||
|
// if the entity is already persisted or
|
||||||
|
// if the identifier (domain name) or structure is already invalid
|
||||||
|
return violations;
|
||||||
|
}
|
||||||
|
|
||||||
|
final var dnsResult = new Dns(assetEntity.getIdentifier()).fetchRecordsOfType("TXT");
|
||||||
|
switch (dnsResult.status()) {
|
||||||
|
case Dns.Status.SUCCESS:
|
||||||
|
violations.addAll(handleDomainNameFound(assetEntity, dnsResult));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case Dns.Status.NAME_NOT_FOUND:
|
||||||
|
violations.addAll(handleDomainNameNotFoundError(assetEntity, dnsResult));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case Dns.Status.INVALID_NAME:
|
||||||
|
// should not happen because we validate the domain name at booking item level
|
||||||
|
violations.add("[DNS] invalid domain name '" + assetEntity.getIdentifier() + "'");
|
||||||
|
break;
|
||||||
|
|
||||||
|
case Dns.Status.SERVICE_UNAVAILABLE:
|
||||||
|
case Dns.Status.UNKNOWN_FAILURE:
|
||||||
|
violations.add("[DNS] lookup failed for domain name '" + assetEntity.getIdentifier() + "': " + dnsResult.exception());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return violations;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String verificationCode(final HsHostingAsset assetEntity) {
|
||||||
|
return assetEntity.getBookingItem().getDirectValue("verificationCode", String.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected Pattern identifierPattern(final HsHostingAsset assetEntity) {
|
||||||
|
if (assetEntity.getBookingItem() != null) {
|
||||||
|
final var bookingItemDomainName = assetEntity.getBookingItem()
|
||||||
|
.getDirectValue(DOMAIN_NAME_PROPERTY_NAME, String.class);
|
||||||
|
return Pattern.compile(bookingItemDomainName, Pattern.CASE_INSENSITIVE | Pattern.LITERAL);
|
||||||
|
}
|
||||||
|
final var parentDomainName = assetEntity.getParentAsset().getIdentifier();
|
||||||
|
return Pattern.compile(SUBDOMAIN_NAME_REGEX + "\\." + parentDomainName.replace(".", "\\."), Pattern.CASE_INSENSITIVE);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<String> handleDomainNameFound(final HsHostingAsset assetEntity, final Dns.Result dnsResult) {
|
||||||
|
final var violations = new ArrayList<String>();
|
||||||
|
final var expectedTxtRecordValue = "Hostsharing-domain-setup-verification-code=" + verificationCode(assetEntity);
|
||||||
|
final var verificationFound = findTxtRecord(dnsResult, expectedTxtRecordValue)
|
||||||
|
.or(() -> superDomain(assetEntity.getIdentifier())
|
||||||
|
.flatMap(superDomainName -> findTxtRecord(
|
||||||
|
new Dns(superDomainName).fetchRecordsOfType("TXT"),
|
||||||
|
expectedTxtRecordValue))
|
||||||
|
);
|
||||||
|
if (verificationFound.isEmpty()) {
|
||||||
|
violations.add(
|
||||||
|
"[DNS] no TXT record '" + expectedTxtRecordValue +
|
||||||
|
"' found for domain name '" + assetEntity.getIdentifier() + "' (nor in its super-domain)");
|
||||||
|
}
|
||||||
|
return violations;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<String> handleDomainNameNotFoundError(final HsHostingAsset assetEntity, final Dns.Result dnsResult) {
|
||||||
|
final var violations = new ArrayList<String>();
|
||||||
|
if (isDnsVerificationRequiredForUnregisteredDomain(assetEntity)) {
|
||||||
|
final var superDomain = superDomain(assetEntity.getIdentifier());
|
||||||
|
final var expectedTxtRecordValue = "Hostsharing-domain-setup-verification-code=" + verificationCode(assetEntity);
|
||||||
|
final var verificationFoundInSuperDomain = superDomain.map(superDomainName ->
|
||||||
|
{
|
||||||
|
final Dns.Result superDomainDnsResult = new Dns(superDomainName).fetchRecordsOfType("TXT");
|
||||||
|
if (superDomainDnsResult.status() != Dns.Status.SUCCESS) {
|
||||||
|
violations.add("[DNS] lookup failed for domain name '" + superDomainName + "': " + dnsResult.exception());
|
||||||
|
}
|
||||||
|
return superDomainDnsResult;
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.flatMap(records -> findTxtRecord(records, expectedTxtRecordValue));
|
||||||
|
if (verificationFoundInSuperDomain.isEmpty()) {
|
||||||
|
violations.add(
|
||||||
|
"[DNS] no TXT record '" + expectedTxtRecordValue +
|
||||||
|
"' found for domain name '" + superDomain.orElseThrow() + "'");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// otherwise no DNS verification to be able to setup DNS for domains to register
|
||||||
|
}
|
||||||
|
return violations;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isDnsVerificationRequiredForUnregisteredDomain(final HsHostingAsset assetEntity) {
|
||||||
|
return !Dns.isRegistrableDomain(assetEntity.getIdentifier())
|
||||||
|
&& assetEntity.getParentAsset() == null;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private static Optional<String> findTxtRecord(final Dns.Result result, final String expectedTxtRecordValue) {
|
||||||
|
return result.records().stream()
|
||||||
|
.filter(r -> r.contains(expectedTxtRecordValue))
|
||||||
|
.findAny();
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,34 @@
|
|||||||
|
package net.hostsharing.hsadminng.hs.hosting.asset.validators;
|
||||||
|
|
||||||
|
import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAsset;
|
||||||
|
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
import static java.util.Optional.ofNullable;
|
||||||
|
import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.DOMAIN_SMTP_SETUP;
|
||||||
|
|
||||||
|
class HsDomainSmtpSetupHostingAssetValidator extends HostingAssetEntityValidator {
|
||||||
|
|
||||||
|
public static final String IDENTIFIER_SUFFIX = "|SMTP";
|
||||||
|
|
||||||
|
HsDomainSmtpSetupHostingAssetValidator() {
|
||||||
|
super(
|
||||||
|
DOMAIN_SMTP_SETUP,
|
||||||
|
AlarmContact.isOptional(),
|
||||||
|
|
||||||
|
NO_EXTRA_PROPERTIES);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected Pattern identifierPattern(final HsHostingAsset assetEntity) {
|
||||||
|
return Pattern.compile("^" + Pattern.quote(assetEntity.getParentAsset().getIdentifier() + IDENTIFIER_SUFFIX) + "$");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void preprocessEntity(final HsHostingAsset entity) {
|
||||||
|
super.preprocessEntity(entity);
|
||||||
|
if (entity.getIdentifier() == null) {
|
||||||
|
ofNullable(entity.getParentAsset()).ifPresent(pa -> entity.setIdentifier(pa.getIdentifier() + IDENTIFIER_SUFFIX));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,53 @@
|
|||||||
|
package net.hostsharing.hsadminng.hs.hosting.asset.validators;
|
||||||
|
|
||||||
|
import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAsset;
|
||||||
|
import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType;
|
||||||
|
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
import static java.util.Optional.ofNullable;
|
||||||
|
import static net.hostsharing.hsadminng.hs.validation.ArrayProperty.arrayOf;
|
||||||
|
import static net.hostsharing.hsadminng.hs.validation.StringProperty.stringProperty;
|
||||||
|
|
||||||
|
class HsEMailAddressHostingAssetValidator extends HostingAssetEntityValidator {
|
||||||
|
|
||||||
|
private static final String TARGET_MAILBOX_REGEX = "^[a-z][a-z0-9]{2}[0-9]{2}(-[a-z0-9][a-z0-9\\.+_-]*)?$"; // also accepts legacy pac-names
|
||||||
|
private static final String EMAIL_ADDRESS_LOCAL_PART_REGEX = "[a-zA-Z0-9_!#$%&'*+/=?`{|}~^.-]+"; // RFC 5322
|
||||||
|
private static final String EMAIL_ADDRESS_DOMAIN_PART_REGEX = "[a-zA-Z0-9.-]+";
|
||||||
|
private static final String EMAIL_ADDRESS_FULL_REGEX = "^(" + EMAIL_ADDRESS_LOCAL_PART_REGEX + ")?@" + EMAIL_ADDRESS_DOMAIN_PART_REGEX + "$";
|
||||||
|
private static final String NOBODY_REGEX = "^nobody$";
|
||||||
|
private static final String DEVNULL_REGEX = "^/dev/null$";
|
||||||
|
public static final int EMAIL_ADDRESS_MAX_LENGTH = 320; // according to RFC 5321 and RFC 5322
|
||||||
|
|
||||||
|
HsEMailAddressHostingAssetValidator() {
|
||||||
|
super( HsHostingAssetType.EMAIL_ADDRESS,
|
||||||
|
AlarmContact.isOptional(),
|
||||||
|
|
||||||
|
stringProperty("local-part").matchesRegEx("^" + EMAIL_ADDRESS_LOCAL_PART_REGEX + "$").writeOnce().optional(),
|
||||||
|
stringProperty("sub-domain").matchesRegEx("^" + EMAIL_ADDRESS_LOCAL_PART_REGEX + "$").writeOnce().optional(),
|
||||||
|
arrayOf(
|
||||||
|
stringProperty("target").maxLength(EMAIL_ADDRESS_MAX_LENGTH).matchesRegEx(TARGET_MAILBOX_REGEX, EMAIL_ADDRESS_FULL_REGEX, NOBODY_REGEX, DEVNULL_REGEX)
|
||||||
|
).required().minLength(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void preprocessEntity(final HsHostingAsset entity) {
|
||||||
|
super.preprocessEntity(entity);
|
||||||
|
super.preprocessEntity(entity);
|
||||||
|
if (entity.getIdentifier() == null) {
|
||||||
|
entity.setIdentifier(combineIdentifier(entity));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected Pattern identifierPattern(final HsHostingAsset assetEntity) {
|
||||||
|
return Pattern.compile("^"+ Pattern.quote(combineIdentifier(assetEntity)) + "$");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String combineIdentifier(final HsHostingAsset emailAddressAssetEntity) {
|
||||||
|
return ofNullable(emailAddressAssetEntity.getDirectValue("local-part", String.class)).orElse("")
|
||||||
|
+ "@"
|
||||||
|
+ ofNullable(emailAddressAssetEntity.getDirectValue("sub-domain", String.class)).map(s -> s + ".").orElse("")
|
||||||
|
+ emailAddressAssetEntity.getParentAsset().getParentAsset().getIdentifier();
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,34 @@
|
|||||||
|
package net.hostsharing.hsadminng.hs.hosting.asset.validators;
|
||||||
|
|
||||||
|
import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAsset;
|
||||||
|
import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType;
|
||||||
|
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
import static net.hostsharing.hsadminng.hs.validation.ArrayProperty.arrayOf;
|
||||||
|
import static net.hostsharing.hsadminng.hs.validation.StringProperty.stringProperty;
|
||||||
|
|
||||||
|
class HsEMailAliasHostingAssetValidator extends HostingAssetEntityValidator {
|
||||||
|
|
||||||
|
private static final String UNIX_USER_REGEX = "^[a-z][a-z0-9]{2}[0-9]{2}(-[a-z0-9][a-z0-9\\._-]*)?$"; // also accepts legacy pac-names
|
||||||
|
private static final String EMAIL_ADDRESS_REGEX = "^[a-zA-Z0-9_!#$%&'*+/=?`{|}~^.-]+@[a-zA-Z0-9.-]+$"; // RFC 5322
|
||||||
|
private static final String INCLUDE_REGEX = "^:include:/.*$";
|
||||||
|
private static final String PIPE_REGEX = "^\\|.*$";
|
||||||
|
private static final String DEV_NULL_REGEX = "^/dev/null$";
|
||||||
|
public static final int EMAIL_ADDRESS_MAX_LENGTH = 320; // according to RFC 5321 and RFC 5322
|
||||||
|
|
||||||
|
HsEMailAliasHostingAssetValidator() {
|
||||||
|
super( HsHostingAssetType.EMAIL_ALIAS,
|
||||||
|
AlarmContact.isOptional(),
|
||||||
|
|
||||||
|
arrayOf(
|
||||||
|
stringProperty("target").maxLength(EMAIL_ADDRESS_MAX_LENGTH).matchesRegEx(UNIX_USER_REGEX, EMAIL_ADDRESS_REGEX, INCLUDE_REGEX, PIPE_REGEX, DEV_NULL_REGEX)
|
||||||
|
).required().minLength(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected Pattern identifierPattern(final HsHostingAsset assetEntity) {
|
||||||
|
final var webspaceIdentifier = assetEntity.getParentAsset().getIdentifier();
|
||||||
|
return Pattern.compile("^"+webspaceIdentifier+"$|^"+webspaceIdentifier+"-[a-z0-9][a-z0-9\\._-]*$");
|
||||||
|
}
|
||||||
|
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user