1
0
Эх сурвалжийг харах

Move `ProjectRoutingInfo` and related classes (#135586)

This PR moves `ProjectRoutingInfo` and related classes into core to be
used by the IndexAbstractionResolver (see bigger [draft
PR](https://github.com/elastic/elasticsearch/pull/135346/files#diff-3e278f8a5f49993b4e491a25ddeaf382289d122a69492902cf61ede33589e9a6R53)).
This is a copy & paste refactor without functional changes.
Nikolaj Volgushev 2 долоо хоног өмнө
parent
commit
c56b4dc636

+ 1 - 0
server/src/main/java/module-info.java

@@ -491,4 +491,5 @@ module org.elasticsearch.server {
     exports org.elasticsearch.inference.telemetry;
     exports org.elasticsearch.index.codec.vectors.diskbbq to org.elasticsearch.test.knn;
     exports org.elasticsearch.index.codec.vectors.cluster to org.elasticsearch.test.knn;
+    exports org.elasticsearch.search.crossproject;
 }

+ 8 - 0
server/src/main/java/org/elasticsearch/ElasticsearchException.java

@@ -45,6 +45,7 @@ import org.elasticsearch.search.TooManyScrollContextsException;
 import org.elasticsearch.search.aggregations.AggregationExecutionException;
 import org.elasticsearch.search.aggregations.MultiBucketConsumerService;
 import org.elasticsearch.search.aggregations.UnsupportedAggregationOnDownsampledIndex;
+import org.elasticsearch.search.crossproject.NoMatchingProjectException;
 import org.elasticsearch.search.query.SearchTimeoutException;
 import org.elasticsearch.transport.TcpTransport;
 import org.elasticsearch.xcontent.ParseField;
@@ -79,6 +80,7 @@ import static java.util.Collections.unmodifiableMap;
 import static org.elasticsearch.cluster.metadata.IndexMetadata.INDEX_UUID_NA_VALUE;
 import static org.elasticsearch.common.xcontent.XContentParserUtils.ensureExpectedToken;
 import static org.elasticsearch.common.xcontent.XContentParserUtils.ensureFieldName;
+import static org.elasticsearch.search.crossproject.CrossProjectIndexExpressionsRewriter.NO_MATCHING_PROJECT_EXCEPTION_VERSION;
 
 /**
  * A base class for all elasticsearch exceptions.
@@ -2022,6 +2024,12 @@ public class ElasticsearchException extends RuntimeException implements ToXConte
             184,
             TransportVersions.REMOTE_EXCEPTION,
             TransportVersions.REMOTE_EXCEPTION_8_19
+        ),
+        NO_MATCHING_PROJECT_EXCEPTION(
+            NoMatchingProjectException.class,
+            NoMatchingProjectException::new,
+            185,
+            NO_MATCHING_PROJECT_EXCEPTION_VERSION
         );
 
         final Class<? extends ElasticsearchException> exceptionClass;

+ 179 - 0
server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectIndexExpressionsRewriter.java

@@ -0,0 +1,179 @@
+/*
+ * 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.TransportVersion;
+import org.elasticsearch.cluster.metadata.ClusterNameExpressionResolver;
+import org.elasticsearch.cluster.metadata.IndexNameExpressionResolver;
+import org.elasticsearch.core.Nullable;
+import org.elasticsearch.logging.LogManager;
+import org.elasticsearch.logging.Logger;
+import org.elasticsearch.transport.NoSuchRemoteClusterException;
+import org.elasticsearch.transport.RemoteClusterAware;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * Utility class for rewriting cross-project index expressions.
+ * Provides methods that can rewrite qualified and unqualified index expressions to canonical CCS.
+ */
+public class CrossProjectIndexExpressionsRewriter {
+    public static TransportVersion NO_MATCHING_PROJECT_EXCEPTION_VERSION = TransportVersion.fromName("no_matching_project_exception");
+
+    private static final Logger logger = LogManager.getLogger(CrossProjectIndexExpressionsRewriter.class);
+    private static final String ORIGIN_PROJECT_KEY = "_origin";
+    private static final String WILDCARD = "*";
+    private static final String[] MATCH_ALL = new String[] { WILDCARD };
+    private static final String EXCLUSION = "-";
+    private static final String DATE_MATH = "<";
+
+    /**
+     * Rewrites index expressions for cross-project search requests.
+     * Handles qualified and unqualified expressions and match-all cases will also hand exclusions in the future.
+     *
+     * @param originProject the _origin project with its alias
+     * @param linkedProjects the list of linked and available projects to consider for a request
+     * @param originalIndices the array of index expressions to be rewritten to canonical CCS
+     * @return a map from original index expressions to lists of canonical index expressions
+     * @throws IllegalArgumentException if exclusions, date math or selectors are present in the index expressions
+     * @throws NoMatchingProjectException if a qualified resource cannot be resolved because a project is missing
+     */
+    public static Map<String, List<String>> rewriteIndexExpressions(
+        ProjectRoutingInfo originProject,
+        List<ProjectRoutingInfo> linkedProjects,
+        final String[] originalIndices
+    ) {
+        final String[] indices;
+        if (originalIndices == null || originalIndices.length == 0) { // handling of match all cases besides _all and `*`
+            indices = MATCH_ALL;
+        } else {
+            indices = originalIndices;
+        }
+        assert false == IndexNameExpressionResolver.isNoneExpression(indices)
+            : "expression list is *,-* which effectively means a request that requests no indices";
+        assert originProject != null || linkedProjects.isEmpty() == false
+            : "either origin project or linked projects must be in project target set";
+
+        Set<String> linkedProjectNames = linkedProjects.stream().map(ProjectRoutingInfo::projectAlias).collect(Collectors.toSet());
+        Map<String, List<String>> canonicalExpressionsMap = new LinkedHashMap<>(indices.length);
+        for (String resource : indices) {
+            if (canonicalExpressionsMap.containsKey(resource)) {
+                continue;
+            }
+            maybeThrowOnUnsupportedResource(resource);
+
+            boolean isQualified = RemoteClusterAware.isRemoteIndexName(resource);
+            if (isQualified) {
+                // handing of qualified expressions
+                String[] splitResource = RemoteClusterAware.splitIndexName(resource);
+                assert splitResource.length == 2
+                    : "Expected two strings (project and indexExpression) for a qualified resource ["
+                        + resource
+                        + "], but found ["
+                        + splitResource.length
+                        + "]";
+                String projectAlias = splitResource[0];
+                assert projectAlias != null : "Expected a project alias for a qualified resource but was null";
+                String indexExpression = splitResource[1];
+                maybeThrowOnUnsupportedResource(indexExpression);
+
+                List<String> canonicalExpressions = rewriteQualified(projectAlias, indexExpression, originProject, linkedProjectNames);
+
+                canonicalExpressionsMap.put(resource, canonicalExpressions);
+                logger.debug("Rewrote qualified expression [{}] to [{}]", resource, canonicalExpressions);
+            } else {
+                // un-qualified expression, i.e. flat-world
+                List<String> canonicalExpressions = rewriteUnqualified(resource, originProject, linkedProjects);
+                canonicalExpressionsMap.put(resource, canonicalExpressions);
+                logger.debug("Rewrote unqualified expression [{}] to [{}]", resource, canonicalExpressions);
+            }
+        }
+        return canonicalExpressionsMap;
+    }
+
+    private static List<String> rewriteUnqualified(
+        String indexExpression,
+        @Nullable ProjectRoutingInfo origin,
+        List<ProjectRoutingInfo> projects
+    ) {
+        List<String> canonicalExpressions = new ArrayList<>();
+        if (origin != null) {
+            canonicalExpressions.add(indexExpression); // adding the original indexExpression for the _origin cluster.
+        }
+        for (ProjectRoutingInfo targetProject : projects) {
+            canonicalExpressions.add(RemoteClusterAware.buildRemoteIndexName(targetProject.projectAlias(), indexExpression));
+        }
+        return canonicalExpressions;
+    }
+
+    private static List<String> rewriteQualified(
+        String requestedProjectAlias,
+        String indexExpression,
+        @Nullable ProjectRoutingInfo originProject,
+        Set<String> allProjectAliases
+    ) {
+        if (originProject != null && ORIGIN_PROJECT_KEY.equals(requestedProjectAlias)) {
+            // handling case where we have a qualified expression like: _origin:indexName
+            return List.of(indexExpression);
+        }
+
+        if (originProject == null && ORIGIN_PROJECT_KEY.equals(requestedProjectAlias)) {
+            // handling case where we have a qualified expression like: _origin:indexName but no _origin project is set
+            throw new NoMatchingProjectException(requestedProjectAlias);
+        }
+
+        try {
+            if (originProject != null) {
+                allProjectAliases.add(originProject.projectAlias());
+            }
+            List<String> resourcesMatchingAliases = new ArrayList<>();
+            List<String> allProjectsMatchingAlias = ClusterNameExpressionResolver.resolveClusterNames(
+                allProjectAliases,
+                requestedProjectAlias
+            );
+
+            if (allProjectsMatchingAlias.isEmpty()) {
+                throw new NoMatchingProjectException(requestedProjectAlias);
+            }
+
+            for (String project : allProjectsMatchingAlias) {
+                if (originProject != null && project.equals(originProject.projectAlias())) {
+                    resourcesMatchingAliases.add(indexExpression);
+                } else {
+                    resourcesMatchingAliases.add(RemoteClusterAware.buildRemoteIndexName(project, indexExpression));
+                }
+            }
+
+            return resourcesMatchingAliases;
+        } catch (NoSuchRemoteClusterException ex) {
+            logger.debug(ex.getMessage(), ex);
+            throw new NoMatchingProjectException(requestedProjectAlias);
+        }
+    }
+
+    private static void maybeThrowOnUnsupportedResource(String resource) {
+        // TODO To be handled in future PR.
+        if (resource.startsWith(EXCLUSION)) {
+            throw new IllegalArgumentException("Exclusions are not currently supported but was found in the expression [" + resource + "]");
+        }
+        if (resource.startsWith(DATE_MATH)) {
+            throw new IllegalArgumentException("Date math are not currently supported but was found in the expression [" + resource + "]");
+        }
+        if (IndexNameExpressionResolver.hasSelectorSuffix(resource)) {
+            throw new IllegalArgumentException("Selectors are not currently supported but was found in the expression [" + resource + "]");
+
+        }
+    }
+}

+ 30 - 0
server/src/main/java/org/elasticsearch/search/crossproject/NoMatchingProjectException.java

@@ -0,0 +1,30 @@
+/*
+ * 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.ResourceNotFoundException;
+import org.elasticsearch.common.io.stream.StreamInput;
+
+import java.io.IOException;
+
+/**
+ * An exception that a project is missing
+ */
+public final class NoMatchingProjectException extends ResourceNotFoundException {
+
+    public NoMatchingProjectException(String projectName) {
+        super("No such project: [" + projectName + "]");
+    }
+
+    public NoMatchingProjectException(StreamInput in) throws IOException {
+        super(in);
+    }
+
+}

+ 27 - 0
server/src/main/java/org/elasticsearch/search/crossproject/ProjectRoutingInfo.java

@@ -0,0 +1,27 @@
+/*
+ * 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.cluster.metadata.ProjectId;
+
+/**
+ * Information about a project used for routing in cross-project search.
+ */
+public record ProjectRoutingInfo(
+    ProjectId projectId,
+    String projectType,
+    String projectAlias,
+    String organizationId,
+    ProjectTags projectTags
+) {
+    public ProjectRoutingInfo(ProjectId projectId, ProjectTags projectTags) {
+        this(projectId, projectTags.projectType(), projectTags.projectAlias(), projectTags.organizationId(), projectTags);
+    }
+}

+ 62 - 0
server/src/main/java/org/elasticsearch/search/crossproject/ProjectTags.java

@@ -0,0 +1,62 @@
+/*
+ * 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 java.util.Map;
+
+/**
+ * Project tags used for cross-project search routing.
+ * @param tags the map of tags -- contains both built-in (Elastic-supplied) and custom user-defined tags.
+ *             All built-in tags are prefixed with an underscore (_).
+ */
+public record ProjectTags(Map<String, String> tags) {
+    public static final String PROJECT_ID_TAG = "_id";
+    public static final String PROJECT_ALIAS = "_alias";
+    public static final String PROJECT_TYPE_TAG = "_type";
+    public static final String ORGANIZATION_ID_TAG = "_organization";
+
+    public String projectId() {
+        return tags.get(PROJECT_ID_TAG);
+    }
+
+    public String organizationId() {
+        return tags.get(ORGANIZATION_ID_TAG);
+    }
+
+    public String projectType() {
+        return tags.get(PROJECT_TYPE_TAG);
+    }
+
+    public String projectAlias() {
+        return tags.get(PROJECT_ALIAS);
+    }
+
+    /**
+     * Validate that all required tags are present.
+     */
+    public static void validateTags(String projectId, Map<String, String> tags) {
+        if (false == tags.containsKey(PROJECT_ID_TAG)) {
+            throw missingTagException(projectId, PROJECT_ID_TAG);
+        }
+        if (false == tags.containsKey(PROJECT_TYPE_TAG)) {
+            throw missingTagException(projectId, PROJECT_TYPE_TAG);
+        }
+        if (false == tags.containsKey(ORGANIZATION_ID_TAG)) {
+            throw missingTagException(projectId, ORGANIZATION_ID_TAG);
+        }
+        if (false == tags.containsKey(PROJECT_ALIAS)) {
+            throw missingTagException(projectId, PROJECT_ALIAS);
+        }
+    }
+
+    private static IllegalStateException missingTagException(String projectId, String tagKey) {
+        return new IllegalStateException("Project configuration for [" + projectId + "] is missing required tag [" + tagKey + "]");
+    }
+}

+ 1 - 0
server/src/main/resources/transport/definitions/referable/no_matching_project_exception.csv

@@ -0,0 +1 @@
+9178000

+ 1 - 1
server/src/main/resources/transport/upper_bounds/9.2.csv

@@ -1 +1 @@
-extended_search_usage_telemetry,9177000
+no_matching_project_exception,9178000

+ 2 - 0
server/src/test/java/org/elasticsearch/ExceptionSerializationTests.java

@@ -86,6 +86,7 @@ import org.elasticsearch.search.TooManyScrollContextsException;
 import org.elasticsearch.search.aggregations.AggregationExecutionException;
 import org.elasticsearch.search.aggregations.MultiBucketConsumerService;
 import org.elasticsearch.search.aggregations.UnsupportedAggregationOnDownsampledIndex;
+import org.elasticsearch.search.crossproject.NoMatchingProjectException;
 import org.elasticsearch.search.internal.ShardSearchContextId;
 import org.elasticsearch.search.query.SearchTimeoutException;
 import org.elasticsearch.snapshots.Snapshot;
@@ -846,6 +847,7 @@ public class ExceptionSerializationTests extends ESTestCase {
         ids.put(182, IngestPipelineException.class);
         ids.put(183, IndexDocFailureStoreStatus.ExceptionWithFailureStoreStatus.class);
         ids.put(184, RemoteException.class);
+        ids.put(185, NoMatchingProjectException.class);
 
         Map<Class<? extends ElasticsearchException>, Integer> reverse = new HashMap<>();
         for (Map.Entry<Integer, Class<? extends ElasticsearchException>> entry : ids.entrySet()) {

+ 427 - 0
server/src/test/java/org/elasticsearch/search/crossproject/CrossProjectIndexExpressionsRewriterTests.java

@@ -0,0 +1,427 @@
+/*
+ * 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.ResourceNotFoundException;
+import org.elasticsearch.cluster.metadata.ProjectId;
+import org.elasticsearch.test.ESTestCase;
+
+import java.util.List;
+import java.util.Map;
+
+import static org.hamcrest.Matchers.containsInAnyOrder;
+
+public class CrossProjectIndexExpressionsRewriterTests extends ESTestCase {
+
+    public void testFlatOnlyRewrite() {
+        ProjectRoutingInfo origin = createRandomProjectWithAlias("P0");
+        List<ProjectRoutingInfo> linked = List.of(
+            createRandomProjectWithAlias("P1"),
+            createRandomProjectWithAlias("P2"),
+            createRandomProjectWithAlias("P3")
+        );
+        String[] requestedResources = new String[] { "logs*", "metrics*" };
+
+        Map<String, List<String>> canonical = CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions(
+            origin,
+            linked,
+            requestedResources
+        );
+
+        assertThat(canonical.keySet(), containsInAnyOrder("logs*", "metrics*"));
+        assertThat(canonical.get("logs*"), containsInAnyOrder("logs*", "P1:logs*", "P2:logs*", "P3:logs*"));
+        assertThat(canonical.get("metrics*"), containsInAnyOrder("metrics*", "P1:metrics*", "P2:metrics*", "P3:metrics*"));
+    }
+
+    public void testFlatAndQualifiedRewrite() {
+        ProjectRoutingInfo origin = createRandomProjectWithAlias("P0");
+        List<ProjectRoutingInfo> linked = List.of(
+            createRandomProjectWithAlias("P1"),
+            createRandomProjectWithAlias("P2"),
+            createRandomProjectWithAlias("P3")
+        );
+        String[] requestedResources = new String[] { "P1:logs*", "metrics*" };
+
+        Map<String, List<String>> canonical = CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions(
+            origin,
+            linked,
+            requestedResources
+        );
+
+        assertThat(canonical.keySet(), containsInAnyOrder("P1:logs*", "metrics*"));
+        assertThat(canonical.get("P1:logs*"), containsInAnyOrder("P1:logs*"));
+        assertThat(canonical.get("metrics*"), containsInAnyOrder("metrics*", "P1:metrics*", "P2:metrics*", "P3:metrics*"));
+    }
+
+    public void testQualifiedOnlyRewrite() {
+        ProjectRoutingInfo origin = createRandomProjectWithAlias("P0");
+        List<ProjectRoutingInfo> linked = List.of(
+            createRandomProjectWithAlias("P1"),
+            createRandomProjectWithAlias("P2"),
+            createRandomProjectWithAlias("P3")
+        );
+        String[] requestedResources = new String[] { "P1:logs*", "P2:metrics*" };
+
+        Map<String, List<String>> canonical = CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions(
+            origin,
+            linked,
+            requestedResources
+        );
+
+        assertThat(canonical.keySet(), containsInAnyOrder("P1:logs*", "P2:metrics*"));
+        assertThat(canonical.get("P1:logs*"), containsInAnyOrder("P1:logs*"));
+        assertThat(canonical.get("P2:metrics*"), containsInAnyOrder("P2:metrics*"));
+    }
+
+    public void testOriginQualifiedOnlyRewrite() {
+        ProjectRoutingInfo origin = createRandomProjectWithAlias("P0");
+        List<ProjectRoutingInfo> linked = List.of(
+            createRandomProjectWithAlias("P1"),
+            createRandomProjectWithAlias("P2"),
+            createRandomProjectWithAlias("P3")
+        );
+        String[] requestedResources = new String[] { "_origin:logs*", "_origin:metrics*" };
+
+        Map<String, List<String>> canonical = CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions(
+            origin,
+            linked,
+            requestedResources
+        );
+
+        assertThat(canonical.keySet(), containsInAnyOrder("_origin:logs*", "_origin:metrics*"));
+        assertThat(canonical.get("_origin:logs*"), containsInAnyOrder("logs*"));
+        assertThat(canonical.get("_origin:metrics*"), containsInAnyOrder("metrics*"));
+    }
+
+    public void testOriginQualifiedOnlyRewriteWithNoLikedProjects() {
+        ProjectRoutingInfo origin = createRandomProjectWithAlias("P0");
+        List<ProjectRoutingInfo> linked = List.of();
+        String[] requestedResources = new String[] { "_origin:logs*", "_origin:metrics*" };
+
+        Map<String, List<String>> canonical = CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions(
+            origin,
+            linked,
+            requestedResources
+        );
+
+        assertThat(canonical.keySet(), containsInAnyOrder("_origin:logs*", "_origin:metrics*"));
+        assertThat(canonical.get("_origin:logs*"), containsInAnyOrder("logs*"));
+        assertThat(canonical.get("_origin:metrics*"), containsInAnyOrder("metrics*"));
+    }
+
+    public void testOriginWithDifferentAliasQualifiedOnlyRewrite() {
+        String aliasForOrigin = randomAlphaOfLength(10);
+        ProjectRoutingInfo origin = createRandomProjectWithAlias(aliasForOrigin);
+        List<ProjectRoutingInfo> linked = List.of(
+            createRandomProjectWithAlias("P1"),
+            createRandomProjectWithAlias("P2"),
+            createRandomProjectWithAlias("P3")
+        );
+        String logIndexAlias = "logs*";
+        String logResource = aliasForOrigin + ":" + logIndexAlias;
+        String metricsIndexAlias = "metrics*";
+        String metricResource = aliasForOrigin + ":" + metricsIndexAlias;
+        String[] requestedResources = new String[] { logResource, metricResource };
+
+        Map<String, List<String>> canonical = CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions(
+            origin,
+            linked,
+            requestedResources
+        );
+
+        assertThat(canonical.keySet(), containsInAnyOrder(logResource, metricResource));
+        assertThat(canonical.get(logResource), containsInAnyOrder(logIndexAlias));
+        assertThat(canonical.get(metricResource), containsInAnyOrder(metricsIndexAlias));
+    }
+
+    public void testQualifiedLinkedAndOriginRewrite() {
+        ProjectRoutingInfo origin = createRandomProjectWithAlias("P0");
+        List<ProjectRoutingInfo> linked = List.of(
+            createRandomProjectWithAlias("P1"),
+            createRandomProjectWithAlias("P2"),
+            createRandomProjectWithAlias("P3")
+        );
+        String[] requestedResources = new String[] { "P1:logs*", "_origin:metrics*" };
+
+        Map<String, List<String>> canonical = CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions(
+            origin,
+            linked,
+            requestedResources
+        );
+
+        assertThat(canonical.keySet(), containsInAnyOrder("P1:logs*", "_origin:metrics*"));
+        assertThat(canonical.get("P1:logs*"), containsInAnyOrder("P1:logs*"));
+        assertThat(canonical.get("_origin:metrics*"), containsInAnyOrder("metrics*"));
+    }
+
+    public void testQualifiedStartsWithProjectWildcardRewrite() {
+        ProjectRoutingInfo origin = createRandomProjectWithAlias("P0");
+        List<ProjectRoutingInfo> linked = List.of(
+            createRandomProjectWithAlias("P1"),
+            createRandomProjectWithAlias("P2"),
+            createRandomProjectWithAlias("Q1"),
+            createRandomProjectWithAlias("Q2")
+        );
+        String[] requestedResources = new String[] { "Q*:metrics*" };
+
+        Map<String, List<String>> canonical = CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions(
+            origin,
+            linked,
+            requestedResources
+        );
+
+        assertThat(canonical.keySet(), containsInAnyOrder("Q*:metrics*"));
+        assertThat(canonical.get("Q*:metrics*"), containsInAnyOrder("Q1:metrics*", "Q2:metrics*"));
+    }
+
+    public void testQualifiedEndsWithProjectWildcardRewrite() {
+        ProjectRoutingInfo origin = createRandomProjectWithAlias("P0");
+        List<ProjectRoutingInfo> linked = List.of(
+            createRandomProjectWithAlias("P1"),
+            createRandomProjectWithAlias("P2"),
+            createRandomProjectWithAlias("Q1"),
+            createRandomProjectWithAlias("Q2")
+        );
+        String[] requestedResources = new String[] { "*1:metrics*" };
+
+        Map<String, List<String>> canonical = CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions(
+            origin,
+            linked,
+            requestedResources
+        );
+
+        assertThat(canonical.keySet(), containsInAnyOrder("*1:metrics*"));
+        assertThat(canonical.get("*1:metrics*"), containsInAnyOrder("P1:metrics*", "Q1:metrics*"));
+    }
+
+    public void testOriginProjectMatchingTwice() {
+        ProjectRoutingInfo origin = createRandomProjectWithAlias("P0");
+        List<ProjectRoutingInfo> linked = List.of(createRandomProjectWithAlias("P1"), createRandomProjectWithAlias("P2"));
+        String[] requestedResources = new String[] { "P0:metrics*", "_origin:metrics*" };
+
+        Map<String, List<String>> canonical = CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions(
+            origin,
+            linked,
+            requestedResources
+        );
+
+        assertThat(canonical.keySet(), containsInAnyOrder("P0:metrics*", "_origin:metrics*"));
+        assertThat(canonical.get("P0:metrics*"), containsInAnyOrder("metrics*"));
+        assertThat(canonical.get("_origin:metrics*"), containsInAnyOrder("metrics*"));
+    }
+
+    public void testUnderscoreWildcardShouldNotMatchOrigin() {
+        ProjectRoutingInfo origin = createRandomProjectWithAlias("P0");
+        List<ProjectRoutingInfo> linked = List.of(createRandomProjectWithAlias("_P1"), createRandomProjectWithAlias("_P2"));
+        String[] requestedResources = new String[] { "_*:metrics*" };
+
+        Map<String, List<String>> canonical = CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions(
+            origin,
+            linked,
+            requestedResources
+        );
+
+        assertThat(canonical.keySet(), containsInAnyOrder("_*:metrics*"));
+        assertThat(canonical.get("_*:metrics*"), containsInAnyOrder("_P1:metrics*", "_P2:metrics*"));
+    }
+
+    public void testDuplicateInputShouldProduceSingleOutput() {
+        ProjectRoutingInfo origin = createRandomProjectWithAlias("P0");
+        List<ProjectRoutingInfo> linked = List.of(
+            createRandomProjectWithAlias("P1"),
+            createRandomProjectWithAlias("P2"),
+            createRandomProjectWithAlias("Q1"),
+            createRandomProjectWithAlias("Q2")
+        );
+        String indexPattern = "Q*:metrics*";
+        String[] requestedResources = new String[] { indexPattern, indexPattern };
+
+        Map<String, List<String>> canonical = CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions(
+            origin,
+            linked,
+            requestedResources
+        );
+
+        assertThat(canonical.keySet(), containsInAnyOrder(indexPattern));
+        assertThat(canonical.get(indexPattern), containsInAnyOrder("Q1:metrics*", "Q2:metrics*"));
+    }
+
+    public void testProjectWildcardNotMatchingAnythingShouldThrow() {
+        ProjectRoutingInfo origin = createRandomProjectWithAlias("P0");
+        List<ProjectRoutingInfo> linked = List.of(
+            createRandomProjectWithAlias("P1"),
+            createRandomProjectWithAlias("P2"),
+            createRandomProjectWithAlias("Q1"),
+            createRandomProjectWithAlias("Q2")
+        );
+        String[] requestedResources = new String[] { "S*:metrics*" };
+
+        expectThrows(
+            ResourceNotFoundException.class,
+            () -> CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions(origin, linked, requestedResources)
+        );
+    }
+
+    public void testRewritingShouldThrowOnIndexExclusions() {
+        // This will fail when we implement index exclusions
+        ProjectRoutingInfo origin = createRandomProjectWithAlias("P0");
+        List<ProjectRoutingInfo> linked = List.of(
+            createRandomProjectWithAlias("P1"),
+            createRandomProjectWithAlias("P2"),
+            createRandomProjectWithAlias("Q1"),
+            createRandomProjectWithAlias("Q2")
+        );
+        String[] requestedResources = new String[] { "P*:metrics*", "-P1:metrics*" };
+
+        expectThrows(
+            IllegalArgumentException.class,
+            () -> CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions(origin, linked, requestedResources)
+        );
+    }
+
+    public void testRewritingShouldThrowOnIndexSelectors() {
+        // This will fail when we implement index exclusions
+        ProjectRoutingInfo origin = createRandomProjectWithAlias("P0");
+        List<ProjectRoutingInfo> linked = List.of(
+            createRandomProjectWithAlias("P1"),
+            createRandomProjectWithAlias("P2"),
+            createRandomProjectWithAlias("Q1"),
+            createRandomProjectWithAlias("Q2")
+        );
+        String[] requestedResources = new String[] { "index::data" };
+
+        expectThrows(
+            IllegalArgumentException.class,
+            () -> CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions(origin, linked, requestedResources)
+        );
+    }
+
+    public void testWildcardOnlyProjectRewrite() {
+        ProjectRoutingInfo origin = createRandomProjectWithAlias("P0");
+        List<ProjectRoutingInfo> linked = List.of(
+            createRandomProjectWithAlias("P1"),
+            createRandomProjectWithAlias("P2"),
+            createRandomProjectWithAlias("Q1"),
+            createRandomProjectWithAlias("Q2")
+        );
+        String[] requestedResources = new String[] { "*:metrics*" };
+
+        Map<String, List<String>> canonical = CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions(
+            origin,
+            linked,
+            requestedResources
+        );
+
+        assertThat(canonical.keySet(), containsInAnyOrder("*:metrics*"));
+        assertThat(canonical.get("*:metrics*"), containsInAnyOrder("P1:metrics*", "P2:metrics*", "Q1:metrics*", "Q2:metrics*", "metrics*"));
+    }
+
+    public void testWildcardMatchesOnlyOriginProject() {
+        ProjectRoutingInfo origin = createRandomProjectWithAlias("aliasForOrigin");
+        List<ProjectRoutingInfo> linked = List.of(
+            createRandomProjectWithAlias("P1"),
+            createRandomProjectWithAlias("P2"),
+            createRandomProjectWithAlias("Q1"),
+            createRandomProjectWithAlias("Q2")
+        );
+        String[] requestedResources = new String[] { "alias*:metrics*" };
+
+        Map<String, List<String>> canonical = CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions(
+            origin,
+            linked,
+            requestedResources
+        );
+
+        assertThat(canonical.keySet(), containsInAnyOrder("alias*:metrics*"));
+        assertThat(canonical.get("alias*:metrics*"), containsInAnyOrder("metrics*"));
+    }
+
+    public void testEmptyExpressionShouldMatchAll() {
+        ProjectRoutingInfo origin = createRandomProjectWithAlias("P0");
+        List<ProjectRoutingInfo> linked = List.of(createRandomProjectWithAlias("P1"), createRandomProjectWithAlias("P2"));
+        String[] requestedResources = new String[] {};
+
+        Map<String, List<String>> canonical = CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions(
+            origin,
+            linked,
+            requestedResources
+        );
+
+        assertThat(canonical.keySet(), containsInAnyOrder("*"));
+        assertThat(canonical.get("*"), containsInAnyOrder("P1:*", "P2:*", "*"));
+    }
+
+    public void testNullExpressionShouldMatchAll() {
+        ProjectRoutingInfo origin = createRandomProjectWithAlias("P0");
+        List<ProjectRoutingInfo> linked = List.of(createRandomProjectWithAlias("P1"), createRandomProjectWithAlias("P2"));
+
+        Map<String, List<String>> canonical = CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions(origin, linked, null);
+
+        assertThat(canonical.keySet(), containsInAnyOrder("*"));
+        assertThat(canonical.get("*"), containsInAnyOrder("P1:*", "P2:*", "*"));
+    }
+
+    public void testWildcardExpressionShouldMatchAll() {
+        ProjectRoutingInfo origin = createRandomProjectWithAlias("P0");
+        List<ProjectRoutingInfo> linked = List.of(createRandomProjectWithAlias("P1"), createRandomProjectWithAlias("P2"));
+        String[] requestedResources = new String[] { "*" };
+
+        Map<String, List<String>> canonical = CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions(
+            origin,
+            linked,
+            requestedResources
+        );
+
+        assertThat(canonical.keySet(), containsInAnyOrder("*"));
+        assertThat(canonical.get("*"), containsInAnyOrder("P1:*", "P2:*", "*"));
+    }
+
+    public void test_ALLExpressionShouldMatchAll() {
+        ProjectRoutingInfo origin = createRandomProjectWithAlias("P0");
+        List<ProjectRoutingInfo> linked = List.of(createRandomProjectWithAlias("P1"), createRandomProjectWithAlias("P2"));
+        String all = randomBoolean() ? "_ALL" : "_all";
+        String[] requestedResources = new String[] { all };
+
+        Map<String, List<String>> canonical = CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions(
+            origin,
+            linked,
+            requestedResources
+        );
+
+        assertThat(canonical.keySet(), containsInAnyOrder(all));
+        assertThat(canonical.get(all), containsInAnyOrder("P1:" + all, "P2:" + all, all));
+    }
+
+    public void testRewritingShouldThrowIfNotProjectMatchExpression() {
+        ProjectRoutingInfo origin = createRandomProjectWithAlias("P0");
+        List<ProjectRoutingInfo> linked = List.of(
+            createRandomProjectWithAlias("P1"),
+            createRandomProjectWithAlias("P2"),
+            createRandomProjectWithAlias("Q1"),
+            createRandomProjectWithAlias("Q2")
+        );
+        String[] requestedResources = new String[] { "X*:metrics" };
+
+        expectThrows(
+            NoMatchingProjectException.class,
+            () -> CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions(origin, linked, requestedResources)
+        );
+    }
+
+    private ProjectRoutingInfo createRandomProjectWithAlias(String alias) {
+        ProjectId projectId = randomUniqueProjectId();
+        String type = randomFrom("elasticsearch", "security", "observability");
+        String org = randomAlphaOfLength(10);
+
+        Map<String, String> tags = Map.of("_id", projectId.id(), "_type", type, "_organization", org, "_alias", alias);
+        ProjectTags projectTags = new ProjectTags(tags);
+        return new ProjectRoutingInfo(projectId, type, alias, org, projectTags);
+    }
+}