diff --git a/.github/openshift/deploy.backend.yml b/.github/openshift/deploy.backend.yml index a97d8d3a1..a4858074e 100644 --- a/.github/openshift/deploy.backend.yml +++ b/.github/openshift/deploy.backend.yml @@ -55,6 +55,9 @@ parameters: - name: DATABASE_PASSWORD description: Password for the database connection required: true + - name: KEYCLOAK_REALM_URL + description: Keycloak realm address + required: true objects: - apiVersion: v1 kind: ImageStream @@ -119,6 +122,8 @@ objects: value: ${DATABASE_USER} - name: DATABASE_PASSWORD value: ${DATABASE_PASSWORD} + - name: KEYCLOAK_REALM_URL + value: ${KEYCLOAK_REALM_URL} ports: - containerPort: 8090 protocol: TCP diff --git a/.github/workflows/merge-main.yml b/.github/workflows/merge-main.yml index 975c12d2e..aaba58e3a 100644 --- a/.github/workflows/merge-main.yml +++ b/.github/workflows/merge-main.yml @@ -231,6 +231,7 @@ jobs: -p DATABASE_USER=${{ secrets.DATABASE_USER }} \ -p DATABASE_PASSWORD=${{ secrets.DATABASE_PASSWORD }} \ -p ALLOWED_ORIGINS=${{ secrets.ALLOWED_ORIGINS }} \ + -p KEYCLOAK_REALM_URL=${{ secrets.KEYCLOAK_REALM_URL }} \ -p PROMOTE=${{ github.repository }}:${{ env.ZONE }}-service-api | oc apply -f - # Follow any active rollouts (see deploymentconfigs) #oc rollout status dc/${{ env.NAME }}-${{ env.ZONE }}-database -w @@ -412,6 +413,7 @@ jobs: -p DATABASE_USER=${{ secrets.DATABASE_USER }} \ -p DATABASE_PASSWORD=${{ secrets.DATABASE_PASSWORD }} \ -p ALLOWED_ORIGINS=${{ secrets.ALLOWED_ORIGINS }} \ + -p KEYCLOAK_REALM_URL=${{ secrets.KEYCLOAK_REALM_URL }} \ -p PROMOTE=${{ github.repository }}:${{ env.PREV }}-service-api | oc apply -f - # Follow any active rollouts (see deploymentconfigs) diff --git a/.github/workflows/pr-open.yml b/.github/workflows/pr-open.yml index d951c7b3b..ddde4d8a6 100644 --- a/.github/workflows/pr-open.yml +++ b/.github/workflows/pr-open.yml @@ -13,6 +13,7 @@ on: env: REGISTRY: ghcr.io NAME: nrbestapi + KEYCLOAK_REALM_URL: ${{ secrets.KEYCLOAK_REALM_URL }} concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -364,6 +365,7 @@ jobs: -p SERVICE_NAME=${{ secrets.SERVICE_NAME }} \ -p DATABASE_USER=${{ secrets.DATABASE_USER }} \ -p DATABASE_PASSWORD=${{ secrets.DATABASE_PASSWORD }} \ + -p KEYCLOAK_REALM_URL=${{ secrets.KEYCLOAK_REALM_URL }} \ -p PROMOTE=${{ github.repository }}:${{ env.ZONE }}-service-api | oc apply -f - # Follow any active rollouts (see deploymentconfigs) diff --git a/Dockerfile b/Dockerfile index e5e58baf2..fe7d547e1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,5 @@ FROM maven:3.8.6-openjdk-18-slim AS build + COPY src /home/app/src COPY pom.xml /home/app RUN mvn --no-transfer-progress -f /home/app/pom.xml clean package diff --git a/README.md b/README.md index 73e20ff16..35fed6392 100644 --- a/README.md +++ b/README.md @@ -312,6 +312,39 @@ the `status` property should have the value *UP*. Before writing your first line of code, and learn more about the checks, including tests, please take a moment and check out our [CONTRIBUTING](CONTRIBUTING.md) guide. +## Quick look + +But if all you want is to take a quick look on the running service, you can do it by +using Docker. + +Note that you'll need these environment variables: +``` +NRBESTAPI_VERSION=local +DATABASE_HOST= +DATABASE_PORT= +SERVICE_NAME= +DATABASE_USER= +DATABASE_PASSWORD= +KEYCLOAK_REALM_URL= +``` + +Build the service: +``` +docker build -t bcgov/nrbestapi-test-service-api:latest . +``` + +Then run with: +``` +docker run -p 8090:8090 \ + -e KEYCLOAK_REALM_URL=https://dev.loginproxy.gov.bc.ca/auth/realms/standard \ + -t bcgov/nrbestapi-test-service-api:latest +``` + +However, if you have docker-compose you can do: +``` +docker-compose --env-file .env -f ./docker-compose.yml up --build --force-recreate --no-deps +``` + ## Getting help <<<<<<< HEAD diff --git a/docker-compose.yml b/docker-compose.yml index 2f9762b19..08e8e0492 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,8 +11,8 @@ services: SERVICE_NAME: ${SERVICE_NAME} DATABASE_USER: ${DATABASE_USER} DATABASE_PASSWORD: ${DATABASE_PASSWORD} + KEYCLOAK_REALM_URL: ${KEYCLOAK_REALM_URL} hostname: service-api - network_mode: "host" dns: 142.22.202.100 ports: - "8090:8090" diff --git a/pom.xml b/pom.xml index 656d27af0..566b220fb 100644 --- a/pom.xml +++ b/pom.xml @@ -73,26 +73,28 @@ org.springframework.boot spring-boot-starter-validation - - org.springframework.boot - spring-boot-starter-jdbc - - org.projectlombok lombok true + org.springframework.boot - spring-boot-starter-data-jpa + spring-boot-starter-oauth2-client org.springframework.boot - spring-boot-starter-web + spring-boot-starter-oauth2-resource-server + + + + + org.springframework.boot + spring-boot-starter-actuator @@ -110,11 +112,16 @@ ojdbc11 + org.springframework.boot spring-boot-starter-test test + + org.springframework.security + spring-security-test + diff --git a/src/main/java/ca/bc/gov/backendstartapi/config/SecurityConfig.java b/src/main/java/ca/bc/gov/backendstartapi/config/SecurityConfig.java index 852be2497..9608a6442 100644 --- a/src/main/java/ca/bc/gov/backendstartapi/config/SecurityConfig.java +++ b/src/main/java/ca/bc/gov/backendstartapi/config/SecurityConfig.java @@ -1,5 +1,7 @@ package ca.bc.gov.backendstartapi.config; +import ca.bc.gov.backendstartapi.util.ObjectUtil; +import com.nimbusds.jose.shaded.json.JSONArray; import java.util.ArrayList; import java.util.Collection; import java.util.List; @@ -9,7 +11,7 @@ import org.springframework.core.convert.converter.Converter; import org.springframework.http.HttpMethod; import org.springframework.security.authentication.AbstractAuthenticationToken; -import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.core.GrantedAuthority; @@ -22,7 +24,7 @@ /** This class contains all configurations related to security and authentication. */ @Configuration @EnableWebSecurity -@EnableMethodSecurity(prePostEnabled = true) +@EnableGlobalMethodSecurity(prePostEnabled = true) public class SecurityConfig { @Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}") @@ -42,10 +44,10 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .csrf() .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) .and() - .authorizeHttpRequests() - .requestMatchers("/api/**") + .authorizeRequests() + .antMatchers("/api/**") .authenticated() - .requestMatchers(HttpMethod.OPTIONS, "/**") + .antMatchers(HttpMethod.OPTIONS, "/**") .permitAll() .anyRequest() .permitAll() @@ -62,28 +64,27 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { private Converter converter() { JwtAuthenticationConverter converter = new JwtAuthenticationConverter(); - converter.setJwtGrantedAuthoritiesConverter(roleConverter); + converter.setJwtGrantedAuthoritiesConverter(roleConverter()); return converter; } - /** - * Parse the roles of a client from the JWT, if they're present; if not, subjects with service - * accounts are granted read and write permissions. - */ - private final Converter> roleConverter = - jwt -> { - if (!jwt.getClaims().containsKey("client_roles")) { - String sub = String.valueOf(jwt.getClaims().get("sub")); - return (sub.startsWith("service-account-nr-fsa")) - ? List.of( - new SimpleGrantedAuthority("ROLE_user_read"), - new SimpleGrantedAuthority("ROLE_user_write")) - : List.of(); + private Converter> roleConverter() { + return jwt -> { + final JSONArray realmAccess = (JSONArray) jwt.getClaims().get("client_roles"); + List authorities = new ArrayList<>(); + if (ObjectUtil.isEmptyOrNull(realmAccess)) { + String sub = String.valueOf(jwt.getClaims().get("sub")); + if (sub.startsWith("service-account-nr-fsa")) { + authorities.add(new SimpleGrantedAuthority("ROLE_user_read")); + authorities.add(new SimpleGrantedAuthority("ROLE_user_write")); } - final List realmAccess = (ArrayList) jwt.getClaims().get("client_roles"); - return realmAccess.stream() - .map(roleName -> "ROLE_" + roleName) - .map(roleName -> (GrantedAuthority) new SimpleGrantedAuthority(roleName)) - .toList(); - }; + return authorities; + } + realmAccess.stream() + .map(roleName -> "ROLE_" + roleName) + .map(SimpleGrantedAuthority::new) + .forEach(authorities::add); + return authorities; + }; + } } diff --git a/src/main/java/ca/bc/gov/backendstartapi/endpoint/UserEndpoint.java b/src/main/java/ca/bc/gov/backendstartapi/endpoint/UserEndpoint.java index c102d73c0..cb032444c 100644 --- a/src/main/java/ca/bc/gov/backendstartapi/endpoint/UserEndpoint.java +++ b/src/main/java/ca/bc/gov/backendstartapi/endpoint/UserEndpoint.java @@ -11,6 +11,7 @@ import lombok.Setter; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.MediaType; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -22,7 +23,7 @@ /** This class exposes user related endpoints. */ @NoArgsConstructor @RestController -@RequestMapping("/users") +@RequestMapping("/api/users") @Setter public class UserEndpoint { @@ -42,6 +43,7 @@ public UserEndpoint(UserRepository userRepository) { @PostMapping( consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasRole('user_write')") public UserDto create(@Valid @RequestBody UserDto user) { return userRepository.save(user); } @@ -55,6 +57,7 @@ public UserDto create(@Valid @RequestBody UserDto user) { @GetMapping( value = "/find-all-by-first-name/{firstName}", produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasRole('user_read')") public List readByFirstName(@PathVariable("firstName") String firstName) { List userList = userRepository.findAllByFirstName(firstName); if (userList.isEmpty()) { @@ -72,6 +75,7 @@ public List readByFirstName(@PathVariable("firstName") String firstName @GetMapping( value = "/find-all-by-last-name/{lastName}", produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasRole('user_read')") public List readByLastName(@PathVariable("lastName") String lastName) { List userList = userRepository.findAllByLastName(lastName); if (userList.isEmpty()) { @@ -88,6 +92,7 @@ public List readByLastName(@PathVariable("lastName") String lastName) { * @return a UserDto instance containing the found user or a 404 if not found. */ @GetMapping(value = "/find/{firstName}/{lastName}", produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasRole('user_read')") public UserDto readByUser( @PathVariable("firstName") String firstName, @PathVariable("lastName") String lastName) { Optional userDtoOp = userRepository.find(firstName, lastName); @@ -104,6 +109,7 @@ public UserDto readByUser( * @return a Collection containing all found users or a 404 if not found. */ @GetMapping(value = "/find-all", produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasRole('user_read')") public Collection readAllUsers() { return userRepository.findAll(); } @@ -116,6 +122,7 @@ public Collection readAllUsers() { * @return a UserDto instance containing the removed user info. */ @DeleteMapping(value = "/{firstName}/{lastName}", produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasRole('user_write')") public UserDto deleteUser( @PathVariable("firstName") String firstName, @PathVariable("lastName") String lastName) { return userRepository.delete(new UserDto(firstName, lastName)); diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 717e94d91..f3f5bdcae 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,12 +1,22 @@ +# Server and application +spring.application.name = nr-fsa-service-api-4139 server.port = 8090 -nrbestapi.version = ${NRBESTAPI_VERSION:#{'dev'}} -server.allowed.cors.origins = ${ALLOWED_ORIGINS:#{'http://localhost:300*'}} -spring.datasource.driver-class-name=oracle.jdbc.driver.OracleDriver -spring.jpa.database-platform=org.hibernate.dialect.Oracle12cDialect +# Key Cloak, authentication and security +keycloak-auth = ${KEYCLOAK_REALM_URL:https://empty.com/auth} +spring.security.oauth2.resourceserver.jwt.issuer-uri = ${keycloak-auth} +spring.security.oauth2.resourceserver.jwt.jwk-set-uri = ${keycloak-auth}/protocol/openid-connect/certs +# Database, datasource and JPA +spring.datasource.driver-class-name=oracle.jdbc.driver.OracleDriver spring.datasource.url = jdbc:oracle:thin:@tcps://${DATABASE_HOST}:${DATABASE_PORT}/${SERVICE_NAME} spring.datasource.username = ${DATABASE_USER} spring.datasource.password = ${DATABASE_PASSWORD} +spring.jpa.database-platform=org.hibernate.dialect.Oracle12cDialect +# Actuator and ops management.endpoint.health.show-details = always + +# Others +nrbestapi.version = ${NRBESTAPI_VERSION:#{'dev'}} +server.allowed.cors.origins = ${ALLOWED_ORIGINS:#{'http://localhost:300*'}} \ No newline at end of file diff --git a/src/test/java/ca/bc/gov/backendstartapi/endpoint/UserEndpointTest.java b/src/test/java/ca/bc/gov/backendstartapi/endpoint/UserEndpointTest.java index 6d13356e0..1ad6f3d70 100644 --- a/src/test/java/ca/bc/gov/backendstartapi/endpoint/UserEndpointTest.java +++ b/src/test/java/ca/bc/gov/backendstartapi/endpoint/UserEndpointTest.java @@ -1,5 +1,7 @@ package ca.bc.gov.backendstartapi.endpoint; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; @@ -28,6 +30,7 @@ import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.context.junit.jupiter.SpringExtension; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; @@ -51,7 +54,8 @@ class UserEndpointTest { @BeforeEach public void setup() { - this.mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build(); + this.mockMvc = + MockMvcBuilders.webAppContextSetup(webApplicationContext).apply(springSecurity()).build(); } private String getUserDtoString(UserDto userDto) throws Exception { @@ -64,10 +68,12 @@ private String getUserDtoString(UserDto userDto) throws Exception { @Test @Order(1) @DisplayName("Create user with success") + @WithMockUser(roles = "user_write") void createSuccess() throws Exception { mockMvc .perform( - post("/users") + post("/api/users") + .with(csrf().asHeader()) .header("Content-Type", "application/json") .accept(MediaType.APPLICATION_JSON) .content(getUserDtoString(USERDTO))) @@ -80,12 +86,14 @@ void createSuccess() throws Exception { @Test @Order(2) @DisplayName("Create user without firstName") + @WithMockUser(roles = "user_write") void createWithoutFirstName() throws Exception { UserDto userDtoPartial = new UserDto(null, LAST_NAME); mockMvc .perform( - post("/users") + post("/api/users") + .with(csrf().asHeader()) .header("Content-Type", "application/json") .accept(MediaType.APPLICATION_JSON) .content(getUserDtoString(userDtoPartial))) @@ -99,12 +107,14 @@ void createWithoutFirstName() throws Exception { @Test @Order(3) @DisplayName("Create user without lastName") + @WithMockUser(roles = "user_write") void createWithoutLastName() throws Exception { UserDto userDtoPartial = new UserDto(FIRST_NAME, null); mockMvc .perform( - post("/users") + post("/api/users") + .with(csrf().asHeader()) .header("Content-Type", "application/json") .accept(MediaType.APPLICATION_JSON) .content(getUserDtoString(userDtoPartial))) @@ -118,12 +128,14 @@ void createWithoutLastName() throws Exception { @Test @Order(4) @DisplayName("Create user with bellow minimum lastName size") + @WithMockUser(roles = "user_write") void createSizeMin() throws Exception { UserDto userDtoError = new UserDto(FIRST_NAME, "C"); mockMvc .perform( - post("/users") + post("/api/users") + .with(csrf().asHeader()) .header("Content-Type", "application/json") .accept(MediaType.APPLICATION_JSON) .content(getUserDtoString(userDtoError))) @@ -137,12 +149,14 @@ void createSizeMin() throws Exception { @Test @Order(5) @DisplayName("Create user with above than maximum lastName size") + @WithMockUser(roles = "user_write") void createSizeMax() throws Exception { UserDto userDtoError = new UserDto("Ricardo", "CamposCamposCamposCampos"); mockMvc .perform( - post("/users") + post("/api/users") + .with(csrf().asHeader()) .header("Content-Type", "application/json") .accept(MediaType.APPLICATION_JSON) .content(getUserDtoString(userDtoError))) @@ -156,10 +170,12 @@ void createSizeMax() throws Exception { @Test @Order(6) @DisplayName("Try to create existing user") + @WithMockUser(roles = "user_write") void createExisting() throws Exception { mockMvc .perform( - post("/users") + post("/api/users") + .with(csrf().asHeader()) .header("Content-Type", "application/json") .accept(MediaType.APPLICATION_JSON) .content(getUserDtoString(USERDTO))) @@ -173,10 +189,12 @@ void createExisting() throws Exception { @Test @Order(7) @DisplayName("Find users by first name") + @WithMockUser(roles = "user_read") void findUsersByFirstName() throws Exception { mockMvc .perform( - get("/users/find-all-by-first-name/{firstName}", FIRST_NAME) + get("/api/users/find-all-by-first-name/{firstName}", FIRST_NAME) + .with(csrf().asHeader()) .header("Content-Type", "application/json") .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) @@ -188,10 +206,12 @@ void findUsersByFirstName() throws Exception { @Test @Order(8) @DisplayName("Find users by last name") + @WithMockUser(roles = "user_read") void findUsersByLastName() throws Exception { mockMvc .perform( - get("/users/find-all-by-last-name/{lastName}", LAST_NAME) + get("/api/users/find-all-by-last-name/{lastName}", LAST_NAME) + .with(csrf().asHeader()) .header("Content-Type", "application/json") .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) @@ -202,11 +222,28 @@ void findUsersByLastName() throws Exception { @Test @Order(9) - @DisplayName("Find users by first and last name") + @DisplayName("Find users by first and last name - 403 Forbidden") + @WithMockUser + void findUsersByFirstAndLastNameUnauthorized() throws Exception { + mockMvc + .perform( + get("/api/users/find/{firstName}/{lastName}", FIRST_NAME, LAST_NAME) + .with(csrf().asHeader()) + .header("Content-Type", "application/json") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().is(HttpStatus.FORBIDDEN.value())) + .andReturn(); + } + + @Test + @Order(9) + @DisplayName("Find users by first and last name - Authenticated") + @WithMockUser(roles = "user_read") void findUsersByFirstAndLastName() throws Exception { mockMvc .perform( - get("/users/find/{firstName}/{lastName}", FIRST_NAME, LAST_NAME) + get("/api/users/find/{firstName}/{lastName}", FIRST_NAME, LAST_NAME) + .with(csrf().asHeader()) .header("Content-Type", "application/json") .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) @@ -218,11 +255,14 @@ void findUsersByFirstAndLastName() throws Exception { @Test @Order(10) @DisplayName("Find all users") + @WithMockUser(roles = "user_read") void findAllUsers() throws Exception { mockMvc .perform( - get("/users/find-all") + get("/api/users/find-all") + .with(csrf().asHeader()) .header("Content-Type", "application/json") + .header("X-Authorization", "Bearer lalala") .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(jsonPath("$[0].firstName").value(FIRST_NAME)) @@ -233,10 +273,12 @@ void findAllUsers() throws Exception { @Test @Order(11) @DisplayName("Find users by last name not found") + @WithMockUser(roles = "user_read") void findUsersNotFound() throws Exception { mockMvc .perform( - get("/users/find-all-by-first-name/{firstName}", "RRR") + get("/api/users/find-all-by-first-name/{firstName}", "RRR") + .with(csrf().asHeader()) .header("Content-Type", "application/json") .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isNotFound()) @@ -250,10 +292,12 @@ void findUsersNotFound() throws Exception { @Test @Order(12) @DisplayName("Delete user that doesn't exist") + @WithMockUser(roles = "user_write") void deleteUserDoesNotExist() throws Exception { mockMvc .perform( - delete("/users/{firstName}/{lastName}", "User", "Name") + delete("/api/users/{firstName}/{lastName}", "User", "Name") + .with(csrf().asHeader()) .header("Content-Type", "application/json") .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isNotFound())