Skip to content
Closed
Show file tree
Hide file tree
Changes from 70 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,37 @@
/*
* 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();

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

record AuthorizedProjects(@Nullable String origin, List<String> projects) {
public static AuthorizedProjects NOT_CROSS_PROJECT = new AuthorizedProjects(null, List.of());

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

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

public final class CrossProjectReplacedIndexExpressions extends ReplacedIndexExpressions {

public CrossProjectReplacedIndexExpressions(Map<String, ReplacedIndexExpression> replacedExpressionMap) {
super(replacedExpressionMap);
}

public static boolean isQualifiedIndexExpression(String indexExpression) {
return RemoteClusterAware.isRemoteIndexName(indexExpression);
}

public List<String> getLocalExpressions() {
return replacedExpressionMap.values()
.stream()
.filter(e -> hasCanonicalExpressionForOrigin(e.replacedBy()))
.map(ReplacedIndexExpression::original)
.toList();
}

public static boolean hasCanonicalExpressionForOrigin(List<String> replacedBy) {
return replacedBy.stream().anyMatch(e -> false == isQualifiedIndexExpression(e));
}

public void replaceLocalExpressions(ReplacedIndexExpressions localResolved) {
if (localResolved == null || localResolved.asMap() == null || localResolved.asMap().isEmpty()) {
return;
}

for (Map.Entry<String, ReplacedIndexExpression> e : localResolved.asMap().entrySet()) {
final String original = e.getKey();
final ReplacedIndexExpression local = e.getValue();
if (local == null) {
continue;
}

final ReplacedIndexExpression current = replacedExpressionMap.get(original);
if (current == null) {
continue;
}

final List<String> qualified = current.replacedBy()
.stream()
.filter(org.elasticsearch.action.CrossProjectReplacedIndexExpressions::isQualifiedIndexExpression)
.toList();

final List<String> resolvedLocal = local.replacedBy();

final List<String> combined = new ArrayList<>(resolvedLocal.size() + qualified.size());
combined.addAll(resolvedLocal);
combined.addAll(qualified);

replacedExpressionMap.put(
original,
new ReplacedIndexExpression(original, combined, local.authorized(), local.existsAndVisible(), null)
Copy link
Member

Choose a reason for hiding this comment

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

This means both authorized and existsAndVisible are applicable only to the local expression in the combined list. Might be ok. But it does feel a bit odd.

Copy link
Contributor Author

@n1v0lg n1v0lg Sep 3, 2025

Choose a reason for hiding this comment

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

Yes true, and yes it's odd. I don't think CrossProjectReplacedIndexExpressions is quite the right abstraction (or at least not the right shape) for the job still...

);
}
}

@Override
public String toString() {
return "CrossProjectReplacedIndexExpressions[" + "replacedExpressionMap=" + replacedExpressionMap + ']';
}
}
105 changes: 104 additions & 1 deletion server/src/main/java/org/elasticsearch/action/IndicesRequest.java
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,22 @@

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.core.Nullable;
import org.elasticsearch.index.IndexNotFoundException;
import org.elasticsearch.index.shard.ShardId;
import org.elasticsearch.rest.RestStatus;

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

import static org.elasticsearch.action.CrossProjectReplacedIndexExpressions.hasCanonicalExpressionForOrigin;

/**
* Needs to be implemented by all {@link org.elasticsearch.action.ActionRequest} subclasses that relate to
Expand Down Expand Up @@ -48,9 +60,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 +80,90 @@ interface Replaceable extends IndicesRequest {
default boolean allowsRemoteIndices() {
return false;
}

// TODO probably makes more sense on a service class as opposed to the request itself
default <T extends ResponseWithReplacedIndexExpressions> void remoteFanoutErrorHandling(Map<String, T> remoteResults) {}
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 method is needed only on CrossProjectSearchCapable and not here?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

True

}

interface CrossProjectSearchCapable extends Replaceable {
Logger logger = LogManager.getLogger(CrossProjectSearchCapable.class);

@Nullable
String getProjectRouting();

@Override
default boolean allowsRemoteIndices() {
return true;
}

default boolean crossProjectMode() {
return getReplacedIndexExpressions() instanceof CrossProjectReplacedIndexExpressions;
}

@Override
default <T extends ResponseWithReplacedIndexExpressions> void remoteFanoutErrorHandling(Map<String, T> remoteResults) {
logger.info("Checking if we should throw in flat world for [{}]", getReplacedIndexExpressions());
// No CPS nothing to do
if (false == crossProjectMode()) {
logger.info("Skipping because no cross-project expressions found...");
return;
}
if (indicesOptions().allowNoIndices() && 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 = getReplacedIndexExpressions().asMap();
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 = hasCanonicalExpressionForOrigin(replacedIndexExpression.replacedBy())
&& replacedIndexExpression.existsAndVisible();
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().asMap();
assert resolved != null;
var r = resolved.get(original);
if (r != null && r.existsAndVisible() && 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.authorized() == false : "we should never get an error if we are authorized";
exceptions.add(resolved.get(original).authorizationError());
}
}

if (false == exists && false == 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);
}
}
}
}
}

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

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
/*
* 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 java.io.IOException;
import java.util.List;
import java.util.Map;
import java.util.Objects;

/**
* Represents the result of replacing an original index expression with the concrete index resources it resolves to.
* Also tracks whether the replacement was authorized, whether the indices exist and are visible, and any
* authorization errors encountered during the process.
*/
public final class ReplacedIndexExpression implements Writeable {
private final String original;
private final List<String> replacedBy;
private final boolean authorized;
private final boolean existsAndVisible;
@Nullable
private ElasticsearchException authorizationError;

public ReplacedIndexExpression(StreamInput in) throws IOException {
this.original = in.readString();
this.replacedBy = in.readCollectionAsList(StreamInput::readString);
this.authorized = in.readBoolean();
this.existsAndVisible = in.readBoolean();
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

boolean authorized,
boolean existsAndVisible,
@Nullable ElasticsearchException exception
) {
this.original = original;
this.replacedBy = replacedBy;
this.authorized = authorized;
this.existsAndVisible = existsAndVisible;
this.authorizationError = exception;
}

public static String[] toIndices(Map<String, ReplacedIndexExpression> replacedExpressions) {
return replacedExpressions.values()
.stream()
.flatMap(indexExpression -> indexExpression.replacedBy().stream())
.toArray(String[]::new);
}

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

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

public String original() {
return original;
}

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

public boolean authorized() {
return authorized;
}

public boolean existsAndVisible() {
return existsAndVisible;
}

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

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

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

@Override
public String toString() {
return "ReplacedExpression{"
+ "original='"
+ original
+ '\''
+ ", replacedBy="
+ replacedBy
+ ", authorized="
+ authorized
+ ", existsAndVisible="
+ existsAndVisible
+ ", authorizationError="
+ authorizationError
+ '}';
}

@Override
public void writeTo(StreamOutput out) throws IOException {
out.writeString(original);
out.writeStringCollection(replacedBy);
out.writeBoolean(authorized);
out.writeBoolean(existsAndVisible);
ElasticsearchException.writeException(authorizationError, out);
}
}
Loading