diff --git a/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpression.java b/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpression.java index ee18470237bc3..2c441b4b45b4f 100644 --- a/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpression.java +++ b/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpression.java @@ -16,6 +16,7 @@ import org.elasticsearch.core.Nullable; import java.io.IOException; +import java.util.Objects; import java.util.Set; /** @@ -76,14 +77,72 @@ public enum LocalIndexResolutionResult { /** * Represents local (non-remote) resolution results, including expanded indices, and a {@link LocalIndexResolutionResult}. */ - public record LocalExpressions( - Set expressions, - LocalIndexResolutionResult localIndexResolutionResult, - @Nullable ElasticsearchException exception - ) implements Writeable { - public LocalExpressions { + public static final class LocalExpressions implements Writeable { + private final Set expressions; + private final LocalIndexResolutionResult localIndexResolutionResult; + @Nullable + private ElasticsearchException exception; + + public LocalExpressions( + Set 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 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 + + ']'; } // Singleton for the case where all expressions in a ResolvedIndexExpression instance are remote diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/AuthorizationService.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/AuthorizationService.java index 9297fce4326ca..1a7f7f70bf598 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/AuthorizationService.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/AuthorizationService.java @@ -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; @@ -108,6 +109,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; @@ -1022,6 +1024,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, @@ -1127,8 +1142,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); } } diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/AuthorizationServiceTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/AuthorizationServiceTests.java index cb11095a5aad8..c24004215058c 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/AuthorizationServiceTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/AuthorizationServiceTests.java @@ -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; @@ -216,6 +218,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; @@ -832,6 +836,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); @@ -848,6 +857,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)); } /** @@ -857,6 +870,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); @@ -873,6 +894,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() { @@ -1537,7 +1564,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, @@ -1567,6 +1604,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 { @@ -3249,7 +3301,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); @@ -3267,6 +3322,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() { @@ -3748,4 +3811,16 @@ public String getWriteableName() { @Override public void writeTo(StreamOutput out) throws IOException {} } + + private static ResolvedIndexExpression resolvedIndexExpression( + String original, + Set localExpressions, + ResolvedIndexExpression.LocalIndexResolutionResult localIndexResolutionResult + ) { + return new ResolvedIndexExpression( + original, + new ResolvedIndexExpression.LocalExpressions(localExpressions, localIndexResolutionResult, null), + Set.of() + ); + } }