diff --git a/src/main/java/fr/insee/genesis/configuration/auth/security/ApplicationRole.java b/src/main/java/fr/insee/genesis/configuration/auth/security/ApplicationRole.java new file mode 100644 index 00000000..212749e8 --- /dev/null +++ b/src/main/java/fr/insee/genesis/configuration/auth/security/ApplicationRole.java @@ -0,0 +1,10 @@ +package fr.insee.genesis.configuration.auth.security; + +public enum ApplicationRole { + ADMIN, + USER_KRAFTWERK, + USER_PLATINE, + COLLECT_PLATFORM, + READER +} + diff --git a/src/main/java/fr/insee/genesis/configuration/auth/security/OIDCSecurityConfig.java b/src/main/java/fr/insee/genesis/configuration/auth/security/OIDCSecurityConfig.java index ac524ce0..fa7b9635 100644 --- a/src/main/java/fr/insee/genesis/configuration/auth/security/OIDCSecurityConfig.java +++ b/src/main/java/fr/insee/genesis/configuration/auth/security/OIDCSecurityConfig.java @@ -1,48 +1,120 @@ package fr.insee.genesis.configuration.auth.security; -import fr.insee.genesis.configuration.Config; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.Setter; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.core.convert.converter.Converter; +import org.springframework.http.HttpMethod; import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.www.BasicAuthenticationFilter; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; + @Configuration @EnableWebSecurity +@EnableMethodSecurity(prePostEnabled = true) @Slf4j @ConditionalOnProperty(name = "fr.insee.genesis.authentication", havingValue = "OIDC") +@ConfigurationProperties(prefix = "fr.insee.genesis.security") +@RequiredArgsConstructor public class OIDCSecurityConfig { - Config config; - @Autowired - public OIDCSecurityConfig(Config config) { - this.config = config; - } + @Getter + @Setter + private String[] whitelistMatchers; + private static final String ROLE_PREFIX = "ROLE_"; + private final RoleConfiguration roleConfiguration; + private final SecurityTokenProperties inseeSecurityTokenProperties; - @Bean - public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { - http - .csrf(AbstractHttpConfigurer::disable) - .sessionManagement(sess -> sess.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); - for (var pattern : config.getWhiteList()) { - http.authorizeHttpRequests(authorize -> - authorize - .requestMatchers(AntPathRequestMatcher.antMatcher(pattern)).permitAll() - ); - } - http - .authorizeHttpRequests(configurer -> configurer - .anyRequest().authenticated() - ) - .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults())); - return http.build(); + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + .sessionManagement(sess -> sess.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); + for (var pattern : whitelistMatchers) { + http.authorizeHttpRequests(authorize -> + authorize + .requestMatchers(AntPathRequestMatcher.antMatcher(pattern)).permitAll() + ); } + http + .authorizeHttpRequests(configurer -> configurer + .requestMatchers(HttpMethod.GET,"/questionnaires/**").hasRole(String.valueOf(ApplicationRole.READER)) + .requestMatchers(HttpMethod.GET,"/modes/**").hasRole(String.valueOf(ApplicationRole.READER)) + .requestMatchers(HttpMethod.GET,"/interrogations/**").hasRole(String.valueOf(ApplicationRole.READER)) + .requestMatchers(HttpMethod.GET,"/campaigns/**").hasRole(String.valueOf(ApplicationRole.READER)) + .anyRequest().authenticated() + ) + .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults())); + return http.build(); + } + + @Bean + JwtAuthenticationConverter jwtAuthenticationConverter() { + JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter(); + jwtAuthenticationConverter.setPrincipalClaimName(inseeSecurityTokenProperties.getOidcClaimUsername()); + jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(jwtGrantedAuthoritiesConverter()); + return jwtAuthenticationConverter; + } + + Converter> jwtGrantedAuthoritiesConverter() { + return new Converter>() { + @Override + @SuppressWarnings({"unchecked"}) + public Collection convert(Jwt source) { + + String[] claimPath = inseeSecurityTokenProperties.getOidcClaimRole().split("\\."); + Map claims = source.getClaims(); + try { + for (int i = 0; i < claimPath.length - 1; i++) { + claims = (Map) claims.get(claimPath[i]); + } + if (claims != null) { + List tokenClaims = (List) claims.getOrDefault(claimPath[claimPath.length - 1], List.of()); + // Collect distinct values from mapping associated with input keys + List claimedRoles = tokenClaims.stream() + .filter(roleConfiguration.getRolesByClaim()::containsKey) // Ensure the key exists in the mapping + .flatMap(key -> roleConfiguration.getRolesByClaim().get(key).stream()) // Get the list of values associated with the key + .distinct() // Remove duplicates + .toList(); + + return Collections.unmodifiableCollection(claimedRoles.stream().map(s -> new GrantedAuthority() { + @Override + public String getAuthority() { + return ROLE_PREFIX + s; + } + + @Override + public String toString() { + return getAuthority(); + } + }).toList()); + } + } catch (ClassCastException e) { + // role path not correctly found, assume that no role for this user + return List.of(); + } + return List.of(); + } + }; + } } diff --git a/src/main/java/fr/insee/genesis/configuration/auth/security/RoleConfiguration.java b/src/main/java/fr/insee/genesis/configuration/auth/security/RoleConfiguration.java new file mode 100644 index 00000000..67b017da --- /dev/null +++ b/src/main/java/fr/insee/genesis/configuration/auth/security/RoleConfiguration.java @@ -0,0 +1,97 @@ +package fr.insee.genesis.configuration.auth.security; + +import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.access.expression.method.DefaultMethodSecurityExpressionHandler; +import org.springframework.security.access.expression.method.MethodSecurityExpressionHandler; +import org.springframework.security.access.hierarchicalroles.RoleHierarchy; +import org.springframework.security.access.hierarchicalroles.RoleHierarchyImpl; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Configuration +@Slf4j +public class RoleConfiguration { + + //Mapping des claims du jeton sur les roles applicatifs + @Value("#{'${app.role.admin.claims}'.split(',')}") + private List adminClaims; + @Value("#{'${app.role.user-kraftwerk.claims}'.split(',')}") + private List userKraftwerkClaims; + @Value("#{'${app.role.user-platine.claims}'.split(',')}") + private List userPlatineClaims; + @Value("#{'${app.role.reader.claims}'.split(',')}") + private List readerClaims; + @Value("#{'${app.role.collect-platform.claims}'.split(',')}") + private List collectPlatformClaims; + + public Map> getRolesByClaim() { + return rolesByClaim; + } + + private Map> rolesByClaim; + + //Defines a role hierarchy + //ADMIN implies USER role too + //USER implies READER role too + //so an admin has 2 roles: ADMIN/USER + @Bean + static RoleHierarchy roleHierarchy() { + return RoleHierarchyImpl.withDefaultRolePrefix() + .role(ApplicationRole.ADMIN.toString()).implies(ApplicationRole.USER_KRAFTWERK.toString()) + .role(ApplicationRole.ADMIN.toString()).implies(ApplicationRole.USER_PLATINE.toString()) + .role(ApplicationRole.ADMIN.toString()).implies(ApplicationRole.COLLECT_PLATFORM.toString()) + .role(ApplicationRole.USER_KRAFTWERK.toString()).implies(ApplicationRole.READER.toString()) + .role(ApplicationRole.USER_PLATINE.toString()).implies(ApplicationRole.READER.toString()) + .build(); + } + + // and, if using pre-post method security also add + @Bean + static MethodSecurityExpressionHandler methodSecurityExpressionHandler(RoleHierarchy roleHierarchy) { + DefaultMethodSecurityExpressionHandler expressionHandler = new DefaultMethodSecurityExpressionHandler(); + expressionHandler.setRoleHierarchy(roleHierarchy); + return expressionHandler; + } + + @PostConstruct + public void initialization() { + + rolesByClaim = new HashMap<>(); + + // Ajout des claims pour le rôle ADMIN + adminClaims.forEach(claim -> rolesByClaim + .computeIfAbsent(claim, k -> new ArrayList<>()) + .add(String.valueOf(ApplicationRole.ADMIN))); + + // Ajout des claims pour le rôle USER_KRAFTWERK + userKraftwerkClaims.forEach(claim -> rolesByClaim + .computeIfAbsent(claim, k -> new ArrayList<>()) + .add(String.valueOf(ApplicationRole.USER_KRAFTWERK))); + + // Ajout des claims pour le rôle USER_PLATINE + userPlatineClaims.forEach(claim -> rolesByClaim + .computeIfAbsent(claim, k -> new ArrayList<>()) + .add(String.valueOf(ApplicationRole.USER_PLATINE))); + + // Ajout des claims pour le rôle COLLECT_PLATFORM + collectPlatformClaims.forEach(claim -> rolesByClaim + .computeIfAbsent(claim, k -> new ArrayList<>()) + .add(String.valueOf(ApplicationRole.COLLECT_PLATFORM))); + + // Ajout des claims pour le rôle READER + readerClaims.forEach(claim -> rolesByClaim + .computeIfAbsent(claim, k -> new ArrayList<>()) + .add(String.valueOf(ApplicationRole.READER))); + + + log.info("Roles configuration : {}", rolesByClaim); + } + +} diff --git a/src/main/java/fr/insee/genesis/configuration/auth/security/SecurityTokenProperties.java b/src/main/java/fr/insee/genesis/configuration/auth/security/SecurityTokenProperties.java new file mode 100644 index 00000000..1990b957 --- /dev/null +++ b/src/main/java/fr/insee/genesis/configuration/auth/security/SecurityTokenProperties.java @@ -0,0 +1,19 @@ +package fr.insee.genesis.configuration.auth.security; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration +@ConfigurationProperties( + prefix = "fr.insee.genesis.security.token" +) +@Data +public class SecurityTokenProperties { + + //Chemin pour récupérer la liste des rôles dans le jwt (token) + private String oidcClaimRole; + //Chemin pour récupérer le username dans le jwt (token) + private String oidcClaimUsername; + +} diff --git a/src/main/java/fr/insee/genesis/controller/rest/ScheduleController.java b/src/main/java/fr/insee/genesis/controller/rest/ScheduleController.java index 9be6b1e7..b238d8f2 100644 --- a/src/main/java/fr/insee/genesis/controller/rest/ScheduleController.java +++ b/src/main/java/fr/insee/genesis/controller/rest/ScheduleController.java @@ -15,6 +15,7 @@ import io.swagger.v3.oas.annotations.Parameter; import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; @@ -48,6 +49,7 @@ public ScheduleController(ScheduleApiPort scheduleApiPort, FileUtils fileUtils) @Operation(summary = "Fetch all schedules") @GetMapping(path = "/all") + @PreAuthorize("hasRole('READER')") public ResponseEntity getAllSchedules() { log.debug("Got GET all schedules request"); @@ -59,6 +61,7 @@ public ResponseEntity getAllSchedules() { @Operation(summary = "Schedule a Kraftwerk execution") @PutMapping(path = "/create") + @PreAuthorize("hasRole('ADMIN')") public ResponseEntity addSchedule( @Parameter(description = "Survey name to call Kraftwerk on") @RequestParam("surveyName") String surveyName, @Parameter(description = "Kraftwerk endpoint") @RequestParam(value = "serviceTocall", defaultValue = Constants.KRAFTWERK_MAIN_ENDPOINT) ServiceToCall serviceToCall, @@ -106,6 +109,7 @@ public ResponseEntity addSchedule( @Operation(summary = "Delete a Kraftwerk execution schedule(s) by its survey name") @DeleteMapping(path = "/delete") + @PreAuthorize("hasRole('ADMIN')") public ResponseEntity deleteSchedule( @Parameter(description = "Survey name of the schedule(s) to delete") @RequestParam("surveyName") String surveyName ){ @@ -122,6 +126,7 @@ public ResponseEntity deleteSchedule( @Operation(summary = "Set last execution date with new date or empty") @PostMapping(path = "/setLastExecutionDate") + @PreAuthorize("hasRole('ADMIN')") public ResponseEntity setSurveyLastExecution( @Parameter(description = "Survey name to call Kraftwerk on") @RequestBody String surveyName, @Parameter(description = "Date to save as last execution date", example = "2024-01-01T12:00:00") @RequestParam("newDate") LocalDateTime newDate @@ -138,6 +143,7 @@ public ResponseEntity setSurveyLastExecution( @Operation(summary = "Delete expired schedules") @DeleteMapping(path = "/delete/expired-schedules") + @PreAuthorize("hasRole('ADMIN')") public ResponseEntity deleteExpiredSchedules() throws NotFoundException, IOException { Set storedSurveySchedulesNames = new HashSet<>(); for(ScheduleModel scheduleModel : scheduleApiPort.getAllSchedules()){ diff --git a/src/main/java/fr/insee/genesis/controller/rest/UtilsController.java b/src/main/java/fr/insee/genesis/controller/rest/UtilsController.java index 4def6092..3859a372 100644 --- a/src/main/java/fr/insee/genesis/controller/rest/UtilsController.java +++ b/src/main/java/fr/insee/genesis/controller/rest/UtilsController.java @@ -7,6 +7,7 @@ import io.swagger.v3.oas.annotations.tags.Tag; import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestMapping; @@ -35,6 +36,7 @@ public UtilsController(SurveyUnitApiPort surveyUnitService,VolumetryLogService v @Operation(summary = "Split a XML file into smaller ones") @PutMapping(path = "/utils/split/lunatic-xml") + @PreAuthorize("hasRole('ADMIN')") public ResponseEntity saveResponsesFromXmlFile(@RequestParam("inputFolder") String inputFolder, @RequestParam("outputFolder") String outputFolder, @RequestParam("filename") String filename, @@ -46,6 +48,7 @@ public ResponseEntity saveResponsesFromXmlFile(@RequestParam("inputFolde @Operation(summary = "Record volumetrics of each campaign in a folder") @PutMapping(path = "/volumetrics/save-all-campaigns") + @PreAuthorize("hasRole('ADMIN')") public ResponseEntity saveVolumetry() throws IOException { volumetryLogService.writeVolumetries(surveyUnitService); volumetryLogService.cleanOldFiles(); diff --git a/src/main/java/fr/insee/genesis/controller/rest/responses/ResponseController.java b/src/main/java/fr/insee/genesis/controller/rest/responses/ResponseController.java index 82c39352..4529ede9 100644 --- a/src/main/java/fr/insee/genesis/controller/rest/responses/ResponseController.java +++ b/src/main/java/fr/insee/genesis/controller/rest/responses/ResponseController.java @@ -35,6 +35,7 @@ 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; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; @@ -99,6 +100,7 @@ public ResponseController(SurveyUnitApiPort surveyUnitService, //SAVE @Operation(summary = "Save one file of responses to Genesis Database, passing its path as a parameter") @PutMapping(path = "/lunatic-xml/save-one") + @PreAuthorize("hasRole('ADMIN')") public ResponseEntity saveResponsesFromXmlFile(@RequestParam("pathLunaticXml") String xmlFile, @RequestParam(value = "pathSpecFile") String metadataFilePath, @RequestParam(value = "mode") Mode modeSpecified, @@ -133,6 +135,7 @@ public ResponseEntity saveResponsesFromXmlFile(@RequestParam("pathLunati @Operation(summary = "Save multiple files to Genesis Database from the campaign root folder") @PutMapping(path = "/lunatic-xml/save-folder") + @PreAuthorize("hasRole('ADMIN')") public ResponseEntity saveResponsesFromXmlCampaignFolder(@RequestParam("campaignName") String campaignName, @RequestParam(value = "mode", required = false) Mode modeSpecified, @RequestParam(value = "withDDI", defaultValue = "true") boolean withDDI @@ -164,6 +167,7 @@ public ResponseEntity saveResponsesFromXmlCampaignFolder(@RequestParam(" @Operation(summary = "Save one file of raw responses to Genesis Database, passing its path as a parameter") @PutMapping(path = "/lunatic-xml/raw/save-one") + @PreAuthorize("hasRole('ADMIN')") public ResponseEntity saveRawResponsesFromXmlFile(@RequestParam("pathLunaticXml") String xmlFile, @RequestParam(value = "mode") Mode modeSpecified )throws Exception { @@ -175,6 +179,7 @@ public ResponseEntity saveRawResponsesFromXmlFile(@RequestParam("pathLun @Operation(summary = "Save multiple raw files to Genesis Database from the campaign root folder") @PutMapping(path = "/lunatic-xml/raw/save-folder") + @PreAuthorize("hasRole('ADMIN')") public ResponseEntity saveRawResponsesFromXmlCampaignFolder(@RequestParam("campaignName") String campaignName, @RequestParam(value = "mode", required = false) Mode modeSpecified )throws Exception { @@ -202,6 +207,7 @@ public ResponseEntity saveRawResponsesFromXmlCampaignFolder(@RequestPara //JSON @Operation(summary = "Save lunatic json data to Genesis Database from the campaign root folder") @PutMapping(path = "/lunatic-json/raw/save") + @PreAuthorize("hasRole('COLLECT_PLATFORM')") public ResponseEntity saveRawResponsesFromJsonBody( @RequestParam("campaignName") String campaignName, @RequestParam(value = "mode", required = false) Mode modeSpecified, @@ -222,6 +228,7 @@ public ResponseEntity saveRawResponsesFromJsonBody( //SAVE ALL @Operation(summary = "Save all files to Genesis Database (differential data folder only), regardless of the campaign") @PutMapping(path = "/lunatic-xml/save-all-campaigns") + @PreAuthorize("hasRole('ADMIN')") public ResponseEntity saveResponsesFromAllCampaignFolders(){ List errors = new ArrayList<>(); List campaignFolders = fileUtils.listAllSpecsFolders(); @@ -258,6 +265,7 @@ public ResponseEntity saveResponsesFromAllCampaignFolders(){ //DELETE @Operation(summary = "Delete all responses associated with a questionnaire") @DeleteMapping(path = "/delete/by-questionnaire") + @PreAuthorize("hasRole('ADMIN')") public ResponseEntity deleteAllResponsesByQuestionnaire(@RequestParam("questionnaireId") String questionnaireId) { log.info("Try to delete all responses of questionnaire : {}", questionnaireId); Long ndDocuments = surveyUnitService.deleteByQuestionnaireId(questionnaireId); @@ -268,6 +276,7 @@ public ResponseEntity deleteAllResponsesByQuestionnaire(@RequestParam("q //GET @Operation(summary = "Retrieve responses for an interrogation, using interrogationId and questionnaireId from Genesis Database") @GetMapping(path = "/by-ue-and-questionnaire") + @PreAuthorize("hasRole('ADMIN')") public ResponseEntity> findResponsesByInterrogationAndQuestionnaire(@RequestParam("interrogationId") String interrogationId, @RequestParam("questionnaireId") String questionnaireId) { List responses = surveyUnitService.findByIdsInterrogationAndQuestionnaire(interrogationId, questionnaireId); @@ -276,6 +285,7 @@ public ResponseEntity> findResponsesByInterrogationAndQues @Operation(summary = "Retrieve responses for an interrogation, using interrogationId and questionnaireId from Genesis Database with the latest value for each available state of every variable") @GetMapping(path = "/by-ue-and-questionnaire/latest-states") + @PreAuthorize("hasRole('USER_PLATINE')") public ResponseEntity findResponsesByInterrogationAndQuestionnaireLatestStates( @RequestParam("interrogationId") String interrogationId, @RequestParam("questionnaireId") String questionnaireId) { @@ -286,6 +296,7 @@ public ResponseEntity findResponsesByInterrogationAndQ @Operation(summary = "Retrieve all responses (for all interrogations) of one questionnaire") @GetMapping(path = "/by-questionnaire") + @PreAuthorize("hasRole('ADMIN')") public ResponseEntity findAllResponsesByQuestionnaire(@RequestParam("questionnaireId") String questionnaireId) { log.info("Try to find all responses of questionnaire : {}", questionnaireId); @@ -308,6 +319,7 @@ public ResponseEntity findAllResponsesByQuestionnaire(@RequestParam("quest @Operation(summary = "Retrieve responses for an interrogation, using interrogationId and questionnaireId from Genesis Database. It returns only the latest value of each variable regardless of the state.") @GetMapping(path = "/by-ue-and-questionnaire/latest") + @PreAuthorize("hasRole('ADMIN')") public ResponseEntity> getLatestByInterrogation(@RequestParam("interrogationId") String interrogationId, @RequestParam("questionnaireId") String questionnaireId) { List responses = surveyUnitService.findLatestByIdAndByQuestionnaireId(interrogationId, questionnaireId); @@ -316,6 +328,7 @@ public ResponseEntity> getLatestByInterrogation(@RequestPa @Operation(summary = "Retrieve responses for an interrogation, using interrogationId and questionnaireId from Genesis Database. For a given mode, it returns only the latest value of each variable regardless of the state. The result is one object by mode in the output") @GetMapping(path = "/simplified/by-ue-questionnaire-and-mode/latest") + @PreAuthorize("hasRole('USER_KRAFTWERK')") public ResponseEntity getLatestByInterrogationOneObject(@RequestParam("interrogationId") String interrogationId, @RequestParam("questionnaireId") String questionnaireId, @RequestParam("mode") Mode mode) { @@ -340,6 +353,7 @@ public ResponseEntity getLatestByInterrogationOneObject(@R description = "Return the latest state for each variable for the given ids and a given questionnaire.
" + "For a given id, the endpoint returns a document by collection mode (if there is more than one).") @PostMapping(path = "/simplified/by-list-interrogation-and-questionnaire/latest") + @PreAuthorize("hasRole('USER_KRAFTWERK')") public ResponseEntity> getLatestForInterrogationList(@RequestParam("questionnaireId") String questionnaireId, @RequestBody List interrogationIds) { List results = new ArrayList<>(); @@ -371,6 +385,7 @@ public ResponseEntity> getLatestForInterrogationList( @Operation(summary = "Save edited variables", description = "Save edited variables document into database") @PostMapping(path = "/save-edited") + @PreAuthorize("hasRole('USER_PLATINE')") public ResponseEntity saveEditedVariables( @RequestBody SurveyUnitInputDto surveyUnitInputDto ){ diff --git a/src/main/resources/application-dev.properties b/src/main/resources/application-dev.properties index 6588b136..64590247 100644 --- a/src/main/resources/application-dev.properties +++ b/src/main/resources/application-dev.properties @@ -12,4 +12,13 @@ fr.insee.genesis.persistence.database.mongodb.username=user #-------------------------------------------------------------------------- fr.insee.genesis.oidc.auth-server-url=*** fr.insee.genesis.oidc.realm=*** -springdoc.swagger-ui.oauth.client-id=*** \ No newline at end of file +springdoc.swagger-ui.oauth.client-id=*** + +#-------------------------------------------------------------------------- +# Role Configuration (change these with the real roles) +#-------------------------------------------------------------------------- +app.role.admin.claims=*** +app.role.user-kraftwerk.claims=*** +app.role.user-platine.claims=*** +app.role.reader.claims=*** +app.role.collect-platform.claims=*** \ No newline at end of file diff --git a/src/main/resources/application-preprod.properties b/src/main/resources/application-preprod.properties index c0192fce..f9a21054 100644 --- a/src/main/resources/application-preprod.properties +++ b/src/main/resources/application-preprod.properties @@ -12,4 +12,13 @@ fr.insee.genesis.persistence.database.mongodb.username=user #-------------------------------------------------------------------------- fr.insee.genesis.oidc.auth-server-url=*** fr.insee.genesis.oidc.realm=*** -springdoc.swagger-ui.oauth.client-id=*** \ No newline at end of file +springdoc.swagger-ui.oauth.client-id=*** + +#-------------------------------------------------------------------------- +# Role Configuration (change these with the real roles) +#-------------------------------------------------------------------------- +app.role.admin.claims=*** +app.role.user-kraftwerk.claims=*** +app.role.user-platine.claims=*** +app.role.reader.claims=*** +app.role.collect-platform.claims=*** \ No newline at end of file diff --git a/src/main/resources/application-prod.properties b/src/main/resources/application-prod.properties index a28c41aa..e5aa173a 100644 --- a/src/main/resources/application-prod.properties +++ b/src/main/resources/application-prod.properties @@ -12,4 +12,13 @@ fr.insee.genesis.persistence.database.mongodb.username=user #-------------------------------------------------------------------------- fr.insee.genesis.oidc.auth-server-url=*** fr.insee.genesis.oidc.realm=*** -springdoc.swagger-ui.oauth.client-id=*** \ No newline at end of file +springdoc.swagger-ui.oauth.client-id=*** + +#-------------------------------------------------------------------------- +# Role Configuration (change these with the real roles) +#-------------------------------------------------------------------------- +app.role.admin.claims=*** +app.role.user-kraftwerk.claims=*** +app.role.user-platine.claims=*** +app.role.reader.claims=*** +app.role.collect-platform.claims=*** \ No newline at end of file diff --git a/src/main/resources/application-test.properties b/src/main/resources/application-test.properties new file mode 100644 index 00000000..173f7deb --- /dev/null +++ b/src/main/resources/application-test.properties @@ -0,0 +1,15 @@ +fr.insee.genesis.application.host.url=http://localhost:8080 +fr.insee.genesis.sourcefolder.data = /data/genesis +fr.insee.genesis.sourcefolder.specifications = /data/genesis + +app.role.admin.claims=administrateur_traiter +app.role.user-kraftwerk.claims=utilisateur_Kraftwerk +app.role.user-platine.claims=utilisateur_Platine +app.role.reader.claims=lecteur_traiter +app.role.collect-platform.claims=protools + +logging.file.name = /logs/genesis-api.log + +fr.insee.genesis.oidc.auth-server-url=https://organisation.server.auth/auth +fr.insee.genesis.oidc.realm=test-realm +springdoc.swagger-ui.oauth.client-id=client-id-test \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index d39d50f8..19ab5801 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -25,6 +25,7 @@ fr.insee.genesis.security.token.oidc-claim-role=realm_access.roles fr.insee.genesis.security.token.oidc-claim-username=name spring.security.oauth2.resourceserver.jwt.issuer-uri=${fr.insee.genesis.oidc.auth-server-url}/realms/${fr.insee.genesis.oidc.realm} fr.insee.genesis.security.whitelist-matchers=/v3/api-docs/**,/swagger-ui/**,/swagger-ui.html,/actuator/**,/error,/,/health-check/** +springdoc.swagger-ui.oauth.scopes=openid,profile,roles #-------------------------------------------------------------------------- # Actuator diff --git a/src/test/java/fr/insee/genesis/controller/rest/ControllerAccessTest.java b/src/test/java/fr/insee/genesis/controller/rest/ControllerAccessTest.java new file mode 100644 index 00000000..623058d8 --- /dev/null +++ b/src/test/java/fr/insee/genesis/controller/rest/ControllerAccessTest.java @@ -0,0 +1,250 @@ +package fr.insee.genesis.controller.rest; + +import fr.insee.genesis.domain.ports.api.ScheduleApiPort; +import fr.insee.genesis.domain.ports.api.SurveyUnitApiPort; +import fr.insee.genesis.infrastructure.repository.LunaticJsonMongoDBRepository; +import fr.insee.genesis.infrastructure.repository.LunaticXmlMongoDBRepository; +import fr.insee.genesis.infrastructure.repository.RundeckExecutionDBRepository; +import fr.insee.genesis.infrastructure.repository.ScheduleMongoDBRepository; +import fr.insee.genesis.infrastructure.repository.SurveyUnitMongoDBRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.MockedStatic; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.data.mongo.MongoDataAutoConfiguration; +import org.springframework.boot.autoconfigure.mongo.MongoAutoConfiguration; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.http.MediaType; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@EnableAutoConfiguration(exclude = {MongoAutoConfiguration.class, MongoDataAutoConfiguration.class}) +class ControllerAccessTest { + + // JWT claim properties loaded from application properties + @Value("${fr.insee.genesis.security.token.oidc-claim-role}") + private String claimRoleDotRoles; + @Value("${fr.insee.genesis.security.token.oidc-claim-username}") + private String claimName; + + @Autowired + private MockMvc mockMvc; // Simulates HTTP requests to the REST endpoints + @MockitoBean + private JwtDecoder jwtDecoder; + @MockitoBean + private MongoTemplate mongoTemplate; + @MockitoBean + private ScheduleApiPort scheduleApiPort; + @MockitoBean + private SurveyUnitApiPort surveyUnitApiPort; + @MockitoBean + private SurveyUnitMongoDBRepository surveyUnitMongoDBRepository; + @MockitoBean + private LunaticJsonMongoDBRepository lunaticJsonMongoDBRepository; + @MockitoBean + private LunaticXmlMongoDBRepository lunaticXmlMongoDBRepository; + @MockitoBean + private RundeckExecutionDBRepository rundeckExecutionDBRepository; + @MockitoBean + private ScheduleMongoDBRepository scheduleMongoDBRepository; + + // Constants for user roles + private static final String USER = "USER"; + private static final String USER_KRAFTWERK = "USER_KRAFTWERK"; + private static final String USER_PLATINE = "USER_PLATINE"; + private static final String ADMIN = "ADMIN"; + private static final String READER = "READER"; + + /** + * Provides a stream of URIs that are allowed for reader. + */ + private static Stream endpointsReader(){ + return Stream.of( + Arguments.of("/questionnaires/with-campaigns"), + Arguments.of("/questionnaires/by-campaign?campaignId=CAMPAIGNTEST"), + Arguments.of("/questionnaires/"), + Arguments.of("/modes/by-questionnaire?questionnaireId=QUESTTEST"), + Arguments.of("/modes/by-campaign?campaignId=CAMPAIGNTEST"), + Arguments.of("/interrogations/by-questionnaire?questionnaireId=QUESTTEST"), + Arguments.of("/campaigns/with-questionnaires"), + Arguments.of("/campaigns/") + ); + } + + /** + * Tests that users with the "ADMIN" role can access read-only endpoints. + */ + @ParameterizedTest + @MethodSource("endpointsReader") + @DisplayName("Admins should access reader-allowed services") + void admin_should_access_reader_allowed_services(String endpointURI) throws Exception{ + Jwt jwt = generateJwt(List.of("administrateur_traiter"), ADMIN); + when(jwtDecoder.decode(anyString())).thenReturn(jwt); + mockMvc.perform(get(endpointURI).header("Authorization", "bearer token_blabla")) + .andExpect(status().isOk()); + } + + /** + * Tests that users with the "USER_KRAFTWERK" role can access read-only endpoints. + */ + @ParameterizedTest + @MethodSource("endpointsReader") + @DisplayName("Kraftwerk users should access reader-allowed services") + void kraftwerk_users_should_access_reader_allowed_services(String endpointURI) throws Exception{ + Jwt jwt = generateJwt(List.of("utilisateur_Kraftwerk"), USER_KRAFTWERK); + when(jwtDecoder.decode(anyString())).thenReturn(jwt); + mockMvc.perform(get(endpointURI).header("Authorization", "bearer token_blabla")) + .andExpect(status().isOk()); + } + + /** + * Tests that users with the "USER_PLATINE" role can access read-only endpoints. + */ + @ParameterizedTest + @MethodSource("endpointsReader") + @DisplayName("Platine users should access reader-allowed services") + void platine_users_should_access_reader_allowed_services(String endpointURI) throws Exception{ + Jwt jwt = generateJwt(List.of("utilisateur_Platine"), USER_PLATINE); + when(jwtDecoder.decode(anyString())).thenReturn(jwt); + mockMvc.perform(get(endpointURI).header("Authorization", "bearer token_blabla")) + .andExpect(status().isOk()); + } + + /** + * Tests that users with the "READER" role can access read-only endpoints. + */ + @ParameterizedTest + @MethodSource("endpointsReader") + @DisplayName("Readers should access reader-allowed services") + void reader_should_access_reader_allowed_services(String endpointURI) throws Exception{ + Jwt jwt = generateJwt(List.of("lecteur_traiter"), "reader"); + when(jwtDecoder.decode(anyString())).thenReturn(jwt); + mockMvc.perform(get(endpointURI).header("Authorization", "bearer token_blabla")) + .andExpect(status().isOk()); + } + + /** + * Tests that users with invalid role are denied. + */ + @ParameterizedTest + @MethodSource("endpointsReader") + @DisplayName("User with invalid roles should not access reader-allowed services") + void invalid_user_should_not_access_reader_allowed_services(String endpointURI) throws Exception{ + Jwt jwt = generateJwt(List.of("toto"), "invalid_role"); + when(jwtDecoder.decode(anyString())).thenReturn(jwt); + mockMvc.perform(get(endpointURI).header("Authorization", "bearer token_blabla")) + .andExpect(status().isForbidden()); + } + + /** + * Test that reader can access the schedule/all endpoint. + */ + @Test + @DisplayName("Reader should access schedule/all endpoint") + void reader_should_access_schedules_services() throws Exception{ + Jwt jwt = generateJwt(List.of("lecteur_traiter"), READER); + when(jwtDecoder.decode(anyString())).thenReturn(jwt); + mockMvc.perform(get("/schedule/all").header("Authorization", "bearer token_blabla")) + .andExpect(status().isOk()); + } + + /** + * Test that reader can not access other schedule endpoints. + */ + @Test + @DisplayName("Reader should not access other schedule endpoints") + void reader_should_not_access_other_schedules_services() throws Exception{ + doNothing().when(scheduleApiPort).deleteSchedule(anyString()); + Jwt jwt = generateJwt(List.of("lecteur_traiter"), READER); + when(jwtDecoder.decode(anyString())).thenReturn(jwt); + mockMvc.perform(delete("/schedule/delete?surveyName=ENQ_TEST").header("Authorization", "bearer token_blabla")) + .andExpect(status().isForbidden()); + } + + /** + * Test that kraftwerk users can't access the schedule endpoints. + */ + @Test + @DisplayName("Kraftwerk users should access schedules service") + void kraftwerk_users_should_not_access_schedules_services() throws Exception{ + Jwt jwt = generateJwt(List.of("utilisateur_Kraftwerk"), USER_KRAFTWERK); + when(jwtDecoder.decode(anyString())).thenReturn(jwt); + mockMvc.perform(get("/schedule/all").header("Authorization", "bearer token_blabla")) + .andExpect(status().isOk()); + } + + /** + * Test that admins can access the schedule endpoints. + */ + @Test + @DisplayName("Admins should access schedules service") + void admins_should_access_schedules_services() throws Exception{ + Jwt jwt = generateJwt(List.of("administrateur_traiter"), ADMIN); + when(jwtDecoder.decode(anyString())).thenReturn(jwt); + mockMvc.perform(get("/schedule/all").header("Authorization", "bearer token_blabla")) + .andExpect(status().isOk()); + } + + /** + * Test that invalid roles can't access the schedule endpoints. + */ + @Test + @DisplayName("Invalid roles should not access schedules service") + void invalid_roles_should_access_schedules_services() throws Exception{ + Jwt jwt = generateJwt(List.of("invalid_role"), "invalid_role"); + when(jwtDecoder.decode(anyString())).thenReturn(jwt); + mockMvc.perform(get("/schedule/all").header("Authorization", "bearer token_blabla")) + .andExpect(status().isForbidden()); + } + + /** + * Generates a mock JWT token with specified roles and username. + * + * @param roles List of roles assigned to the user. + * @param name Username for the JWT. + * @return A mock Jwt object. + */ + public Jwt generateJwt(List roles, String name) { + Date issuedAt = new Date(); + Date expiresAT = Date.from((new Date()).toInstant().plusSeconds(100)); + var claimRole = claimRoleDotRoles.split("\\.")[0]; + var attributRole = claimRoleDotRoles.split("\\.")[1]; + return new Jwt("token", issuedAt.toInstant(), expiresAT.toInstant(), + Map.of("alg", "RS256", "typ", "JWT"), + Map.of(claimRole, Map.of(attributRole, roles), + claimName, name + ) + ); + } + +}