Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
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;
}

@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()
);
}
}