From 5c2aa228ccec87b83a2b15fad096b435a829215b Mon Sep 17 00:00:00 2001 From: Pat Whelan Date: Tue, 7 Oct 2025 15:29:15 -0400 Subject: [PATCH] [CPS] Relocate CrossProjectRoutingResolver Move CrossProjectRoutingResolver to the same search.crossproject package as the other cross-project index classes. --- .../CrossProjectRoutingResolver.java | 220 ++++++++++++++++ .../CrossProjectRoutingResolverTests.java | 245 ++++++++++++++++++ 2 files changed, 465 insertions(+) create mode 100644 server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectRoutingResolver.java create mode 100644 server/src/test/java/org/elasticsearch/search/crossproject/CrossProjectRoutingResolverTests.java diff --git a/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectRoutingResolver.java b/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectRoutingResolver.java new file mode 100644 index 0000000000000..67bf575e46a0d --- /dev/null +++ b/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectRoutingResolver.java @@ -0,0 +1,220 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +package org.elasticsearch.search.crossproject; + +import org.elasticsearch.ElasticsearchStatusException; + +import java.util.List; +import java.util.Set; +import java.util.function.Predicate; +import java.util.stream.IntStream; +import java.util.stream.Stream; + +import static org.elasticsearch.rest.RestStatus.BAD_REQUEST; + +/** + * Tech Preview. + * Resolves a single entry _alias for a cross-project request specifying a project_routing. + * We currently only support a single entry routing containing either a specific name, a prefix, a suffix, or a match-all (*). + */ +public class CrossProjectRoutingResolver { + private static final String ALIAS = "_alias:"; + private static final String ORIGIN = "_origin"; + private static final int ALIAS_LENGTH = ALIAS.length(); + private static final String ALIAS_MATCH_ALL = ALIAS + "*"; + private static final String ALIAS_MATCH_ORIGIN = ALIAS + ORIGIN; + + /** + * Initially, we only support the "*" wildcard. + * https://www.elastic.co/docs/reference/query-languages/query-dsl/query-dsl-query-string-query + */ + private static final Set UNSUPPORTED_CHARACTERS = Set.of( + '+', + '-', + '=', + '&', + '|', + '>', + '<', + '!', + '(', + ')', + '{', + '}', + '[', + ']', + '^', + '"', + '~', + '?', + ':', + '\\', + '/' + ); + + /** + * @param projectRouting the project_routing specified in the request object. + * @param originProject the project alias where this function is being called. + * @param candidateProjects the list of project aliases for the active request. This list must *NOT* contain the originProject entry. + * @return the filtered list of projects matching the projectRouting, or an empty list if none are found. + * @throws ElasticsearchStatusException if the projectRouting is null, empty, does not start with "_alias:", contains more than one + * entry, or contains an '*' in the middle of a string. + */ + public List resolve( + String projectRouting, + ProjectRoutingInfo originProject, + List candidateProjects + ) { + assert originProject.projectAlias().equalsIgnoreCase(ORIGIN) == false : "origin project alias must not be " + ORIGIN; + + var candidateProjectStream = candidateProjects.stream().peek(candidateProject -> { + assert candidateProject.projectAlias().equalsIgnoreCase(ORIGIN) == false : "project alias must not be " + ORIGIN; + }).filter(candidateProject -> { + assert candidateProject.equals(originProject) == false : "origin project must not be in the candidateProjects list"; + return candidateProject.equals(originProject) == false; // assertions are disabled in prod, instead we should filter this out + }); + + if (ALIAS_MATCH_ORIGIN.equalsIgnoreCase(projectRouting)) { + return List.of(originProject); + } + + if (projectRouting == null || projectRouting.isEmpty() || ALIAS_MATCH_ALL.equalsIgnoreCase(projectRouting)) { + return Stream.concat(Stream.of(originProject), candidateProjectStream).toList(); + } + + validateProjectRouting(projectRouting); + + var matchesSpecifiedRoute = createRoutingEntryFilter(projectRouting); + return Stream.concat(Stream.of(originProject), candidateProjectStream).filter(matchesSpecifiedRoute).toList(); + } + + private static void validateProjectRouting(String projectRouting) { + var startsWithAlias = startsWithIgnoreCase(ALIAS, projectRouting); + if (startsWithAlias && projectRouting.length() == ALIAS_LENGTH) { + throw new ElasticsearchStatusException("project_routing expression [{}] cannot be empty", BAD_REQUEST, projectRouting); + } + if ((startsWithAlias == false) && projectRouting.contains(":")) { + throw new ElasticsearchStatusException( + "Unsupported tag [{}] in project_routing expression [{}]. Supported tags [_alias].", + BAD_REQUEST, + projectRouting.substring(0, projectRouting.indexOf(":")), + projectRouting + ); + } + if (startsWithAlias == false) { + throw new ElasticsearchStatusException( + "project_routing [{}] must start with the prefix [_alias:]", + BAD_REQUEST, + projectRouting + ); + } + } + + private static Predicate createRoutingEntryFilter(String projectRouting) { + // we're using index pointers and directly accessing the internal character array rather than using higher abstraction + // methods like String.split or creating multiple substrings. we don't expect a lot of linked projects or long project routing + // expressions, but this is expected to run on every search request so we're opting to avoid creating multiple objects. + // plus we plan to replace this all soon anyway... + var matchPrefix = projectRouting.charAt(projectRouting.length() - 1) == '*'; + var matchSuffix = projectRouting.charAt(ALIAS_LENGTH) == '*'; + + int foundAsterix = -1; + int startIndex = matchSuffix ? ALIAS_LENGTH + 1 : ALIAS_LENGTH; + int endIndex = matchPrefix ? projectRouting.length() - 1 : projectRouting.length(); + + for (int i = startIndex; i < endIndex; ++i) { + var nextChar = projectRouting.charAt(i); + + // verify that there are no whitespaces, unsupported characters, + // or more complex asterisk expressions (*pro*_2 is unsupported, pro*_2, pro*, and *project_2 are all supported) + if (Character.isWhitespace(nextChar) + || UNSUPPORTED_CHARACTERS.contains(nextChar) + || (nextChar == '*' && (foundAsterix >= 0 || matchPrefix || matchSuffix))) { + throw new ElasticsearchStatusException( + "Unsupported project_routing expression [{}]. " + + "Tech Preview only supports project routing via a single project alias or wildcard alias expression", + BAD_REQUEST, + projectRouting.substring(ALIAS_LENGTH) + ); + } + + if (nextChar == '*') { + foundAsterix = i; + } + } + + if (foundAsterix >= 0) { + var prefix = projectRouting.substring(startIndex, foundAsterix); + var suffix = projectRouting.substring(foundAsterix + 1, endIndex); + return possibleRoute -> startsWithIgnoreCase(prefix, possibleRoute.projectAlias()) + && endsWithIgnoreCase(suffix, possibleRoute.projectAlias()); + } + + var routingEntry = projectRouting.substring(startIndex, endIndex); + if (matchPrefix && matchSuffix) { + return possibleRoute -> containsIgnoreCase(routingEntry, possibleRoute.projectAlias()); + } else if (matchPrefix) { + return possibleRoute -> startsWithIgnoreCase(routingEntry, possibleRoute.projectAlias()); + } else if (matchSuffix) { + return possibleRoute -> endsWithIgnoreCase(routingEntry, possibleRoute.projectAlias()); + } else { + return possibleRoute -> possibleRoute.projectAlias().equalsIgnoreCase(routingEntry); + } + } + + private static boolean startsWithIgnoreCase(String prefix, String str) { + if (prefix == null || str == null) { + return false; + } + if (str.startsWith(prefix)) { + return true; + } + if (str.length() < prefix.length()) { + return false; + } + if (str.length() == prefix.length() && str.equalsIgnoreCase(prefix)) { + return true; + } + return str.substring(0, prefix.length()).equalsIgnoreCase(prefix); + } + + private static boolean endsWithIgnoreCase(String suffix, String str) { + if (suffix == null || str == null) { + return false; + } + if (str.endsWith(suffix)) { + return true; + } + if (str.length() < suffix.length()) { + return false; + } + if (str.length() == suffix.length() && str.equalsIgnoreCase(suffix)) { + return true; + } + return str.substring(str.length() - suffix.length()).equalsIgnoreCase(suffix); + } + + private static boolean containsIgnoreCase(String substring, String str) { + if (substring == null || str == null) { + return false; + } + if (str.contains(substring)) { + return true; + } + if (str.length() < substring.length()) { + return false; + } + if (str.length() == substring.length() && str.equalsIgnoreCase(substring)) { + return true; + } + var substringLength = substring.length(); + return IntStream.range(0, str.length() - substringLength).anyMatch(i -> str.regionMatches(true, i, substring, 0, substringLength)); + } +} diff --git a/server/src/test/java/org/elasticsearch/search/crossproject/CrossProjectRoutingResolverTests.java b/server/src/test/java/org/elasticsearch/search/crossproject/CrossProjectRoutingResolverTests.java new file mode 100644 index 0000000000000..654832960c97c --- /dev/null +++ b/server/src/test/java/org/elasticsearch/search/crossproject/CrossProjectRoutingResolverTests.java @@ -0,0 +1,245 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +package org.elasticsearch.search.crossproject; + +import com.carrotsearch.randomizedtesting.annotations.ParametersFactory; + +import org.elasticsearch.ElasticsearchStatusException; +import org.elasticsearch.cluster.metadata.ProjectId; +import org.elasticsearch.rest.RestStatus; +import org.elasticsearch.test.ESTestCase; +import org.hamcrest.Matchers; + +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Stream; + +import static org.hamcrest.Matchers.equalTo; + +/** + * Assumptions to clarify: + * 1. p* means "starting with 'p'" and not the regex. + * 2. *p means "ending in 'p'" and not the regex. + * 3. * means "return everything". + * 4. An unsupported projectRouting is treated as 400 Bad Requests. This is also true for projectRouting we plan to support. + */ +public class CrossProjectRoutingResolverTests extends ESTestCase { + + @ParametersFactory(shuffle = false) + public static Iterable parameters() { + return Stream.of( + testCase(null, "my_project_alias", List.of("project_2", "oh_snap"), List.of("my_project_alias", "project_2", "oh_snap")), + testCase("", "my_project_alias", List.of("project_2", "oh_snap"), List.of("my_project_alias", "project_2", "oh_snap")), + testCase("_alias:my_project_alias", "my_project_alias", List.of("project_2", "oh_snap"), List.of("my_project_alias")), + testCase("_alias:p*", "my_project_alias", List.of("project_2", "oh_snap"), List.of("project_2")), + testCase("_alias:*p", "my_project_alias", List.of("project_2", "oh_snap"), List.of("oh_snap")), + testCase("_alias:*", "my_project_alias", List.of("project_2", "oh_snap"), List.of("my_project_alias", "project_2", "oh_snap")), + testCase("_alias:*s*", "my_project_alias", List.of("oh_snap"), List.of("my_project_alias", "oh_snap")), + testCase("_alias:_origin", "my_project_alias", List.of("project_2", "oh_snap"), List.of("my_project_alias")), + testCase("_alias:hello", "my_project_alias", List.of("project_2", "oh_snap"), List.of()), + testCase( + "_alias:", + "my_project_alias", + List.of("project_2", "oh_snap"), + new ElasticsearchStatusException("project_routing expression [_alias:] cannot be empty", RestStatus.BAD_REQUEST) + ), + testCase( + "hello", + "my_project_alias", + List.of("project_2", "oh_snap"), + new ElasticsearchStatusException("project_routing [hello] must start with the prefix [_alias:]", RestStatus.BAD_REQUEST) + ), + testCase( + "_region:us-east-1", + "my_project_alias", + List.of("project_2", "oh_snap"), + new ElasticsearchStatusException( + "Unsupported tag [_region] in project_routing expression [_region:us-east-1]. Supported tags [_alias].", + RestStatus.BAD_REQUEST + ) + ), + testCase("_alias:pro*_2", "my_project_alias", List.of("project_2", "oh_snap"), List.of("project_2")), + testCase( + "_alias:*pro*_2", + "my_project_alias", + List.of("project_2", "oh_snap"), + new ElasticsearchStatusException( + "Unsupported project_routing expression [*pro*_2]. " + + "Tech Preview only supports project routing via a single project alias or wildcard alias expression", + RestStatus.BAD_REQUEST + ) + ), + testCase( + "_alias:_origin OR _alias:*p", + "my_project_alias", + List.of("project_2", "oh_snap"), + new ElasticsearchStatusException( + "Unsupported project_routing expression [_origin OR _alias:*p]. " + + "Tech Preview only supports project routing via a single project alias or wildcard alias expression", + RestStatus.BAD_REQUEST + ) + ), + testCase( + "_alias:_origin AND *p", + "my_project_alias", + List.of("project_2", "oh_snap"), + new ElasticsearchStatusException( + "Unsupported project_routing expression [_origin AND *p]. " + + "Tech Preview only supports project routing via a single project alias or wildcard alias expression", + RestStatus.BAD_REQUEST + ) + ), + testCase( + "_alias:_origin\tAND\t*p", + "my_project_alias", + List.of("project_2", "oh_snap"), + new ElasticsearchStatusException( + "Unsupported project_routing expression [_origin\tAND\t*p]. " + + "Tech Preview only supports project routing via a single project alias or wildcard alias expression", + RestStatus.BAD_REQUEST + ) + ), + testCase("_alias:**", "my_project_alias", List.of("project_2", "oh_snap"), List.of("my_project_alias", "project_2", "oh_snap")), + testCase( + "_alias:_origin\tAND\t*p", + "my_project_alias", + List.of("project_2", "oh_snap"), + new ElasticsearchStatusException( + "Unsupported project_routing expression [_origin\tAND\t*p]. " + + "Tech Preview only supports project routing via a single project alias or wildcard alias expression", + RestStatus.BAD_REQUEST + ) + ), + testCase( + "_alias:_origin\tAND\t*p", + "my_project_alias", + List.of("project_2", "oh_snap"), + new ElasticsearchStatusException( + "Unsupported project_routing expression [_origin\tAND\t*p]. " + + "Tech Preview only supports project routing via a single project alias or wildcard alias expression", + RestStatus.BAD_REQUEST + ) + ), + testCase("_alias:_*", "my_project_alias", List.of("project_2", "oh_snap"), List.of()), + testCase("_alias:*in", "my_project_alias", List.of("project_2", "oh_snap"), List.of()), + testCase("_alias:*or*", "my_project_alias", List.of("project_2", "oh_snap"), List.of()), + testCase("_alias:_alias", "my_project_alias", List.of("project_2", "oh_snap"), List.of()), + testCase("_alias:*oh_SNAP", "my_project_alias", List.of("project_2", "oh_snap"), List.of("oh_snap")), + testCase("_alias:oh_SNAP*", "my_project_alias", List.of("project_2", "oh_snap"), List.of("oh_snap")), + testCase("_alias:*oh_SNAP*", "my_project_alias", List.of("project_2", "oh_snap"), List.of("oh_snap")), + testCase("_alias:*SNAP", "my_project_alias", List.of("project_2", "oh_snap"), List.of("oh_snap")), + testCase("_alias:oh_SN*", "my_project_alias", List.of("project_2", "oh_snap"), List.of("oh_snap")), + testCase("_alias:*SN*", "my_project_alias", List.of("project_2", "oh_snap"), List.of("oh_snap")), + testCase( + "_alias: myalias", + "my_project_alias", + List.of("project_2", "oh_snap"), + new ElasticsearchStatusException( + "Unsupported project_routing expression [ myalias]. " + + "Tech Preview only supports project routing via a single project alias or wildcard alias expression", + RestStatus.BAD_REQUEST + ) + ), + testCase( + "_alias :myalias", + "my_project_alias", + List.of("project_2", "oh_snap"), + new ElasticsearchStatusException( + "Unsupported tag [_alias ] in project_routing expression [_alias :myalias]. Supported tags [_alias].", + RestStatus.BAD_REQUEST + ) + ) + ).toList(); + } + + private static final CrossProjectRoutingResolver crossProjectRoutingResolver = new CrossProjectRoutingResolver(); + private final TestCase testCase; + + public CrossProjectRoutingResolverTests(TestCase testCase) { + this.testCase = testCase; + } + + public void test() { + if (testCase.expectedResolvedProjectAliases != null && testCase.expectedResolvedProjectAliases.isEmpty()) { + assertThat( + crossProjectRoutingResolver.resolve(testCase.projectRouting, testCase.originProject, testCase.candidateProjects), + Matchers.empty() + ); + } else if (testCase.expectedResolvedProjectAliases != null) { + assertThat( + crossProjectRoutingResolver.resolve(testCase.projectRouting, testCase.originProject, testCase.candidateProjects) + .stream() + .map(ProjectRoutingInfo::projectAlias) + .toList(), + Matchers.containsInAnyOrder(testCase.expectedResolvedProjectAliases.toArray()) + ); + } else if (testCase.expectedException != null) { + var actualException = assertThrows( + ElasticsearchStatusException.class, + () -> crossProjectRoutingResolver.resolve(testCase.projectRouting, testCase.originProject, testCase.candidateProjects) + ); + assertThat(actualException.getMessage(), equalTo(testCase.expectedException.getMessage())); + assertThat(actualException.status(), equalTo(testCase.expectedException.status())); + } else { + fail("Either expectedResolvedProjectAliases or expectedException must be provided"); + } + } + + private static Object[] testCase( + String projectRouting, + String originProjectAlias, + List projectAliases, + List expectedResolvedProjectAliases + ) { + return new Object[] { + new TestCase( + projectRouting, + projectWithAlias(originProjectAlias), + projectAliases.stream().map(CrossProjectRoutingResolverTests::projectWithAlias).toList(), + expectedResolvedProjectAliases, + null + ) }; + } + + private static ProjectRoutingInfo projectWithAlias(String alias) { + return new ProjectRoutingInfo(ProjectId.DEFAULT, "projectType", alias, "organizationId", new ProjectTags(Map.of())); + } + + private static Object[] testCase( + String projectRouting, + String originProjectAlias, + List projectAliases, + ElasticsearchStatusException expectedException + ) { + return new Object[] { + new TestCase( + projectRouting, + projectWithAlias(originProjectAlias), + projectAliases.stream().map(CrossProjectRoutingResolverTests::projectWithAlias).toList(), + null, + expectedException + ) }; + } + + private record TestCase( + String projectRouting, + ProjectRoutingInfo originProject, + List candidateProjects, + List expectedResolvedProjectAliases, + ElasticsearchStatusException expectedException + ) { + private TestCase { + Objects.requireNonNull(originProject); + Objects.requireNonNull(candidateProjects); + assert expectedResolvedProjectAliases != null ^ expectedException != null; + } + } +}