Skip to content
Closed
Show file tree
Hide file tree
Changes from 76 commits
Commits
Show all changes
85 commits
Select commit Hold shift + click to select a range
980723e
poc
n1v0lg Jun 24, 2025
4ff5e6e
poc
n1v0lg Jun 24, 2025
3df7a1d
Fix ups
n1v0lg Jun 26, 2025
c21a0a9
The missing commit
n1v0lg Jun 26, 2025
5266376
Merge branch 'main' into poc-flat-world
n1v0lg Jul 9, 2025
650dd77
Merge branch 'main' into poc-flat-world
n1v0lg Jul 10, 2025
9d63d77
More
n1v0lg Jul 10, 2025
8eec08e
Moar
n1v0lg Jul 10, 2025
fe6b696
Also resolver
n1v0lg Jul 10, 2025
8dee748
field caps
n1v0lg Jul 10, 2025
f9d6407
Merge branch 'main' into poc-flat-world
n1v0lg Jul 11, 2025
be2ab99
SPI
n1v0lg Jul 11, 2025
710d789
SPI
n1v0lg Jul 11, 2025
3587e6a
Remote conns
n1v0lg Jul 11, 2025
26a49c0
More
n1v0lg Jul 12, 2025
2a45f5b
Extract interface
n1v0lg Jul 12, 2025
9d29d4f
Inject authenticator
n1v0lg Jul 13, 2025
0ef2258
Simplify
n1v0lg Jul 13, 2025
88104ce
Scope
n1v0lg Jul 13, 2025
035ffc1
Compile
n1v0lg Jul 14, 2025
f5cad57
Merge branch 'main' into poc-cps-e2e
n1v0lg Jul 14, 2025
fe3dea0
Query routing
n1v0lg Jul 14, 2025
1153dfa
Some clean up
n1v0lg Jul 14, 2025
3fd532e
WIP esql
n1v0lg Jul 15, 2025
512222e
Fix interface
n1v0lg Jul 15, 2025
b867e69
WIP resolver
n1v0lg Jul 15, 2025
08d3e4e
Only inject resolver
n1v0lg Jul 15, 2025
d36c3f6
Clean up
n1v0lg Jul 15, 2025
d20a8c9
esql patch by @idegtiarenko
n1v0lg Jul 16, 2025
32ed394
More context
n1v0lg Jul 17, 2025
67f5cdf
More still
n1v0lg Jul 17, 2025
e25d8b2
CrossProjectAware
n1v0lg Jul 17, 2025
137195f
Javadoc
n1v0lg Jul 17, 2025
ee93568
Merge and clean up
n1v0lg Aug 18, 2025
18d6bcb
More
n1v0lg Aug 19, 2025
2ae90af
Local index resolution
n1v0lg Aug 19, 2025
2b092fb
WIP
n1v0lg Aug 20, 2025
d834784
More
n1v0lg Aug 22, 2025
7de6de0
More
n1v0lg Aug 22, 2025
9e53cc3
WIP e2e error handling
n1v0lg Aug 22, 2025
2d8a118
Flat world maybe
n1v0lg Aug 25, 2025
01ad89b
Merge branch 'main' into poc-cps-e2e
n1v0lg Aug 25, 2025
4cc41c3
Small clean up
n1v0lg Aug 25, 2025
254977c
TODO
n1v0lg Aug 25, 2025
e427303
A v big refactor
n1v0lg Aug 27, 2025
efbd82e
V confusing double resolution fix
n1v0lg Aug 27, 2025
d8103f9
Ominous TODO
n1v0lg Aug 27, 2025
1d3dc40
Nits
n1v0lg Aug 27, 2025
c03c73a
Tweaks
n1v0lg Aug 28, 2025
1f04a53
[CI] Auto commit changes from spotless
Aug 28, 2025
9f341be
Merge
n1v0lg Aug 28, 2025
7c6b1fc
Consolidate more index resolution
n1v0lg Aug 28, 2025
91bf747
Missing
n1v0lg Aug 28, 2025
266a822
fix
n1v0lg Aug 28, 2025
bf307e2
Undo
n1v0lg Aug 28, 2025
768affc
More
n1v0lg Aug 28, 2025
d1bd040
Merge
n1v0lg Aug 28, 2025
a78010b
TODO
n1v0lg Aug 28, 2025
ca5b153
Project routing
n1v0lg Aug 28, 2025
aaab227
More abstractions
n1v0lg Aug 29, 2025
1967bfa
Fixes
n1v0lg Aug 29, 2025
700af92
More
n1v0lg Aug 29, 2025
652caf3
Merge branch 'main' into poc-cps-e2e
n1v0lg Aug 29, 2025
3a8c4dc
Renames
n1v0lg Aug 29, 2025
24b9f7c
Clean up
n1v0lg Aug 31, 2025
804069c
More clean up
n1v0lg Sep 1, 2025
a61ad84
Also xpack
n1v0lg Sep 1, 2025
ab675dc
Merge branch 'main' into poc-cps-e2e
n1v0lg Sep 1, 2025
b11d30a
Checkstyle
n1v0lg Sep 1, 2025
cd6511f
Nits
n1v0lg Sep 2, 2025
da74cb5
Rename
n1v0lg Sep 3, 2025
5373143
Merge
n1v0lg Sep 3, 2025
6544589
Cut down on number of classes
n1v0lg Sep 3, 2025
7b7aa7d
More
n1v0lg Sep 3, 2025
2af9134
Clean up
n1v0lg Sep 4, 2025
c17b5c5
More
n1v0lg Sep 4, 2025
7d9a438
Tweaks
n1v0lg Sep 8, 2025
80bd89e
Nits
n1v0lg Sep 9, 2025
0a4046c
Fix compl
n1v0lg Sep 9, 2025
49814b1
Merge branch 'main' into poc-cps-e2e
n1v0lg Sep 12, 2025
01276d5
Revert "Merge branch 'main' into poc-cps-e2e"
n1v0lg Sep 12, 2025
d1bb772
CPS service and fix refactor
n1v0lg Sep 12, 2025
d5af5bc
Index resolution
n1v0lg Sep 12, 2025
f380e82
xpack
n1v0lg Sep 12, 2025
fd1df71
Persist headers
n1v0lg Sep 12, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/*
* 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.action;

import org.elasticsearch.core.Nullable;

import java.util.List;

/**
* Returns authorized projects (linked and origin), if running in a cross-project environment.
* In non-cross-project environments, returns a special value indicating that cross-project does not apply at all.
*/
public interface AuthorizedProjectsSupplier {
AuthorizedProjects get();

AuthorizedProjectsSupplier DEFAULT = new Default();

default boolean runCps() {
return true; // ??
}

class Default implements AuthorizedProjectsSupplier {
@Override
public AuthorizedProjects get() {
return AuthorizedProjects.NOT_CROSS_PROJECT;
}
}

// Note: in the final implementation these won't be strings but Project record classes with additional info
// relevant to e.g. project routing
record AuthorizedProjects(@Nullable String originProject, List<String> linkedProjects) {
public static AuthorizedProjects NOT_CROSS_PROJECT = new AuthorizedProjects(null, List.of());

public boolean isOriginOnly() {
return originProject != null && linkedProjects.isEmpty();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/*
* 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.action;

import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.elasticsearch.ElasticsearchException;
import org.elasticsearch.ElasticsearchSecurityException;
import org.elasticsearch.action.support.IndicesOptions;
import org.elasticsearch.index.IndexNotFoundException;
import org.elasticsearch.rest.RestStatus;
import org.elasticsearch.transport.RemoteClusterService;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;

public class CrossProjectSearchErrorHandler {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this class can also have different implementations and the one with actual error handling is provided by serverless.

private static final Logger logger = LogManager.getLogger(CrossProjectSearchErrorHandler.class);

private final AuthorizedProjectsSupplier supplier;
private final RemoteClusterService remoteClusterService;

public CrossProjectSearchErrorHandler(AuthorizedProjectsSupplier supplier, RemoteClusterService remoteClusterService) {
this.supplier = supplier;
this.remoteClusterService = remoteClusterService;
}

public boolean enabled() {
return supplier.get() != AuthorizedProjectsSupplier.AuthorizedProjects.NOT_CROSS_PROJECT;
}

public Map<String, OriginalIndices> groupIndicesForFanoutAction(IndicesRequest.Replaceable replaceable) {
return remoteClusterService.groupIndices(getIndicesOptions(replaceable.indicesOptions()), replaceable.indices());
}

private IndicesOptions getIndicesOptions(IndicesOptions indicesOptions) {
return enabled() ? lenientIndicesOptions(indicesOptions) : indicesOptions;
}

private static IndicesOptions lenientIndicesOptions(IndicesOptions indicesOptions) {
return IndicesOptions.builder(indicesOptions)
.concreteTargetOptions(new IndicesOptions.ConcreteTargetOptions(true))
.wildcardOptions(IndicesOptions.WildcardOptions.builder(indicesOptions.wildcardOptions()).allowEmptyExpressions(true).build())
.build();
}

public <T extends ResponseWithReplacedIndexExpressions> void errorHandling(
IndicesRequest.Replaceable request,
Map<String, T> remoteResults
) {
logger.info("Checking if we should throw for [{}] under CPS", request.getReplacedIndexExpressions());
// No CPS nothing to do
if (false == enabled()) {
logger.info("Skipping because we are not in CPS mode...");
return;
}
if (request.indicesOptions().allowNoIndices() && request.indicesOptions().ignoreUnavailable()) {
// nothing to do since we're in lenient mode
logger.info("Skipping index existence check in lenient mode");
return;
}

Map<String, ReplacedIndexExpression> replacedExpressions = request.getReplacedIndexExpressions().replacedExpressionMap();
assert replacedExpressions != null;
logger.info("Replaced expressions to check: [{}]", replacedExpressions);
for (ReplacedIndexExpression replacedIndexExpression : replacedExpressions.values()) {
// TODO need to handle qualified expressions here, too
String original = replacedIndexExpression.original();
List<ElasticsearchException> exceptions = new ArrayList<>();
boolean exists = replacedIndexExpression.hasLocalIndices()
&& replacedIndexExpression.resolutionResult() == ReplacedIndexExpression.ResolutionResult.SUCCESS;
if (exists) {
logger.info("Local cluster has canonical expression for [{}], skipping remote existence check", original);
continue;
}
if (replacedIndexExpression.authorizationError() != null) {
exceptions.add(replacedIndexExpression.authorizationError());
}

for (var remoteResponse : remoteResults.values()) {
logger.info("Remote response resolved: [{}]", remoteResponse);
Map<String, ReplacedIndexExpression> resolved = remoteResponse.getReplacedIndexExpressions().replacedExpressionMap();
assert resolved != null;
var r = resolved.get(original);
if (r != null
&& replacedIndexExpression.resolutionResult() == ReplacedIndexExpression.ResolutionResult.SUCCESS
&& resolved.get(original).replacedBy().isEmpty() == false) {
logger.info("Remote cluster has resolved entries for [{}], skipping further remote existence check", original);
exists = true;
break;
} else if (r != null && r.authorizationError() != null) {
assert r.resolutionResult() == ReplacedIndexExpression.ResolutionResult.CONCRETE_RESOURCE_UNAUTHORIZED
: "we should never get an error if we are authorized";
exceptions.add(resolved.get(original).authorizationError());
}
}

if (false == exists && false == request.indicesOptions().ignoreUnavailable()) {
if (false == exceptions.isEmpty()) {
// we only ever get exceptions if they are security related
// back and forth on whether a mix or security and non-security (missing indices) exceptions should report
// as 403 or 404
ElasticsearchSecurityException e = new ElasticsearchSecurityException(
"authorization errors while resolving [" + original + "]",
RestStatus.FORBIDDEN
);
exceptions.forEach(e::addSuppressed);
throw e;
} else {
// TODO composite exception based on missing resources
throw new IndexNotFoundException(original);
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
package org.elasticsearch.action;

import org.elasticsearch.action.support.IndicesOptions;
import org.elasticsearch.core.Nullable;
import org.elasticsearch.index.shard.ShardId;

import java.util.Collection;
Expand Down Expand Up @@ -48,9 +49,15 @@ interface Replaceable extends IndicesRequest {
*/
IndicesRequest indices(String... indices);

default void setReplacedIndexExpressions(ReplacedIndexExpressions replacedIndexExpressions) {}

@Nullable
default ReplacedIndexExpressions getReplacedIndexExpressions() {
return null;
}

/**
* Determines whether the request can contain indices on a remote cluster.
*
* NOTE in theory this method can belong to the {@link IndicesRequest} interface because whether a request
* allowing remote indices has no inherent relationship to whether it is {@link Replaceable} or not.
* However, we don't have an existing request that is non-replaceable but allows remote indices.
Expand All @@ -62,6 +69,29 @@ interface Replaceable extends IndicesRequest {
default boolean allowsRemoteIndices() {
return false;
}

// Could this be useful?
default IndexResolutionMode indexResolutionMode() {
return IndexResolutionMode.CANONICAL;
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This feels like another variant of the previous boolean crossProjectMode() method. I'd prefer we rely on the AuthorizedProjectSupplier if possible. I understand the intention is to explicitly tell no flat expansion is required. But I feel exploring other alternatives is more preferrable since this flag can in theory be configured contradictory to the setup and/or we can sometimes have 2 sources of truth.


@Nullable
default String getProjectRouting() {
return null;
}
}

enum IndexResolutionMode {
FLAT,
CANONICAL
}

// Complete marker interface, can be folded into Replaceable if necessary
interface CrossProjectSearchCapable extends Replaceable {
@Override
default boolean allowsRemoteIndices() {
return true;
}
}
Comment on lines +79 to 85
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah unless we want to move getProjectRouting back here, the interface does not seem useful as is.


/**
Expand Down Expand Up @@ -91,4 +121,5 @@ interface RemoteClusterShardRequest extends IndicesRequest {
*/
Collection<ShardId> shards();
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
/*
* 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.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 org.elasticsearch.transport.RemoteClusterAware;

import java.io.IOException;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;

// Represent flat -> canonical CPS rewrites
// Represent local index expansion

public final class ReplacedIndexExpression implements Writeable {
public enum ResolutionResult {
SUCCESS,
CONCRETE_RESOURCE_UNAUTHORIZED,
CONCRETE_RESOURCE_MISSING
}

private final String original;
private final List<String> replacedBy;
private final ResolutionResult resolutionResult;
@Nullable
private ElasticsearchException authorizationError;

public ReplacedIndexExpression(StreamInput in) throws IOException {
this.original = in.readString();
this.replacedBy = in.readCollectionAsList(StreamInput::readString);
this.resolutionResult = in.readEnum(ResolutionResult.class);
this.authorizationError = ElasticsearchException.readException(in);
}

public ReplacedIndexExpression(
String original,
List<String> replacedBy,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking the usage of this field, it does seem make sense to split it into remote and local.

  • On the origin cluster, it has both remote and local. Remote ones are sent to linked projects. Local ones are locally resolved with IndexAbstractionResolver. So the usages are separate.
  • On the linked cluster, only local ones make sense since there is no chaining.

If we represent remote and local with different objects, we can also fold ResolutionResult and exception into the object for local ones. The type for remote ones may simply be a List<String>.

Or maybe the split can be even higher up ReplacedIndexExpression which has a base version just for local and a subclass with the remote ones. The base one should be what we use mostly because they should be the ones used by the linked projects.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indeed. I've pushed #134783 as a first pass on the data model. Fits quite cleanly IMO

ResolutionResult resolutionResult,
@Nullable ElasticsearchException exception
) {
this.original = original;
this.replacedBy = replacedBy;
this.resolutionResult = resolutionResult;
this.authorizationError = exception;
}

public ReplacedIndexExpression(String original, List<String> replacedBy) {
this(original, replacedBy, ResolutionResult.SUCCESS, null);
}

public void setAuthorizationError(ElasticsearchException error) {
assert resolutionResult == ResolutionResult.CONCRETE_RESOURCE_UNAUTHORIZED : "we should never set an error if we are authorized";
this.authorizationError = error;
}

public boolean hasLocalIndices() {
return false == getLocalIndices().isEmpty();
}

public List<String> getLocalIndices() {
return replacedBy.stream().filter(e -> false == RemoteClusterAware.isRemoteIndexName(e)).collect(Collectors.toList());
}

public List<String> getRemoteIndices() {
return replacedBy.stream().filter(RemoteClusterAware::isRemoteIndexName).collect(Collectors.toList());
}

public String original() {
return original;
}

public List<String> replacedBy() {
return replacedBy;
}

public ResolutionResult resolutionResult() {
return resolutionResult;
}

@Nullable
public ElasticsearchException authorizationError() {
return authorizationError;
}

@Override
public void writeTo(StreamOutput out) throws IOException {
out.writeString(original);
out.writeStringCollection(replacedBy);
out.writeEnum(resolutionResult);
ElasticsearchException.writeException(authorizationError, out);
}

@Override
public boolean equals(Object o) {
if (o == null || getClass() != o.getClass()) return false;
ReplacedIndexExpression that = (ReplacedIndexExpression) o;
return Objects.equals(original, that.original)
&& Objects.equals(replacedBy, that.replacedBy)
&& resolutionResult == that.resolutionResult
&& Objects.equals(authorizationError, that.authorizationError);
}

@Override
public int hashCode() {
return Objects.hash(original, replacedBy, resolutionResult, authorizationError);
}

@Override
public String toString() {
return "ReplacedIndexExpression{"
+ "original='"
+ original
+ '\''
+ ", replacedBy="
+ replacedBy
+ ", resolutionResult="
+ resolutionResult
+ ", authorizationError="
+ authorizationError
+ '}';
}
}
Loading