Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
eba4f92
record security exceptions in resolved index expressions
richard-dennehy Sep 29, 2025
e5e3e12
[CI] Auto commit changes from spotless
Sep 29, 2025
d2f5563
override equality in LocalExpressions
richard-dennehy Sep 29, 2025
c236591
merge main
richard-dennehy Oct 7, 2025
e7e8c16
[CI] Auto commit changes from spotless
Oct 7, 2025
6c302bd
record exception for unresolved index when ignore_unavailable is true
richard-dennehy Oct 9, 2025
359fca7
clean up
richard-dennehy Oct 9, 2025
1fdb0a8
spotless
richard-dennehy Oct 9, 2025
4a3ffd4
Merge remote-tracking branch 'upstream/main' into resolved-index-expr…
richard-dennehy Oct 10, 2025
98a50b9
enable CPS mode in test
richard-dennehy Oct 10, 2025
9d89502
Merge remote-tracking branch 'upstream/main' into resolved-index-expr…
richard-dennehy Oct 13, 2025
f143685
include index name in exception message
richard-dennehy Oct 13, 2025
6109ecb
[CI] Auto commit changes from spotless
Oct 13, 2025
b81d263
merge main
richard-dennehy Oct 24, 2025
69e46f3
address review comments
richard-dennehy Oct 24, 2025
a0e594f
[CI] Auto commit changes from spotless
Oct 24, 2025
d99f487
assert exception is not set twice
richard-dennehy Oct 24, 2025
dfb3081
use recorded exception in CrossProjectIndexResolutionValidator
richard-dennehy Oct 27, 2025
38115ad
[CI] Auto commit changes from spotless
Oct 27, 2025
88f63a3
actually only set exception if unset
richard-dennehy Oct 28, 2025
9aef86f
Merge branch 'main' into resolved-index-expression-record-exceptions
richard-dennehy Oct 28, 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
Expand Up @@ -12,6 +12,7 @@
import org.elasticsearch.ElasticsearchException;
import org.elasticsearch.core.Nullable;

import java.util.Objects;
import java.util.Set;

/**
Expand Down Expand Up @@ -56,14 +57,72 @@ public enum LocalIndexResolutionResult {
/**
* Represents local (non-remote) resolution results, including expanded indices, and a {@link LocalIndexResolutionResult}.
*/
public record LocalExpressions(
Set<String> expressions,
LocalIndexResolutionResult localIndexResolutionResult,
@Nullable ElasticsearchException exception
) {
public LocalExpressions {
public static final class LocalExpressions {
private final Set<String> expressions;
private final LocalIndexResolutionResult localIndexResolutionResult;
@Nullable
private ElasticsearchException exception;

public LocalExpressions(
Set<String> expressions,
LocalIndexResolutionResult localIndexResolutionResult,
@Nullable ElasticsearchException exception
) {
assert localIndexResolutionResult != LocalIndexResolutionResult.SUCCESS || exception == null
: "If the local resolution result is SUCCESS, exception must be null";
this.expressions = expressions;
this.localIndexResolutionResult = localIndexResolutionResult;
this.exception = exception;
}

public Set<String> expressions() {
return expressions;
}

public LocalIndexResolutionResult localIndexResolutionResult() {
return localIndexResolutionResult;
}

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

public void setException(ElasticsearchException exception) {
assert localIndexResolutionResult != LocalIndexResolutionResult.SUCCESS
: "If the local resolution result is SUCCESS, exception must be null";
Objects.requireNonNull(exception);

this.exception = exception;
Copy link
Member

Choose a reason for hiding this comment

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

We should also assert this.exception is null before set it

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is causing the CPS Rest IT to fail - I need to figure out why that's happening

Copy link
Member

Choose a reason for hiding this comment

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

Ah OK. I think this is the same double security filtering issue which required the method setResolvedIndexExpressionsIfUnset.

We can do something similar, i.e. set the exception when this.exception is null. If this.exception is already set, assert the two exceptions have the same type and error message (stacktraces are not necessarily the same).

}

@Override
public boolean equals(Object obj) {
if (obj == this) return true;
if (obj == null || obj.getClass() != this.getClass()) return false;
var that = (LocalExpressions) obj;
return Objects.equals(this.expressions, that.expressions)
&& Objects.equals(this.localIndexResolutionResult, that.localIndexResolutionResult)
&& Objects.equals(this.exception, that.exception);
}

@Override
public int hashCode() {
return Objects.hash(expressions, localIndexResolutionResult, exception);
}

@Override
public String toString() {
return "LocalExpressions["
+ "expressions="
+ expressions
+ ", "
+ "localIndexResolutionResult="
+ localIndexResolutionResult
+ ", "
+ "exception="
+ exception
+ ']';
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import org.elasticsearch.action.ActionListener;
import org.elasticsearch.action.DelegatingActionListener;
import org.elasticsearch.action.DocWriteRequest;
import org.elasticsearch.action.IndicesRequest;
import org.elasticsearch.action.admin.indices.alias.Alias;
import org.elasticsearch.action.admin.indices.alias.TransportIndicesAliasesAction;
import org.elasticsearch.action.admin.indices.create.CreateIndexRequest;
Expand Down Expand Up @@ -104,6 +105,7 @@
import java.util.function.Consumer;
import java.util.function.Supplier;

import static org.elasticsearch.action.ResolvedIndexExpression.LocalIndexResolutionResult.CONCRETE_RESOURCE_UNAUTHORIZED;
import static org.elasticsearch.action.support.ContextPreservingActionListener.wrapPreservingContext;
import static org.elasticsearch.xpack.core.security.SecurityField.setting;
import static org.elasticsearch.xpack.core.security.authz.AuthorizationServiceField.ACTION_SCOPE_AUTHORIZATION_KEYS;
Expand Down Expand Up @@ -526,7 +528,9 @@ private void authorizeAction(
if (e instanceof IndexNotFoundException) {
listener.onFailure(e);
} else {
listener.onFailure(actionDenied(authentication, authzInfo, action, request, e));
Copy link
Contributor

@n1v0lg n1v0lg Oct 7, 2025

Choose a reason for hiding this comment

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

We need to set security exceptions in the case where the action actually succeeded (due to ignore_unavailable=true) instead of when it failed. When it failed, the recorded expressions are irrelevant since we will throw directly. So we need to take the request post resolution and see if:

  1. it has resolved expressions recorded
  2. if any of those have CONCRETE_RESOURCE_UNAUTHORIZED set

If the above is true, we need to set an actionDenied exception as you do below

final var denial = actionDenied(authentication, authzInfo, action, request, e);
setResolvedIndexException(request, denial);
listener.onFailure(denial);
}
}
)
Expand Down Expand Up @@ -974,6 +978,19 @@ public ElasticsearchSecurityException remoteActionDenied(Authentication authenti
);
}

private void setResolvedIndexException(TransportRequest request, ElasticsearchSecurityException exception) {
if (request instanceof IndicesRequest.Replaceable replaceable) {
var indexExpressions = replaceable.getResolvedIndexExpressions();
if (indexExpressions != null) {
indexExpressions.expressions().forEach(resolved -> {
if (resolved.localExpressions().localIndexResolutionResult() == CONCRETE_RESOURCE_UNAUTHORIZED) {
resolved.localExpressions().setException(exception);
}
});
}
}
}

ElasticsearchSecurityException actionDenied(
Authentication authentication,
@Nullable AuthorizationInfo authorizationInfo,
Expand Down Expand Up @@ -1079,8 +1096,10 @@ private void handleFailure(@Nullable String context, @Nullable Exception e) {
Authentication authentication = requestInfo.getAuthentication();
String action = requestInfo.getAction();
TransportRequest request = requestInfo.getRequest();
final var denial = actionDenied(authentication, authzInfo, action, request, context, e);
setResolvedIndexException(request, denial);
auditTrailService.get().accessDenied(requestId, authentication, action, request, authzInfo);
failureConsumer.accept(actionDenied(authentication, authzInfo, action, request, context, e));
failureConsumer.accept(denial);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
import org.elasticsearch.action.LatchedActionListener;
import org.elasticsearch.action.MockIndicesRequest;
import org.elasticsearch.action.OriginalIndices;
import org.elasticsearch.action.ResolvedIndexExpression;
import org.elasticsearch.action.ResolvedIndexExpressions;
import org.elasticsearch.action.admin.cluster.health.ClusterHealthRequest;
import org.elasticsearch.action.admin.cluster.health.TransportClusterHealthAction;
import org.elasticsearch.action.admin.indices.alias.Alias;
Expand Down Expand Up @@ -215,6 +217,8 @@
import java.util.function.Supplier;

import static java.util.Arrays.asList;
import static org.elasticsearch.action.ResolvedIndexExpression.LocalIndexResolutionResult.CONCRETE_RESOURCE_UNAUTHORIZED;
import static org.elasticsearch.action.ResolvedIndexExpression.LocalIndexResolutionResult.SUCCESS;
import static org.elasticsearch.test.ActionListenerUtils.anyActionListener;
import static org.elasticsearch.test.ActionListenerUtils.anyCollection;
import static org.elasticsearch.test.SecurityTestsUtils.assertAuthenticationException;
Expand Down Expand Up @@ -830,6 +834,11 @@ public void testUserWithNoRolesPerformsRemoteSearchWithScroll() {
public void testUserWithNoRolesCannotPerformLocalSearch() {
SearchRequest request = new SearchRequest();
request.indices("no_such_cluster:index");
request.setResolvedIndexExpressions(
new ResolvedIndexExpressions(
List.of(resolvedIndexExpression("_all", Set.of("no_such_cluster:index"), CONCRETE_RESOURCE_UNAUTHORIZED))
)
);
final Authentication authentication = createAuthentication(new User("test user"));
mockEmptyMetadata();
final String requestId = AuditUtil.getOrGenerateRequestId(threadContext);
Expand All @@ -846,6 +855,10 @@ public void testUserWithNoRolesCannotPerformLocalSearch() {
authzInfoRoles(Role.EMPTY.names())
);
verifyNoMoreInteractions(auditTrail);

final var authorizationException = request.getResolvedIndexExpressions().expressions().getFirst().localExpressions().exception();
assertThat(authorizationException, is(notNullValue()));
assertThat(authorizationException, instanceOf(ElasticsearchSecurityException.class));
}

/**
Expand All @@ -855,6 +868,14 @@ public void testUserWithNoRolesCannotPerformLocalSearch() {
public void testUserWithNoRolesCanPerformMultiClusterSearch() {
SearchRequest request = new SearchRequest();
request.indices("local_index", "wildcard_*", "other_cluster:remote_index", "*:foo?");
request.setResolvedIndexExpressions(
new ResolvedIndexExpressions(
List.of(
resolvedIndexExpression("local_index", Set.of("local_index"), CONCRETE_RESOURCE_UNAUTHORIZED),
resolvedIndexExpression("wildcard_*", Set.of("wildcard_*"), CONCRETE_RESOURCE_UNAUTHORIZED)
)
)
);
final Authentication authentication = createAuthentication(new User("test user"));
mockEmptyMetadata();
final String requestId = AuditUtil.getOrGenerateRequestId(threadContext);
Expand All @@ -871,6 +892,12 @@ public void testUserWithNoRolesCanPerformMultiClusterSearch() {
authzInfoRoles(Role.EMPTY.names())
);
verifyNoMoreInteractions(auditTrail);

request.getResolvedIndexExpressions().expressions().forEach(expression -> {
final var authorizationException = expression.localExpressions().exception();
assertThat(authorizationException, is(notNullValue()));
assertThat(authorizationException, instanceOf(ElasticsearchSecurityException.class));
});
}

public void testUserWithNoRolesCannotSql() {
Expand Down Expand Up @@ -1535,7 +1562,17 @@ public void testDenialErrorMessagesForSearchAction() {

AuditUtil.getOrGenerateRequestId(threadContext);

TransportRequest request = new SearchRequest("all-1", "read-2", "write-3", "other-4");
SearchRequest request = new SearchRequest("all-1", "read-2", "write-3", "other-4");
request.setResolvedIndexExpressions(
new ResolvedIndexExpressions(
List.of(
resolvedIndexExpression("all-1", Set.of("all-1"), SUCCESS),
resolvedIndexExpression("read-2", Set.of("read-2"), SUCCESS),
resolvedIndexExpression("write-3", Set.of("write-3"), CONCRETE_RESOURCE_UNAUTHORIZED),
resolvedIndexExpression("other-4", Set.of("other-4"), CONCRETE_RESOURCE_UNAUTHORIZED)
)
)
);

ElasticsearchSecurityException securityException = expectThrows(
ElasticsearchSecurityException.class,
Expand Down Expand Up @@ -1565,6 +1602,21 @@ public void testDenialErrorMessagesForSearchAction() {
assertThat(securityException, throwableWithMessage(not(containsString("all-1"))));
assertThat(securityException, throwableWithMessage(not(containsString("read-2"))));
assertThat(securityException, throwableWithMessage(containsString(", this action is granted by the index privileges [read,all]")));

final var expressions = request.getResolvedIndexExpressions().expressions();
var authorizationException = expressions.getFirst().localExpressions().exception();
assertThat(authorizationException, is(nullValue()));

authorizationException = expressions.get(1).localExpressions().exception();
assertThat(authorizationException, is(nullValue()));

authorizationException = expressions.get(2).localExpressions().exception();
assertThat(authorizationException, is(notNullValue()));
assertThat(authorizationException, instanceOf(ElasticsearchSecurityException.class));

authorizationException = expressions.get(3).localExpressions().exception();
assertThat(authorizationException, is(notNullValue()));
assertThat(authorizationException, instanceOf(ElasticsearchSecurityException.class));
}

public void testDenialErrorMessagesForBulkIngest() throws Exception {
Expand Down Expand Up @@ -3245,7 +3297,10 @@ public void testProxyRequestFailsOnNonProxyRequest() {
}

public void testProxyRequestAuthenticationDenied() {
final TransportRequest proxiedRequest = new SearchRequest();
final SearchRequest proxiedRequest = new SearchRequest();
proxiedRequest.setResolvedIndexExpressions(
new ResolvedIndexExpressions(List.of(resolvedIndexExpression("_all", Set.of(), CONCRETE_RESOURCE_UNAUTHORIZED)))
);
final DiscoveryNode node = DiscoveryNodeUtils.create("foo");
final TransportRequest transportRequest = TransportActionProxy.wrapRequest(node, proxiedRequest);
final String action = TransportActionProxy.getProxyAction(SearchTransportService.QUERY_ACTION_NAME);
Expand All @@ -3263,6 +3318,14 @@ public void testProxyRequestAuthenticationDenied() {
authzInfoRoles(new String[] { role.getName() })
);
verifyNoMoreInteractions(auditTrail);

final var authorizationException = proxiedRequest.getResolvedIndexExpressions()
.expressions()
.getFirst()
.localExpressions()
.exception();
assertThat(authorizationException, is(notNullValue()));
assertThat(authorizationException, instanceOf(ElasticsearchSecurityException.class));
}

public void testProxyRequestAuthenticationGrantedWithAllPrivileges() {
Expand Down Expand Up @@ -3742,4 +3805,16 @@ public String getWriteableName() {
@Override
public void writeTo(StreamOutput out) throws IOException {}
}

private static ResolvedIndexExpression resolvedIndexExpression(
String original,
Set<String> localExpressions,
ResolvedIndexExpression.LocalIndexResolutionResult localIndexResolutionResult
) {
return new ResolvedIndexExpression(
original,
new ResolvedIndexExpression.LocalExpressions(localExpressions, localIndexResolutionResult, null),
Set.of()
);
}
}