diff --git a/.env b/.env new file mode 100644 index 00000000..f63a8204 --- /dev/null +++ b/.env @@ -0,0 +1,32 @@ +IMAGES_REGISTRY_DOCKER_IO=docker.io +IMAGES_REGISTRY_QUAY_IO=quay.io + +IMAGE_BDD_POSTGRES=postgres:14.20 +IMAGE_BDD_MONGO=mongo:7.0.5 +IMAGE_PLATINE_PILOTAGE_API=inseefr/platine-management-back-office:4.41.3-rc +IMAGE_KEYCLOAK=keycloak/keycloak:24.0 + +PLATINE_PILOTAGE_DB_USER=mypostgresuser +PLATINE_PILOTAGE_DB_PASSWORD=mypostgrespassword +PLATINE_PILOTAGE_DB_PORT=5433 +PLATINE_PILOTAGE_DB_NAME=platine-management + +PLATINE_PILOTAGE_API_PORT=3000 + +GENESIS_DB_USER=genesisuser +GENESIS_DB_PASSWORD=genesispassword +GENESIS_DB_PORT=27017 +GENESIS_DB_NAME=genesisdb + +KEYCLOAK_PORT=7080 +KEYCLOAK_ADMIN=administrator +KEYCLOAK_ADMIN_PASSWORD=administrator +AUTH_SERVER_URL=http://localhost:${KEYCLOAK_PORT} +AUTH_REALM=platine +JWT_ROLE_CLAIM= +IDP_HINT= +AUTH_CLIENT_ID=myclient +RESPONDENT_ROLE=respondent +INTERNAL_USER_ROLE=gestionnaire +ADMIN_ROLE=admin +WEBCLIENT_ROLE=webclient \ No newline at end of file diff --git a/compose.yml b/compose.yml new file mode 100644 index 00000000..53841367 --- /dev/null +++ b/compose.yml @@ -0,0 +1,167 @@ +x-mongo-volumes-setup-rs-1: &mongo-volumes-setup-rs-1 + - mongo_vol_1:/data/db + - ./container/mongodb/mongodb_rs1_init.sh:/scripts/mongodb_rs_init.sh + +x-mongo-volumes-1: &mongo-volumes-1 + - mongo_vol_1:/data + +services: + #---------------------------------------------------------------------------- + # /) /) + # (^.^) platine (collecte WEB) + # (")_(") + #---------------------------------------------------------------------------- + platine-pilotage-db: + image: ${IMAGES_REGISTRY_DOCKER_IO}/${IMAGE_BDD_POSTGRES} + profiles: + - platine-pilotage-db + - all + environment: + - POSTGRES_USER=${PLATINE_PILOTAGE_DB_USER} + - POSTGRES_PASSWORD=${PLATINE_PILOTAGE_DB_PASSWORD} + - POSTGRES_DB=${PLATINE_PILOTAGE_DB_NAME} + command: ["postgres"] + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${PLATINE_PILOTAGE_DB_USER} -d ${PLATINE_PILOTAGE_DB_NAME} -h localhost"] + interval: 5s + timeout: 10s + retries: 10 + # volumes: + # - ./container/pilotage/init.sql:/docker-entrypoint-initdb.d/init.sql + ports: + - ${PLATINE_PILOTAGE_DB_PORT}:5432 + #---------------------------------------------------------------------------- + platine-pilotage-api: + image: ${IMAGES_REGISTRY_DOCKER_IO}/${IMAGE_PLATINE_PILOTAGE_API} + profiles: + - platine-pilotage-api + - all + depends_on: + platine-pilotage-db: + condition: service_healthy + environment: + - SPRING_DATASOURCE_DRIVERCLASSNAME=org.postgresql.Driver + - SPRING_DATASOURCE_URL=jdbc:postgresql://platine-pilotage-db:5432/${PLATINE_PILOTAGE_DB_NAME} + - SPRING_DATASOURCE_USERNAME=${PLATINE_PILOTAGE_DB_USER} + - SPRING_DATASOURCE_PASSWORD=${PLATINE_PILOTAGE_DB_PASSWORD} + - SPRING_LIQUIBASE_ENABLED=TRUE + # use initdb,prod if database does not already exist in specific profile environment + - SPRING_LIQUIBASE_CONTEXTS=init-db,demo,prod + - SPRING_LIQUIBASE_DEFAULTSCHEMA=public + #- SPRING_LIQUIBASE_CHANGE-LOG=classpath:db/master.xml + - SPRING_LIQUIBASE_CHANGE-LOG=classpath:db/integration-demo.xml + - SPRINGDOC_SWAGGER_UI_OAUTH_ADDITIONALQUERYSTRINGPARAMS_KC_IDP_HINT=${IDP_HINT} + # issuer-uri is used to auto discover keycloak configuration endpoints and to validate the iss in the token (spring boot check that issuer-uri and iss are identical) + # When using jwk-set-uri, issuer-uri is not used anymore to auto discover configuration. + # That's what we want here. By authenticating with swagger, host machine is used and the iss generated in the token is equals to issuer-uri + - SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUERURI=${AUTH_SERVER_URL}/realms/${AUTH_REALM} + - SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_JWKSETURI=http://keycloak:8080/realms/${AUTH_REALM}/protocol/openid-connect/certs + - FR_INSEE_DATACOLLECTIONMANAGEMENT_ROLES_RESPONDENT_ROLE=${RESPONDENT_ROLE} + - FR_INSEE_DATACOLLECTIONMANAGEMENT_ROLES_INTERNAL_USER_ROLE=${INTERNAL_USER_ROLE} + - FR_INSEE_DATACOLLECTIONMANAGEMENT_ROLES_ADMIN_ROLE=${ADMIN_ROLE} + - FR_INSEE_DATACOLLECTIONMANAGEMENT_ROLES_WEBCLIENT_ROLE=${WEBCLIENT_ROLE} + - FR_INSEE_DATACOLLECTIONMANAGEMENT_AUTH_MODE=OIDC + - FR_INSEE_DATACOLLECTIONMANAGEMENT_AUTH_SERVERURL=${AUTH_SERVER_URL} + - FR_INSEE_DATACOLLECTIONMANAGEMENT_AUTH_REALM=${AUTH_REALM} + - FR_INSEE_DATACOLLECTIONMANAGEMENT_API_QUESTIONING_API_URL= + - FR_INSEE_DATACOLLECTIONMANAGEMENT_API_QUESTIONING_SENSITIVE_API_URL= + - FR_INSEE_DATACOLLECTIONMANAGEMENT_API_LUNATIC_NORMAL_URL= + - FR_INSEE_DATACOLLECTIONMANAGEMENT_API_LUNATIC_SENSITIVE_URL= + - FR_INSEE_DATACOLLECTIONMANAGEMENT_API_XFORM1_URL= + - FR_INSEE_DATACOLLECTIONMANAGEMENT_API_XFORM2_URL= + - FR_INSEE_DATACOLLECTIONMANAGEMENT_LDAP_API_ACCREDITATION_ID=id + - JWT_ROLE_CLAIM=${JWT_ROLE_CLAIM} + - SPRINGDOC_SWAGGER_UI_OAUTH_CLIENT_ID=${AUTH_CLIENT_ID} + ports: + - ${PLATINE_PILOTAGE_API_PORT}:8080 + + #---------------------------------------------------------------------------- + # 0"""0 + # ( �.�,) Genesis (traiter) + # (")_(") + #---------------------------------------------------------------------------- + mongo-init: + container_name: mongo-init + image: ${IMAGES_REGISTRY_DOCKER_IO}/${IMAGE_BDD_MONGO} + profiles: + - mongodb + - all + volumes: *mongo-volumes-1 + restart: on-failure + command: + - /bin/bash + - -c + - | + openssl rand -base64 756 > /data/replica.key + chmod 400 /data/replica.key + chown mongodb:mongodb /data/replica.key + ls -ltrah /data + cat /data/replica.key + #---------------------------------------------------------------------------- + mongod1: + container_name: mongod1 + image: ${IMAGES_REGISTRY_DOCKER_IO}/${IMAGE_BDD_MONGO} + profiles: + - mongodb + - all + environment: + MONGO_INITDB_ROOT_USERNAME: ${GENESIS_DB_USER} + MONGO_INITDB_ROOT_PASSWORD: ${GENESIS_DB_PASSWORD} + MONGO_INITDB_DATABASE: ${GENESIS_DB_NAME} + depends_on: + - mongo-init + ports: + - ${GENESIS_DB_PORT}:27017 + volumes: *mongo-volumes-1 + restart: always + healthcheck: + test: ["CMD","mongosh", "--eval", "db.adminCommand('ping')"] + interval: 10s + timeout: 10s + retries: 12 + start_period: 120s + command: "mongod --bind_ip_all --replSet dbrs --keyFile /data/replica.key" + #---------------------------------------------------------------------------- + mongo-setup-rs-1: + container_name: mongo-setup + image: ${IMAGES_REGISTRY_DOCKER_IO}/${IMAGE_BDD_MONGO} + profiles: + - mongodb + - all + environment: + MONGO_INITDB_ROOT_USERNAME: ${GENESIS_DB_USER} + MONGO_INITDB_ROOT_PASSWORD: ${GENESIS_DB_PASSWORD} + depends_on: + - mongod1 + volumes: *mongo-volumes-setup-rs-1 + restart: on-failure + entrypoint: ["/bin/bash", "/scripts/mongodb_rs_init.sh"] + + #---------------------------------------------------------------------------- + # (•_•) + # /( )> Authentification et habilitation + # ^^ ^^ + #---------------------------------------------------------------------------- + keycloak: + image: ${IMAGES_REGISTRY_QUAY_IO}/${IMAGE_KEYCLOAK} + profiles: + - keycloak + - all + environment: + KEYCLOAK_ADMIN: ${KEYCLOAK_ADMIN} + KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD} + KC_HEALTH_ENABLED: "true" + KC_LOG_LEVEL: info + healthcheck: + test: ["CMD", "curl", "-f", "http://keycloak:8080/health/ready"] + interval: 15s + timeout: 2s + retries: 15 + command: ["start-dev", "--import-realm"] + ports: + - "${KEYCLOAK_PORT}:8080" + volumes: + - ./container/keycloak/realms:/opt/keycloak/data/import +#---------------------------------------------------------------------------- +volumes: + mongo_vol_1: \ No newline at end of file diff --git a/container/keycloak/realms/platine.json b/container/keycloak/realms/platine.json new file mode 100644 index 00000000..eb7998d3 --- /dev/null +++ b/container/keycloak/realms/platine.json @@ -0,0 +1,317 @@ +{ + "realm": "platine", + "enabled": true, + "users": [ + { + "username": "gestio1", + "enabled": true, + "emailVerified": true, + "firstName": "Gertrude", + "lastName": "Gestionnaire", + "email": "gestion@insee.fr", + "credentials": [ + { + "type": "password", + "value": "gestio1", + "temporary": false + } + ], + "realmRoles": ["gestionnaire"] + }, + { + "username": "respon1", + "enabled": true, + "emailVerified": true, + "firstName": "Regine", + "lastName": "Respondent 1", + "email": "respondent1@insee.fr", + "credentials": [ + { + "type": "password", + "value": "respon1", + "temporary": false + } + ], + + "realmRoles": ["respondent"] + }, + { + "username": "E2E_RESPON_1", + "enabled": true, + "emailVerified": true, + "firstName": "Keith", + "lastName": "Cozart", + "email": "e2erespon1@insee.fr", + "credentials": [ + { + "type": "password", + "value": "E2E_RESPON_1", + "temporary": false + } + ], + + "realmRoles": ["respondent"] + }, + { + "username": "E2E_RESPON_2", + "enabled": true, + "emailVerified": true, + "firstName": "Jaylah", + "lastName": "Hickmon", + "email": "e2erespon2@insee.fr", + "credentials": [ + { + "type": "password", + "value": "E2E_RESPON_2", + "temporary": false + } + ], + + "realmRoles": ["respondent"] + }, + { + "username": "E2E_RESPON_3", + "enabled": true, + "emailVerified": true, + "firstName": "Patrick Earl", + "lastName": "Houston", + "email": "e2erespon3@insee.fr", + "credentials": [ + { + "type": "password", + "value": "E2E_RESPON_3", + "temporary": false + } + ], + + "realmRoles": ["respondent"] + }, + { + "username": "E2E_RESPON_EEC", + "enabled": true, + "emailVerified": true, + "firstName": "Nayvadius", + "lastName": "Cash", + "email": "e2erespon_eec@insee.fr", + "credentials": [ + { + "type": "password", + "value": "E2E_RESPON_EEC", + "temporary": false + } + ], + + "realmRoles": ["respondent"] + }, + { + "username": "E2E_RESPON_CLOSED", + "enabled": true, + "emailVerified": true, + "firstName": "Skye", + "lastName": "Edwards", + "email": "e2erespon_closed@insee.fr", + "credentials": [ + { + "type": "password", + "value": "E2E_RESPON_CLOSED", + "temporary": false + } + ], + + "realmRoles": ["respondent"] + }, + { + "username": "E2E_RESPON_4", + "enabled": true, + "emailVerified": true, + "firstName": "Dana", + "lastName": "Owens", + "email": "e2erespon4@insee.fr", + "credentials": [ + { + "type": "password", + "value": "E2E_RESPON_4", + "temporary": false + } + ], + + "realmRoles": ["respondent"] + }, + { + "username": "respon2", + "enabled": true, + "emailVerified": true, + "firstName": "Robert", + "lastName": "Respondent 2", + "email": "respondent2@insee.fr", + "credentials": [ + { + "type": "password", + "value": "respon2", + "temporary": false + } + ], + "realmRoles": ["respondent"] + }, + { + "username": "respon3", + "enabled": true, + "emailVerified": true, + "firstName": "Raoul", + "lastName": "Respondent 3", + "email": "respondent3@insee.fr", + "credentials": [ + { + "type": "password", + "value": "respon3", + "temporary": false + } + ], + "realmRoles": ["respondent"] + }, + { + "username": "respon4", + "enabled": true, + "emailVerified": true, + "firstName": "Rico", + "lastName": "Respondent 4", + "email": "respondent4@insee.fr", + "credentials": [ + { + "type": "password", + "value": "respon4", + "temporary": false + } + ], + "realmRoles": ["respondent"] + }, + { + "username": "respon5", + "enabled": true, + "emailVerified": true, + "firstName": "Rocco", + "lastName": "Respondent 5", + "email": "respondent5@insee.fr", + "credentials": [ + { + "type": "password", + "value": "respon5", + "temporary": false + } + ], + "realmRoles": ["respondent"] + }, + { + "username": "admin", + "enabled": true, + "emailVerified": true, + "firstName": "Adele", + "lastName": "Admin", + "email": "adele@insee.fr", + "credentials": [ + { + "type": "password", + "value": "admin", + "temporary": false + } + ], + "realmRoles": ["admin"] + }, + { + "username": "assist", + "enabled": true, + "emailVerified": true, + "firstName": "Alain", + "lastName": "Assistance", + "email": "alain@insee.fr", + "credentials": [ + { + "type": "password", + "value": "assist", + "temporary": false + } + ], + "realmRoles": ["assistance"] + }, + { + "username": "E2E_SUPPORT_1", + "enabled": true, + "emailVerified": true, + "firstName": "Alan", + "lastName": "Maman", + "email": "e2esupport1@insee.fr", + "credentials": [ + { + "type": "password", + "value": "E2E_SUPPORT_1", + "temporary": false + } + ], + "realmRoles": ["assistance"] + }, + { + "id": "15cfea92-65bc-441e-bf55-1234c73b3129", + "username": "service-account-webclient", + "emailVerified": false, + "createdTimestamp": 1739881777223, + "enabled": true, + "totp": false, + "serviceAccountClientId": "webclient", + "disableableCredentialTypes": [], + "requiredActions": [], + "realmRoles": ["default-roles-platine", "webclient"], + "notBefore": 0, + "groups": [] + } + ], + "clients": [ + { + "clientId": "myclient", + "enabled": true, + "serviceAccountsEnabled": false, + "authorizationServicesEnabled": false, + "publicClient": true, + "directAccessGrantsEnabled": true, + "redirectUris": ["*"], + "webOrigins": ["*"], + "protocol": "openid-connect", + "standardFlowEnabled": true, + "implicitFlowEnabled": false + }, + { + "clientId": "webclient", + "enabled": true, + "serviceAccountsEnabled": true, + "authorizationServicesEnabled": false, + "publicClient": false, + "directAccessGrantsEnabled": false, + "redirectUris": ["*"], + "webOrigins": ["*"], + "protocol": "openid-connect", + "standardFlowEnabled": false, + "implicitFlowEnabled": false, + "clientAuthenticatorType": "client-secret", + "secret": "webclient" + } + ], + "roles": { + "realm": [ + { + "name": "gestionnaire", + "description": "gestionnaire role" + }, + { + "name": "admin", + "description": "Admin role" + }, + { + "name": "respondent", + "description": "Respondent role" + }, + { + "name": "webclient", + "description": "webclient role" + } + ] + } +} \ No newline at end of file diff --git a/container/mongodb/mongodb_rs1_init.sh b/container/mongodb/mongodb_rs1_init.sh new file mode 100644 index 00000000..18c69012 --- /dev/null +++ b/container/mongodb/mongodb_rs1_init.sh @@ -0,0 +1,63 @@ +#!/bin/bash + +MONGODB_INITIAL_PRIMARY_HOST=mongod1 +#port=${PORT:-27017} +MONGODB_PORT_NUMBER=27017 + +echo "###### Waiting for ${MONGODB_INITIAL_PRIMARY_HOST} instance startup.." +until mongosh --host ${MONGODB_INITIAL_PRIMARY_HOST}:${MONGODB_PORT_NUMBER} --eval 'quit(db.runCommand({ ping: 1 }).ok ? 0 : 2)' &>/dev/null; do + printf '.' + sleep 1 +done +echo "###### Working ${MONGODB_INITIAL_PRIMARY_HOST} instance found, initiating user setup & initializing rs setup.." + +# setup user + pass and initialize replica sets +mongosh -u $MONGO_INITDB_ROOT_USERNAME -p $MONGO_INITDB_ROOT_PASSWORD --host ${MONGODB_INITIAL_PRIMARY_HOST}:${MONGODB_PORT_NUMBER} <org.springframework.boot spring-boot-starter-oauth2-resource-server + + org.springframework.boot + spring-boot-starter-oauth2-client + org.springframework.boot spring-boot-starter-webflux @@ -125,6 +129,19 @@ ${mapstruct.version} + + org.wiremock + wiremock-jetty12 + 3.13.2 + test + + + + org.wiremock.integrations + wiremock-spring-boot + 3.10.6 + + io.cucumber diff --git a/src/main/java/fr/insee/genesis/configuration/rest/LoggingInterceptor.java b/src/main/java/fr/insee/genesis/configuration/rest/LoggingInterceptor.java new file mode 100644 index 00000000..33088027 --- /dev/null +++ b/src/main/java/fr/insee/genesis/configuration/rest/LoggingInterceptor.java @@ -0,0 +1,33 @@ +package fr.insee.genesis.configuration.rest; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpRequest; +import org.springframework.http.client.ClientHttpRequestExecution; +import org.springframework.http.client.ClientHttpRequestInterceptor; +import org.springframework.http.client.ClientHttpResponse; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +@Slf4j +public class LoggingInterceptor implements ClientHttpRequestInterceptor { + + @Override + public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution) throws IOException { + logRequest(request, body); + ClientHttpResponse response = execution.execute(request, body); + logResponse(response); + return response; + } + + private void logRequest(HttpRequest request, byte[] body) { + log.debug("REQUEST: {} {}", request.getMethod(), request.getURI()); + request.getHeaders().forEach((key, value) -> log.debug("{}: {}", key, value)); + log.debug("BODY: {}", new String(body, StandardCharsets.UTF_8)); + } + + private void logResponse(ClientHttpResponse response) throws IOException { + log.debug("RESPONSE STATUS: {}", response.getStatusCode()); + response.getHeaders().forEach((key, value) -> log.debug("{}: {}", key, value)); + } +} \ No newline at end of file diff --git a/src/main/java/fr/insee/genesis/configuration/rest/RestClientConfiguration.java b/src/main/java/fr/insee/genesis/configuration/rest/RestClientConfiguration.java new file mode 100644 index 00000000..d9f5a942 --- /dev/null +++ b/src/main/java/fr/insee/genesis/configuration/rest/RestClientConfiguration.java @@ -0,0 +1,38 @@ +package fr.insee.genesis.configuration.rest; + +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.client.InterceptingClientHttpRequestFactory; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.web.client.RestClient; + +import java.util.List; + +@Configuration +@Slf4j +@RequiredArgsConstructor +public class RestClientConfiguration { + @Value("${fr.insee.genesis.platine.url}") + private final String platineManagementUrl; + + + @Bean("platineRestClientBuilder") + public RestClient.Builder platineRestClientBuilder() { + return RestClient.builder() + .defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE) + .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .baseUrl(platineManagementUrl) + .requestFactory(new InterceptingClientHttpRequestFactory( + new SimpleClientHttpRequestFactory(), + List.of( + new UserJwtBearerInterceptor(), + new LoggingInterceptor() + ) + )); + } +} \ No newline at end of file diff --git a/src/main/java/fr/insee/genesis/configuration/rest/UserJwtBearerInterceptor.java b/src/main/java/fr/insee/genesis/configuration/rest/UserJwtBearerInterceptor.java new file mode 100644 index 00000000..269da3cf --- /dev/null +++ b/src/main/java/fr/insee/genesis/configuration/rest/UserJwtBearerInterceptor.java @@ -0,0 +1,30 @@ +package fr.insee.genesis.configuration.rest; + +import org.springframework.http.HttpRequest; +import org.springframework.http.client.ClientHttpRequestExecution; +import org.springframework.http.client.ClientHttpRequestInterceptor; +import org.springframework.http.client.ClientHttpResponse; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; + +import java.io.IOException; + +public class UserJwtBearerInterceptor implements ClientHttpRequestInterceptor { + + @Override + public ClientHttpResponse intercept( + HttpRequest request, + byte[] body, + ClientHttpRequestExecution execution + ) throws IOException { + + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth instanceof JwtAuthenticationToken jwtAuth) { + String tokenValue = jwtAuth.getToken().getTokenValue(); + request.getHeaders().setBearerAuth(tokenValue); + } + return execution.execute(request, body); + } +} + diff --git a/src/main/java/fr/insee/genesis/controller/rest/CombinedRawDataController.java b/src/main/java/fr/insee/genesis/controller/rest/CombinedRawDataController.java index a35397ab..1a3421b2 100644 --- a/src/main/java/fr/insee/genesis/controller/rest/CombinedRawDataController.java +++ b/src/main/java/fr/insee/genesis/controller/rest/CombinedRawDataController.java @@ -1,10 +1,12 @@ package fr.insee.genesis.controller.rest; import fr.insee.genesis.controller.dto.rawdata.CombinedRawDataDto; +import fr.insee.genesis.controller.utils.platine.PlatinePermissionHelper; import fr.insee.genesis.domain.service.rawdata.CombinedRawDataService; import io.swagger.v3.oas.annotations.Operation; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.stereotype.Controller; @@ -20,14 +22,19 @@ public class CombinedRawDataController { private static final String INTERROGATION_ID = "interrogationId"; private final CombinedRawDataService combinedRawDataService; + private final PlatinePermissionHelper platinePermissionHelper; @Operation(summary = "Retrieve combined raw responses and Lunatic raw data for a given interrogationId") @GetMapping @PreAuthorize("hasAnyRole('ADMIN', 'USER_PLATINE')") - public ResponseEntity getCombinetRawData( + public ResponseEntity getCombinedRawData( @RequestParam(INTERROGATION_ID) String interrogationId ){ + if(!platinePermissionHelper.hasExportDataPermission(interrogationId)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + CombinedRawDataDto data = combinedRawDataService.getCombinedRawDataByInterrogationId(interrogationId); if (data.rawResponseModels().isEmpty()) { diff --git a/src/main/java/fr/insee/genesis/controller/utils/platine/PlatinePermissionEnum.java b/src/main/java/fr/insee/genesis/controller/utils/platine/PlatinePermissionEnum.java new file mode 100644 index 00000000..b8353a26 --- /dev/null +++ b/src/main/java/fr/insee/genesis/controller/utils/platine/PlatinePermissionEnum.java @@ -0,0 +1,5 @@ +package fr.insee.genesis.controller.utils.platine; + +public enum PlatinePermissionEnum { + INTERROGATION_DATA_EXPORT +} diff --git a/src/main/java/fr/insee/genesis/controller/utils/platine/PlatinePermissionHelper.java b/src/main/java/fr/insee/genesis/controller/utils/platine/PlatinePermissionHelper.java new file mode 100644 index 00000000..15031894 --- /dev/null +++ b/src/main/java/fr/insee/genesis/controller/utils/platine/PlatinePermissionHelper.java @@ -0,0 +1,56 @@ +package fr.insee.genesis.controller.utils.platine; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestClientResponseException; + +@Component +@Slf4j +public class PlatinePermissionHelper { + private final RestClient platineRestClient; + + public PlatinePermissionHelper(RestClient.Builder platineRestClientBuilder) { + this.platineRestClient = platineRestClientBuilder.build(); + } + + public boolean hasExportDataPermission(String interrogationId) { + try { + platineRestClient + .get() + .uri(uriBuilder -> uriBuilder + .path("/api/permissions/check") + .queryParam("permission", PlatinePermissionEnum.INTERROGATION_DATA_EXPORT.name()) + .queryParam("id", interrogationId) + .build()) + .retrieve() + .toBodilessEntity(); + log.info("Platine permission granted for interrogation id {} and permission {}", + interrogationId, + PlatinePermissionEnum.INTERROGATION_DATA_EXPORT.name()); + return true; + } + catch (RestClientResponseException e) { + HttpStatus status = HttpStatus.resolve(e.getStatusCode().value()); + + if (status == HttpStatus.UNAUTHORIZED || status == HttpStatus.FORBIDDEN) { + log.warn("Platine permission denied for interrogation id {} and permission {}, returned http code: {}", + interrogationId, + PlatinePermissionEnum.INTERROGATION_DATA_EXPORT.name(), + status.value()); + return false; + } + throw e; + } + catch (RestClientException e) { + log.error("RestClient failure calling Platine: {}", e.getMessage(), e); + throw e; + } + catch (Exception e) { + log.error("Unexpected error checking Platine permission: {}", e.getMessage(), e); + throw e; + } + } +} diff --git a/src/main/java/lombok.config b/src/main/java/lombok.config new file mode 100644 index 00000000..d0125371 --- /dev/null +++ b/src/main/java/lombok.config @@ -0,0 +1,2 @@ +lombok.addLombokGeneratedAnnotation=true +lombok.copyableAnnotations += org.springframework.beans.factory.annotation.Value \ No newline at end of file diff --git a/src/main/resources/application-compose.properties b/src/main/resources/application-compose.properties new file mode 100644 index 00000000..c9191ec4 --- /dev/null +++ b/src/main/resources/application-compose.properties @@ -0,0 +1,25 @@ +spring.data.mongodb.uri=mongodb://genesisuser:genesispassword@localhost:27017/genesisdb?authSource=genesisdb +fr.insee.genesis.application.host.url=http://localhost:8080 +springdoc.swagger-ui.oauth.client-id=myclient +fr.insee.genesis.authentication=OIDC +fr.insee.genesis.oidc.service.client-id=webclient +fr.insee.genesis.oidc.service.client-secret=webclient +fr.insee.genesis.sourcefolder.data=${java.io.tmpdir} +fr.insee.genesis.sourcefolder.specifications=${java.io.tmpdir} +fr.insee.genesis.oidc.auth-server-url=http://localhost:7080 +fr.insee.genesis.oidc.realm=platine +fr.insee.genesis.oidc.dmz.auth-server-url=http://localhost:7080 +fr.insee.genesis.oidc.dmz.realm=platine +app.role.admin.claims=admin +app.role.user-kraftwerk.claims=admin +app.role.user-platine.claims=admin +app.role.user-back-office.claims=utilisateur_Back_Office +app.role.reader.claims=lecteur_traiter +app.role.collect-platform.claims=protools +app.role.scheduler.claims=scheduler_traiter +app.role.batch-generic.claims=utilisateur_batch_generic +logging.file.name=${java.io.tmpdir} + +fr.insee.genesis.rawdata.process.batch-size=1000 +fr.insee.genesis.survey-quality-tool.url=*** +fr.insee.genesis.platine.url=http://localhost:3000 diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 15d29f60..74efbe37 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -47,4 +47,9 @@ management.endpoint.health.show-details=always #-------------------------------------------------------------------------- spring.data.mongodb.auto-index-creation=true spring.data.mongodb.uri=mongodb://${fr.insee.genesis.persistence.database.mongodb.username}:${fr.insee.genesis.persistence.database.mongodb.password}@${fr.insee.genesis.persistence.database.mongodb.host1}:${fr.insee.genesis.persistence.database.mongodb.port},${fr.insee.genesis.persistence.database.mongodb.host2}:${fr.insee.genesis.persistence.database.mongodb.port},${fr.insee.genesis.persistence.database.mongodb.host3}:${fr.insee.genesis.persistence.database.mongodb.port}/${fr.insee.genesis.persistence.database.mongodb.database} -server.compression.enabled=true \ No newline at end of file +server.compression.enabled=true + +#-------------------------------------------------------------------------- +# Platine management properties +#-------------------------------------------------------------------------- +fr.insee.genesis.platine.url= diff --git a/src/test/java/fr/insee/genesis/configuration/rest/LoggingInterceptorTest.java b/src/test/java/fr/insee/genesis/configuration/rest/LoggingInterceptorTest.java new file mode 100644 index 00000000..d45de35b --- /dev/null +++ b/src/test/java/fr/insee/genesis/configuration/rest/LoggingInterceptorTest.java @@ -0,0 +1,51 @@ +package fr.insee.genesis.configuration.rest; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.mock.http.client.MockClientHttpRequest; +import org.springframework.mock.http.client.MockClientHttpResponse; + +import java.net.URI; + +import static org.assertj.core.api.Assertions.assertThat; + +class LoggingInterceptorTest { + + @Test + void shouldLogRequestAndResponse_onDebug() throws Exception { + Logger logger = (Logger) LoggerFactory.getLogger(LoggingInterceptor.class); + Level old = logger.getLevel(); + logger.setLevel(Level.DEBUG); + + ListAppender appender = new ListAppender<>(); + appender.start(); + logger.addAppender(appender); + + try { + LoggingInterceptor interceptor = new LoggingInterceptor(); + + MockClientHttpRequest request = new MockClientHttpRequest(HttpMethod.GET, URI.create("http://localhost/test")); + byte[] body = "hi".getBytes(); + + try (var response = interceptor.intercept(request, body, (req, b) -> + new MockClientHttpResponse(new byte[0], HttpStatus.OK))) { + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + assertThat(appender.list) + .extracting(ILoggingEvent::getFormattedMessage) + .anyMatch(m -> m.contains("REQUEST: GET http://localhost/test")) + .anyMatch(m -> m.contains("RESPONSE STATUS: 200 OK")); + } finally { + logger.detachAppender(appender); + logger.setLevel(old); + } + } +} diff --git a/src/test/java/fr/insee/genesis/configuration/rest/UserJwtBearerInterceptorTest.java b/src/test/java/fr/insee/genesis/configuration/rest/UserJwtBearerInterceptorTest.java new file mode 100644 index 00000000..e81369ae --- /dev/null +++ b/src/test/java/fr/insee/genesis/configuration/rest/UserJwtBearerInterceptorTest.java @@ -0,0 +1,74 @@ +package fr.insee.genesis.configuration.rest; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.client.ClientHttpRequestExecution; +import org.springframework.http.client.ClientHttpResponse; +import org.springframework.mock.http.client.MockClientHttpRequest; +import org.springframework.mock.http.client.MockClientHttpResponse; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; + +import java.net.URI; + +import static org.assertj.core.api.Assertions.assertThat; + +class UserJwtBearerInterceptorTest { + + private final UserJwtBearerInterceptor interceptor = new UserJwtBearerInterceptor(); + + @AfterEach + void cleanup() { + SecurityContextHolder.clearContext(); + } + + @Test + void shouldAddBearerAuthHeader_whenJwtAuthenticationPresent() throws Exception { + // given + Jwt jwt = Jwt.withTokenValue("token-123") + .header("alg", "none") + .claim("sub", "user") + .build(); + + SecurityContextHolder.getContext().setAuthentication(new JwtAuthenticationToken(jwt)); + + MockClientHttpRequest request = new MockClientHttpRequest(HttpMethod.GET, URI.create("http://localhost/test")); + byte[] body = new byte[0]; + + ClientHttpRequestExecution execution = (req, b) -> { + assertThat(req.getHeaders().getFirst(HttpHeaders.AUTHORIZATION)) + .isEqualTo("Bearer token-123"); + return new MockClientHttpResponse(new byte[0], HttpStatus.OK); + }; + + // when + ClientHttpResponse response = interceptor.intercept(request, body, execution); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @Test + void shouldNotAddBearerAuthHeader_whenNoJwtAuthentication() throws Exception { + // given + SecurityContextHolder.clearContext(); + + MockClientHttpRequest request = new MockClientHttpRequest(HttpMethod.GET, URI.create("http://localhost/test")); + byte[] body = new byte[0]; + + ClientHttpRequestExecution execution = (req, b) -> { + assertThat(req.getHeaders().containsKey(HttpHeaders.AUTHORIZATION)).isFalse(); + return new MockClientHttpResponse(new byte[0], HttpStatus.OK); + }; + + // when + ClientHttpResponse response = interceptor.intercept(request, body, execution); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } +} diff --git a/src/test/java/fr/insee/genesis/controller/rest/CombinedRawDataControllerTest.java b/src/test/java/fr/insee/genesis/controller/rest/CombinedRawDataControllerTest.java new file mode 100644 index 00000000..3dc262d3 --- /dev/null +++ b/src/test/java/fr/insee/genesis/controller/rest/CombinedRawDataControllerTest.java @@ -0,0 +1,108 @@ +package fr.insee.genesis.controller.rest; + +import fr.insee.genesis.controller.dto.rawdata.CombinedRawDataDto; +import fr.insee.genesis.controller.utils.platine.PlatinePermissionHelper; +import fr.insee.genesis.domain.model.surveyunit.rawdata.LunaticJsonRawDataModel; +import fr.insee.genesis.domain.model.surveyunit.rawdata.RawResponseModel; +import fr.insee.genesis.domain.service.rawdata.CombinedRawDataService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import java.util.List; + +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +class CombinedRawDataControllerStandaloneTest { + + private CombinedRawDataService combinedRawDataService; + private PlatinePermissionHelper platinePermissionHelper; + private MockMvc mockMvc; + + @BeforeEach + void setup() { + combinedRawDataService = mock(CombinedRawDataService.class); + platinePermissionHelper = mock(PlatinePermissionHelper.class); + + CombinedRawDataController controller = + new CombinedRawDataController(combinedRawDataService, platinePermissionHelper); + + mockMvc = MockMvcBuilders + .standaloneSetup(controller) + .build(); + } + + @Test + @DisplayName("Should return 403 when PlatinePermissionHelper denies permission") + void shouldReturn403_whenPermissionDenied() throws Exception { + when(platinePermissionHelper.hasExportDataPermission("INT-1")).thenReturn(false); + + mockMvc.perform(get("/combined-raw-data") + .param("interrogationId", "INT-1") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isForbidden()); + + verify(platinePermissionHelper).hasExportDataPermission("INT-1"); + verifyNoInteractions(combinedRawDataService); + } + + @Test + @DisplayName("Should return 404 when service returns empty rawResponseModels") + void shouldReturn404_whenEmptyRawResponses() throws Exception { + when(platinePermissionHelper.hasExportDataPermission("INT-1")).thenReturn(true); + + CombinedRawDataDto dto = new CombinedRawDataDto( + List.of(), // rawResponseModels empty => 404 + List.of() + ); + + when(combinedRawDataService.getCombinedRawDataByInterrogationId("INT-1")) + .thenReturn(dto); + + mockMvc.perform(get("/combined-raw-data") + .param("interrogationId", "INT-1") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()); + + verify(platinePermissionHelper).hasExportDataPermission("INT-1"); + verify(combinedRawDataService).getCombinedRawDataByInterrogationId("INT-1"); + verifyNoMoreInteractions(combinedRawDataService); + } + + @Test + @DisplayName("Should return 200 and JSON body when rawResponseModels is not empty") + void shouldReturn200_whenNonEmptyRawResponses() throws Exception { + when(platinePermissionHelper.hasExportDataPermission("INT-1")).thenReturn(true); + + RawResponseModel raw = mock(RawResponseModel.class); + LunaticJsonRawDataModel lunatic = mock(LunaticJsonRawDataModel.class); + + CombinedRawDataDto dto = new CombinedRawDataDto( + List.of(raw), + List.of(lunatic) + ); + + when(combinedRawDataService.getCombinedRawDataByInterrogationId("INT-1")) + .thenReturn(dto); + + mockMvc.perform(get("/combined-raw-data") + .param("interrogationId", "INT-1") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + // on vérifie la structure : 2 champs, et tailles des tableaux + .andExpect(jsonPath("$.rawResponseModels").isArray()) + .andExpect(jsonPath("$.rawResponseModels.length()").value(1)) + .andExpect(jsonPath("$.lunaticRawDataModels").isArray()) + .andExpect(jsonPath("$.lunaticRawDataModels.length()").value(1)); + + verify(platinePermissionHelper).hasExportDataPermission("INT-1"); + verify(combinedRawDataService).getCombinedRawDataByInterrogationId("INT-1"); + verifyNoMoreInteractions(combinedRawDataService); + } +} diff --git a/src/test/java/fr/insee/genesis/controller/utils/platine/PlatinePermissionHelperTest.java b/src/test/java/fr/insee/genesis/controller/utils/platine/PlatinePermissionHelperTest.java new file mode 100644 index 00000000..73d1d761 --- /dev/null +++ b/src/test/java/fr/insee/genesis/controller/utils/platine/PlatinePermissionHelperTest.java @@ -0,0 +1,104 @@ +package fr.insee.genesis.controller.utils.platine; + +import com.github.tomakehurst.wiremock.WireMockServer; +import fr.insee.genesis.configuration.rest.RestClientConfiguration; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.wiremock.spring.EnableWireMock; +import org.wiremock.spring.InjectWireMock; + +import static com.github.tomakehurst.wiremock.client.WireMock.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@EnableWireMock +@SpringBootTest(classes = { + RestClientConfiguration.class, + PlatinePermissionHelper.class + }, + properties = { + "fr.insee.genesis.platine.url=http://localhost:${wiremock.server.port}" + } +) +class PlatinePermissionHelperTest { + + @InjectWireMock + private WireMockServer wireMock; + + @AfterEach + void cleanup() { + SecurityContextHolder.clearContext(); + } + + @Autowired + PlatinePermissionHelper helper; + + @Test + void shouldReturnTrue_andSendBearerToken_whenPermissionGranted() { + // given + Jwt jwt = Jwt.withTokenValue("token-123") + .header("alg", "none") + .claim("sub", "user") + .build(); + SecurityContextHolder.getContext().setAuthentication(new JwtAuthenticationToken(jwt)); + + wireMock.stubFor(get(urlPathEqualTo("/api/permissions/check")) + .withQueryParam("permission", equalTo("INTERROGATION_DATA_EXPORT")) + .withQueryParam("id", equalTo("INT-1")) + .willReturn(aResponse().withStatus(200))); + + // when + boolean result = helper.hasExportDataPermission("INT-1"); + + // then + assertThat(result).isTrue(); + + wireMock.verify(getRequestedFor(urlPathEqualTo("/api/permissions/check")) + .withHeader("Authorization", equalTo("Bearer token-123")) + .withQueryParam("permission", equalTo("INTERROGATION_DATA_EXPORT")) + .withQueryParam("id", equalTo("INT-1"))); + } + + @Test + void shouldReturnFalse_whenPlatineReturns403() { + wireMock.stubFor(get(urlPathEqualTo("/api/permissions/check")) + .withQueryParam("permission", equalTo("INTERROGATION_DATA_EXPORT")) + .withQueryParam("id", equalTo("INT-2")) + .willReturn(aResponse().withStatus(403))); + + boolean result = helper.hasExportDataPermission("INT-2"); + + assertThat(result).isFalse(); + + wireMock.verify(getRequestedFor(urlPathEqualTo("/api/permissions/check")) + .withQueryParam("permission", equalTo("INTERROGATION_DATA_EXPORT")) + .withQueryParam("id", equalTo("INT-2"))); + } + + @Test + void shouldReturnFalse_whenPlatineReturns401() { + wireMock.stubFor(get(urlPathEqualTo("/api/permissions/check")) + .withQueryParam("permission", equalTo("INTERROGATION_DATA_EXPORT")) + .withQueryParam("id", equalTo("INT-3")) + .willReturn(aResponse().withStatus(401))); + + boolean result = helper.hasExportDataPermission("INT-3"); + + assertThat(result).isFalse(); + } + + @Test + void shouldThrow_whenPlatineReturns500() { + wireMock.stubFor(get(urlPathEqualTo("/api/permissions/check")) + .withQueryParam("permission", equalTo("INTERROGATION_DATA_EXPORT")) + .withQueryParam("id", equalTo("INT-4")) + .willReturn(aResponse().withStatus(500))); + + assertThatThrownBy(() -> helper.hasExportDataPermission("INT-4")) + .isInstanceOf(org.springframework.web.client.RestClientException.class); + } +}