diff --git a/server/src/main/java/org/elasticsearch/ElasticsearchException.java b/server/src/main/java/org/elasticsearch/ElasticsearchException.java index 38d42c9bc567b..fc49b6245faf0 100644 --- a/server/src/main/java/org/elasticsearch/ElasticsearchException.java +++ b/server/src/main/java/org/elasticsearch/ElasticsearchException.java @@ -80,7 +80,7 @@ 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.IndexExpressionsRewriter.NO_MATCHING_PROJECT_EXCEPTION_VERSION; +import static org.elasticsearch.search.crossproject.CrossProjectIndexExpressionsRewriter.NO_MATCHING_PROJECT_EXCEPTION_VERSION; /** * A base class for all elasticsearch exceptions. diff --git a/server/src/main/java/org/elasticsearch/action/IndicesRequest.java b/server/src/main/java/org/elasticsearch/action/IndicesRequest.java index 9a36bfa15d802..e1a0f649621ec 100644 --- a/server/src/main/java/org/elasticsearch/action/IndicesRequest.java +++ b/server/src/main/java/org/elasticsearch/action/IndicesRequest.java @@ -80,6 +80,15 @@ default ResolvedIndexExpressions getResolvedIndexExpressions() { default boolean allowsRemoteIndices() { return false; } + + /** + * Determines whether the request type allows cross-project processing. Cross-project processing entails cross-project search + * index resolution and error handling. Note: this method only determines in the request _supports_ cross-project. + * Whether cross-project processing is actually performed is determined by {@link IndicesOptions}. + */ + default boolean allowsCrossProject() { + return false; + } } /** diff --git a/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpression.java b/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpression.java index c78784cde2519..ee18470237bc3 100644 --- a/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpression.java +++ b/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpression.java @@ -10,8 +10,12 @@ package org.elasticsearch.action; import org.elasticsearch.ElasticsearchException; +import org.elasticsearch.common.io.stream.StreamInput; +import org.elasticsearch.common.io.stream.StreamOutput; +import org.elasticsearch.common.io.stream.Writeable; import org.elasticsearch.core.Nullable; +import java.io.IOException; import java.util.Set; /** @@ -40,7 +44,21 @@ * and failure info * @param remoteExpressions the remote expressions that replace the original */ -public record ResolvedIndexExpression(String original, LocalExpressions localExpressions, Set remoteExpressions) { +public record ResolvedIndexExpression(String original, LocalExpressions localExpressions, Set remoteExpressions) + implements + Writeable { + + public ResolvedIndexExpression(StreamInput in) throws IOException { + this(in.readString(), new LocalExpressions(in), in.readCollectionAsImmutableSet(StreamInput::readString)); + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + out.writeString(original); + localExpressions.writeTo(out); + out.writeStringCollection(remoteExpressions); + } + /** * Indicates if a local index resolution attempt was successful or failed. * Failures can be due to concrete resources not being visible (either missing or not visible due to indices options) @@ -62,7 +80,7 @@ public record LocalExpressions( Set expressions, LocalIndexResolutionResult localIndexResolutionResult, @Nullable ElasticsearchException exception - ) { + ) implements Writeable { public LocalExpressions { assert localIndexResolutionResult != LocalIndexResolutionResult.SUCCESS || exception == null : "If the local resolution result is SUCCESS, exception must be null"; @@ -70,5 +88,20 @@ public record LocalExpressions( // Singleton for the case where all expressions in a ResolvedIndexExpression instance are remote public static final LocalExpressions NONE = new LocalExpressions(Set.of(), LocalIndexResolutionResult.NONE, null); + + public LocalExpressions(StreamInput in) throws IOException { + this( + in.readCollectionAsImmutableSet(StreamInput::readString), + in.readEnum(LocalIndexResolutionResult.class), + ElasticsearchException.readException(in) + ); + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + out.writeStringCollection(expressions); + out.writeEnum(localIndexResolutionResult); + ElasticsearchException.writeException(exception, out); + } } } diff --git a/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpressions.java b/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpressions.java index c02f8dfca3b24..774d62b672bdf 100644 --- a/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpressions.java +++ b/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpressions.java @@ -9,8 +9,13 @@ package org.elasticsearch.action; +import org.elasticsearch.TransportVersion; import org.elasticsearch.action.ResolvedIndexExpression.LocalExpressions; +import org.elasticsearch.common.io.stream.StreamInput; +import org.elasticsearch.common.io.stream.StreamOutput; +import org.elasticsearch.common.io.stream.Writeable; +import java.io.IOException; import java.util.ArrayList; import java.util.HashSet; import java.util.List; @@ -20,37 +25,60 @@ /** * A collection of {@link ResolvedIndexExpression}. */ -public record ResolvedIndexExpressions(List expressions) { +public record ResolvedIndexExpressions(List expressions) implements Writeable { + public static final TransportVersion RESOLVED_INDEX_EXPRESSIONS = TransportVersion.fromName("resolved_index_expressions"); + + public ResolvedIndexExpressions(StreamInput in) throws IOException { + this(in.readCollectionAsImmutableList(ResolvedIndexExpression::new)); + } public List getLocalIndicesList() { return expressions.stream().flatMap(e -> e.localExpressions().expressions().stream()).toList(); } + public List getRemoteIndicesList() { + return expressions.stream().flatMap(e -> e.remoteExpressions().stream()).toList(); + } + public static Builder builder() { return new Builder(); } + @Override + public void writeTo(StreamOutput out) throws IOException { + out.writeCollection(expressions); + } + public static final class Builder { private final List expressions = new ArrayList<>(); /** + * Add a new resolved expression. * @param original the original expression that was resolved -- may be blank for "access all" cases * @param localExpressions is a HashSet as an optimization -- the set needs to be mutable, and we want to avoid copying it. * May be empty. */ - public void addLocalExpressions( + public void addExpressions( String original, HashSet localExpressions, - ResolvedIndexExpression.LocalIndexResolutionResult resolutionResult + ResolvedIndexExpression.LocalIndexResolutionResult resolutionResult, + Set remoteExpressions ) { Objects.requireNonNull(original); Objects.requireNonNull(localExpressions); Objects.requireNonNull(resolutionResult); + Objects.requireNonNull(remoteExpressions); expressions.add( - new ResolvedIndexExpression(original, new LocalExpressions(localExpressions, resolutionResult, null), new HashSet<>()) + new ResolvedIndexExpression(original, new LocalExpressions(localExpressions, resolutionResult, null), remoteExpressions) ); } + public void addRemoteExpressions(String original, Set remoteExpressions) { + Objects.requireNonNull(original); + Objects.requireNonNull(remoteExpressions); + expressions.add(new ResolvedIndexExpression(original, LocalExpressions.NONE, remoteExpressions)); + } + /** * Exclude the given expressions from the local expressions of all prior added {@link ResolvedIndexExpression}. */ @@ -58,12 +86,17 @@ public void excludeFromLocalExpressions(Set expressionsToExclude) { Objects.requireNonNull(expressionsToExclude); if (expressionsToExclude.isEmpty() == false) { for (ResolvedIndexExpression prior : expressions) { - prior.localExpressions().expressions().removeAll(expressionsToExclude); + final Set localExpressions = prior.localExpressions().expressions(); + if (localExpressions.isEmpty()) { + continue; + } + localExpressions.removeAll(expressionsToExclude); } } } public ResolvedIndexExpressions build() { + // TODO make all sets on `expressions` immutable return new ResolvedIndexExpressions(expressions); } } diff --git a/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java b/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java index 58ee074a0b951..7c5f99310f417 100644 --- a/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java +++ b/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java @@ -9,6 +9,8 @@ package org.elasticsearch.action.admin.indices.resolve; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import org.elasticsearch.TransportVersion; import org.elasticsearch.action.ActionListener; import org.elasticsearch.action.ActionRequestValidationException; @@ -18,6 +20,7 @@ import org.elasticsearch.action.LegacyActionRequest; import org.elasticsearch.action.OriginalIndices; import org.elasticsearch.action.RemoteClusterActionType; +import org.elasticsearch.action.ResolvedIndexExpressions; import org.elasticsearch.action.support.ActionFilters; import org.elasticsearch.action.support.HandledTransportAction; import org.elasticsearch.action.support.IndicesOptions; @@ -36,6 +39,7 @@ import org.elasticsearch.common.io.stream.StreamOutput; import org.elasticsearch.common.io.stream.Writeable; import org.elasticsearch.common.regex.Regex; +import org.elasticsearch.common.settings.Settings; import org.elasticsearch.common.util.concurrent.CountDown; import org.elasticsearch.common.util.concurrent.EsExecutors; import org.elasticsearch.core.Nullable; @@ -43,6 +47,8 @@ import org.elasticsearch.index.IndexMode; import org.elasticsearch.injection.guice.Inject; import org.elasticsearch.search.SearchService; +import org.elasticsearch.search.crossproject.CrossProjectIndexResolutionValidator; +import org.elasticsearch.search.crossproject.CrossProjectModeDecider; import org.elasticsearch.tasks.Task; import org.elasticsearch.transport.RemoteClusterAware; import org.elasticsearch.transport.RemoteClusterService; @@ -69,6 +75,7 @@ import java.util.stream.Stream; import static org.elasticsearch.action.search.TransportSearchHelper.checkCCSVersionCompatibility; +import static org.elasticsearch.search.crossproject.CrossProjectIndexResolutionValidator.indicesOptionsForCrossProjectFanout; public class ResolveIndexAction extends ActionType { @@ -90,6 +97,7 @@ public static class Request extends LegacyActionRequest implements IndicesReques private String[] names; private IndicesOptions indicesOptions = DEFAULT_INDICES_OPTIONS; private EnumSet indexModes = EnumSet.noneOf(IndexMode.class); + private ResolvedIndexExpressions resolvedIndexExpressions = null; public Request(String[] names) { this.names = names; @@ -168,6 +176,21 @@ public boolean allowsRemoteIndices() { return true; } + @Override + public boolean allowsCrossProject() { + return true; + } + + @Override + public void setResolvedIndexExpressions(ResolvedIndexExpressions expressions) { + this.resolvedIndexExpressions = expressions; + } + + @Override + public ResolvedIndexExpressions getResolvedIndexExpressions() { + return resolvedIndexExpressions; + } + @Override public boolean includeDataStreams() { // request must allow data streams because the index name expression resolver for the action handler assumes it @@ -461,17 +484,34 @@ public static class Response extends ActionResponse implements ToXContentObject private final List indices; private final List aliases; private final List dataStreams; + @Nullable + private final ResolvedIndexExpressions resolvedIndexExpressions; public Response(List indices, List aliases, List dataStreams) { + this(indices, aliases, dataStreams, null); + } + + public Response( + List indices, + List aliases, + List dataStreams, + ResolvedIndexExpressions resolvedIndexExpressions + ) { this.indices = indices; this.aliases = aliases; this.dataStreams = dataStreams; + this.resolvedIndexExpressions = resolvedIndexExpressions; } public Response(StreamInput in) throws IOException { this.indices = in.readCollectionAsList(ResolvedIndex::new); this.aliases = in.readCollectionAsList(ResolvedAlias::new); this.dataStreams = in.readCollectionAsList(ResolvedDataStream::new); + if (in.getTransportVersion().supports(ResolvedIndexExpressions.RESOLVED_INDEX_EXPRESSIONS)) { + this.resolvedIndexExpressions = in.readOptionalWriteable(ResolvedIndexExpressions::new); + } else { + this.resolvedIndexExpressions = null; + } } public List getIndices() { @@ -491,6 +531,9 @@ public void writeTo(StreamOutput out) throws IOException { out.writeCollection(indices); out.writeCollection(aliases); out.writeCollection(dataStreams); + if (out.getTransportVersion().supports(ResolvedIndexExpressions.RESOLVED_INDEX_EXPRESSIONS)) { + out.writeOptionalWriteable(resolvedIndexExpressions); + } } @Override @@ -515,15 +558,22 @@ public boolean equals(Object o) { public int hashCode() { return Objects.hash(indices, aliases, dataStreams); } + + @Nullable + public ResolvedIndexExpressions getResolvedIndexExpressions() { + return resolvedIndexExpressions; + } } public static class TransportAction extends HandledTransportAction { + private static final Logger logger = LogManager.getLogger(TransportAction.class); private final ClusterService clusterService; private final RemoteClusterService remoteClusterService; private final ProjectResolver projectResolver; private final IndexNameExpressionResolver indexNameExpressionResolver; private final boolean ccsCheckCompatibility; + private final CrossProjectModeDecider crossProjectModeDecider; @Inject public TransportAction( @@ -531,6 +581,7 @@ public TransportAction( ClusterService clusterService, ActionFilters actionFilters, ProjectResolver projectResolver, + Settings settings, IndexNameExpressionResolver indexNameExpressionResolver ) { super(NAME, transportService, actionFilters, Request::new, EsExecutors.DIRECT_EXECUTOR_SERVICE); @@ -538,6 +589,7 @@ public TransportAction( this.remoteClusterService = transportService.getRemoteClusterService(); this.projectResolver = projectResolver; this.indexNameExpressionResolver = indexNameExpressionResolver; + this.crossProjectModeDecider = new CrossProjectModeDecider(settings); this.ccsCheckCompatibility = SearchService.CCS_VERSION_CHECK_SETTING.get(clusterService.getSettings()); } @@ -547,8 +599,10 @@ protected void doExecute(Task task, Request request, final ActionListener remoteClusterIndices = remoteClusterService.groupIndices( - request.indicesOptions(), + resolveCrossProject ? indicesOptionsForCrossProjectFanout(originalIndicesOptions) : originalIndicesOptions, request.indices() ); final OriginalIndices localIndices = remoteClusterIndices.remove(RemoteClusterAware.LOCAL_CLUSTER_GROUP_KEY); @@ -557,12 +611,34 @@ protected void doExecute(Task task, Request request, final ActionListener dataStreams = new ArrayList<>(); resolveIndices(localIndices, projectState, indexNameExpressionResolver, indices, aliases, dataStreams, request.indexModes); + final ResolvedIndexExpressions localResolvedIndexExpressions = request.getResolvedIndexExpressions(); if (remoteClusterIndices.size() > 0) { final int remoteRequests = remoteClusterIndices.size(); final CountDown completionCounter = new CountDown(remoteRequests); final SortedMap remoteResponses = Collections.synchronizedSortedMap(new TreeMap<>()); final Runnable terminalHandler = () -> { if (completionCounter.countDown()) { + if (resolveCrossProject) { + // TODO temporary fix: we need to properly handle the case where a remote does not return a result due to + // a failure -- in the current version of resolve indices though, these are just silently ignored + if (remoteRequests != remoteResponses.size()) { + listener.onFailure( + new IllegalStateException( + "expected [" + remoteRequests + "] remote responses but got only [" + remoteResponses.size() + "]" + ) + ); + return; + } + final Exception ex = CrossProjectIndexResolutionValidator.validate( + originalIndicesOptions, + localResolvedIndexExpressions, + getResolvedExpressionsByRemote(remoteResponses) + ); + if (ex != null) { + listener.onFailure(ex); + return; + } + } mergeResults(remoteResponses, indices, aliases, dataStreams, request.indexModes); listener.onResponse(new Response(indices, aliases, dataStreams)); } @@ -581,13 +657,38 @@ protected void doExecute(Task task, Request request, final ActionListener { remoteResponses.put(clusterAlias, response); terminalHandler.run(); - }, failure -> terminalHandler.run())); + }, failure -> { + logger.info("failed to resolve indices on remote cluster [" + clusterAlias + "]", failure); + terminalHandler.run(); + })); } } else { - listener.onResponse(new Response(indices, aliases, dataStreams)); + if (resolveCrossProject) { + // we still need to call response validation for local results, since qualified expressions like `_origin:index` or + // `:index` also get deferred validation + final Exception ex = CrossProjectIndexResolutionValidator.validate( + originalIndicesOptions, + localResolvedIndexExpressions, + Map.of() + ); + if (ex != null) { + listener.onFailure(ex); + return; + } + } + listener.onResponse(new Response(indices, aliases, dataStreams, localResolvedIndexExpressions)); } } + private Map getResolvedExpressionsByRemote(Map remoteResponses) { + return remoteResponses.entrySet().stream().collect(Collectors.toMap(Map.Entry::getKey, e -> { + final ResolvedIndexExpressions resolvedIndexExpressions = e.getValue().getResolvedIndexExpressions(); + assert resolvedIndexExpressions != null + : "remote response from cluster [" + e.getKey() + "] is missing resolved index expressions"; + return resolvedIndexExpressions; + })); + } + /** * Resolves the specified names and/or wildcard expressions to index abstractions. Returns results in the supplied lists. * diff --git a/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java b/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java index 6b68981661699..246ed0b5f9ebf 100644 --- a/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java +++ b/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java @@ -20,6 +20,8 @@ import org.elasticsearch.index.Index; import org.elasticsearch.index.IndexNotFoundException; import org.elasticsearch.indices.SystemIndices.SystemIndexAccessLevel; +import org.elasticsearch.search.crossproject.CrossProjectIndexExpressionsRewriter; +import org.elasticsearch.search.crossproject.TargetProjects; import java.util.HashSet; import java.util.List; @@ -32,7 +34,6 @@ import static org.elasticsearch.action.ResolvedIndexExpression.LocalIndexResolutionResult.SUCCESS; public class IndexAbstractionResolver { - private final IndexNameExpressionResolver indexNameExpressionResolver; public IndexAbstractionResolver(IndexNameExpressionResolver indexNameExpressionResolver) { @@ -40,24 +41,76 @@ public IndexAbstractionResolver(IndexNameExpressionResolver indexNameExpressionR } public ResolvedIndexExpressions resolveIndexAbstractions( - List indices, - IndicesOptions indicesOptions, - ProjectMetadata projectMetadata, - Function> allAuthorizedAndAvailableBySelector, - BiPredicate isAuthorized, - boolean includeDataStreams + final List indices, + final IndicesOptions indicesOptions, + final ProjectMetadata projectMetadata, + final Function> allAuthorizedAndAvailableBySelector, + final BiPredicate isAuthorized, + final boolean includeDataStreams ) { final ResolvedIndexExpressions.Builder resolvedExpressionsBuilder = ResolvedIndexExpressions.builder(); boolean wildcardSeen = false; - for (String index : indices) { + for (String originalIndexExpression : indices) { wildcardSeen = resolveIndexAbstraction( resolvedExpressionsBuilder, - index, + originalIndexExpression, + originalIndexExpression, // in the case of local resolution, the local expression is always the same as the original indicesOptions, projectMetadata, allAuthorizedAndAvailableBySelector, isAuthorized, includeDataStreams, + Set.of(), + wildcardSeen + ); + } + return resolvedExpressionsBuilder.build(); + } + + public ResolvedIndexExpressions resolveIndexAbstractions( + final List indices, + final IndicesOptions indicesOptions, + final ProjectMetadata projectMetadata, + final Function> allAuthorizedAndAvailableBySelector, + final BiPredicate isAuthorized, + final TargetProjects targetProjects, + final boolean includeDataStreams + ) { + assert targetProjects != TargetProjects.NOT_CROSS_PROJECT + : "cannot resolve indices cross project if target set is NOT_CROSS_PROJECT"; + if (false == targetProjects.crossProject()) { + final String message = "cannot resolve indices cross project if target set is empty"; + assert false : message; + throw new IllegalArgumentException(message); + } + + final String originProjectAlias = targetProjects.originProjectAlias(); + final Set linkedProjectAliases = targetProjects.allProjectAliases(); + final ResolvedIndexExpressions.Builder resolvedExpressionsBuilder = ResolvedIndexExpressions.builder(); + boolean wildcardSeen = false; + for (String originalIndexExpression : indices) { + final CrossProjectIndexExpressionsRewriter.IndexRewriteResult indexRewriteResult = CrossProjectIndexExpressionsRewriter + .rewriteIndexExpression(originalIndexExpression, originProjectAlias, linkedProjectAliases); + + final String localIndexExpression = indexRewriteResult.localExpression(); + if (localIndexExpression == null) { + // TODO we may still need to update the `wildcardSeen` value to correctly handle exclusions + // (there can be an exclusion without any local index expressions) + // nothing to resolve locally so skip resolve abstraction call + resolvedExpressionsBuilder.addRemoteExpressions(originalIndexExpression, indexRewriteResult.remoteExpressions()); + continue; + } + + wildcardSeen = resolveIndexAbstraction( + resolvedExpressionsBuilder, + originalIndexExpression, + localIndexExpression, + indicesOptions, + projectMetadata, + allAuthorizedAndAvailableBySelector, + isAuthorized, + includeDataStreams, + indexRewriteResult.remoteExpressions(), wildcardSeen ); } @@ -65,22 +118,24 @@ public ResolvedIndexExpressions resolveIndexAbstractions( } private boolean resolveIndexAbstraction( - ResolvedIndexExpressions.Builder resolvedExpressionsBuilder, - String index, - IndicesOptions indicesOptions, - ProjectMetadata projectMetadata, - Function> allAuthorizedAndAvailableBySelector, - BiPredicate isAuthorized, - boolean includeDataStreams, + final ResolvedIndexExpressions.Builder resolvedExpressionsBuilder, + final String originalIndexExpression, + final String localIndexExpression, + final IndicesOptions indicesOptions, + final ProjectMetadata projectMetadata, + final Function> allAuthorizedAndAvailableBySelector, + final BiPredicate isAuthorized, + final boolean includeDataStreams, + final Set remoteExpressions, boolean wildcardSeen ) { String indexAbstraction; boolean minus = false; - if (index.charAt(0) == '-' && wildcardSeen) { - indexAbstraction = index.substring(1); + if (localIndexExpression.charAt(0) == '-' && wildcardSeen) { + indexAbstraction = localIndexExpression.substring(1); minus = true; } else { - indexAbstraction = index; + indexAbstraction = localIndexExpression; } // Always check to see if there's a selector on the index expression @@ -117,12 +172,12 @@ && isIndexVisible( if (indicesOptions.allowNoIndices() == false) { throw new IndexNotFoundException(indexAbstraction); } - resolvedExpressionsBuilder.addLocalExpressions(index, new HashSet<>(), SUCCESS); + resolvedExpressionsBuilder.addExpressions(originalIndexExpression, new HashSet<>(), SUCCESS, remoteExpressions); } else { if (minus) { resolvedExpressionsBuilder.excludeFromLocalExpressions(resolvedIndices); } else { - resolvedExpressionsBuilder.addLocalExpressions(index, resolvedIndices, SUCCESS); + resolvedExpressionsBuilder.addExpressions(originalIndexExpression, resolvedIndices, SUCCESS, remoteExpressions); } } } else { @@ -144,14 +199,24 @@ && isIndexVisible( includeDataStreams ); final LocalIndexResolutionResult result = visible ? SUCCESS : CONCRETE_RESOURCE_NOT_VISIBLE; - resolvedExpressionsBuilder.addLocalExpressions(index, resolvedIndices, result); + resolvedExpressionsBuilder.addExpressions(originalIndexExpression, resolvedIndices, result, remoteExpressions); } else if (indicesOptions.ignoreUnavailable()) { // ignoreUnavailable implies that the request should not fail if an index is not authorized // so we map this expression to an empty list, - resolvedExpressionsBuilder.addLocalExpressions(index, new HashSet<>(), CONCRETE_RESOURCE_UNAUTHORIZED); + resolvedExpressionsBuilder.addExpressions( + originalIndexExpression, + new HashSet<>(), + CONCRETE_RESOURCE_UNAUTHORIZED, + remoteExpressions + ); } else { // store the calculated expansion as unauthorized, it will be rejected later - resolvedExpressionsBuilder.addLocalExpressions(index, resolvedIndices, CONCRETE_RESOURCE_UNAUTHORIZED); + resolvedExpressionsBuilder.addExpressions( + originalIndexExpression, + resolvedIndices, + CONCRETE_RESOURCE_UNAUTHORIZED, + remoteExpressions + ); } } } diff --git a/server/src/main/java/org/elasticsearch/search/crossproject/IndexExpressionsRewriter.java b/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectIndexExpressionsRewriter.java similarity index 95% rename from server/src/main/java/org/elasticsearch/search/crossproject/IndexExpressionsRewriter.java rename to server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectIndexExpressionsRewriter.java index 9d21cb01d0255..d913bdff93e10 100644 --- a/server/src/main/java/org/elasticsearch/search/crossproject/IndexExpressionsRewriter.java +++ b/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectIndexExpressionsRewriter.java @@ -19,9 +19,9 @@ import org.elasticsearch.transport.NoSuchRemoteClusterException; import org.elasticsearch.transport.RemoteClusterAware; -import java.util.ArrayList; import java.util.Collections; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Set; @@ -31,10 +31,10 @@ * Utility class for rewriting cross-project index expressions. * Provides methods that can rewrite qualified and unqualified index expressions to canonical CCS. */ -public class IndexExpressionsRewriter { +public class CrossProjectIndexExpressionsRewriter { public static TransportVersion NO_MATCHING_PROJECT_EXCEPTION_VERSION = TransportVersion.fromName("no_matching_project_exception"); - private static final Logger logger = LogManager.getLogger(IndexExpressionsRewriter.class); + private static final Logger logger = LogManager.getLogger(CrossProjectIndexExpressionsRewriter.class); private static final String ORIGIN_PROJECT_KEY = "_origin"; private static final String[] MATCH_ALL = new String[] { Metadata.ALL }; private static final String EXCLUSION = "-"; @@ -51,6 +51,7 @@ public class IndexExpressionsRewriter { * @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 */ + // TODO remove me: only used in tests public static Map rewriteIndexExpressions( ProjectRoutingInfo originProject, List linkedProjects, @@ -127,7 +128,7 @@ private static IndexRewriteResult rewriteUnqualifiedExpression( Set allProjectAliases ) { String localExpression = null; - final List rewrittenExpressions = new ArrayList<>(); + final Set rewrittenExpressions = new LinkedHashSet<>(); if (originAlias != null) { localExpression = indexExpression; // adding the original indexExpression for the _origin cluster. } @@ -177,7 +178,7 @@ private static IndexRewriteResult rewriteQualifiedExpression( } String localExpression = null; - final List resourcesMatchingLinkedProjectAliases = new ArrayList<>(); + final Set resourcesMatchingLinkedProjectAliases = new LinkedHashSet<>(); for (String project : allProjectsMatchingAlias) { if (project.equals(originProjectAlias)) { localExpression = indexExpression; @@ -209,9 +210,9 @@ private static void maybeThrowOnUnsupportedResource(String resource) { /** * A container for a local expression and a list of remote expressions. */ - public record IndexRewriteResult(@Nullable String localExpression, List remoteExpressions) { + public record IndexRewriteResult(@Nullable String localExpression, Set remoteExpressions) { public IndexRewriteResult(String localExpression) { - this(localExpression, List.of()); + this(localExpression, Set.of()); } } } diff --git a/server/src/main/java/org/elasticsearch/search/crossproject/ResponseValidator.java b/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectIndexResolutionValidator.java similarity index 93% rename from server/src/main/java/org/elasticsearch/search/crossproject/ResponseValidator.java rename to server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectIndexResolutionValidator.java index 89d38cf432747..92322b771cd67 100644 --- a/server/src/main/java/org/elasticsearch/search/crossproject/ResponseValidator.java +++ b/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectIndexResolutionValidator.java @@ -17,6 +17,7 @@ import org.elasticsearch.action.ResolvedIndexExpressions; import org.elasticsearch.action.support.IndicesOptions; import org.elasticsearch.index.IndexNotFoundException; +import org.elasticsearch.rest.RestStatus; import org.elasticsearch.transport.RemoteClusterAware; import java.util.Map; @@ -45,8 +46,8 @@ * error response, returning {@link IndexNotFoundException} for missing indices or * {@link ElasticsearchSecurityException} for authorization failures. */ -public class ResponseValidator { - private static final Logger logger = LogManager.getLogger(ResponseValidator.class); +public class CrossProjectIndexResolutionValidator { + private static final Logger logger = LogManager.getLogger(CrossProjectIndexResolutionValidator.class); /** * Validates the results of cross-project index resolution and returns appropriate exceptions based on the provided @@ -165,9 +166,17 @@ public static ElasticsearchException validate( return null; } + public static IndicesOptions indicesOptionsForCrossProjectFanout(IndicesOptions indicesOptions) { + // TODO set resolveCrossProject=false here once we have an IndicesOptions flag for that + return IndicesOptions.builder(indicesOptions) + .concreteTargetOptions(new IndicesOptions.ConcreteTargetOptions(true)) + .wildcardOptions(IndicesOptions.WildcardOptions.builder(indicesOptions.wildcardOptions()).allowEmptyExpressions(true).build()) + .build(); + } + private static ElasticsearchSecurityException securityException(String originalExpression) { // TODO plug in proper recorded authorization exceptions instead, once available - return new ElasticsearchSecurityException("user cannot access [" + originalExpression + "]"); + return new ElasticsearchSecurityException("user cannot access [" + originalExpression + "]", RestStatus.FORBIDDEN); } private static ElasticsearchException checkSingleRemoteExpression( diff --git a/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectModeDecider.java b/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectModeDecider.java new file mode 100644 index 0000000000000..7afdfd181f661 --- /dev/null +++ b/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectModeDecider.java @@ -0,0 +1,56 @@ +/* + * 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.action.IndicesRequest; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.core.Booleans; + +/** + * Utility class to determine whether Cross-Project Search (CPS) applies to an inbound request. + *

+ * CPS applicability is controlled at three levels: + *

    + *
  • Cluster level: The {@code serverless.cross_project.enabled} setting determines + * whether CPS processing is available at all. In the future, all Serverless projects + * will support CPS, so this distinction will depend on whether the cluster is a + * Serverless cluster or not.
  • + *
  • API level: The {@link org.elasticsearch.action.IndicesRequest.Replaceable#allowsCrossProject()} + * method determines whether a particular request type supports CPS processing.
  • + *
  • Request level: An {@link org.elasticsearch.action.support.IndicesOptions} flag + * determines whether CPS should apply to the current + * request being processed. This fine-grained control is required because APIs that + * support CPS may also be used in contexts where CPS should not apply—for example, + * internal searches against the security system index to retrieve user roles, or CPS + * actions that execute in a flow where a parent action has already performed CPS + * processing.
  • + *
+ */ +public class CrossProjectModeDecider { + private static final String CROSS_PROJECT_ENABLED_SETTING_KEY = "serverless.cross_project.enabled"; + private final boolean crossProjectEnabled; + + public CrossProjectModeDecider(Settings settings) { + this.crossProjectEnabled = settings.getAsBoolean(CROSS_PROJECT_ENABLED_SETTING_KEY, false); + } + + public boolean crossProjectEnabled() { + return crossProjectEnabled; + } + + public boolean resolvesCrossProject(IndicesRequest.Replaceable request) { + if (crossProjectEnabled == false) { + return false; + } + // TODO this needs to be based on the IndicesOptions flag instead, once available + final boolean indicesOptionsResolveCrossProject = Booleans.parseBoolean(System.getProperty("cps.resolve_cross_project", "false")); + return request.allowsCrossProject() && indicesOptionsResolveCrossProject; + } +} diff --git a/server/src/main/java/org/elasticsearch/search/crossproject/TargetProjects.java b/server/src/main/java/org/elasticsearch/search/crossproject/TargetProjects.java new file mode 100644 index 0000000000000..8960a7106cd8a --- /dev/null +++ b/server/src/main/java/org/elasticsearch/search/crossproject/TargetProjects.java @@ -0,0 +1,50 @@ +/* + * 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.core.Nullable; + +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * Holds information about the target projects for a cross-project search request. This record is used both by the + * project authorization filter and project routing logic. + * @param originProject the origin project, can be null if the request is not cross-project OR it was excluded by + * project routing + * @param linkedProjects all projects that are linked and authorized, can be empty if the request is not cross-project + */ +public record TargetProjects(@Nullable ProjectRoutingInfo originProject, List linkedProjects) { + public static final TargetProjects NOT_CROSS_PROJECT = new TargetProjects(null, List.of()); + + public TargetProjects(ProjectRoutingInfo originProject) { + this(originProject, List.of()); + } + + @Nullable + public String originProjectAlias() { + return originProject != null ? originProject.projectAlias() : null; + } + + public Set allProjectAliases() { + // TODO consider caching this + final Set allProjectAliases = linkedProjects.stream().map(ProjectRoutingInfo::projectAlias).collect(Collectors.toSet()); + if (originProject != null) { + allProjectAliases.add(originProject.projectAlias()); + } + return Collections.unmodifiableSet(allProjectAliases); + } + + public boolean crossProject() { + return originProject != null || linkedProjects.isEmpty() == false; + } +} diff --git a/server/src/main/resources/transport/definitions/referable/resolved_index_expressions.csv b/server/src/main/resources/transport/definitions/referable/resolved_index_expressions.csv new file mode 100644 index 0000000000000..71ac710f9bfe1 --- /dev/null +++ b/server/src/main/resources/transport/definitions/referable/resolved_index_expressions.csv @@ -0,0 +1 @@ +9187000 diff --git a/server/src/main/resources/transport/upper_bounds/9.3.csv b/server/src/main/resources/transport/upper_bounds/9.3.csv index dfb000bd20c3d..a1daf1f9747d4 100644 --- a/server/src/main/resources/transport/upper_bounds/9.3.csv +++ b/server/src/main/resources/transport/upper_bounds/9.3.csv @@ -1 +1 @@ -esql_plan_with_no_columns,9186000 +resolved_index_expressions,9187000 diff --git a/server/src/test/java/org/elasticsearch/action/admin/indices/resolve/TransportResolveIndexActionTests.java b/server/src/test/java/org/elasticsearch/action/admin/indices/resolve/TransportResolveIndexActionTests.java index 6e37335603572..4e247ad2f122e 100644 --- a/server/src/test/java/org/elasticsearch/action/admin/indices/resolve/TransportResolveIndexActionTests.java +++ b/server/src/test/java/org/elasticsearch/action/admin/indices/resolve/TransportResolveIndexActionTests.java @@ -81,6 +81,7 @@ public void writeTo(StreamOutput out) throws IOException { clusterService, actionFilters, TestProjectResolvers.DEFAULT_PROJECT_ONLY, + Settings.EMPTY, null ); diff --git a/server/src/test/java/org/elasticsearch/search/crossproject/IndexExpressionsRewriterTests.java b/server/src/test/java/org/elasticsearch/search/crossproject/CrossProjectIndexExpressionsRewriterTests.java similarity index 84% rename from server/src/test/java/org/elasticsearch/search/crossproject/IndexExpressionsRewriterTests.java rename to server/src/test/java/org/elasticsearch/search/crossproject/CrossProjectIndexExpressionsRewriterTests.java index 41f0d02f66af5..1b07b6c070c6b 100644 --- a/server/src/test/java/org/elasticsearch/search/crossproject/IndexExpressionsRewriterTests.java +++ b/server/src/test/java/org/elasticsearch/search/crossproject/CrossProjectIndexExpressionsRewriterTests.java @@ -20,7 +20,7 @@ import static org.hamcrest.Matchers.containsInAnyOrder; -public class IndexExpressionsRewriterTests extends ESTestCase { +public class CrossProjectIndexExpressionsRewriterTests extends ESTestCase { public void testFlatOnlyRewrite() { ProjectRoutingInfo origin = createRandomProjectWithAlias("P0"); @@ -31,7 +31,7 @@ public void testFlatOnlyRewrite() { ); String[] requestedResources = new String[] { "logs*", "metrics*" }; - var actual = IndexExpressionsRewriter.rewriteIndexExpressions(origin, linked, requestedResources); + var actual = CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions(origin, linked, requestedResources); assertThat(actual.keySet(), containsInAnyOrder("logs*", "metrics*")); assertIndexRewriteResultsContains(actual.get("logs*"), containsInAnyOrder("logs*", "P1:logs*", "P2:logs*", "P3:logs*")); @@ -50,7 +50,7 @@ public void testFlatAndQualifiedRewrite() { ); String[] requestedResources = new String[] { "P1:logs*", "metrics*" }; - var actual = IndexExpressionsRewriter.rewriteIndexExpressions(origin, linked, requestedResources); + var actual = CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions(origin, linked, requestedResources); assertThat(actual.keySet(), containsInAnyOrder("P1:logs*", "metrics*")); assertIndexRewriteResultsContains(actual.get("P1:logs*"), containsInAnyOrder("P1:logs*")); @@ -69,7 +69,7 @@ public void testQualifiedOnlyRewrite() { ); String[] requestedResources = new String[] { "P1:logs*", "P2:metrics*" }; - var actual = IndexExpressionsRewriter.rewriteIndexExpressions(origin, linked, requestedResources); + var actual = CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions(origin, linked, requestedResources); assertThat(actual.keySet(), containsInAnyOrder("P1:logs*", "P2:metrics*")); assertIndexRewriteResultsContains(actual.get("P1:logs*"), containsInAnyOrder("P1:logs*")); @@ -85,7 +85,7 @@ public void testOriginQualifiedOnlyRewrite() { ); String[] requestedResources = new String[] { "_origin:logs*", "_origin:metrics*" }; - var actual = IndexExpressionsRewriter.rewriteIndexExpressions(origin, linked, requestedResources); + var actual = CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions(origin, linked, requestedResources); assertThat(actual.keySet(), containsInAnyOrder("_origin:logs*", "_origin:metrics*")); assertIndexRewriteResultsContains(actual.get("_origin:logs*"), containsInAnyOrder("logs*")); @@ -97,7 +97,7 @@ public void testOriginQualifiedOnlyRewriteWithNoLikedProjects() { List linked = List.of(); String[] requestedResources = new String[] { "_origin:logs*", "_origin:metrics*" }; - var actual = IndexExpressionsRewriter.rewriteIndexExpressions(origin, linked, requestedResources); + var actual = CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions(origin, linked, requestedResources); assertThat(actual.keySet(), containsInAnyOrder("_origin:logs*", "_origin:metrics*")); assertIndexRewriteResultsContains(actual.get("_origin:logs*"), containsInAnyOrder("logs*")); @@ -118,7 +118,7 @@ public void testOriginWithDifferentAliasQualifiedOnlyRewrite() { String metricResource = aliasForOrigin + ":" + metricsIndexAlias; String[] requestedResources = new String[] { logResource, metricResource }; - var actual = IndexExpressionsRewriter.rewriteIndexExpressions(origin, linked, requestedResources); + var actual = CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions(origin, linked, requestedResources); assertThat(actual.keySet(), containsInAnyOrder(logResource, metricResource)); assertIndexRewriteResultsContains(actual.get(logResource), containsInAnyOrder(logIndexAlias)); @@ -134,7 +134,7 @@ public void testQualifiedLinkedAndOriginRewrite() { ); String[] requestedResources = new String[] { "P1:logs*", "_origin:metrics*" }; - var actual = IndexExpressionsRewriter.rewriteIndexExpressions(origin, linked, requestedResources); + var actual = CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions(origin, linked, requestedResources); assertThat(actual.keySet(), containsInAnyOrder("P1:logs*", "_origin:metrics*")); assertIndexRewriteResultsContains(actual.get("P1:logs*"), containsInAnyOrder("P1:logs*")); @@ -151,7 +151,7 @@ public void testQualifiedStartsWithProjectWildcardRewrite() { ); String[] requestedResources = new String[] { "Q*:metrics*" }; - var actual = IndexExpressionsRewriter.rewriteIndexExpressions(origin, linked, requestedResources); + var actual = CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions(origin, linked, requestedResources); assertThat(actual.keySet(), containsInAnyOrder("Q*:metrics*")); assertIndexRewriteResultsContains(actual.get("Q*:metrics*"), containsInAnyOrder("Q1:metrics*", "Q2:metrics*")); @@ -167,7 +167,7 @@ public void testQualifiedEndsWithProjectWildcardRewrite() { ); String[] requestedResources = new String[] { "*1:metrics*" }; - var actual = IndexExpressionsRewriter.rewriteIndexExpressions(origin, linked, requestedResources); + var actual = CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions(origin, linked, requestedResources); assertThat(actual.keySet(), containsInAnyOrder("*1:metrics*")); assertIndexRewriteResultsContains(actual.get("*1:metrics*"), containsInAnyOrder("P1:metrics*", "Q1:metrics*")); @@ -178,7 +178,7 @@ public void testOriginProjectMatchingTwice() { List linked = List.of(createRandomProjectWithAlias("P1"), createRandomProjectWithAlias("P2")); String[] requestedResources = new String[] { "P0:metrics*", "_origin:metrics*" }; - var actual = IndexExpressionsRewriter.rewriteIndexExpressions(origin, linked, requestedResources); + var actual = CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions(origin, linked, requestedResources); assertThat(actual.keySet(), containsInAnyOrder("P0:metrics*", "_origin:metrics*")); assertIndexRewriteResultsContains(actual.get("P0:metrics*"), containsInAnyOrder("metrics*")); @@ -190,7 +190,7 @@ public void testUnderscoreWildcardShouldNotMatchOrigin() { List linked = List.of(createRandomProjectWithAlias("_P1"), createRandomProjectWithAlias("_P2")); String[] requestedResources = new String[] { "_*:metrics*" }; - var actual = IndexExpressionsRewriter.rewriteIndexExpressions(origin, linked, requestedResources); + var actual = CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions(origin, linked, requestedResources); assertThat(actual.keySet(), containsInAnyOrder("_*:metrics*")); assertIndexRewriteResultsContains(actual.get("_*:metrics*"), containsInAnyOrder("_P1:metrics*", "_P2:metrics*")); @@ -207,7 +207,7 @@ public void testDuplicateInputShouldProduceSingleOutput() { String indexPattern = "Q*:metrics*"; String[] requestedResources = new String[] { indexPattern, indexPattern }; - var actual = IndexExpressionsRewriter.rewriteIndexExpressions(origin, linked, requestedResources); + var actual = CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions(origin, linked, requestedResources); assertThat(actual.keySet(), containsInAnyOrder(indexPattern)); assertIndexRewriteResultsContains(actual.get(indexPattern), containsInAnyOrder("Q1:metrics*", "Q2:metrics*")); @@ -225,7 +225,7 @@ public void testProjectWildcardNotMatchingAnythingShouldThrow() { expectThrows( ResourceNotFoundException.class, - () -> IndexExpressionsRewriter.rewriteIndexExpressions(origin, linked, requestedResources) + () -> CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions(origin, linked, requestedResources) ); } @@ -242,7 +242,7 @@ public void testRewritingShouldThrowOnIndexExclusions() { expectThrows( IllegalArgumentException.class, - () -> IndexExpressionsRewriter.rewriteIndexExpressions(origin, linked, requestedResources) + () -> CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions(origin, linked, requestedResources) ); } @@ -259,7 +259,7 @@ public void testRewritingShouldThrowOnIndexSelectors() { expectThrows( IllegalArgumentException.class, - () -> IndexExpressionsRewriter.rewriteIndexExpressions(origin, linked, requestedResources) + () -> CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions(origin, linked, requestedResources) ); } @@ -273,7 +273,7 @@ public void testWildcardOnlyProjectRewrite() { ); String[] requestedResources = new String[] { "*:metrics*" }; - var actual = IndexExpressionsRewriter.rewriteIndexExpressions(origin, linked, requestedResources); + var actual = CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions(origin, linked, requestedResources); assertThat(actual.keySet(), containsInAnyOrder("*:metrics*")); assertIndexRewriteResultsContains( @@ -292,7 +292,7 @@ public void testWildcardMatchesOnlyOriginProject() { ); String[] requestedResources = new String[] { "alias*:metrics*" }; - var actual = IndexExpressionsRewriter.rewriteIndexExpressions(origin, linked, requestedResources); + var actual = CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions(origin, linked, requestedResources); assertThat(actual.keySet(), containsInAnyOrder("alias*:metrics*")); assertIndexRewriteResultsContains(actual.get("alias*:metrics*"), containsInAnyOrder("metrics*")); @@ -303,7 +303,7 @@ public void testEmptyExpressionShouldMatchAll() { List linked = List.of(createRandomProjectWithAlias("P1"), createRandomProjectWithAlias("P2")); String[] requestedResources = new String[] {}; - var actual = IndexExpressionsRewriter.rewriteIndexExpressions(origin, linked, requestedResources); + var actual = CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions(origin, linked, requestedResources); assertThat(actual.keySet(), containsInAnyOrder("_all")); assertIndexRewriteResultsContains(actual.get("_all"), containsInAnyOrder("P1:_all", "P2:_all", "_all")); @@ -313,7 +313,7 @@ public void testNullExpressionShouldMatchAll() { ProjectRoutingInfo origin = createRandomProjectWithAlias("P0"); List linked = List.of(createRandomProjectWithAlias("P1"), createRandomProjectWithAlias("P2")); - var actual = IndexExpressionsRewriter.rewriteIndexExpressions(origin, linked, null); + var actual = CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions(origin, linked, null); assertThat(actual.keySet(), containsInAnyOrder("_all")); assertIndexRewriteResultsContains(actual.get("_all"), containsInAnyOrder("P1:_all", "P2:_all", "_all")); @@ -324,7 +324,7 @@ public void testWildcardExpressionShouldMatchAll() { List linked = List.of(createRandomProjectWithAlias("P1"), createRandomProjectWithAlias("P2")); String[] requestedResources = new String[] { "*" }; - var actual = IndexExpressionsRewriter.rewriteIndexExpressions(origin, linked, requestedResources); + var actual = CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions(origin, linked, requestedResources); assertThat(actual.keySet(), containsInAnyOrder("*")); assertIndexRewriteResultsContains(actual.get("*"), containsInAnyOrder("P1:*", "P2:*", "*")); @@ -336,7 +336,7 @@ public void test_ALLExpressionShouldMatchAll() { String all = randomBoolean() ? "_ALL" : "_all"; String[] requestedResources = new String[] { all }; - var actual = IndexExpressionsRewriter.rewriteIndexExpressions(origin, linked, requestedResources); + var actual = CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions(origin, linked, requestedResources); assertThat(actual.keySet(), containsInAnyOrder(all)); assertIndexRewriteResultsContains(actual.get(all), containsInAnyOrder("P1:" + all, "P2:" + all, all)); @@ -354,7 +354,7 @@ public void testRewritingShouldThrowIfNotProjectMatchExpression() { expectThrows( NoMatchingProjectException.class, - () -> IndexExpressionsRewriter.rewriteIndexExpressions(origin, linked, requestedResources) + () -> CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions(origin, linked, requestedResources) ); } @@ -369,15 +369,15 @@ private ProjectRoutingInfo createRandomProjectWithAlias(String alias) { } private static void assertIndexRewriteResultsContains( - IndexExpressionsRewriter.IndexRewriteResult actual, + CrossProjectIndexExpressionsRewriter.IndexRewriteResult actual, Matcher> iterableMatcher ) { assertThat(resultAsList(actual), iterableMatcher); } - private static List resultAsList(IndexExpressionsRewriter.IndexRewriteResult result) { + private static List resultAsList(CrossProjectIndexExpressionsRewriter.IndexRewriteResult result) { if (result.localExpression() == null) { - return result.remoteExpressions(); + return List.copyOf(result.remoteExpressions()); } List all = new ArrayList<>(); all.add(result.localExpression()); diff --git a/server/src/test/java/org/elasticsearch/search/crossproject/ResponseValidatorTests.java b/server/src/test/java/org/elasticsearch/search/crossproject/CrossProjectIndexResolutionValidatorTests.java similarity index 90% rename from server/src/test/java/org/elasticsearch/search/crossproject/ResponseValidatorTests.java rename to server/src/test/java/org/elasticsearch/search/crossproject/CrossProjectIndexResolutionValidatorTests.java index 508966b383354..98fd314bff8ed 100644 --- a/server/src/test/java/org/elasticsearch/search/crossproject/ResponseValidatorTests.java +++ b/server/src/test/java/org/elasticsearch/search/crossproject/CrossProjectIndexResolutionValidatorTests.java @@ -24,11 +24,11 @@ import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.instanceOf; -public class ResponseValidatorTests extends ESTestCase { +public class CrossProjectIndexResolutionValidatorTests extends ESTestCase { public void testLenientIndicesOptions() { // with lenient IndicesOptions we early terminate without error - assertNull(ResponseValidator.validate(getLenientIndicesOptions(), null, null)); + assertNull(CrossProjectIndexResolutionValidator.validate(getLenientIndicesOptions(), null, null)); } public void testFlatExpressionWithStrictIgnoreUnavailableMatchingInOriginProject() { @@ -47,7 +47,7 @@ public void testFlatExpressionWithStrictIgnoreUnavailableMatchingInOriginProject ); // we matched resource locally thus no error - assertNull(ResponseValidator.validate(getStrictIgnoreUnavailable(), local, null)); + assertNull(CrossProjectIndexResolutionValidator.validate(getStrictIgnoreUnavailable(), local, null)); } public void testFlatExpressionWithStrictIgnoreUnavailableMatchingInLinkedProject() { @@ -83,7 +83,7 @@ public void testFlatExpressionWithStrictIgnoreUnavailableMatchingInLinkedProject ); // we matched the flat resource in a linked project thus no error - assertNull(ResponseValidator.validate(getStrictIgnoreUnavailable(), local, remote)); + assertNull(CrossProjectIndexResolutionValidator.validate(getStrictIgnoreUnavailable(), local, remote)); } public void testMissingFlatExpressionWithStrictIgnoreUnavailable() { @@ -117,7 +117,7 @@ public void testMissingFlatExpressionWithStrictIgnoreUnavailable() { ) ) ); - var e = ResponseValidator.validate(getStrictIgnoreUnavailable(), local, remote); + var e = CrossProjectIndexResolutionValidator.validate(getStrictIgnoreUnavailable(), local, remote); assertNotNull(e); assertThat(e, instanceOf(IndexNotFoundException.class)); assertThat(e.getMessage(), containsString("no such index [logs]")); @@ -155,7 +155,7 @@ public void testUnauthorizedFlatExpressionWithStrictIgnoreUnavailable() { ) ); - var e = ResponseValidator.validate(getStrictIgnoreUnavailable(), local, remote); + var e = CrossProjectIndexResolutionValidator.validate(getStrictIgnoreUnavailable(), local, remote); assertNotNull(e); assertThat(e, instanceOf(ElasticsearchSecurityException.class)); assertThat(e.getMessage(), containsString("user cannot access [logs]")); @@ -177,7 +177,7 @@ public void testQualifiedExpressionWithStrictIgnoreUnavailableMatchingInOriginPr ); // we matched locally thus no error - assertNull(ResponseValidator.validate(getStrictIgnoreUnavailable(), local, null)); + assertNull(CrossProjectIndexResolutionValidator.validate(getStrictIgnoreUnavailable(), local, null)); } public void testQualifiedOriginExpressionWithStrictIgnoreUnavailableNotMatching() { @@ -195,7 +195,7 @@ public void testQualifiedOriginExpressionWithStrictIgnoreUnavailableNotMatching( ) ); - var e = ResponseValidator.validate(getStrictIgnoreUnavailable(), local, null); + var e = CrossProjectIndexResolutionValidator.validate(getStrictIgnoreUnavailable(), local, null); assertNotNull(e); assertThat(e, instanceOf(IndexNotFoundException.class)); assertThat(e.getMessage(), containsString("no such index [_origin:logs]")); @@ -224,7 +224,7 @@ public void testQualifiedExpressionWithStrictIgnoreUnavailableMatchingInLinkedPr ); // we matched the flat resource in a linked project thus no error - assertNull(ResponseValidator.validate(getStrictIgnoreUnavailable(), local, remote)); + assertNull(CrossProjectIndexResolutionValidator.validate(getStrictIgnoreUnavailable(), local, remote)); } public void testMissingQualifiedExpressionWithStrictIgnoreUnavailable() { @@ -259,7 +259,7 @@ public void testMissingQualifiedExpressionWithStrictIgnoreUnavailable() { ) ); - var e = ResponseValidator.validate(getStrictIgnoreUnavailable(), local, remote); + var e = CrossProjectIndexResolutionValidator.validate(getStrictIgnoreUnavailable(), local, remote); assertNotNull(e); assertThat(e, instanceOf(IndexNotFoundException.class)); assertThat(e.getMessage(), containsString("no such index [P1:logs]")); @@ -287,7 +287,7 @@ public void testUnauthorizedQualifiedExpressionWithStrictIgnoreUnavailable() { ) ); - var e = ResponseValidator.validate(getStrictIgnoreUnavailable(), local, remote); + var e = CrossProjectIndexResolutionValidator.validate(getStrictIgnoreUnavailable(), local, remote); assertNotNull(e); assertThat(e, instanceOf(ElasticsearchSecurityException.class)); assertThat(e.getMessage(), containsString("user cannot access [P1:logs]")); @@ -309,7 +309,7 @@ public void testFlatExpressionWithStrictAllowNoIndicesMatchingInOriginProject() ); // we matched resource locally thus no error - assertNull(ResponseValidator.validate(getStrictAllowNoIndices(), local, null)); + assertNull(CrossProjectIndexResolutionValidator.validate(getStrictAllowNoIndices(), local, null)); } public void testAllowNoIndicesFoundEmptyResultsOnOriginAndLinked() { @@ -344,7 +344,7 @@ public void testAllowNoIndicesFoundEmptyResultsOnOriginAndLinked() { ) ); - ElasticsearchException ex = ResponseValidator.validate(getIndicesOptions(false, false), local, remote); + ElasticsearchException ex = CrossProjectIndexResolutionValidator.validate(getIndicesOptions(false, false), local, remote); assertNotNull(ex); assertThat(ex, instanceOf(IndexNotFoundException.class)); } @@ -382,7 +382,7 @@ public void testFlatExpressionWithStrictAllowNoIndicesMatchingInLinkedProject() ); // we matched the flat resource in a linked project thus no error - assertNull(ResponseValidator.validate(getStrictAllowNoIndices(), local, remote)); + assertNull(CrossProjectIndexResolutionValidator.validate(getStrictAllowNoIndices(), local, remote)); } public void testMissingFlatExpressionWithStrictAllowNoIndices() { @@ -417,7 +417,7 @@ public void testMissingFlatExpressionWithStrictAllowNoIndices() { ) ); - var e = ResponseValidator.validate(getStrictAllowNoIndices(), local, remote); + var e = CrossProjectIndexResolutionValidator.validate(getStrictAllowNoIndices(), local, remote); assertNotNull(e); assertThat(e, instanceOf(IndexNotFoundException.class)); assertThat(e.getMessage(), containsString("no such index [logs*]")); @@ -455,7 +455,7 @@ public void testUnauthorizedFlatExpressionWithStrictAllowNoIndices() { ) ); - var e = ResponseValidator.validate(getStrictAllowNoIndices(), local, remote); + var e = CrossProjectIndexResolutionValidator.validate(getStrictAllowNoIndices(), local, remote); assertNotNull(e); assertThat(e, instanceOf(IndexNotFoundException.class)); assertThat(e.getMessage(), containsString("no such index [logs*]")); @@ -477,7 +477,7 @@ public void testQualifiedExpressionWithStrictAllowNoIndicesMatchingInOriginProje ); // we matched locally thus no error - assertNull(ResponseValidator.validate(getStrictAllowNoIndices(), local, null)); + assertNull(CrossProjectIndexResolutionValidator.validate(getStrictAllowNoIndices(), local, null)); } public void testQualifiedOriginExpressionWithStrictAllowNoIndicesNotMatching() { @@ -494,7 +494,7 @@ public void testQualifiedOriginExpressionWithStrictAllowNoIndicesNotMatching() { ) ) ); - var e = ResponseValidator.validate(getStrictAllowNoIndices(), local, null); + var e = CrossProjectIndexResolutionValidator.validate(getStrictAllowNoIndices(), local, null); assertNotNull(e); assertThat(e, instanceOf(IndexNotFoundException.class)); assertThat(e.getMessage(), containsString("no such index [_origin:logs*]")); @@ -515,7 +515,7 @@ public void testQualifiedOriginExpressionWithWildcardAndStrictAllowNoIndicesMatc ) ) ); - assertNull(ResponseValidator.validate(getIndicesOptions(randomBoolean(), randomBoolean()), local, Map.of())); + assertNull(CrossProjectIndexResolutionValidator.validate(getIndicesOptions(randomBoolean(), randomBoolean()), local, Map.of())); } } @@ -542,7 +542,7 @@ public void testQualifiedExpressionWithStrictAllowNoIndicesMatchingInLinkedProje ); // we matched the flat resource in a linked project thus no error - assertNull(ResponseValidator.validate(getStrictAllowNoIndices(), local, remote)); + assertNull(CrossProjectIndexResolutionValidator.validate(getStrictAllowNoIndices(), local, remote)); } public void testMissingQualifiedExpressionWithStrictAllowNoIndices() { @@ -577,7 +577,7 @@ public void testMissingQualifiedExpressionWithStrictAllowNoIndices() { ) ); - var e = ResponseValidator.validate(getStrictAllowNoIndices(), local, remote); + var e = CrossProjectIndexResolutionValidator.validate(getStrictAllowNoIndices(), local, remote); assertNotNull(e); assertThat(e, instanceOf(IndexNotFoundException.class)); assertThat(e.getMessage(), containsString("no such index [P1:logs*]")); @@ -614,7 +614,7 @@ public void testUnauthorizedQualifiedExpressionWithStrictAllowNoIndices() { ) ) ); - var e = ResponseValidator.validate(getStrictAllowNoIndices(), local, remote); + var e = CrossProjectIndexResolutionValidator.validate(getStrictAllowNoIndices(), local, remote); assertNotNull(e); assertThat(e, instanceOf(IndexNotFoundException.class)); assertThat(e.getMessage(), containsString("no such index [P1:logs*]")); diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/SecurityExtension.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/SecurityExtension.java index c4ac7183d2690..4c4db9a035e93 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/SecurityExtension.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/SecurityExtension.java @@ -21,6 +21,7 @@ import org.elasticsearch.xpack.core.security.authc.service.ServiceAccountTokenStore; import org.elasticsearch.xpack.core.security.authc.support.UserRoleMapper; import org.elasticsearch.xpack.core.security.authz.AuthorizationEngine; +import org.elasticsearch.xpack.core.security.authz.AuthorizedProjectsResolver; import org.elasticsearch.xpack.core.security.authz.RoleDescriptor; import org.elasticsearch.xpack.core.security.authz.store.RoleRetrievalResult; @@ -148,4 +149,8 @@ default AuthorizationEngine getAuthorizationEngine(Settings settings) { default String extensionName() { return getClass().getName(); } + + default AuthorizedProjectsResolver getAuthorizedProjectsResolver(SecurityComponents components) { + return null; + } } diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/AuthorizedProjectsResolver.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/AuthorizedProjectsResolver.java new file mode 100644 index 0000000000000..d73488f710bc1 --- /dev/null +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/AuthorizedProjectsResolver.java @@ -0,0 +1,26 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.core.security.authz; + +import org.elasticsearch.action.ActionListener; +import org.elasticsearch.search.crossproject.TargetProjects; + +/** + * A resolver of authorized projects for the current user. This includes the origin project and all linked projects the user has access to. + * If we are not in a cross-project search context, the supplier returns {@link TargetProjects#NOT_CROSS_PROJECT}. + */ +public interface AuthorizedProjectsResolver { + void resolveAuthorizedProjects(ActionListener listener); + + class Default implements AuthorizedProjectsResolver { + @Override + public void resolveAuthorizedProjects(ActionListener listener) { + listener.onResponse(TargetProjects.NOT_CROSS_PROJECT); + } + } +} diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java index 04331311ef296..6a87e3fde2b99 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java @@ -210,6 +210,7 @@ import org.elasticsearch.xpack.core.security.authc.support.UserRoleMapper; import org.elasticsearch.xpack.core.security.authc.support.UsernamePasswordToken; import org.elasticsearch.xpack.core.security.authz.AuthorizationEngine; +import org.elasticsearch.xpack.core.security.authz.AuthorizedProjectsResolver; import org.elasticsearch.xpack.core.security.authz.RestrictedIndices; import org.elasticsearch.xpack.core.security.authz.RoleDescriptor; import org.elasticsearch.xpack.core.security.authz.accesscontrol.DocumentSubsetBitsetCache; @@ -1160,7 +1161,8 @@ Collection createComponents( restrictedIndices, authorizationDenialMessages.get(), linkedProjectConfigService, - projectResolver + projectResolver, + getCustomAuthorizedProjectsResolverOrDefault(extensionComponents) ); components.add(nativeRolesStore); // used by roles actions @@ -1345,6 +1347,27 @@ private List getCustomAuthenticatorFromExtensions(SecurityE } } + private AuthorizedProjectsResolver getCustomAuthorizedProjectsResolverOrDefault( + SecurityExtension.SecurityComponents extensionComponents + ) { + final AuthorizedProjectsResolver customAuthorizedProjectsResolver = findValueFromExtensions( + "authorized projects resolver", + extension -> { + final AuthorizedProjectsResolver authorizedProjectsResolver = extension.getAuthorizedProjectsResolver(extensionComponents); + if (authorizedProjectsResolver != null && isInternalExtension(extension) == false) { + throw new IllegalStateException( + "The [" + + extension.getClass().getName() + + "] extension tried to install a custom AuthorizedProjectsResolver. This functionality is not available to " + + "external extensions." + ); + } + return authorizedProjectsResolver; + } + ); + return customAuthorizedProjectsResolver == null ? new AuthorizedProjectsResolver.Default() : customAuthorizedProjectsResolver; + } + private ServiceAccountService createServiceAccountService( List components, CacheInvalidatorRegistry cacheInvalidatorRegistry, diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/AuthorizationService.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/AuthorizationService.java index ce73c4603d6bc..9297fce4326ca 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/AuthorizationService.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/AuthorizationService.java @@ -48,6 +48,9 @@ import org.elasticsearch.index.IndexNotFoundException; import org.elasticsearch.indices.InvalidIndexNameException; import org.elasticsearch.license.XPackLicenseState; +import org.elasticsearch.search.crossproject.CrossProjectModeDecider; +import org.elasticsearch.search.crossproject.NoMatchingProjectException; +import org.elasticsearch.search.crossproject.TargetProjects; import org.elasticsearch.threadpool.ThreadPool; import org.elasticsearch.transport.LinkedProjectConfigService; import org.elasticsearch.transport.TransportActionProxy; @@ -70,6 +73,7 @@ import org.elasticsearch.xpack.core.security.authz.AuthorizationEngine.ParentActionAuthorization; import org.elasticsearch.xpack.core.security.authz.AuthorizationEngine.RequestInfo; import org.elasticsearch.xpack.core.security.authz.AuthorizationServiceField; +import org.elasticsearch.xpack.core.security.authz.AuthorizedProjectsResolver; import org.elasticsearch.xpack.core.security.authz.ResolvedIndices; import org.elasticsearch.xpack.core.security.authz.RestrictedIndices; import org.elasticsearch.xpack.core.security.authz.RoleDescriptorsIntersection; @@ -148,6 +152,7 @@ public class AuthorizationService { private final boolean isAnonymousEnabled; private final boolean anonymousAuthzExceptionEnabled; private final DlsFlsFeatureTrackingIndicesAccessControlWrapper indicesAccessControlWrapper; + private final AuthorizedProjectsResolver authorizedProjectsResolver; public AuthorizationService( Settings settings, @@ -166,12 +171,18 @@ public AuthorizationService( RestrictedIndices restrictedIndices, AuthorizationDenialMessages authorizationDenialMessages, LinkedProjectConfigService linkedProjectConfigService, - ProjectResolver projectResolver + ProjectResolver projectResolver, + AuthorizedProjectsResolver authorizedProjectsResolver ) { this.clusterService = clusterService; this.auditTrailService = auditTrailService; this.restrictedIndices = restrictedIndices; - this.indicesAndAliasesResolver = new IndicesAndAliasesResolver(settings, linkedProjectConfigService, resolver, false); + this.indicesAndAliasesResolver = new IndicesAndAliasesResolver( + settings, + linkedProjectConfigService, + resolver, + new CrossProjectModeDecider(settings) + ); this.authcFailureHandler = authcFailureHandler; this.threadContext = threadPool.getThreadContext(); this.securityContext = new SecurityContext(settings, this.threadContext); @@ -192,6 +203,7 @@ public AuthorizationService( this.indicesAccessControlWrapper = new DlsFlsFeatureTrackingIndicesAccessControlWrapper(settings, licenseState); this.authorizationDenialMessages = authorizationDenialMessages; this.projectResolver = projectResolver; + this.authorizedProjectsResolver = authorizedProjectsResolver; } public void checkPrivileges( @@ -498,39 +510,40 @@ private void authorizeAction( return SubscribableListener.newSucceeded(resolvedIndices); } else { final SubscribableListener resolvedIndicesListener = new SubscribableListener<>(); + final var authorizedIndicesListener = new SubscribableListener(); + authorizedIndicesListener.>andThen( + (l, authorizedIndices) -> { + if (indicesAndAliasesResolver.resolvesCrossProject(request)) { + authorizedProjectsResolver.resolveAuthorizedProjects( + l.map(targetProjects -> new Tuple<>(authorizedIndices, targetProjects)) + ); + } else { + l.onResponse(new Tuple<>(authorizedIndices, TargetProjects.NOT_CROSS_PROJECT)); + } + } + ) + .addListener( + ActionListener.wrap( + authorizedIndicesAndProjects -> resolvedIndicesListener.onResponse( + indicesAndAliasesResolver.resolve( + action, + request, + projectMetadata, + authorizedIndicesAndProjects.v1(), + authorizedIndicesAndProjects.v2() + ) + ), + e -> onAuthorizedResourceLoadFailure(requestId, requestInfo, authzInfo, auditTrail, listener, e) + ) + ); + authzEngine.loadAuthorizedIndices( requestInfo, authzInfo, projectMetadata.getIndicesLookup(), - ActionListener.wrap( - authorizedIndices -> resolvedIndicesListener.onResponse( - indicesAndAliasesResolver.resolve(action, request, projectMetadata, authorizedIndices) - ), - e -> { - if (e instanceof InvalidIndexNameException - || e instanceof InvalidSelectorException - || e instanceof UnsupportedSelectorException) { - logger.debug( - () -> Strings.format( - "failed [%s] action authorization for [%s] due to [%s] exception", - action, - authentication, - e.getClass().getSimpleName() - ), - e - ); - listener.onFailure(e); - return; - } - auditTrail.accessDenied(requestId, authentication, action, request, authzInfo); - if (e instanceof IndexNotFoundException) { - listener.onFailure(e); - } else { - listener.onFailure(actionDenied(authentication, authzInfo, action, request, e)); - } - } - ) + authorizedIndicesListener ); + return resolvedIndicesListener; } }); @@ -563,6 +576,41 @@ private void authorizeAction( } } + private void onAuthorizedResourceLoadFailure( + String requestId, + RequestInfo requestInfo, + AuthorizationInfo authzInfo, + AuditTrail auditTrail, + ActionListener listener, + Exception ex + ) { + final String action = requestInfo.getAction(); + final TransportRequest request = requestInfo.getRequest(); + final Authentication authentication = requestInfo.getAuthentication(); + + if (ex instanceof InvalidIndexNameException + || ex instanceof InvalidSelectorException + || ex instanceof UnsupportedSelectorException) { + logger.info( + () -> Strings.format( + "failed [%s] action authorization for [%s] due to [%s] exception", + action, + authentication, + ex.getClass().getSimpleName() + ), + ex + ); + listener.onFailure(ex); + return; + } + auditTrail.accessDenied(requestId, authentication, action, request, authzInfo); + if (ex instanceof IndexNotFoundException || ex instanceof NoMatchingProjectException) { + listener.onFailure(ex); + } else { + listener.onFailure(actionDenied(authentication, authzInfo, action, request, ex)); + } + } + private void handleIndexActionAuthorizationResult( final IndexAuthorizationResult result, final RequestInfo requestInfo, diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java index fa7512914b5c9..c2daabd741926 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java @@ -32,6 +32,8 @@ import org.elasticsearch.core.Tuple; import org.elasticsearch.index.Index; import org.elasticsearch.index.IndexNotFoundException; +import org.elasticsearch.search.crossproject.CrossProjectModeDecider; +import org.elasticsearch.search.crossproject.TargetProjects; import org.elasticsearch.transport.LinkedProjectConfig; import org.elasticsearch.transport.LinkedProjectConfigService; import org.elasticsearch.transport.NoSuchRemoteClusterException; @@ -54,6 +56,7 @@ import java.util.function.BiPredicate; import static org.elasticsearch.cluster.metadata.IndexNameExpressionResolver.isNoneExpression; +import static org.elasticsearch.search.crossproject.CrossProjectIndexResolutionValidator.indicesOptionsForCrossProjectFanout; import static org.elasticsearch.xpack.core.security.authz.IndicesAndAliasesResolverField.NO_INDEX_PLACEHOLDER; class IndicesAndAliasesResolver { @@ -63,18 +66,18 @@ class IndicesAndAliasesResolver { private final IndexNameExpressionResolver nameExpressionResolver; private final IndexAbstractionResolver indexAbstractionResolver; private final RemoteClusterResolver remoteClusterResolver; - private final boolean recordResolvedIndexExpressions; + private final CrossProjectModeDecider crossProjectModeDecider; IndicesAndAliasesResolver( Settings settings, LinkedProjectConfigService linkedProjectConfigService, IndexNameExpressionResolver resolver, - boolean recordResolvedIndexExpressions + CrossProjectModeDecider crossProjectModeDecider ) { this.nameExpressionResolver = resolver; this.indexAbstractionResolver = new IndexAbstractionResolver(resolver); this.remoteClusterResolver = new RemoteClusterResolver(settings, linkedProjectConfigService); - this.recordResolvedIndexExpressions = recordResolvedIndexExpressions; + this.crossProjectModeDecider = crossProjectModeDecider; } /** @@ -115,12 +118,12 @@ class IndicesAndAliasesResolver { * resolving wildcards. *

*/ - ResolvedIndices resolve( String action, TransportRequest request, ProjectMetadata projectMetadata, - AuthorizationEngine.AuthorizedIndices authorizedIndices + AuthorizationEngine.AuthorizedIndices authorizedIndices, + TargetProjects authorizedProjects ) { if (request instanceof IndicesAliasesRequest indicesAliasesRequest) { ResolvedIndices.Builder resolvedIndicesBuilder = new ResolvedIndices.Builder(); @@ -136,7 +139,7 @@ ResolvedIndices resolve( if (request instanceof IndicesRequest == false) { throw new IllegalStateException("Request [" + request + "] is not an Indices request, but should be."); } - return resolveIndicesAndAliases(action, (IndicesRequest) request, projectMetadata, authorizedIndices); + return resolveIndicesAndAliases(action, (IndicesRequest) request, projectMetadata, authorizedIndices, authorizedProjects); } /** @@ -157,6 +160,10 @@ ResolvedIndices tryResolveWithoutWildcards(String action, TransportRequest trans return resolveIndicesAndAliasesWithoutWildcards(action, indicesRequest); } + boolean resolvesCrossProject(TransportRequest request) { + return request instanceof IndicesRequest.Replaceable replaceable && crossProjectModeDecider.resolvesCrossProject(replaceable); + } + private static boolean requiresWildcardExpansion(IndicesRequest indicesRequest) { // IndicesAliasesRequest requires special handling because it can have wildcards in request body if (indicesRequest instanceof IndicesAliasesRequest) { @@ -288,6 +295,16 @@ ResolvedIndices resolveIndicesAndAliases( IndicesRequest indicesRequest, ProjectMetadata projectMetadata, AuthorizationEngine.AuthorizedIndices authorizedIndices + ) { + return resolveIndicesAndAliases(action, indicesRequest, projectMetadata, authorizedIndices, TargetProjects.NOT_CROSS_PROJECT); + } + + ResolvedIndices resolveIndicesAndAliases( + String action, + IndicesRequest indicesRequest, + ProjectMetadata projectMetadata, + AuthorizationEngine.AuthorizedIndices authorizedIndices, + TargetProjects authorizedProjects ) { final ResolvedIndices.Builder resolvedIndicesBuilder = new ResolvedIndices.Builder(); boolean indicesReplacedWithNoIndices = false; @@ -331,6 +348,7 @@ ResolvedIndices resolveIndicesAndAliases( throw new UnsupportedSelectorException(originalIndexExpression); } if (indicesOptions.expandWildcardExpressions()) { + // TODO implement CPS index rewriting for all-indices requests IndexComponentSelector selector = IndexComponentSelector.getByKeyOrThrow(allIndicesPatternSelector); for (String authorizedIndex : authorizedIndices.all(selector)) { if (IndexAbstractionResolver.isIndexVisible( @@ -351,37 +369,59 @@ ResolvedIndices resolveIndicesAndAliases( // if we cannot replace wildcards the indices list stays empty. Same if there are no authorized indices. // we honour allow_no_indices like es core does. } else { - final ResolvedIndices split; - if (replaceable.allowsRemoteIndices()) { - split = remoteClusterResolver.splitLocalAndRemoteIndexNames(indicesRequest.indices()); - } else { - split = new ResolvedIndices(Arrays.asList(indicesRequest.indices()), Collections.emptyList()); - } - final ResolvedIndexExpressions resolved = indexAbstractionResolver.resolveIndexAbstractions( - split.getLocal(), - indicesOptions, - projectMetadata, - authorizedIndices::all, - authorizedIndices::check, - indicesRequest.includeDataStreams() - ); - // only store resolved expressions if configured, to avoid unnecessary memory usage - // once we've migrated from `indices()` to using resolved expressions holistically, - // we will always store them - if (recordResolvedIndexExpressions) { + assert indicesRequest.indices() != null : "indices() cannot be null when resolving non-all-index expressions"; + if (crossProjectModeDecider.resolvesCrossProject(replaceable) + // a none expression should not go through cross-project resolution -- fall back to local resolution logic + && false == IndexNameExpressionResolver.isNoneExpression(replaceable.indices())) { + assert replaceable.allowsRemoteIndices() : "cross-project requests must allow remote indices"; + assert authorizedProjects.crossProject() : "cross-project requests must have cross-project target set"; + + final ResolvedIndexExpressions resolved = indexAbstractionResolver.resolveIndexAbstractions( + Arrays.asList(replaceable.indices()), + indicesOptionsForCrossProjectFanout(indicesOptions), + projectMetadata, + authorizedIndices::all, + authorizedIndices::check, + authorizedProjects, + indicesRequest.includeDataStreams() + ); setResolvedIndexExpressionsIfUnset(replaceable, resolved); + resolvedIndicesBuilder.addLocal(resolved.getLocalIndicesList()); + resolvedIndicesBuilder.addRemote(resolved.getRemoteIndicesList()); + } else { + final ResolvedIndices split; + if (replaceable.allowsRemoteIndices()) { + split = remoteClusterResolver.splitLocalAndRemoteIndexNames(indicesRequest.indices()); + } else { + split = new ResolvedIndices(Arrays.asList(indicesRequest.indices()), Collections.emptyList()); + } + final ResolvedIndexExpressions resolved = indexAbstractionResolver.resolveIndexAbstractions( + split.getLocal(), + indicesOptions, + projectMetadata, + authorizedIndices::all, + authorizedIndices::check, + indicesRequest.includeDataStreams() + ); + // only store resolved expressions if configured, to avoid unnecessary memory usage + // once we've migrated from `indices()` to using resolved expressions holistically, + // we will always store them + if (crossProjectModeDecider.crossProjectEnabled()) { + setResolvedIndexExpressionsIfUnset(replaceable, resolved); + } + resolvedIndicesBuilder.addLocal(resolved.getLocalIndicesList()); + resolvedIndicesBuilder.addRemote(split.getRemote()); } - resolvedIndicesBuilder.addLocal(resolved.getLocalIndicesList()); - resolvedIndicesBuilder.addRemote(split.getRemote()); } - if (resolvedIndicesBuilder.isEmpty()) { - if (indicesOptions.allowNoIndices()) { + // if we resolved the request according to CPS rules, error handling (like throwing IndexNotFoundException) happens later + // therefore, don't throw here + if (indicesOptions.allowNoIndices() || crossProjectModeDecider.resolvesCrossProject(replaceable)) { + indicesReplacedWithNoIndices = true; // this is how we tell es core to return an empty response, we can let the request through being sure // that the '-*' wildcard expression will be resolved to no indices. We can't let empty indices through // as that would be resolved to _all by es core. replaceable.indices(IndicesAndAliasesResolverField.NO_INDICES_OR_ALIASES_ARRAY); - indicesReplacedWithNoIndices = true; resolvedIndicesBuilder.addLocal(NO_INDEX_PLACEHOLDER); } else { throw new IndexNotFoundException(Arrays.toString(indicesRequest.indices())); @@ -453,7 +493,7 @@ private static void setResolvedIndexExpressionsIfUnset(IndicesRequest.Replaceabl + replaceable.getClass().getName() + "]"; logger.debug(message); - // we are excepting `*,-*` below since we've observed this already -- keeping this assertion catch other cases + // we are excepting `*,-*` below since we've observed this already -- keeping this assertion to catch other cases assert replaceable.indices() == null || isNoneExpression(replaceable.indices()) : message; } } diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/AuthorizationServiceTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/AuthorizationServiceTests.java index 56d71e27353f9..cb11095a5aad8 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/AuthorizationServiceTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/AuthorizationServiceTests.java @@ -157,6 +157,7 @@ import org.elasticsearch.xpack.core.security.authc.Subject; import org.elasticsearch.xpack.core.security.authz.AuthorizationEngine; import org.elasticsearch.xpack.core.security.authz.AuthorizationEngine.AuthorizationInfo; +import org.elasticsearch.xpack.core.security.authz.AuthorizedProjectsResolver; import org.elasticsearch.xpack.core.security.authz.IndicesAndAliasesResolverField; import org.elasticsearch.xpack.core.security.authz.ResolvedIndices; import org.elasticsearch.xpack.core.security.authz.RoleDescriptor; @@ -341,7 +342,8 @@ public void setup() { RESTRICTED_INDICES, new AuthorizationDenialMessages.Default(), linkedProjectConfigService, - projectResolver + projectResolver, + new AuthorizedProjectsResolver.Default() ); } @@ -1775,7 +1777,8 @@ public void testDenialForAnonymousUser() { RESTRICTED_INDICES, new AuthorizationDenialMessages.Default(), linkedProjectConfigService, - projectResolver + projectResolver, + new AuthorizedProjectsResolver.Default() ); RoleDescriptor role = new RoleDescriptor( @@ -1826,7 +1829,8 @@ public void testDenialForAnonymousUserAuthorizationExceptionDisabled() { RESTRICTED_INDICES, new AuthorizationDenialMessages.Default(), linkedProjectConfigService, - projectResolver + projectResolver, + new AuthorizedProjectsResolver.Default() ); RoleDescriptor role = new RoleDescriptor( @@ -3365,7 +3369,8 @@ public void testAuthorizationEngineSelectionForCheckPrivileges() throws Exceptio RESTRICTED_INDICES, new AuthorizationDenialMessages.Default(), linkedProjectConfigService, - projectResolver + projectResolver, + new AuthorizedProjectsResolver.Default() ); Subject subject = new Subject(new User("test", "a role"), mock(RealmRef.class)); @@ -3522,7 +3527,8 @@ public void getUserPrivileges(AuthorizationInfo authorizationInfo, ActionListene RESTRICTED_INDICES, new AuthorizationDenialMessages.Default(), linkedProjectConfigService, - projectResolver + projectResolver, + new AuthorizedProjectsResolver.Default() ); Authentication authentication; try (StoredContext ignore = threadContext.stashContext()) { diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolverTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolverTests.java index d9847c9bc8cda..e28c256e5ed18 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolverTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolverTests.java @@ -61,6 +61,8 @@ import org.elasticsearch.indices.TestIndexNameExpressionResolver; import org.elasticsearch.license.MockLicenseState; import org.elasticsearch.protocol.xpack.graph.GraphExploreRequest; +import org.elasticsearch.search.crossproject.CrossProjectModeDecider; +import org.elasticsearch.search.crossproject.TargetProjects; import org.elasticsearch.search.internal.ShardSearchRequest; import org.elasticsearch.test.ESTestCase; import org.elasticsearch.transport.ClusterSettingsLinkedProjectConfigService; @@ -159,6 +161,9 @@ public void setup() { ).put("cluster.remote.other_remote.seeds", "127.0.0.1:" + randomIntBetween(9351, 9399)).build(); IndexNameExpressionResolver indexNameExpressionResolver = TestIndexNameExpressionResolver.newInstance(); + CrossProjectModeDecider crossProjectModeDecider = mock(CrossProjectModeDecider.class); + when(crossProjectModeDecider.crossProjectEnabled()).thenReturn(true); + when(crossProjectModeDecider.resolvesCrossProject(any(IndicesRequest.Replaceable.class))).thenReturn(false); DateFormatter dateFormatter = DateFormatter.forPattern("uuuu.MM.dd"); Instant now = Instant.now(Clock.systemUTC()); @@ -432,7 +437,7 @@ public void setup() { settings, new ClusterSettingsLinkedProjectConfigService(settings, clusterService.getClusterSettings(), projectResolver), indexNameExpressionResolver, - true + crossProjectModeDecider ); } @@ -2906,7 +2911,7 @@ private ResolvedIndices resolveIndices(TransportRequest request, AuthorizedIndic } private ResolvedIndices resolveIndices(String action, TransportRequest request, AuthorizedIndices authorizedIndices) { - return defaultIndicesResolver.resolve(action, request, this.projectMetadata, authorizedIndices); + return defaultIndicesResolver.resolve(action, request, this.projectMetadata, authorizedIndices, TargetProjects.NOT_CROSS_PROJECT); } private static void assertNoIndices(IndicesRequest.Replaceable request, ResolvedIndices resolvedIndices) {