Skip to content

Commit 0c2c9ce

Browse files
authored
S2D1 - CPS Error handling (elastic#135358)
* impl * Update docs/changelog/135358.yaml * iter * javadoc * better handing of qualified expressions * iter * iter * updated tests * updated tests * updated tests * refactor CPS error util * logic rework * deleted unused class * renames + static utility * cleaner code + java docs * cleaner * iter * reworked * iter * Delete docs/changelog/135358.yaml * iter * optimized lookup * Revert "optimized lookup" This reverts commit 8c66dd0.
1 parent b5d6d68 commit 0c2c9ce

File tree

3 files changed

+887
-0
lines changed

3 files changed

+887
-0
lines changed

server/src/main/java/org/elasticsearch/action/ResolvedIndexExpression.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,10 @@ public record ResolvedIndexExpression(String original, LocalExpressions localExp
4646
* Failures can be due to concrete resources not being visible (either missing or not visible due to indices options)
4747
* or unauthorized concrete resources.
4848
* A wildcard expression resolving to nothing is still considered a successful resolution.
49+
* The NONE result indicates that no local resolution was attempted, because the expression is known to be remote-only.
4950
*/
5051
public enum LocalIndexResolutionResult {
52+
NONE,
5153
SUCCESS,
5254
CONCRETE_RESOURCE_NOT_VISIBLE,
5355
CONCRETE_RESOURCE_UNAUTHORIZED,
@@ -65,5 +67,8 @@ public record LocalExpressions(
6567
assert localIndexResolutionResult != LocalIndexResolutionResult.SUCCESS || exception == null
6668
: "If the local resolution result is SUCCESS, exception must be null";
6769
}
70+
71+
// Singleton for the case where all expressions in a ResolvedIndexExpression instance are remote
72+
public static final LocalExpressions NONE = new LocalExpressions(Set.of(), LocalIndexResolutionResult.NONE, null);
6873
}
6974
}
Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the "Elastic License
4+
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
5+
* Public License v 1"; you may not use this file except in compliance with, at
6+
* your election, the "Elastic License 2.0", the "GNU Affero General Public
7+
* License v3.0 only", or the "Server Side Public License, v 1".
8+
*/
9+
10+
package org.elasticsearch.search.crossproject;
11+
12+
import org.apache.logging.log4j.LogManager;
13+
import org.apache.logging.log4j.Logger;
14+
import org.elasticsearch.ElasticsearchException;
15+
import org.elasticsearch.ElasticsearchSecurityException;
16+
import org.elasticsearch.action.ResolvedIndexExpression;
17+
import org.elasticsearch.action.ResolvedIndexExpressions;
18+
import org.elasticsearch.action.support.IndicesOptions;
19+
import org.elasticsearch.index.IndexNotFoundException;
20+
import org.elasticsearch.transport.RemoteClusterAware;
21+
22+
import java.util.Map;
23+
import java.util.Set;
24+
25+
import static org.elasticsearch.action.ResolvedIndexExpression.LocalIndexResolutionResult.CONCRETE_RESOURCE_NOT_VISIBLE;
26+
import static org.elasticsearch.action.ResolvedIndexExpression.LocalIndexResolutionResult.CONCRETE_RESOURCE_UNAUTHORIZED;
27+
import static org.elasticsearch.action.ResolvedIndexExpression.LocalIndexResolutionResult.SUCCESS;
28+
29+
/**
30+
* Utility class for validating index resolution results in cross-project operations.
31+
* <p>
32+
* This class provides consistent error handling for scenarios where index resolution
33+
* spans multiple projects, taking into account the provided {@link IndicesOptions}.
34+
* It handles:
35+
* <ul>
36+
* <li>Validation of index existence in both origin and linked projects based on IndicesOptions
37+
* (ignoreUnavailable, allowNoIndices)</li>
38+
* <li>Authorization issues during cross-project index resolution, returning appropriate
39+
* {@link ElasticsearchSecurityException} responses</li>
40+
* <li>Both flat (unqualified) and qualified index expressions (including "_origin:" prefixed indices)</li>
41+
* <li>Wildcard index patterns that may resolve differently across projects</li>
42+
* </ul>
43+
* <p>
44+
* The validator examines both local and remote resolution results to determine the appropriate
45+
* error response, returning {@link IndexNotFoundException} for missing indices or
46+
* {@link ElasticsearchSecurityException} for authorization failures.
47+
*/
48+
public class ResponseValidator {
49+
private static final Logger logger = LogManager.getLogger(ResponseValidator.class);
50+
51+
/**
52+
* Validates the results of cross-project index resolution and returns appropriate exceptions based on the provided
53+
* {@link IndicesOptions}.
54+
* <p>
55+
* This method handles error scenarios when resolving indices across multiple projects:
56+
* <ul>
57+
* <li>If both {@code ignoreUnavailable} and {@code allowNoIndices} are true, the method returns null without validation
58+
* (lenient mode)</li>
59+
* <li>For wildcard patterns that resolve to no indices, validates against {@code allowNoIndices}</li>
60+
* <li>For concrete indices that don't exist, validates against {@code ignoreUnavailable}</li>
61+
* <li>For indices with authorization issues, returns security exceptions</li>
62+
* </ul>
63+
* <p>
64+
* The method considers both flat (unqualified) and qualified index expressions, as well as
65+
* local and linked project resolution results when determining the appropriate error response.
66+
*
67+
* @param indicesOptions Controls error behavior for missing indices
68+
* @param localResolvedExpressions Resolution results from the origin project
69+
* @param remoteResolvedExpressions Resolution results from linked projects
70+
* @return a {@link ElasticsearchException} if validation fails, null if validation passes
71+
*/
72+
public static ElasticsearchException validate(
73+
IndicesOptions indicesOptions,
74+
ResolvedIndexExpressions localResolvedExpressions,
75+
Map<String, ResolvedIndexExpressions> remoteResolvedExpressions
76+
) {
77+
if (indicesOptions.allowNoIndices() && indicesOptions.ignoreUnavailable()) {
78+
logger.debug("Skipping index existence check in lenient mode");
79+
return null;
80+
}
81+
82+
logger.debug(
83+
"Checking index existence for [{}] and [{}] with indices options [{}]",
84+
localResolvedExpressions,
85+
remoteResolvedExpressions,
86+
indicesOptions
87+
);
88+
89+
for (ResolvedIndexExpression localResolvedIndices : localResolvedExpressions.expressions()) {
90+
String originalExpression = localResolvedIndices.original();
91+
logger.debug("Checking replaced expression for original expression [{}]", originalExpression);
92+
93+
// Check if this is a qualified resource (project:index pattern)
94+
boolean isQualifiedExpression = RemoteClusterAware.isRemoteIndexName(originalExpression);
95+
96+
Set<String> remoteExpressions = localResolvedIndices.remoteExpressions();
97+
ResolvedIndexExpression.LocalExpressions localExpressions = localResolvedIndices.localExpressions();
98+
ResolvedIndexExpression.LocalIndexResolutionResult result = localExpressions.localIndexResolutionResult();
99+
if (isQualifiedExpression) {
100+
ElasticsearchException e = checkResolutionFailure(
101+
localExpressions.expressions(),
102+
result,
103+
originalExpression,
104+
indicesOptions
105+
);
106+
if (e != null) {
107+
return e;
108+
}
109+
// qualified linked project expression
110+
for (String remoteExpression : remoteExpressions) {
111+
String[] splitResource = splitQualifiedResource(remoteExpression);
112+
ElasticsearchException exception = checkSingleRemoteExpression(
113+
remoteResolvedExpressions,
114+
splitResource[0], // projectAlias
115+
splitResource[1], // resource
116+
remoteExpression,
117+
indicesOptions
118+
);
119+
if (exception != null) {
120+
return exception;
121+
}
122+
}
123+
} else {
124+
ElasticsearchException localException = checkResolutionFailure(
125+
localExpressions.expressions(),
126+
result,
127+
originalExpression,
128+
indicesOptions
129+
);
130+
if (localException == null) {
131+
// found locally, continue to next expression
132+
continue;
133+
}
134+
boolean isUnauthorized = localException instanceof ElasticsearchSecurityException;
135+
boolean foundFlat = false;
136+
// checking if flat expression matched remotely
137+
for (String remoteExpression : remoteExpressions) {
138+
String[] splitResource = splitQualifiedResource(remoteExpression);
139+
ElasticsearchException exception = checkSingleRemoteExpression(
140+
remoteResolvedExpressions,
141+
splitResource[0], // projectAlias
142+
splitResource[1], // resource
143+
remoteExpression,
144+
indicesOptions
145+
);
146+
if (exception == null) {
147+
// found flat expression somewhere
148+
foundFlat = true;
149+
break;
150+
}
151+
if (false == isUnauthorized && exception instanceof ElasticsearchSecurityException) {
152+
isUnauthorized = true;
153+
}
154+
}
155+
if (foundFlat) {
156+
continue;
157+
}
158+
if (isUnauthorized) {
159+
return securityException(originalExpression);
160+
}
161+
return new IndexNotFoundException(originalExpression);
162+
}
163+
}
164+
// if we didn't throw before it means that we can proceed with the request
165+
return null;
166+
}
167+
168+
private static ElasticsearchSecurityException securityException(String originalExpression) {
169+
// TODO plug in proper recorded authorization exceptions instead, once available
170+
return new ElasticsearchSecurityException("user cannot access [" + originalExpression + "]");
171+
}
172+
173+
private static ElasticsearchException checkSingleRemoteExpression(
174+
Map<String, ResolvedIndexExpressions> remoteResolvedExpressions,
175+
String projectAlias,
176+
String resource,
177+
String remoteExpression,
178+
IndicesOptions indicesOptions
179+
) {
180+
ResolvedIndexExpressions resolvedExpressionsInProject = remoteResolvedExpressions.get(projectAlias);
181+
assert resolvedExpressionsInProject != null : "We should always have resolved expressions from linked project";
182+
183+
ResolvedIndexExpression.LocalExpressions matchingExpression = findMatchingExpression(resolvedExpressionsInProject, resource);
184+
if (matchingExpression == null) {
185+
assert false : "Expected to find matching expression [" + resource + "] in project [" + projectAlias + "]";
186+
return new IndexNotFoundException(remoteExpression);
187+
}
188+
189+
return checkResolutionFailure(
190+
matchingExpression.expressions(),
191+
matchingExpression.localIndexResolutionResult(),
192+
remoteExpression,
193+
indicesOptions
194+
);
195+
}
196+
197+
private static String[] splitQualifiedResource(String resource) {
198+
String[] splitResource = RemoteClusterAware.splitIndexName(resource);
199+
assert splitResource.length == 2
200+
: "Expected two strings (project and indexExpression) for a qualified resource ["
201+
+ resource
202+
+ "], but found ["
203+
+ splitResource.length
204+
+ "]";
205+
return splitResource;
206+
}
207+
208+
// TODO optimize with a precomputed Map<String, ResolvedIndexExpression.LocalExpressions> instead
209+
private static ResolvedIndexExpression.LocalExpressions findMatchingExpression(
210+
ResolvedIndexExpressions projectExpressions,
211+
String resource
212+
) {
213+
return projectExpressions.expressions()
214+
.stream()
215+
.filter(expr -> expr.original().equals(resource))
216+
.map(ResolvedIndexExpression::localExpressions)
217+
.findFirst()
218+
.orElse(null);
219+
}
220+
221+
private static ElasticsearchException checkResolutionFailure(
222+
Set<String> localExpressions,
223+
ResolvedIndexExpression.LocalIndexResolutionResult result,
224+
String expression,
225+
IndicesOptions indicesOptions
226+
) {
227+
assert false == (indicesOptions.allowNoIndices() && indicesOptions.ignoreUnavailable())
228+
: "Should not be checking index existence in lenient mode";
229+
230+
if (indicesOptions.ignoreUnavailable() == false) {
231+
if (result == CONCRETE_RESOURCE_NOT_VISIBLE) {
232+
return new IndexNotFoundException(expression);
233+
} else if (result == CONCRETE_RESOURCE_UNAUTHORIZED) {
234+
return securityException(expression);
235+
}
236+
}
237+
238+
if (indicesOptions.allowNoIndices() == false && result == SUCCESS && localExpressions.isEmpty()) {
239+
return new IndexNotFoundException(expression);
240+
}
241+
242+
return null;
243+
}
244+
}

0 commit comments

Comments
 (0)