feature/8-secure-without-content #10

Merged
william merged 7 commits from feature/8-secure-without-content into develop 2021-08-06 08:38:07 -04:00
15 changed files with 199 additions and 98 deletions

11
.dockerignore Normal file
View File

@ -0,0 +1,11 @@
.gradle
.idea
**/build
**/data
**/gradle
**/logs
.gitignore
.gitlab-ci.yml
docker-compose.yml
Dockerfile
gradlew**

View File

@ -1,38 +1,51 @@
---
global-variables:
environment: &environment
JAVA_VERSION: 11
GRADLE_VERSION: 7.1
CRE_VERSION: dev-${DRONE_BUILD_NUMBER}
CRE_ARTIFACT_NAME: ColorRecipesExplorer
CRE_REGISTRY_IMAGE: registry.fyloz.dev:5443/colorrecipesexplorer/backend
CRE_PORT: 9101
gradle-image: &gradle-image gradle:7.1-jdk11
alpine-image: &alpine-image alpine:latest
docker-registry-repo: &docker-registry-repo registry.fyloz.dev:5443/colorrecipesexplorer/backend
kind: pipeline
name: default
type: docker
environment:
CRE_VERSION: ${DRONE_BUILD_NUMBER}
CRE_ARTIFACT_NAME: ColorRecipesExplorer
CRE_REGISTRY_IMAGE: registry.fyloz.dev:5443/colorrecipesexplorer/backend
CRE_PORT: 9101
steps:
- name: test
image: gradle:7.1-jdk11
- name: set-docker-tags
image: *alpine-image
environment:
<<: *environment
commands:
- gradle test
- echo -n "latest,dev-$CRE_VERSION" > .tags
- cat .tags
when:
branch: develop
events: push
- name: build
image: gradle:7.1-jdk11
- name: gradle-test
image: *gradle-image
commands:
- gradle bootJar -Pversion=$CRE_VERSION
- mv build/libs/ColorRecipesExplorer-$CRE_VERSION.jar $CRE_ARTIFACT_NAME.jar
- echo -n "latest,$CRE_VERSION" > .tags
- gradle test
when:
branch: master
events: push
branch: develop
events: [push, pull_request]
- name: containerize
image: plugins/docker
environment:
<<: *environment
settings:
build_args:
- JAVA_VERSION=11
repo: registry.fyloz.dev:5443/colorrecipesexplorer/backend
build_args_from_env:
- GRADLE_VERSION
- JAVA_VERSION
- CRE_VERSION
- CRE_PORT
repo: *docker-registry-repo
when:
branch: master
events: push
@ -40,6 +53,8 @@ steps:
- name: deploy
image: alpine:latest
environment:
<<: *environment
CRE_REGISTRY_IMAGE: *docker-registry-repo
DEPLOY_SERVER:
from_secret: deploy_server
DEPLOY_SERVER_USERNAME:
@ -64,9 +79,14 @@ steps:
- '[[ -f /.dockerenv ]] && echo -e "Host *\n\tStrictHostKeyChecking no\n\n" > ~/.ssh/config'
- ssh -p $DEPLOY_SERVER_SSH_PORT $DEPLOY_SERVER_USERNAME@$DEPLOY_SERVER "docker stop $DEPLOY_CONTAINER_NAME || true && docker rm $DEPLOY_CONTAINER_NAME || true"
- ssh -p $DEPLOY_SERVER_SSH_PORT $DEPLOY_SERVER_USERNAME@$DEPLOY_SERVER "docker pull $CRE_REGISTRY_IMAGE:latest"
- ssh -p $DEPLOY_SERVER_SSH_PORT $DEPLOY_SERVER_USERNAME@$DEPLOY_SERVER "docker run -d -p $CRE_PORT:9090 --name=$DEPLOY_CONTAINER_NAME -v $DEPLOY_DATA_VOLUME:/usr/bin/cre/data -v $DEPLOY_CONFIG_VOLUME:/usr/bin/cre/config -e spring_profiles_active=$DEPLOY_SPRING_PROFILES $CRE_REGISTRY_IMAGE"
- ssh -p $DEPLOY_SERVER_SSH_PORT $DEPLOY_SERVER_USERNAME@$DEPLOY_SERVER "docker run -d -p $CRE_PORT:9090 --name=$DEPLOY_CONTAINER_NAME -v $DEPLOY_DATA_VOLUME:/usr/bin/data -v $DEPLOY_CONFIG_VOLUME:/usr/bin/config -e spring_profiles_active=$DEPLOY_SPRING_PROFILES $CRE_REGISTRY_IMAGE"
when:
branch: master
events: push
trigger:
branch:
- develop
- master

View File

@ -1,11 +1,21 @@
ARG GRADLE_VERSION=7.1
ARG JAVA_VERSION=11
ARG CRE_VERSION=dev
FROM openjdk:$JAVA_VERSION
FROM gradle:$GRADLE_VERSION-jdk$JAVA_VERSION AS build
WORKDIR /usr/src
WORKDIR /usr/bin/cre/
COPY . .
RUN gradle bootJar -Pversion=$CRE_VERSION
ARG CRE_ARTIFACT_NAME=ColorRecipesExplorer
COPY $CRE_ARTIFACT_NAME.jar ColorRecipesExplorer.jar
FROM alpine:latest
WORKDIR /usr/bin
ARG JAVA_VERSION
RUN apk add --no-cache openjdk$JAVA_VERSION
ARG CRE_VERSION
COPY --from=build /usr/src/build/libs/ColorRecipesExplorer.jar ColorRecipesExplorer.jar
ARG CRE_PORT=9090
EXPOSE $CRE_PORT
@ -16,7 +26,7 @@ ENV spring_datasource_url=jdbc:h2:mem:cre
ENV spring_datasource_username=root
ENV spring_datasource_password=pass
VOLUME /usr/bin/cre/data
VOLUME /usr/bin/cre/config
VOLUME /usr/bin/data
VOLUME /usr/bin/config
ENTRYPOINT ["java", "-jar", "ColorRecipesExplorer.jar"]

View File

@ -1,10 +0,0 @@
ARG JDK_VERSION=11
ARG GRADLE_VERSION=7.1
FROM gradle:$GRADLE_VERSION-jdk$JDK_VERSION
WORKDIR /usr/src/cre/
COPY build.gradle.kts build.gradle.kts
COPY settings.gradle.kts settings.gradle.kts
COPY src src

View File

@ -3,23 +3,24 @@ package dev.fyloz.colorrecipesexplorer
import dev.fyloz.colorrecipesexplorer.databasemanager.CreDatabase
import dev.fyloz.colorrecipesexplorer.databasemanager.databaseContext
import dev.fyloz.colorrecipesexplorer.databasemanager.databaseUpdaterProperties
import dev.fyloz.colorrecipesexplorer.model.Configuration
import dev.fyloz.colorrecipesexplorer.model.ConfigurationType
import dev.fyloz.colorrecipesexplorer.service.config.ConfigurationService
import org.slf4j.Logger
import org.springframework.boot.jdbc.DataSourceBuilder
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.context.annotation.DependsOn
import org.springframework.context.annotation.Profile
import org.springframework.core.env.ConfigurableEnvironment
import javax.sql.DataSource
import org.springframework.context.annotation.Configuration as SpringConfiguration
const val SUPPORTED_DATABASE_VERSION = 5
const val ENV_VAR_ENABLE_DATABASE_UPDATE_NAME = "CRE_ENABLE_DB_UPDATE"
val DATABASE_NAME_REGEX = Regex("(\\w+)$")
@Profile("!emergency")
@Configuration
@SpringConfiguration
@DependsOn("configurationsInitializer", "configurationService")
class DataSourceConfiguration {
@Bean(name = ["dataSource"])
@ -29,7 +30,8 @@ class DataSourceConfiguration {
configurationService: ConfigurationService
): DataSource {
fun getConfiguration(type: ConfigurationType) =
configurationService.get(type).content
if (type.secure) configurationService.getSecure(type)
else configurationService.getContent(type)
val databaseUrl = "jdbc:" + getConfiguration(ConfigurationType.DATABASE_URL)
val databaseUsername = getConfiguration(ConfigurationType.DATABASE_USER)

View File

@ -12,20 +12,25 @@ import javax.persistence.Id
import javax.persistence.Table
import javax.validation.constraints.NotBlank
data class Configuration(
sealed class ConfigurationBase(
@JsonIgnore
val type: ConfigurationType,
val content: String,
val lastUpdated: LocalDateTime
) {
val key = type.key
val requireRestart = type.requireRestart
val editable = !type.computed
}
class Configuration(type: ConfigurationType, val content: String, lastUpdated: LocalDateTime) :
ConfigurationBase(type, lastUpdated) {
fun toEntity() =
ConfigurationEntity(key, content, lastUpdated)
}
class SecureConfiguration(type: ConfigurationType, lastUpdated: LocalDateTime) :
ConfigurationBase(type, lastUpdated)
@Entity
@Table(name = "configuration")
data class ConfigurationEntity(
@ -76,6 +81,15 @@ fun configuration(
configuration(type = key.toConfigurationType(), content = content)
}
fun secureConfiguration(
type: ConfigurationType,
lastUpdated: LocalDateTime? = null
) = SecureConfiguration(type, lastUpdated ?: LocalDateTime.now())
fun secureConfiguration(
configuration: Configuration
) = secureConfiguration(configuration.type, configuration.lastUpdated)
enum class ConfigurationType(
val key: String,
val defaultContent: Any? = null,
@ -92,7 +106,13 @@ enum class ConfigurationType(
DATABASE_URL("database.url", defaultContent = "mysql://localhost/cre", file = true, requireRestart = true),
DATABASE_USER("database.user", defaultContent = "cre", file = true, requireRestart = true),
DATABASE_PASSWORD("database.password", defaultContent = "asecurepassword", file = true, requireRestart = true, secure = true),
DATABASE_PASSWORD(
"database.password",
defaultContent = "asecurepassword",
file = true,
requireRestart = true,
secure = true
),
DATABASE_SUPPORTED_VERSION("database.version.supported", computed = true),
RECIPE_APPROBATION_EXPIRATION("recipe.approbation.expiration", defaultContent = 4.months),
@ -128,15 +148,15 @@ class InvalidConfigurationKeyException(val key: String) :
)
class InvalidImageConfigurationException(val type: ConfigurationType) :
RestException(
"invalid-configuration-image",
"Invalid image configuration",
HttpStatus.BAD_REQUEST,
"The configuration with the key '${type.key}' does not accept images as content",
mapOf(
"key" to type.key
)
)
RestException(
"invalid-configuration-image",
"Invalid image configuration",
HttpStatus.BAD_REQUEST,
"The configuration with the key '${type.key}' does not accept images as content",
mapOf(
"key" to type.key
)
)
class ConfigurationNotSetException(val type: ConfigurationType) :
RestException(

View File

@ -1,6 +1,6 @@
package dev.fyloz.colorrecipesexplorer.rest
import dev.fyloz.colorrecipesexplorer.model.Configuration
import dev.fyloz.colorrecipesexplorer.model.ConfigurationBase
import dev.fyloz.colorrecipesexplorer.model.ConfigurationDto
import dev.fyloz.colorrecipesexplorer.model.ConfigurationImageDto
import dev.fyloz.colorrecipesexplorer.model.account.Permission
@ -20,13 +20,11 @@ class ConfigurationController(val configurationService: ConfigurationService) {
fun getAll(@RequestParam(required = false) keys: String?, authentication: Authentication?) =
ok(with(configurationService) {
if (keys != null) getAll(keys) else getAll()
}.filter {
!it.type.secure && authentication.hasAuthority(it)
})
}.filter { authentication.hasAuthority(it) })
@GetMapping("{key}")
fun get(@PathVariable key: String, authentication: Authentication?) = with(configurationService.get(key)) {
if (!this.type.secure && authentication.hasAuthority(this)) ok(this) else forbidden()
if (authentication.hasAuthority(this)) ok(this) else forbidden()
}
@PutMapping
@ -48,7 +46,7 @@ class ConfigurationController(val configurationService: ConfigurationService) {
}
}
private fun Authentication?.hasAuthority(configuration: Configuration) = when {
private fun Authentication?.hasAuthority(configuration: ConfigurationBase) = when {
configuration.type.public -> true
this != null && Permission.ADMIN.toAuthority() in this.authorities -> true
else -> false

View File

@ -59,7 +59,7 @@ class MaterialServiceImpl(
isMixType = this.isMixType,
materialType = this.materialType!!,
simdutUrl = if (fileService.exists(this.simdutFilePath))
"${configService.get(ConfigurationType.INSTANCE_URL).content}$FILE_CONTROLLER_PATH?path=${
"${configService.getContent(ConfigurationType.INSTANCE_URL)}$FILE_CONTROLLER_PATH?path=${
URLEncoder.encode(
this.simdutFilePath,
StandardCharsets.UTF_8

View File

@ -78,7 +78,7 @@ class RecipeServiceImpl(
}.toSet(),
this.groupsInformation,
recipeImageService.getAllImages(this)
.map { this.imageUrl(configService.get(ConfigurationType.INSTANCE_URL).content, it) }
.map { this.imageUrl(configService.getContent(ConfigurationType.INSTANCE_URL), it) }
.toSet()
)
@ -87,7 +87,7 @@ class RecipeServiceImpl(
repository.existsByNameAndCompany(name, company)
override fun isApprobationExpired(recipe: Recipe): Boolean? =
with(Period.parse(configService.get(ConfigurationType.RECIPE_APPROBATION_EXPIRATION).content)) {
with(Period.parse(configService.getContent(ConfigurationType.RECIPE_APPROBATION_EXPIRATION))) {
recipe.approbationDate?.plus(this)?.isBefore(LocalDate.now())
}

View File

@ -48,7 +48,7 @@ class TouchUpKitServiceImpl(
touchUpKitRepository
), TouchUpKitService {
private val cacheGeneratedFiles by lazy {
configService.get(ConfigurationType.TOUCH_UP_KIT_CACHE_PDF).content == true.toString()
configService.getContent(ConfigurationType.TOUCH_UP_KIT_CACHE_PDF) == true.toString()
}
override fun idNotFoundException(id: Long) = touchUpKitIdNotFoundException(id)
@ -90,7 +90,7 @@ class TouchUpKitServiceImpl(
}
override fun isExpired(touchUpKit: TouchUpKit) =
with(Period.parse(configService.get(ConfigurationType.TOUCH_UP_KIT_EXPIRATION).content)) {
with(Period.parse(configService.getContent(ConfigurationType.TOUCH_UP_KIT_EXPIRATION))) {
touchUpKit.completed && touchUpKit.completionDate!!.plus(this) < LocalDate.now()
}
@ -144,5 +144,5 @@ class TouchUpKitServiceImpl(
"$TOUCH_UP_KIT_FILES_PATH/$this.pdf"
private fun TouchUpKit.pdfUrl() =
"${configService.get(ConfigurationType.INSTANCE_URL).content}$TOUCH_UP_KIT_CONTROLLER_PATH/pdf?job=$project"
"${configService.getContent(ConfigurationType.INSTANCE_URL)}$TOUCH_UP_KIT_CONTROLLER_PATH/pdf?job=$project"
}

View File

@ -12,22 +12,28 @@ import org.springframework.stereotype.Service
interface ConfigurationService {
/** Gets all set configurations. */
fun getAll(): List<Configuration>
fun getAll(): List<ConfigurationBase>
/**
* Gets all configurations with keys contained in the given [formattedKeyList].
* The [formattedKeyList] contains wanted configuration keys separated by a semi-colon.
*/
fun getAll(formattedKeyList: String): List<Configuration>
fun getAll(formattedKeyList: String): List<ConfigurationBase>
/**
* Gets the configuration with the given [key].
* If the [key] does not exists, an [InvalidConfigurationKeyException] will be thrown.
*/
fun get(key: String): Configuration
fun get(key: String): ConfigurationBase
/** Gets the configuration with the given [type]. */
fun get(type: ConfigurationType): Configuration
fun get(type: ConfigurationType): ConfigurationBase
/** Gets the content of the configuration with the given [type]. */
fun getContent(type: ConfigurationType): String
/** Gets the content of the secure configuration with the given [type]. Should not be accessible to the users. */
fun getSecure(type: ConfigurationType): String
/** Sets the content of each configuration in the given [configurations] list. */
fun set(configurations: List<ConfigurationDto>)
@ -89,18 +95,32 @@ class ConfigurationServiceImpl(
override fun get(key: String) =
get(key.toConfigurationType())
override fun get(type: ConfigurationType): Configuration {
override fun get(type: ConfigurationType): ConfigurationBase {
// Encryption salt should never be returned, but cannot be set as "secure" without encrypting it
if (type == ConfigurationType.GENERATED_ENCRYPTION_SALT) throw InvalidConfigurationKeyException(type.key)
val configuration = configurationSource.get(type) ?: throw ConfigurationNotSetException(type)
return if (type.secure) {
decryptConfiguration(configuration)
secureConfiguration(configuration)
} else {
configuration
}
}
override fun getContent(type: ConfigurationType): String {
val configuration = get(type)
if (configuration is SecureConfiguration) throw UnsupportedOperationException("Cannot get '${type.key}' configuration content because it is secure")
return (configuration as Configuration).content
}
override fun getSecure(type: ConfigurationType): String {
if (!type.secure) throw UnsupportedOperationException("Cannot get configuration of type '${type.key}' because it is not a secure configuration")
val configuration = configurationSource.get(type) ?: throw ConfigurationNotSetException(type)
return decryptConfiguration(configuration).content
}
override fun set(configurations: List<ConfigurationDto>) {
configurationSource.set(
configurations

View File

@ -10,6 +10,7 @@ import io.mockk.*
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import kotlin.UnsupportedOperationException
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
@ -165,7 +166,47 @@ class ConfigurationServiceTest {
}
@Test
fun `get(type) decrypts configuration content when the given ConfigurationType is secure`() {
fun `get(type) returns a SecureConfiguration when the given ConfigurationType is secure`() {
val type = ConfigurationType.DATABASE_PASSWORD
val configuration = configuration(
type = type,
content = "securepassword".encrypt(type.key, securityProperties.configSalt!!)
)
every { configurationSource.get(type) } returns configuration
val found = service.get(type)
assertTrue { found is SecureConfiguration }
}
@Test
fun `getContent(type) returns configuration content`() {
val type = ConfigurationType.INSTANCE_NAME
val configuration = configuration(
type = type,
content = "content"
)
every { service.get(type) } returns configuration
val found = service.getContent(type)
assertEquals(configuration.content, found)
}
@Test
fun `getContent(type) throws UnsupportedOperationException when configuration is secure`() {
val type = ConfigurationType.DATABASE_PASSWORD
val configuration = secureConfiguration(type)
every { service.get(type) } returns configuration
assertThrows<UnsupportedOperationException> { service.getContent(type) }
}
@Test
fun `getSecure(type) returns decrypted configuration content`() {
val type = ConfigurationType.DATABASE_PASSWORD
val content = "securepassword"
val configuration = configuration(
@ -175,9 +216,16 @@ class ConfigurationServiceTest {
every { configurationSource.get(type) } returns configuration
val found = service.get(type)
val found = service.getSecure(type)
assertEquals(content, found.content)
assertEquals(content, found)
}
@Test
fun `getSecure(type) throws UnsupportedOperationException when configuration is not secure`() {
val type = ConfigurationType.INSTANCE_NAME
assertThrows<UnsupportedOperationException> { service.getSecure(type) }
}
@Test
@ -197,7 +245,7 @@ class ConfigurationServiceTest {
fun `set(configuration) encrypts secure configurations`() {
val type = ConfigurationType.DATABASE_PASSWORD
val content = "securepassword"
val encryptedContent =content.encrypt(type.key, securityProperties.configSalt!!)
val encryptedContent = content.encrypt(type.key, securityProperties.configSalt!!)
val configuration = configuration(type = type, content = content)
mockkStatic(String::encrypt)

View File

@ -80,9 +80,9 @@ class RecipeServiceTest :
@Test
fun `isApprobationExpired() returns false when the approbation date of the given recipe is within the configured period`() {
val period = Period.ofMonths(4)
val config = configuration(type = ConfigurationType.RECIPE_APPROBATION_EXPIRATION, content = period.toString())
val recipe = recipe(approbationDate = LocalDate.now())
whenever(configService.get(ConfigurationType.RECIPE_APPROBATION_EXPIRATION)).doReturn(config)
whenever(configService.getContent(ConfigurationType.RECIPE_APPROBATION_EXPIRATION)).doReturn(period.toString())
val approbationExpired = service.isApprobationExpired(recipe)
@ -93,9 +93,9 @@ class RecipeServiceTest :
@Test
fun `isApprobationExpired() returns true when the approbation date of the given recipe is outside the configured period`() {
val period = Period.ofMonths(4)
val config = configuration(type = ConfigurationType.RECIPE_APPROBATION_EXPIRATION, content = period.toString())
val recipe = recipe(approbationDate = LocalDate.now().minus(period).minusMonths(1))
whenever(configService.get(ConfigurationType.RECIPE_APPROBATION_EXPIRATION)).doReturn(config)
whenever(configService.getContent(ConfigurationType.RECIPE_APPROBATION_EXPIRATION)).doReturn(period.toString())
val approbationExpired = service.isApprobationExpired(recipe)
@ -106,9 +106,9 @@ class RecipeServiceTest :
@Test
fun `isApprobationExpired() returns null when the given recipe as no approbation date`() {
val period = Period.ofMonths(4)
val config = configuration(type = ConfigurationType.RECIPE_APPROBATION_EXPIRATION, content = period.toString())
val recipe = recipe(approbationDate = null)
whenever(configService.get(ConfigurationType.RECIPE_APPROBATION_EXPIRATION)).doReturn(config)
whenever(configService.getContent(ConfigurationType.RECIPE_APPROBATION_EXPIRATION)).doReturn(period.toString())
val approbationExpired = service.isApprobationExpired(recipe)

View File

@ -131,10 +131,7 @@ class TouchUpKitServiceTest {
this.setCachePdf(false)
private fun TouchUpKitServiceTestContext.setCachePdf(enabled: Boolean) {
every { configService.get(ConfigurationType.TOUCH_UP_KIT_CACHE_PDF) } returns configuration(
type = ConfigurationType.TOUCH_UP_KIT_CACHE_PDF,
enabled.toString()
)
every { configService.getContent(ConfigurationType.TOUCH_UP_KIT_CACHE_PDF) } returns enabled.toString()
}
private fun test(test: TouchUpKitServiceTestContext.() -> Unit) {

View File

@ -1,15 +0,0 @@
== Icônes pour recettes non-approuvés / quantité faible ==
== Texte SIMDUT inexistant (fiche signalitique) pour les matériaux ==
== Comptes ==
No employé - Permissions - Employés
== Kits de retouche ==
No Job - No Dossier - Qté - Description - Case à cocher - Note
Bouton compléter si tout est coché/imprimé ?
Enregistrer localdatetime/personne pendant une certaine durée