diff --git a/pom.xml b/pom.xml index bfe8e24dc..37a7f9b81 100644 --- a/pom.xml +++ b/pom.xml @@ -482,6 +482,13 @@ ${geojson-jackson.version} + + + com.jayway.jsonpath + json-path + 2.8.0 + + org.keycloak diff --git a/shogun-boot/src/test/java/de/terrestris/shogun/boot/controller/ApplicationControllerTest.java b/shogun-boot/src/test/java/de/terrestris/shogun/boot/controller/ApplicationControllerTest.java index d970b7bdd..2f256e0d8 100644 --- a/shogun-boot/src/test/java/de/terrestris/shogun/boot/controller/ApplicationControllerTest.java +++ b/shogun-boot/src/test/java/de/terrestris/shogun/boot/controller/ApplicationControllerTest.java @@ -17,12 +17,21 @@ package de.terrestris.shogun.boot.controller; import de.terrestris.shogun.lib.controller.ApplicationController; +import de.terrestris.shogun.lib.enumeration.PermissionCollectionType; import de.terrestris.shogun.lib.model.Application; import de.terrestris.shogun.lib.repository.ApplicationRepository; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.test.web.servlet.result.MockMvcResultMatchers; import java.util.ArrayList; import java.util.List; +import static org.hamcrest.Matchers.hasSize; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; + public class ApplicationControllerTest extends BaseControllerTest { public void setBaseEntity() { @@ -53,4 +62,56 @@ public void insertTestData() { testData = persistedEntities; } + @Test + public void findAll_shouldReturnFilteredEntitiesForAdminUsers() throws Exception { + this.mockMvc + .perform( + MockMvcRequestBuilders + .get(basePath + "?filter=$.name == \"Application 1\"") + .with(authentication(getMockAuthentication(this.adminUser))) + ) + .andExpect(MockMvcResultMatchers.status().isOk()) + .andExpect(MockMvcResultMatchers.content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$").isMap()) + .andExpect(jsonPath("$.content").isArray()) + .andExpect(jsonPath("$.content", hasSize(1))); + } + + @Test + public void findAll_shouldReturnFilteredEntitiesForUsersByUserInstancePermission() throws Exception { + + userInstancePermissionService.setPermission(testData.get(0), this.user, PermissionCollectionType.READ); + userInstancePermissionService.setPermission(testData.get(1), this.user, PermissionCollectionType.READ); + + this.mockMvc + .perform( + MockMvcRequestBuilders + .get(basePath + "?filter=$.name == \"Application 1\"") + .with(authentication(getMockAuthentication(this.user))) + ) + .andExpect(MockMvcResultMatchers.status().isOk()) + .andExpect(MockMvcResultMatchers.content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$").isMap()) + .andExpect(jsonPath("$.content").isArray()) + .andExpect(jsonPath("$.content", hasSize(1))); + } + + @Test + public void findAll_shouldReturnFilteredEntitiesForUsersByGroupInstancePermission() throws Exception { + + groupInstancePermissionService.setPermission(testData.get(0), this.group, PermissionCollectionType.READ); + groupInstancePermissionService.setPermission(testData.get(1), this.group, PermissionCollectionType.READ); + + this.mockMvc + .perform( + MockMvcRequestBuilders + .get(basePath + "?filter=$.name == \"Application 1\"") + .with(authentication(getMockAuthentication(this.user))) + ) + .andExpect(MockMvcResultMatchers.status().isOk()) + .andExpect(MockMvcResultMatchers.content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$").isMap()) + .andExpect(jsonPath("$.content").isArray()) + .andExpect(jsonPath("$.content", hasSize(1))); + } } diff --git a/shogun-boot/src/test/java/de/terrestris/shogun/boot/controller/BaseControllerTest.java b/shogun-boot/src/test/java/de/terrestris/shogun/boot/controller/BaseControllerTest.java index 4f3fada17..9536218c0 100644 --- a/shogun-boot/src/test/java/de/terrestris/shogun/boot/controller/BaseControllerTest.java +++ b/shogun-boot/src/test/java/de/terrestris/shogun/boot/controller/BaseControllerTest.java @@ -24,20 +24,28 @@ import de.terrestris.shogun.lib.controller.BaseController; import de.terrestris.shogun.lib.enumeration.PermissionCollectionType; import de.terrestris.shogun.lib.model.BaseEntity; +import de.terrestris.shogun.lib.model.Group; import de.terrestris.shogun.lib.model.User; import de.terrestris.shogun.lib.repository.BaseCrudRepository; +import de.terrestris.shogun.lib.repository.GroupRepository; import de.terrestris.shogun.lib.repository.UserRepository; +import de.terrestris.shogun.lib.repository.security.permission.GroupClassPermissionRepository; +import de.terrestris.shogun.lib.repository.security.permission.GroupInstancePermissionRepository; import de.terrestris.shogun.lib.repository.security.permission.UserClassPermissionRepository; import de.terrestris.shogun.lib.repository.security.permission.UserInstancePermissionRepository; +import de.terrestris.shogun.lib.service.security.permission.GroupInstancePermissionService; import de.terrestris.shogun.lib.service.security.permission.UserClassPermissionService; import de.terrestris.shogun.lib.service.security.permission.UserInstancePermissionService; +import de.terrestris.shogun.lib.service.security.provider.GroupProviderService; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.keycloak.representations.IDToken; +import org.keycloak.representations.idm.GroupRepresentation; import org.keycloak.representations.idm.UserRepresentation; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.boot.web.servlet.server.Encoding; import org.springframework.http.MediaType; import org.springframework.security.core.authority.SimpleGrantedAuthority; @@ -57,6 +65,7 @@ import static org.hamcrest.Matchers.*; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotEquals; +import static org.mockito.Mockito.when; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; @@ -79,15 +88,27 @@ public abstract class BaseControllerTest entityClass; protected String basePath; @@ -109,6 +133,8 @@ public abstract class BaseControllerTest group = new Group(); + String authProviderId = "c886684b-1a22-4443-b0d8-36c006c19f08"; + group.setAuthProviderId(authProviderId); + GroupRepresentation groupRepresentation = new GroupRepresentation(); + groupRepresentation.setName("SHOGun Group"); + group.setProviderDetails(groupRepresentation); + + this.group = groupRepository.save(group); + + when(groupProviderService.getGroupsForUser()).thenReturn(List.of(this.group)); + when(groupProviderService.findByUser(this.user)).thenReturn(List.of(this.group)); + } + public void cleanupPermissions() { userInstancePermissionRepository.deleteAll(); + groupInstancePermissionRepository.deleteAll(); userClassPermissionRepository.deleteAll(); + groupClassPermissionRepository.deleteAll(); } public void deinitAdminUser() { @@ -162,6 +204,10 @@ public void deinitUser() { userRepository.delete(this.user); } + public void deinitGroup() { + groupRepository.delete(this.group); + } + public void cleanupTestData() { repository.deleteAll(); } @@ -192,6 +238,7 @@ public void setUp() { initMockMvc(); initAdminUser(); initUser(); + initGroup(); setBaseEntity(); setBasePath(); insertTestData(); @@ -202,6 +249,7 @@ public void teardown() { cleanupPermissions(); deinitUser(); deinitAdminUser(); + deinitGroup(); cleanupTestData(); } diff --git a/shogun-boot/src/test/java/de/terrestris/shogun/boot/controller/GroupControllerTest.java b/shogun-boot/src/test/java/de/terrestris/shogun/boot/controller/GroupControllerTest.java index c115b233a..114996c85 100644 --- a/shogun-boot/src/test/java/de/terrestris/shogun/boot/controller/GroupControllerTest.java +++ b/shogun-boot/src/test/java/de/terrestris/shogun/boot/controller/GroupControllerTest.java @@ -16,16 +16,33 @@ */ package de.terrestris.shogun.boot.controller; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; import de.terrestris.shogun.lib.controller.GroupController; +import de.terrestris.shogun.lib.enumeration.PermissionCollectionType; import de.terrestris.shogun.lib.model.Group; import de.terrestris.shogun.lib.repository.GroupRepository; +import org.junit.jupiter.api.Test; +import org.springframework.boot.web.servlet.server.Encoding; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.test.web.servlet.result.MockMvcResultMatchers; import java.util.ArrayList; import java.util.List; import java.util.UUID; +import static org.hamcrest.Matchers.hasKey; +import static org.hamcrest.Matchers.hasSize; +import static org.junit.Assert.assertEquals; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; + public class GroupControllerTest extends BaseControllerTest { + private int numberOfInternalTestGroups = 1; + public void setBaseEntity() { entityClass = Group.class; } @@ -52,4 +69,204 @@ public void insertTestData() { testData = (List) repository.saveAll(entities); } + @Test + @Override + public void findAll_shouldReturnAllAvailableEntitiesForRoleAdmin() throws Exception { + this.mockMvc + .perform( + MockMvcRequestBuilders + .get(basePath) + .with(authentication(getMockAuthentication(this.adminUser))) + ) + .andExpect(MockMvcResultMatchers.status().isOk()) + .andExpect(MockMvcResultMatchers.content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$").isMap()) + .andExpect(jsonPath("$.content").isArray()) + .andExpect(jsonPath("$.content", hasSize(testData.size() + numberOfInternalTestGroups))); + } + + @Test + @Override + public void findAll_shouldReturnAllAvailableEntitiesWithUserClassPermissions() throws Exception { + + userClassPermissionService.setPermission(entityClass, this.user, PermissionCollectionType.READ); + + this.mockMvc + .perform( + MockMvcRequestBuilders + .get(basePath) + .with(authentication(getMockAuthentication(this.user))) + ) + .andExpect(MockMvcResultMatchers.status().isOk()) + .andExpect(MockMvcResultMatchers.content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$").isMap()) + .andExpect(jsonPath("$.content").isArray()) + .andExpect(jsonPath("$.content", hasSize(testData.size() + numberOfInternalTestGroups))); + } + + @Test + @Override + public void add_shouldDenyAccessForRoleAnonymous() throws Exception { + JsonNode insertNode = objectMapper.valueToTree(testData.get(0)); + List fieldsToRemove = List.of("id", "created", "modified"); + insertNode = ((ObjectNode) insertNode).remove(fieldsToRemove); + + this.mockMvc + .perform( + MockMvcRequestBuilders + .post(String.format("%s", basePath)) + .contentType(MediaType.APPLICATION_JSON) + .characterEncoding(Encoding.DEFAULT_CHARSET.toString()) + .content(objectMapper.writeValueAsString(insertNode)) + .with(csrf()) + ) + .andExpect(MockMvcResultMatchers.status().isUnauthorized()); + + List persistedEntities = repository.findAll(); + assertEquals(testData.size() + numberOfInternalTestGroups, persistedEntities.size()); + } + + @Test + @Override + public void add_shouldDenyAccessForRoleUserWithoutExplicitPermission() throws Exception { + JsonNode insertNode = objectMapper.valueToTree(testData.get(0)); + List fieldsToRemove = List.of("id", "created", "modified"); + insertNode = ((ObjectNode) insertNode).remove(fieldsToRemove); + + this.mockMvc + .perform( + MockMvcRequestBuilders + .post(String.format("%s", basePath)) + .contentType(MediaType.APPLICATION_JSON) + .characterEncoding(Encoding.DEFAULT_CHARSET.toString()) + .content(objectMapper.writeValueAsString(insertNode)) + .with(authentication(getMockAuthentication(this.user))) + .with(csrf()) + ) + .andExpect(MockMvcResultMatchers.status().isNotFound()); + + List persistedEntities = repository.findAll(); + assertEquals(testData.size() + numberOfInternalTestGroups, persistedEntities.size()); + } + + @Test + @Override + public void add_shouldCreateTheEntityForRoleUser() throws Exception { + + userClassPermissionService.setPermission(entityClass, this.user, PermissionCollectionType.CREATE); + + JsonNode insertNode = objectMapper.valueToTree(testData.get(0)); + List fieldsToRemove = List.of("id", "created", "modified"); + insertNode = ((ObjectNode) insertNode).remove(fieldsToRemove); + + this.mockMvc + .perform( + MockMvcRequestBuilders + .post(String.format("%s", basePath)) + .contentType(MediaType.APPLICATION_JSON) + .characterEncoding(Encoding.DEFAULT_CHARSET.toString()) + .content(objectMapper.writeValueAsString(insertNode)) + .with(authentication(getMockAuthentication(this.adminUser))) + .with(csrf()) + ) + .andExpect(MockMvcResultMatchers.status().isCreated()) + .andExpect(MockMvcResultMatchers.content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$").exists()) + .andExpect(jsonPath("$", hasKey("id"))); + + List persistedEntities = repository.findAll(); + assertEquals(testData.size() + numberOfInternalTestGroups + 1, persistedEntities.size()); + } + + @Test + @Override + public void add_shouldCreateTheEntityForRoleAdmin() throws Exception { + JsonNode insertNode = objectMapper.valueToTree(testData.get(0)); + List fieldsToRemove = List.of("id", "created", "modified"); + insertNode = ((ObjectNode) insertNode).remove(fieldsToRemove); + + this.mockMvc + .perform( + MockMvcRequestBuilders + .post(String.format("%s", basePath)) + .contentType(MediaType.APPLICATION_JSON) + .characterEncoding(Encoding.DEFAULT_CHARSET.toString()) + .content(objectMapper.writeValueAsString(insertNode)) + .with(authentication(getMockAuthentication(this.adminUser))) + .with(csrf()) + ) + .andExpect(MockMvcResultMatchers.status().isCreated()) + .andExpect(MockMvcResultMatchers.content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$").exists()) + .andExpect(jsonPath("$", hasKey("id"))); + + List persistedEntities = repository.findAll(); + assertEquals(testData.size() + numberOfInternalTestGroups + 1, persistedEntities.size()); + } + + @Test + @Override + public void delete_shouldDenyAccessForRoleAnonymous() throws Exception { + this.mockMvc + .perform( + MockMvcRequestBuilders + .delete(String.format("%s/%s", basePath, testData.get(0).getId())) + .with(csrf()) + ) + .andExpect(MockMvcResultMatchers.status().isUnauthorized()); + + List persistedEntities = repository.findAll(); + assertEquals(testData.size() + numberOfInternalTestGroups, persistedEntities.size()); + } + + @Test + @Override + public void delete_shouldDenyAccessForRoleUserWithoutExplicitPermission() throws Exception { + this.mockMvc + .perform( + MockMvcRequestBuilders + .delete(String.format("%s/%s", basePath, testData.get(0).getId())) + .with(authentication(getMockAuthentication(this.user))) + .with(csrf()) + ) + .andExpect(MockMvcResultMatchers.status().isNotFound()); + + List persistedEntities = repository.findAll(); + assertEquals(testData.size() + numberOfInternalTestGroups, persistedEntities.size()); + } + + @Test + @Override + public void delete_shouldDeleteAnAvailableEntityForRoleUser() throws Exception { + + userInstancePermissionService.setPermission(testData.get(0), this.user, PermissionCollectionType.DELETE); + + this.mockMvc + .perform( + MockMvcRequestBuilders + .delete(String.format("%s/%s", basePath, testData.get(0).getId())) + .with(authentication(getMockAuthentication(this.adminUser))) + .with(csrf()) + ) + .andExpect(MockMvcResultMatchers.status().isNoContent()); + + List persistedEntities = repository.findAll(); + assertEquals(testData.size() + numberOfInternalTestGroups - 1, persistedEntities.size()); + } + + @Test + @Override + public void delete_shouldDeleteAnAvailableEntityForRoleAdmin() throws Exception { + this.mockMvc + .perform( + MockMvcRequestBuilders + .delete(String.format("%s/%s", basePath, testData.get(0).getId())) + .with(authentication(getMockAuthentication(this.adminUser))) + .with(csrf()) + ) + .andExpect(MockMvcResultMatchers.status().isNoContent()); + + List persistedEntities = repository.findAll(); + assertEquals(testData.size() + numberOfInternalTestGroups - 1, persistedEntities.size()); + } } diff --git a/shogun-lib/pom.xml b/shogun-lib/pom.xml index db18ab20c..6a274c059 100644 --- a/shogun-lib/pom.xml +++ b/shogun-lib/pom.xml @@ -212,6 +212,11 @@ tika-core + + com.jayway.jsonpath + json-path + + org.springframework diff --git a/shogun-lib/src/main/java/de/terrestris/shogun/lib/controller/BaseController.java b/shogun-lib/src/main/java/de/terrestris/shogun/lib/controller/BaseController.java index 71ce4b7b4..1287ecdf9 100644 --- a/shogun-lib/src/main/java/de/terrestris/shogun/lib/controller/BaseController.java +++ b/shogun-lib/src/main/java/de/terrestris/shogun/lib/controller/BaseController.java @@ -17,16 +17,20 @@ package de.terrestris.shogun.lib.controller; import com.github.fge.jsonpatch.mergepatch.JsonMergePatch; +import com.jayway.jsonpath.Filter; import de.terrestris.shogun.lib.controller.security.permission.BasePermissionController; import de.terrestris.shogun.lib.model.BaseEntity; import de.terrestris.shogun.lib.service.BaseService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.Parameters; import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import lombok.extern.log4j.Log4j2; +import org.apache.commons.lang3.StringUtils; import org.springdoc.api.annotations.ParameterObject; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.MessageSource; @@ -64,6 +68,34 @@ public abstract class BaseController, S extends Base summary = "Returns all entities", security = { @SecurityRequirement(name = "bearer-key") } ) + @Parameters( + @Parameter( + name = "filter", + description = "JSONPath predicate, see https://github.com/json-path/JsonPath#predicates", + examples = { + @ExampleObject( + name = "Example 1", + description = "Get all entities with name \"Countries\"", + value = "$.name == \"Countries\"" + ), + @ExampleObject( + name = "Example 2", + description = "Get all hoverable layers", + value = "$.clientConfig.hoverable == true" + ), + @ExampleObject( + name = "Example 3", + description = "Get all layers with a greater minResolution of 100 and of type TILEWMS", + value = "$.clientConfig.minResolution > 100 && $.type == \"TILEWMS\"" + ), + @ExampleObject( + name = "Example 4", + description = "Get all entities with the names \"Countries\" or \"World Map\"", + value = "$.name == \"Countries\" || $.name == \"World Map\"" + ) + } + ) + ) @ApiResponses(value = { @ApiResponse( responseCode = "200", @@ -80,14 +112,21 @@ public abstract class BaseController, S extends Base ), @ApiResponse( responseCode = "500", - description = "Internal Server Error: Something internal went wrong while deleting the entity" + description = "Internal Server Error: Something internal went wrong while getting the entity list" ) }) - public Page findAll(@PageableDefault(Integer.MAX_VALUE) @ParameterObject Pageable pageable) { + public Page findAll(@PageableDefault(Integer.MAX_VALUE) @ParameterObject Pageable pageable, @RequestParam(required = false) String filter) { log.trace("Requested to return all entities of type {}", getGenericClassName()); try { - Page persistedEntities = service.findAll(pageable); + Filter compiledFilter = null; + if (StringUtils.isNotEmpty(filter)) { + compiledFilter = Filter.parse(String.format("[?(%s)]", filter)); + + log.trace("Got filter " + compiledFilter.toString().replace("'", "\"")); + } + + Page persistedEntities = service.findAll(pageable, compiledFilter); log.trace("Successfully got all entities of type {} (count: {})", getGenericClassName(), persistedEntities.getTotalElements()); diff --git a/shogun-lib/src/main/java/de/terrestris/shogun/lib/repository/BaseCrudRepository.java b/shogun-lib/src/main/java/de/terrestris/shogun/lib/repository/BaseCrudRepository.java index ee9eed334..b3912c6da 100644 --- a/shogun-lib/src/main/java/de/terrestris/shogun/lib/repository/BaseCrudRepository.java +++ b/shogun-lib/src/main/java/de/terrestris/shogun/lib/repository/BaseCrudRepository.java @@ -16,6 +16,7 @@ */ package de.terrestris.shogun.lib.repository; +import com.jayway.jsonpath.Filter; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.Query; @@ -44,17 +45,49 @@ public interface BaseCrudRepository extends * @param userId ID of the authenticated user. * @return A page of entities. */ - @Query(""" - FROM #{#entityName} m - WHERE EXISTS ( - SELECT 1 FROM userinstancepermissions uip - WHERE uip.user.id = :userId - AND uip.entityId = m.id - AND uip.permission.name IN ('ADMIN', 'READ', 'CREATE_READ', 'CREATE_READ_UPDATE', 'CREATE_READ_DELETE', 'READ_UPDATE', 'READ_DELETE', 'READ_UPDATE_DELETE') - ) - """) - @QueryHints(@QueryHint(name = org.hibernate.annotations.QueryHints.CACHEABLE, value = "true")) - Page findAll(Pageable pageable, Long userId); + @Query( + value = """ + SELECT + * + FROM + {h-schema}#{#entityName} e + WHERE (EXISTS ( + SELECT + 1 + FROM + {h-schema}userinstancepermissions uip + LEFT JOIN {h-schema}permissions p ON p.id = uip.permission_id + WHERE + uip.user_id = :userId AND + uip.entity_id = e.id AND + p."name" IN ( + 'ADMIN', + 'READ', + 'CREATE_READ', + 'CREATE_READ_UPDATE', + 'CREATE_READ_DELETE', + 'READ_UPDATE', + 'READ_DELETE', + 'READ_UPDATE_DELETE' + ) + )) AND ( + :#{T(de.terrestris.shogun.lib.util.JsonPathFilterUtil).writeFilter(#filter)} = '$' OR CAST(row_to_json(e) AS JSONB) @@ CAST(:#{T(de.terrestris.shogun.lib.util.JsonPathFilterUtil).writeFilter(#filter)} AS JSONPATH) + ) + """, + // TODO This should include the exists filter + countQuery = """ + SELECT + COUNT(e.*) + FROM + {h-schema}#{#entityName} e + """, + nativeQuery = true + ) + @QueryHints( + value = @QueryHint(name = org.hibernate.annotations.QueryHints.CACHEABLE, value = "true"), + forCounting = false + ) + Page findAll(Pageable pageable, Filter filter, Long userId); /** * Returns a {@link Page} of entities for which the user with userId has permission via UserInstancePermission or GroupInstancePermission. @@ -64,22 +97,67 @@ AND uip.permission.name IN ('ADMIN', 'READ', 'CREATE_READ', 'CREATE_READ_UPDATE' * @param groupIds All IDs of the groups of the authenticated user. * @return A page of entities. */ - @Query(""" - FROM #{#entityName} m - WHERE EXISTS ( - SELECT 1 FROM userinstancepermissions uip - WHERE uip.user.id = :userId - AND uip.entityId = m.id - AND uip.permission.name IN ('ADMIN', 'READ', 'CREATE_READ', 'CREATE_READ_UPDATE', 'CREATE_READ_DELETE', 'READ_UPDATE', 'READ_DELETE', 'READ_UPDATE_DELETE') - ) OR EXISTS ( - SELECT 1 FROM groupinstancepermissions gip - WHERE gip.group.id IN :groupIds - AND gip.entityId = m.id - AND gip.permission.name IN ('ADMIN', 'READ', 'CREATE_READ', 'CREATE_READ_UPDATE', 'CREATE_READ_DELETE', 'READ_UPDATE', 'READ_DELETE', 'READ_UPDATE_DELETE') - ) - """) - @QueryHints(@QueryHint(name = org.hibernate.annotations.QueryHints.CACHEABLE, value = "true")) - Page findAll(Pageable pageable, Long userId, List groupIds); + @Query( + value = """ + SELECT + * + FROM + {h-schema}#{#entityName} e + WHERE (EXISTS ( + SELECT + 1 + FROM + {h-schema}userinstancepermissions uip + LEFT JOIN {h-schema}permissions p ON p.id = uip.permission_id + WHERE + uip.user_id = :userId AND + uip.entity_id = e.id AND + p."name" IN ( + 'ADMIN', + 'READ', + 'CREATE_READ', + 'CREATE_READ_UPDATE', + 'CREATE_READ_DELETE', + 'READ_UPDATE', + 'READ_DELETE', + 'READ_UPDATE_DELETE' + ) + ) OR EXISTS ( + SELECT + 1 + FROM + {h-schema}groupinstancepermissions gip + LEFT JOIN {h-schema}permissions p ON p.id = gip.permission_id + WHERE + gip.group_id IN :groupIds AND + gip.entity_id = e.id AND + p."name" IN ( + 'ADMIN', + 'READ', + 'CREATE_READ', + 'CREATE_READ_UPDATE', + 'CREATE_READ_DELETE', + 'READ_UPDATE', + 'READ_DELETE', + 'READ_UPDATE_DELETE' + ) + )) AND ( + :#{T(de.terrestris.shogun.lib.util.JsonPathFilterUtil).writeFilter(#filter)} = '$' OR CAST(row_to_json(e) AS JSONB) @@ CAST(:#{T(de.terrestris.shogun.lib.util.JsonPathFilterUtil).writeFilter(#filter)} AS JSONPATH) + ) + """, + countQuery = """ + SELECT + COUNT(e.*) + FROM + {h-schema}#{#entityName} e + """, + nativeQuery = true + ) + @QueryHints( + value = @QueryHint(name = org.hibernate.annotations.QueryHints.CACHEABLE, value = "true"), + forCounting = false + ) + Page findAll(Pageable pageable, Filter filter, Long userId, List groupIds); /** * Returns a {@link Page} of entities without checking any permissions. @@ -88,7 +166,27 @@ AND gip.permission.name IN ('ADMIN', 'READ', 'CREATE_READ', 'CREATE_READ_UPDATE' * {@literal null}. * @return A page of entities. */ - @QueryHints(@QueryHint(name = org.hibernate.annotations.QueryHints.CACHEABLE, value = "true")) - Page findAll(Pageable pageable); + @Query( + value = """ + SELECT + e.* + FROM + {h-schema}#{#entityName} e + WHERE + :#{T(de.terrestris.shogun.lib.util.JsonPathFilterUtil).writeFilter(#filter)} = '$' OR CAST(row_to_json(e) AS JSONB) @@ CAST(:#{T(de.terrestris.shogun.lib.util.JsonPathFilterUtil).writeFilter(#filter)} AS JSONPATH) + """, + countQuery = """ + SELECT + COUNT(e.*) + FROM + {h-schema}#{#entityName} e + """, + nativeQuery = true + ) + @QueryHints( + value = @QueryHint(name = org.hibernate.annotations.QueryHints.CACHEABLE, value = "true"), + forCounting = false + ) + Page findAll(Pageable pageable, Filter filter); } diff --git a/shogun-lib/src/main/java/de/terrestris/shogun/lib/security/access/entity/BaseEntityPermissionEvaluator.java b/shogun-lib/src/main/java/de/terrestris/shogun/lib/security/access/entity/BaseEntityPermissionEvaluator.java index 29d55fad7..02c45df16 100644 --- a/shogun-lib/src/main/java/de/terrestris/shogun/lib/security/access/entity/BaseEntityPermissionEvaluator.java +++ b/shogun-lib/src/main/java/de/terrestris/shogun/lib/security/access/entity/BaseEntityPermissionEvaluator.java @@ -16,6 +16,7 @@ */ package de.terrestris.shogun.lib.security.access.entity; +import com.jayway.jsonpath.Filter; import de.terrestris.shogun.lib.enumeration.PermissionType; import de.terrestris.shogun.lib.model.BaseEntity; import de.terrestris.shogun.lib.model.Group; @@ -167,7 +168,6 @@ public boolean hasPermission(User user, Class clazz, PermissionType permissio log.trace("Evaluating whether user with ID '{}' has permission '{}' on class '{}'", user.getId(), permission, clazz.getCanonicalName()); - Optional userClassPermission = userClassPermissionService.findFor((Class) clazz, user); Optional groupClassPermission = groupClassPermissionService.findFor((Class) clazz, user); @@ -255,7 +255,7 @@ public boolean hasPermissionByGroupClassPermission(User user, BaseEntity entity, * @return A page of entities. */ @Override - public Page findAll(User user, Pageable pageable, BaseCrudRepository repository, Class baseEntityClass) { + public Page findAll(User user, Pageable pageable, Filter filter, BaseCrudRepository repository, Class baseEntityClass) { if (user == null) { throw new RuntimeException("No user provided!"); } @@ -269,7 +269,7 @@ public Page findAll(User user, Pageable pageable, BaseCrudRepository ); if (isAdmin) { - return repository.findAll(pageable); + return repository.findAll(pageable, filter); } // option B: user has permission through class permissions @@ -277,20 +277,20 @@ public Page findAll(User user, Pageable pageable, BaseCrudRepository Optional groupClassPermission = groupClassPermissionService.findFor(baseEntityClass, user); if (containsReadPermission(userClassPermission.orElse(null), groupClassPermission.orElse(null))) { - return repository.findAll(pageable); + return repository.findAll(pageable, filter); } // option C: check instance permissions for each entity with a single query List> userGroups = groupProviderService.getGroupsForUser(); if (userGroups.isEmpty()) { // user has no groups so only user instance permissions have to be checked - return repository.findAll(pageable, user.getId()); + return repository.findAll(pageable, filter, user.getId()); } else { // check both user and group instance permissions List groupIds = userGroups.stream() .map(BaseEntity::getId) .toList(); - return repository.findAll(pageable, user.getId(), groupIds); + return repository.findAll(pageable, filter, user.getId(), groupIds); } } diff --git a/shogun-lib/src/main/java/de/terrestris/shogun/lib/security/access/entity/EntityPermissionEvaluator.java b/shogun-lib/src/main/java/de/terrestris/shogun/lib/security/access/entity/EntityPermissionEvaluator.java index c3e6b67f7..672df3a3f 100644 --- a/shogun-lib/src/main/java/de/terrestris/shogun/lib/security/access/entity/EntityPermissionEvaluator.java +++ b/shogun-lib/src/main/java/de/terrestris/shogun/lib/security/access/entity/EntityPermissionEvaluator.java @@ -16,6 +16,7 @@ */ package de.terrestris.shogun.lib.security.access.entity; +import com.jayway.jsonpath.Filter; import de.terrestris.shogun.lib.enumeration.PermissionType; import de.terrestris.shogun.lib.model.User; import de.terrestris.shogun.lib.repository.BaseCrudRepository; @@ -37,5 +38,5 @@ public interface EntityPermissionEvaluator { * with pagination. See {@link BaseEntityPermissionEvaluator#findAll(User, Pageable, BaseCrudRepository)} for the * default implementation for {@link de.terrestris.shogun.lib.model.BaseEntity}. */ - Page findAll(User user, Pageable pageable, BaseCrudRepository repository, Class baseEntityClass); + Page findAll(User user, Pageable pageable, Filter filter, BaseCrudRepository repository, Class baseEntityClass); } diff --git a/shogun-lib/src/main/java/de/terrestris/shogun/lib/security/access/entity/GroupPermissionEvaluator.java b/shogun-lib/src/main/java/de/terrestris/shogun/lib/security/access/entity/GroupPermissionEvaluator.java index 6a37fc64e..a2d771bf9 100644 --- a/shogun-lib/src/main/java/de/terrestris/shogun/lib/security/access/entity/GroupPermissionEvaluator.java +++ b/shogun-lib/src/main/java/de/terrestris/shogun/lib/security/access/entity/GroupPermissionEvaluator.java @@ -16,6 +16,7 @@ */ package de.terrestris.shogun.lib.security.access.entity; +import com.jayway.jsonpath.Filter; import de.terrestris.shogun.lib.model.Group; import de.terrestris.shogun.lib.model.User; import de.terrestris.shogun.lib.repository.BaseCrudRepository; @@ -32,9 +33,9 @@ public class GroupPermissionEvaluator extends BaseEntityPermissionEvaluator findAll(User user, Pageable pageable, BaseCrudRepository repository, - Class baseEntityClass) { - Page groups = super.findAll(user, pageable, repository, baseEntityClass); + public Page findAll(User user, Pageable pageable, Filter filter, BaseCrudRepository repository, + Class baseEntityClass) { + Page groups = super.findAll(user, pageable, filter, repository, baseEntityClass); groups.forEach(u -> groupProviderService.setTransientRepresentations(u)); diff --git a/shogun-lib/src/main/java/de/terrestris/shogun/lib/security/access/entity/UserPermissionEvaluator.java b/shogun-lib/src/main/java/de/terrestris/shogun/lib/security/access/entity/UserPermissionEvaluator.java index f7ac61b78..900d14f33 100644 --- a/shogun-lib/src/main/java/de/terrestris/shogun/lib/security/access/entity/UserPermissionEvaluator.java +++ b/shogun-lib/src/main/java/de/terrestris/shogun/lib/security/access/entity/UserPermissionEvaluator.java @@ -16,6 +16,7 @@ */ package de.terrestris.shogun.lib.security.access.entity; +import com.jayway.jsonpath.Filter; import de.terrestris.shogun.lib.model.User; import de.terrestris.shogun.lib.repository.BaseCrudRepository; import de.terrestris.shogun.lib.service.security.provider.UserProviderService; @@ -31,9 +32,9 @@ public class UserPermissionEvaluator extends BaseEntityPermissionEvaluator UserProviderService userProviderService; @Override - public Page findAll(User user, Pageable pageable, BaseCrudRepository repository, - Class baseEntityClass) { - Page users = super.findAll(user, pageable, repository, baseEntityClass); + public Page findAll(User user, Pageable pageable, Filter filter, BaseCrudRepository repository, + Class baseEntityClass) { + Page users = super.findAll(user, pageable, filter, repository, baseEntityClass); users.forEach(u -> userProviderService.setTransientRepresentations(u)); diff --git a/shogun-lib/src/main/java/de/terrestris/shogun/lib/service/BaseService.java b/shogun-lib/src/main/java/de/terrestris/shogun/lib/service/BaseService.java index 4118013d6..a29e13758 100644 --- a/shogun-lib/src/main/java/de/terrestris/shogun/lib/service/BaseService.java +++ b/shogun-lib/src/main/java/de/terrestris/shogun/lib/service/BaseService.java @@ -20,6 +20,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.github.fge.jsonpatch.JsonPatchException; import com.github.fge.jsonpatch.mergepatch.JsonMergePatch; +import com.jayway.jsonpath.Filter; import de.terrestris.shogun.lib.enumeration.PermissionCollectionType; import de.terrestris.shogun.lib.model.BaseEntity; import de.terrestris.shogun.lib.model.User; @@ -89,7 +90,7 @@ public List findAll() { } @Transactional(readOnly = true) - public Page findAll(Pageable pageable) { + public Page findAll(Pageable pageable, Filter filter) { // note: security check is done in permission evaluator Optional userOpt = userProviderService.getUserBySession(); @@ -99,7 +100,12 @@ public Page findAll(Pageable pageable) { BaseEntityPermissionEvaluator entityPermissionEvaluator = this.getPermissionEvaluatorForClass(entityClass.getCanonicalName()); - return entityPermissionEvaluator.findAll(userOpt.orElse(null), pageable, repository, entityClass); + return entityPermissionEvaluator.findAll(userOpt.orElse(null), pageable, filter, repository, entityClass); + } + + @Transactional(readOnly = true) + public Page findAll(Pageable pageable) { + return this.findAll(pageable, null); } @PostFilter("hasRole('ROLE_ADMIN') or hasPermission(filterObject, 'READ')") diff --git a/shogun-lib/src/main/java/de/terrestris/shogun/lib/util/JsonPathFilterUtil.java b/shogun-lib/src/main/java/de/terrestris/shogun/lib/util/JsonPathFilterUtil.java new file mode 100644 index 000000000..26701ee24 --- /dev/null +++ b/shogun-lib/src/main/java/de/terrestris/shogun/lib/util/JsonPathFilterUtil.java @@ -0,0 +1,77 @@ +/* SHOGun, https://terrestris.github.io/shogun/ + * + * Copyright © 2023-present terrestris GmbH & Co. KG + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0.txt + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.terrestris.shogun.lib.util; + +import com.jayway.jsonpath.Filter; +import lombok.extern.log4j.Log4j2; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@Log4j2 +public class JsonPathFilterUtil { + + /** + * Receives a {@link com.jayway.jsonpath.Filter} and returns the PostgreSQL compatible + * JSON path predicate. + * + * @param filter The filter to write as string. + * @return {@link String} The JSON path predicate. + */ + public static String writeFilter(Filter filter) { + String placeholder = "$"; + + if (filter == null) { + return placeholder; + } + + String filterString = filter.toString(); + + Pattern pathPattern = Pattern.compile("\\[\\?\\((.+)\\)\\]", Pattern.CASE_INSENSITIVE); + Matcher pathPatternMatcher = pathPattern.matcher(filterString); + + if (!pathPatternMatcher.find()) { + return placeholder; + } + + String extractedFilterString = pathPatternMatcher.group(1); + + Pattern camelCasePattern = Pattern.compile("(?<=\\$\\[')(\\w+)(?='\\])", Pattern.CASE_INSENSITIVE); + Matcher camelCasePatternMatcher = camelCasePattern.matcher(extractedFilterString); + + String replacedFilterString = camelCasePatternMatcher + .replaceAll(match -> match.group() + .replaceAll("([a-z])([A-Z]+)", "$1_$2") + .toLowerCase() + ); + + Pattern regeExPattern = Pattern.compile("=~ \\/(.+)\\/"); + Matcher regeExPatternMatcher = regeExPattern.matcher(replacedFilterString); + + replacedFilterString = regeExPatternMatcher + .replaceAll(match -> match.group() + .replace("/", "\"") + .replace("=~", "like_regex") + ); + + return replacedFilterString + .replace("['", ".") + .replace("']", "") + .replace("'", "\""); + } + +} diff --git a/shogun-lib/src/test/java/de/terrestris/shogun/lib/util/JsonPathFilterUtilTest.java b/shogun-lib/src/test/java/de/terrestris/shogun/lib/util/JsonPathFilterUtilTest.java new file mode 100644 index 000000000..943a0efa8 --- /dev/null +++ b/shogun-lib/src/test/java/de/terrestris/shogun/lib/util/JsonPathFilterUtilTest.java @@ -0,0 +1,86 @@ +/* SHOGun, https://terrestris.github.io/shogun/ + * + * Copyright © 2023-present terrestris GmbH & Co. KG + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0.txt + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.terrestris.shogun.lib.util; + +import com.jayway.jsonpath.Filter; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + +public class JsonPathFilterUtilTest { + + @Test + public void writeFilter_returns_a_placeholder_without_any_given_filter() { + String filter = JsonPathFilterUtil.writeFilter(null); + + assertEquals("$", filter); + } + + @Test + public void writeFilter_returns_the_filter_without_surrounding_brackets() { + Filter testFilter = Filter.parse("[?($.name == \"Countries\")]"); + String filter = JsonPathFilterUtil.writeFilter(testFilter); + + assertEquals("$.name == \"Countries\"", filter); + + testFilter = Filter.parse("[?($.min > 1909)]"); + filter = JsonPathFilterUtil.writeFilter(testFilter); + + assertEquals("$.min > 1909", filter); + } + +// // TODO Not supported right now. +// @Test +// public void writeFilter_returns_the_negate_filter() { +// Filter testFilter = Filter.parse("[?(!($.min > 1909))]"); +// String filter = JsonPathFilterUtil.writeFilter(testFilter); +// +// assertEquals("$.min > 1909", filter); +// } + + @Test + public void writeFilter_returns_the_regex_filter() { + Filter testFilter = Filter.parse("[?($.name =~ /^ab.*c/)]"); + String filter = JsonPathFilterUtil.writeFilter(testFilter); + + assertEquals("$.name like_regex \"^ab.*c\"", filter); + } + + @Test + public void writeFilter_replaces_camel_case_attributes_with_snake_case() { + Filter testFilter = Filter.parse("[?($.clientConfig.minResolution > 1909)]"); + String filter = JsonPathFilterUtil.writeFilter(testFilter); + + assertEquals("$.client_config.minResolution > 1909", filter); + + testFilter = Filter.parse("[?($.clientConfig.minResolution > 1909 && $.sourceConfig.maxResolution < 1909)]"); + filter = JsonPathFilterUtil.writeFilter(testFilter); + + assertEquals("$.client_config.minResolution > 1909 && $.source_config.maxResolution < 1909", filter); + + testFilter = Filter.parse("[?($.clientConfig.minResolution > 1909 && $.clientConfig.maxResolution < 1909)]"); + filter = JsonPathFilterUtil.writeFilter(testFilter); + + assertEquals("$.client_config.minResolution > 1909 && $.client_config.maxResolution < 1909", filter); + + testFilter = Filter.parse("[?($.clientConfig.clientConfig.minResolution > 1909 && $.clientConfig.maxResolution < 1909)]"); + filter = JsonPathFilterUtil.writeFilter(testFilter); + + assertEquals("$.client_config.clientConfig.minResolution > 1909 && $.client_config.maxResolution < 1909", filter); + } + +}