Skip to content

Commit

Permalink
Merge pull request #254 from kit-data-manager/issue-253-Search_proxy_…
Browse files Browse the repository at this point in the history
…not_considering_ROLE_ADMINISTRATOR

Search proxy not considering ROLE_ADMINISTRATOR
  • Loading branch information
ThomasJejkal committed Apr 9, 2024
2 parents f523093 + 222f0a8 commit 877cc53
Show file tree
Hide file tree
Showing 2 changed files with 170 additions and 166 deletions.
92 changes: 46 additions & 46 deletions src/main/java/edu/kit/datamanager/controller/SearchController.java
Original file line number Diff line number Diff line change
Expand Up @@ -52,55 +52,55 @@
@ConditionalOnExpression("${repo.search.enabled:false}")
public class SearchController {

static final Logger LOG = LoggerFactory.getLogger(SearchController.class);
static final Logger LOG = LoggerFactory.getLogger(SearchController.class);

@Autowired
private SearchConfiguration searchConfiguration;
@Autowired
private SearchConfiguration searchConfiguration;

public static final String POST_FILTER = "post_filter";
public static final String POST_FILTER = "post_filter";

@Operation(operationId = "search",
summary = "Search for resources.",
description = "Search for resources using the configured Elastic backend. This endpoint serves as direct proxy to the RESTful endpoint of Elastic. "
+ "In the body, a query document following the Elastic query format has to be provided. Format errors are returned directly from Elastic. "
+ "This endpoint also supports authentication and authorization. User information obtained via JWT is applied to the provided query as "
+ "post filter. If a post filter was already provided with the query it will be replaced. Furthermore, this endpoint supports pagination. "
+ "'page' and 'size' query parameters are translated into the Elastic attributes 'from' and 'size' automatically, "
+ "if not already provided within the query by the caller.", security = {
@SecurityRequirement(name = "bearer-jwt")})
@RequestMapping(value = "/{index}/_search", method = RequestMethod.POST)
@ResponseBody
@PageableAsQueryParam
public ResponseEntity<?> proxy(
@PathVariable("index") final String index,
@RequestBody JsonNode body,
ProxyExchange<JsonNode> proxy,
@Parameter(hidden = true) final Pageable pgbl) throws Exception {
LOG.trace("Provided Elastic query: '{}'", body.toString());
@Operation(operationId = "search",
summary = "Search for resources.",
description = "Search for resources using the configured Elastic backend. This endpoint serves as direct proxy to the RESTful endpoint of Elastic. "
+ "In the body, a query document following the Elastic query format has to be provided. Format errors are returned directly from Elastic. "
+ "This endpoint also supports authentication and authorization. User information obtained via JWT is applied to the provided query as "
+ "post filter. If a post filter was already provided with the query it will be replaced. Furthermore, this endpoint supports pagination. "
+ "'page' and 'size' query parameters are translated into the Elastic attributes 'from' and 'size' automatically, "
+ "if not already provided within the query by the caller.", security = {
@SecurityRequirement(name = "bearer-jwt")})
@RequestMapping(value = "/{index}/_search", method = RequestMethod.POST)
@ResponseBody
@PageableAsQueryParam
public ResponseEntity<?> proxy(
@PathVariable("index") final String index,
@RequestBody JsonNode body,
ProxyExchange<JsonNode> proxy,
@Parameter(hidden = true) final Pageable pgbl) throws Exception {
LOG.trace("Provided Elastic query: '{}'", body.toString());

// Set or replace post-filter
ObjectNode on = (ObjectNode) body;
ElasticSearchUtil.addPaginationInformation(on, pgbl.getPageNumber(), pgbl.getPageSize());
ElasticSearchUtil.buildPostFilter(on);
// Set or replace post-filter
ObjectNode on = (ObjectNode) body;
ElasticSearchUtil.addPaginationInformation(on, pgbl.getPageNumber(), pgbl.getPageSize());
ElasticSearchUtil.buildPostFilter(on);

LOG.trace("Forwarding Elastic query to {}.", searchConfiguration.getUrl() + "/" + index + "/_search");
return proxy.uri(searchConfiguration.getUrl() + "/" + index + "/_search").post();
}
@Operation(operationId = "search",
summary = "Search for resources.",
description = "This endpoint is identical to _search but kept for "
+ "legacy reasons. In future implementations _search should "
+ "be used as Elatic also offers _search and some libraries "
+ "expect _search as default endpoint.", security = {
@SecurityRequirement(name = "bearer-jwt")})
@RequestMapping(value = "/search", method = RequestMethod.POST)
@ResponseBody
@PageableAsQueryParam
public ResponseEntity<?> proxy_legacy(
@RequestBody JsonNode body,
ProxyExchange<JsonNode> proxy,
@Parameter(hidden = true) final Pageable pgbl) throws Exception {
return proxy(searchConfiguration.getIndex(), body, proxy, pgbl);
}
LOG.trace("Forwarding Elastic query to {}.", searchConfiguration.getUrl() + "/" + index + "/_search");
return proxy.uri(searchConfiguration.getUrl() + "/" + index + "/_search").post();
}

@Operation(operationId = "search",
summary = "Search for resources.",
description = "This endpoint is identical to _search but kept for "
+ "legacy reasons. In future implementations _search should "
+ "be used as Elatic also offers _search and some libraries "
+ "expect _search as default endpoint.", security = {
@SecurityRequirement(name = "bearer-jwt")})
@RequestMapping(value = "/search", method = RequestMethod.POST)
@ResponseBody
@PageableAsQueryParam
public ResponseEntity<?> proxy_legacy(
@RequestBody JsonNode body,
ProxyExchange<JsonNode> proxy,
@Parameter(hidden = true) final Pageable pgbl) throws Exception {
return proxy(searchConfiguration.getIndex(), body, proxy, pgbl);
}
}
244 changes: 124 additions & 120 deletions src/main/java/edu/kit/datamanager/util/ElasticSearchUtil.java
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import com.fasterxml.jackson.databind.node.JsonNodeFactory;
import com.fasterxml.jackson.databind.node.ObjectNode;
import static edu.kit.datamanager.controller.SearchController.POST_FILTER;
import edu.kit.datamanager.entities.RepoUserRole;
import edu.kit.datamanager.validator.SearchIndexValidator;
import java.net.URL;
import org.slf4j.Logger;
Expand All @@ -36,107 +37,109 @@
*/
public class ElasticSearchUtil {

/**
* Logger for this class.
*/
private final static Logger LOGGER = LoggerFactory.getLogger(ElasticSearchUtil.class);
/**
* Logger for this class.
*/
private final static Logger LOGGER = LoggerFactory.getLogger(ElasticSearchUtil.class);

final static JsonNodeFactory factory = JsonNodeFactory.instance;
final static JsonNodeFactory factory = JsonNodeFactory.instance;

public static final String RESULTS_FROM = "from";
public static final String RESULTS_SIZE = "size";
static final String SID_READ = "read";
public static final String RESULTS_FROM = "from";
public static final String RESULTS_SIZE = "size";
static final String SID_READ = "read";

private static final int NO_OF_RETRIES = 3;
private static final int NO_OF_RETRIES = 3;

/**
* Test URL for pointing to a running elasticsearch instance.
*
* @param elasticsearchURL the given URL to check for an elasticsearch
* instance.I
* @return true if server is available.
*/
public static boolean testForElasticsearch(URL elasticsearchURL) {
boolean validElasticSearchServer = false;
if (elasticsearchURL != null) {
String baseUrl = elasticsearchURL.toString();
// test for trailing '/'
if (baseUrl.trim().endsWith("/")) {
LOGGER.error("Invalid elasticsearch URL. Please remove trailing '/' from URL '{}'!", baseUrl);
/**
* Test URL for pointing to a running elasticsearch instance.
*
* @param elasticsearchURL the given URL to check for an elasticsearch
* instance.I
* @return true if server is available.
*/
public static boolean testForElasticsearch(URL elasticsearchURL) {
boolean validElasticSearchServer = false;
if (elasticsearchURL != null) {
String baseUrl = elasticsearchURL.toString();
// test for trailing '/'
if (baseUrl.trim().endsWith("/")) {
LOGGER.error("Invalid elasticsearch URL. Please remove trailing '/' from URL '{}'!", baseUrl);
} else {
String accessUrl = baseUrl + "/_search";
RestTemplate restTemplate = new RestTemplate();
int retries = 1;
LOGGER.trace("Trying to connect to elasticsearch instance.");
while (retries <= NO_OF_RETRIES) {
try {
ResponseEntity<String> entity = restTemplate.getForEntity(accessUrl,
String.class,
baseUrl);
LOGGER.trace("Status code value: " + entity.getStatusCodeValue());
LOGGER.trace("HTTP Header 'ContentType': " + entity.getHeaders().getContentType());
if (entity.getStatusCodeValue() == HttpStatus.OK.value()) {
LOGGER.info("Elasticsearch server at '{}' seems to be up and running!", baseUrl);
validElasticSearchServer = true;
break;
} else {
String accessUrl = baseUrl + "/_search";
RestTemplate restTemplate = new RestTemplate();
int retries = 1;
LOGGER.trace("Trying to connect to elasticsearch instance.");
while (retries <= NO_OF_RETRIES) {
try {
ResponseEntity<String> entity = restTemplate.getForEntity(accessUrl,
String.class,
baseUrl);
LOGGER.trace("Status code value: " + entity.getStatusCodeValue());
LOGGER.trace("HTTP Header 'ContentType': " + entity.getHeaders().getContentType());
if (entity.getStatusCodeValue() == HttpStatus.OK.value()) {
LOGGER.info("Elasticsearch server at '{}' seems to be up and running!", baseUrl);
validElasticSearchServer = true;
break;
} else {
LOGGER.debug("Invalid response from elasticsearch server. Expected HTTP 200, received HTTP " + entity.getStatusCodeValue() + ". Aborting.");
}
} catch (RestClientException ex) {
LOGGER.warn("Failed accessing elasticsearch server.", ex);
}
LOGGER.warn("Attempt {}/{} failed!", retries, NO_OF_RETRIES);
if (retries < NO_OF_RETRIES) {
LOGGER.warn("Retrying in 5 seconds...");
try {
Thread.sleep(5000);
} catch (InterruptedException ie) {
}
}
retries++;
}
LOGGER.debug("Invalid response from elasticsearch server. Expected HTTP 200, received HTTP " + entity.getStatusCodeValue() + ". Aborting.");
}
if (!validElasticSearchServer) {
LOGGER.trace("Unable to connect to elasticsearch instance at '{}' within '{}' attempts!", baseUrl, NO_OF_RETRIES);
} catch (RestClientException ex) {
LOGGER.warn("Failed accessing elasticsearch server.", ex);
}
LOGGER.warn("Attempt {}/{} failed!", retries, NO_OF_RETRIES);
if (retries < NO_OF_RETRIES) {
LOGGER.warn("Retrying in 5 seconds...");
try {
Thread.sleep(5000);
} catch (InterruptedException ie) {
}
} else {
LOGGER.warn("No elasticsearch URL provided. Aborting.");
}
retries++;
}
return validElasticSearchServer;
}
if (!validElasticSearchServer) {
LOGGER.trace("Unable to connect to elasticsearch instance at '{}' within '{}' attempts!", baseUrl, NO_OF_RETRIES);
}
} else {
LOGGER.warn("No elasticsearch URL provided. Aborting.");
}
return validElasticSearchServer;
}

/**
* Test if string is a valid elasticsearch index. If not - change to lower
* case - replace all invalid characters by '_'
*
* @param elasticsearchIndex index to test.
* @return valid index
*/
public static String testForValidIndex(String elasticsearchIndex) {
String validIndex = elasticsearchIndex;
/**
* Test if string is a valid elasticsearch index. If not - change to lower
* case - replace all invalid characters by '_'
*
* @param elasticsearchIndex index to test.
* @return valid index
*/
public static String testForValidIndex(String elasticsearchIndex) {
String validIndex = elasticsearchIndex;

boolean valid = new SearchIndexValidator().isValid(validIndex, null);
if (!valid) {
String pattern = "[" + SearchIndexValidator.SPECIAL_CHARACTERS + "]";
validIndex = validIndex.toLowerCase().replaceAll(pattern, "_");
}
return validIndex;
boolean valid = new SearchIndexValidator().isValid(validIndex, null);
if (!valid) {
String pattern = "[" + SearchIndexValidator.SPECIAL_CHARACTERS + "]";
validIndex = validIndex.toLowerCase().replaceAll(pattern, "_");
}
return validIndex;
}

/**
* Build post filter to restrict only to authorized results.
*
* @param queryNode Node holding query.
*/
public static void buildPostFilter(ObjectNode queryNode) {
boolean havePostFilter = false;
if (queryNode.has(POST_FILTER)) {
LOGGER.warn("PostFilter found in provided query. Filter will be replaced!");
havePostFilter = true;
}
/**
* Build post filter to restrict only to authorized results.
*
* @param queryNode Node holding query.
*/
public static void buildPostFilter(ObjectNode queryNode) {
// No need to add/edit post filter if user is an ADMINISTRATOR.
if (!AuthenticationHelper.hasAuthority(RepoUserRole.ADMINISTRATOR.getValue())) {
boolean havePostFilter = false;
if (queryNode.has(POST_FILTER)) {
LOGGER.warn("PostFilter found in provided query. Filter will be replaced!");
havePostFilter = true;
}

JsonNode postFilter;
/* Post filter may look like this:
JsonNode postFilter;
/* Post filter may look like this:
{
"bool" : {
"should" : [
Expand All @@ -146,40 +149,41 @@ public static void buildPostFilter(ObjectNode queryNode) {
"minimum_should_match" : 1
}
}
*/
LOGGER.trace("Adding PostFilter to elastic query.");
ArrayNode arrayNode = factory.arrayNode();
for (String sid : AuthenticationHelper.getAuthorizationIdentities()) {
JsonNode match = factory.objectNode().set("match", factory.objectNode().put(SID_READ, sid));
arrayNode.add(match);
}
ObjectNode should = factory.objectNode().set("should", arrayNode);
should.put("minimum_should_match", 1);
postFilter = factory.objectNode().set("bool", should);
if (havePostFilter) {
ArrayNode mustNode = (ArrayNode) queryNode.get(POST_FILTER).get("bool").get("must");
mustNode.add(postFilter);
} else {
LOGGER.trace("PostFilter: '{}'", postFilter);
queryNode.replace(POST_FILTER, postFilter);
}
*/
LOGGER.trace("Adding PostFilter to elastic query.");
ArrayNode arrayNode = factory.arrayNode();
for (String sid : AuthenticationHelper.getAuthorizationIdentities()) {
JsonNode match = factory.objectNode().set("match", factory.objectNode().put(SID_READ, sid));
arrayNode.add(match);
}
ObjectNode should = factory.objectNode().set("should", arrayNode);
should.put("minimum_should_match", 1);
postFilter = factory.objectNode().set("bool", should);
if (havePostFilter) {
ArrayNode mustNode = (ArrayNode) queryNode.get(POST_FILTER).get("bool").get("must");
mustNode.add(postFilter);
} else {
LOGGER.trace("PostFilter: '{}'", postFilter);
queryNode.replace(POST_FILTER, postFilter);
}
}
}

/**
* Add pagination to query if not already present.
*
* @param queryNode Node holding query.
* @param page Number of the page.
* @param size Size of the page.
*/
public static void addPaginationInformation(ObjectNode queryNode, int page, int size) {
if (queryNode.has(RESULTS_FROM) || queryNode.has(RESULTS_SIZE)) {
LOGGER.trace("Provided query already specifies 'from' and/or 'size'. Ignoring pagination information from request.");
} else {
LOGGER.trace("Provided query does not specify 'from' and/or 'size'. Using pagination information with page {} and size {}", page, size);
queryNode.replace(RESULTS_FROM, factory.numberNode(page * size));
queryNode.replace(RESULTS_SIZE, factory.numberNode(size));
}

/**
* Add pagination to query if not already present.
*
* @param queryNode Node holding query.
* @param page Number of the page.
* @param size Size of the page.
*/
public static void addPaginationInformation(ObjectNode queryNode, int page, int size) {
if (queryNode.has(RESULTS_FROM) || queryNode.has(RESULTS_SIZE)) {
LOGGER.trace("Provided query already specifies 'from' and/or 'size'. Ignoring pagination information from request.");
} else {
LOGGER.trace("Provided query does not specify 'from' and/or 'size'. Using pagination information with page {} and size {}", page, size);
queryNode.replace(RESULTS_FROM, factory.numberNode(page * size));
queryNode.replace(RESULTS_SIZE, factory.numberNode(size));
}

}
}

0 comments on commit 877cc53

Please sign in to comment.