From 11e955a0c6ccdb6333cc5ddb0e3e1f191ad721ae Mon Sep 17 00:00:00 2001 From: Nikolaj Volgushev Date: Mon, 15 Sep 2025 12:09:40 +0200 Subject: [PATCH 01/89] WIP CPS authz service --- .../main/java/org/elasticsearch/action/IndicesRequest.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/server/src/main/java/org/elasticsearch/action/IndicesRequest.java b/server/src/main/java/org/elasticsearch/action/IndicesRequest.java index 176ecbc7a2395..15ad2707ff4af 100644 --- a/server/src/main/java/org/elasticsearch/action/IndicesRequest.java +++ b/server/src/main/java/org/elasticsearch/action/IndicesRequest.java @@ -62,6 +62,10 @@ interface Replaceable extends IndicesRequest { default boolean allowsRemoteIndices() { return false; } + + default boolean allowsCrossProjectSearch() { + return false; + } } /** From 23ed699adc6b99f64475a7806c9dffe4c9d13f6b Mon Sep 17 00:00:00 2001 From: Nikolaj Volgushev Date: Mon, 15 Sep 2025 14:44:53 +0200 Subject: [PATCH 02/89] WIP injection --- .../core/security/SecurityExtension.java | 5 + ...ProjectSearchIndexExpressionsRewriter.java | 22 ++++ .../xpack/security/Security.java | 18 ++- .../security/authz/AuthorizationService.java | 113 +++++++++++++----- 4 files changed, 130 insertions(+), 28 deletions(-) create mode 100644 x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/CrossProjectSearchIndexExpressionsRewriter.java diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/SecurityExtension.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/SecurityExtension.java index 449246fbb5c92..e2a14c05d9e98 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/SecurityExtension.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/SecurityExtension.java @@ -21,6 +21,7 @@ import org.elasticsearch.xpack.core.security.authc.service.ServiceAccountTokenStore; import org.elasticsearch.xpack.core.security.authc.support.UserRoleMapper; import org.elasticsearch.xpack.core.security.authz.AuthorizationEngine; +import org.elasticsearch.xpack.core.security.authz.CrossProjectSearchIndexExpressionsRewriter; import org.elasticsearch.xpack.core.security.authz.RoleDescriptor; import org.elasticsearch.xpack.core.security.authz.store.RoleRetrievalResult; @@ -148,4 +149,8 @@ default AuthorizationEngine getAuthorizationEngine(Settings settings) { default String extensionName() { return getClass().getName(); } + + default CrossProjectSearchIndexExpressionsRewriter getCrossProjectSearchIndexExpressionsRewriter(SecurityComponents components) { + return null; + } } diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/CrossProjectSearchIndexExpressionsRewriter.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/CrossProjectSearchIndexExpressionsRewriter.java new file mode 100644 index 0000000000000..19fb51e8d118b --- /dev/null +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/CrossProjectSearchIndexExpressionsRewriter.java @@ -0,0 +1,22 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.core.security.authz; + +import org.elasticsearch.action.ActionListener; +import org.elasticsearch.action.IndicesRequest; + +public interface CrossProjectSearchIndexExpressionsRewriter { + void rewriteIndexExpressions(IndicesRequest.Replaceable request, ActionListener listener); + + class Default implements CrossProjectSearchIndexExpressionsRewriter { + @Override + public void rewriteIndexExpressions(IndicesRequest.Replaceable request, ActionListener listener) { + listener.onResponse(null); + } + } +} diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java index c9f0771e93068..4b31229310326 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java @@ -208,6 +208,7 @@ import org.elasticsearch.xpack.core.security.authc.support.UserRoleMapper; import org.elasticsearch.xpack.core.security.authc.support.UsernamePasswordToken; import org.elasticsearch.xpack.core.security.authz.AuthorizationEngine; +import org.elasticsearch.xpack.core.security.authz.CrossProjectSearchIndexExpressionsRewriter; import org.elasticsearch.xpack.core.security.authz.RestrictedIndices; import org.elasticsearch.xpack.core.security.authz.RoleDescriptor; import org.elasticsearch.xpack.core.security.authz.accesscontrol.DocumentSubsetBitsetCache; @@ -1131,6 +1132,9 @@ Collection createComponents( if (authorizationDenialMessages.get() == null) { authorizationDenialMessages.set(new AuthorizationDenialMessages.Default()); } + + final CrossProjectSearchIndexExpressionsRewriter crossProjectSearchIndexExpressionsRewriter = + getCrossProjectSearchIndexExpressionsRewriter(extensionComponents); final AuthorizationService authzService = new AuthorizationService( settings, allRolesStore, @@ -1148,7 +1152,10 @@ Collection createComponents( restrictedIndices, authorizationDenialMessages.get(), linkedProjectConfigService, - projectResolver + projectResolver, + crossProjectSearchIndexExpressionsRewriter == null + ? new CrossProjectSearchIndexExpressionsRewriter.Default() + : crossProjectSearchIndexExpressionsRewriter ); components.add(nativeRolesStore); // used by roles actions @@ -1290,6 +1297,15 @@ private List getCustomAuthenticatorFromExtensions(SecurityE } } + private CrossProjectSearchIndexExpressionsRewriter getCrossProjectSearchIndexExpressionsRewriter( + SecurityExtension.SecurityComponents extensionComponents + ) { + return findValueFromExtensions( + "cross-project search index expressions rewriter", + extension -> extension.getCrossProjectSearchIndexExpressionsRewriter(extensionComponents) + ); + } + private ServiceAccountService createServiceAccountService( List components, CacheInvalidatorRegistry cacheInvalidatorRegistry, 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 0d0ffc43229e4..b219e607774fd 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; @@ -70,6 +71,7 @@ import org.elasticsearch.xpack.core.security.authz.AuthorizationEngine.ParentActionAuthorization; import org.elasticsearch.xpack.core.security.authz.AuthorizationEngine.RequestInfo; import org.elasticsearch.xpack.core.security.authz.AuthorizationServiceField; +import org.elasticsearch.xpack.core.security.authz.CrossProjectSearchIndexExpressionsRewriter; import org.elasticsearch.xpack.core.security.authz.ResolvedIndices; import org.elasticsearch.xpack.core.security.authz.RestrictedIndices; import org.elasticsearch.xpack.core.security.authz.RoleDescriptorsIntersection; @@ -148,6 +150,7 @@ public class AuthorizationService { private final boolean isAnonymousEnabled; private final boolean anonymousAuthzExceptionEnabled; private final DlsFlsFeatureTrackingIndicesAccessControlWrapper indicesAccessControlWrapper; + private final CrossProjectSearchIndexExpressionsRewriter crossProjectSearchIndexExpressionsRewriter; public AuthorizationService( Settings settings, @@ -167,6 +170,48 @@ public AuthorizationService( AuthorizationDenialMessages authorizationDenialMessages, LinkedProjectConfigService linkedProjectConfigService, ProjectResolver projectResolver + ) { + this( + settings, + rolesStore, + fieldPermissionsCache, + clusterService, + auditTrailService, + authcFailureHandler, + threadPool, + anonymousUser, + authorizationEngine, + requestInterceptors, + licenseState, + resolver, + operatorPrivilegesService, + restrictedIndices, + authorizationDenialMessages, + linkedProjectConfigService, + projectResolver, + new CrossProjectSearchIndexExpressionsRewriter.Default() + ); + } + + public AuthorizationService( + Settings settings, + CompositeRolesStore rolesStore, + FieldPermissionsCache fieldPermissionsCache, + ClusterService clusterService, + AuditTrailService auditTrailService, + AuthenticationFailureHandler authcFailureHandler, + ThreadPool threadPool, + AnonymousUser anonymousUser, + @Nullable AuthorizationEngine authorizationEngine, + Set requestInterceptors, + XPackLicenseState licenseState, + IndexNameExpressionResolver resolver, + OperatorPrivilegesService operatorPrivilegesService, + RestrictedIndices restrictedIndices, + AuthorizationDenialMessages authorizationDenialMessages, + LinkedProjectConfigService linkedProjectConfigService, + ProjectResolver projectResolver, + CrossProjectSearchIndexExpressionsRewriter crossProjectSearchIndexExpressionsRewriter ) { this.clusterService = clusterService; this.auditTrailService = auditTrailService; @@ -192,6 +237,7 @@ public AuthorizationService( this.indicesAccessControlWrapper = new DlsFlsFeatureTrackingIndicesAccessControlWrapper(settings, licenseState); this.authorizationDenialMessages = authorizationDenialMessages; this.projectResolver = projectResolver; + this.crossProjectSearchIndexExpressionsRewriter = crossProjectSearchIndexExpressionsRewriter; } public void checkPrivileges( @@ -502,34 +548,47 @@ private void authorizeAction( requestInfo, authzInfo, projectMetadata.getIndicesLookup(), - ActionListener.wrap( - authorizedIndices -> resolvedIndicesListener.onResponse( - indicesAndAliasesResolver.resolve(action, request, projectMetadata, authorizedIndices) - ), - e -> { - if (e instanceof InvalidIndexNameException - || e instanceof InvalidSelectorException - || e instanceof UnsupportedSelectorException) { - logger.debug( - () -> Strings.format( - "failed [%s] action authorization for [%s] due to [%s] exception", - action, - authentication, - e.getClass().getSimpleName() - ), - e - ); - listener.onFailure(e); - return; - } - auditTrail.accessDenied(requestId, authentication, action, request, authzInfo); - if (e instanceof IndexNotFoundException) { - listener.onFailure(e); - } else { - listener.onFailure(actionDenied(authentication, authzInfo, action, request, e)); - } + ActionListener.wrap(authorizedIndices -> { + if (request instanceof IndicesRequest.Replaceable replaceable) { + crossProjectSearchIndexExpressionsRewriter.rewriteIndexExpressions(replaceable, new ActionListener() { + @Override + public void onResponse(Void unused) { + indicesAndAliasesResolver.resolve(action, request, projectMetadata, authorizedIndices); + } + + @Override + public void onFailure(Exception e) { + resolvedIndicesListener.onFailure(e); + } + }); + } else { + resolvedIndicesListener.onResponse( + indicesAndAliasesResolver.resolve(action, request, projectMetadata, authorizedIndices) + ); } - ) + }, e -> { + if (e instanceof InvalidIndexNameException + || e instanceof InvalidSelectorException + || e instanceof UnsupportedSelectorException) { + logger.debug( + () -> Strings.format( + "failed [%s] action authorization for [%s] due to [%s] exception", + action, + authentication, + e.getClass().getSimpleName() + ), + e + ); + listener.onFailure(e); + return; + } + auditTrail.accessDenied(requestId, authentication, action, request, authzInfo); + if (e instanceof IndexNotFoundException) { + listener.onFailure(e); + } else { + listener.onFailure(actionDenied(authentication, authzInfo, action, request, e)); + } + }) ); return resolvedIndicesListener; } From ba246362b6ad12772457e6434d1f4ad7c714099e Mon Sep 17 00:00:00 2001 From: Nikolaj Volgushev Date: Fri, 19 Sep 2025 14:13:31 +0200 Subject: [PATCH 03/89] Index resolutions record resolved expressions --- .../action/ResolvedIndexExpression.java | 7 +- .../action/ResolvedIndexExpressions.java | 7 +- .../metadata/IndexAbstractionResolver.java | 119 ++++++++++++++---- .../IndexAbstractionResolverTests.java | 4 +- .../authz/IndicesAndAliasesResolver.java | 6 +- 5 files changed, 110 insertions(+), 33 deletions(-) diff --git a/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpression.java b/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpression.java index 85a50679af9fd..36ba5e37d222c 100644 --- a/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpression.java +++ b/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpression.java @@ -41,12 +41,17 @@ * @param remoteExpressions the remote expressions that replace the original */ public record ResolvedIndexExpression(String original, LocalExpressions localExpressions, List remoteExpressions) { + + public ResolvedIndexExpression(String original, List localExpressions) { + this(original, new LocalExpressions(localExpressions, LocalIndexResolutionResult.SUCCESS, null), List.of()); + } + /** * Indicates if a local index resolution attempt was successful or failed. * Failures can be due to missing concrete resources or unauthorized concrete resources. * A wildcard expression resolving to nothing is still considered a successful resolution. */ - enum LocalIndexResolutionResult { + public enum LocalIndexResolutionResult { SUCCESS, CONCRETE_RESOURCE_MISSING, CONCRETE_RESOURCE_UNAUTHORIZED, diff --git a/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpressions.java b/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpressions.java index 418394f864a48..2e1b3ba18546f 100644 --- a/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpressions.java +++ b/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpressions.java @@ -9,6 +9,7 @@ package org.elasticsearch.action; +import java.util.List; import java.util.Map; /** @@ -37,4 +38,8 @@ * } * } */ -public record ResolvedIndexExpressions(Map expressions) {} +public record ResolvedIndexExpressions(Map expressions) { + public List getLocalIndicesList() { + return expressions.values().stream().flatMap(e -> e.localExpressions().expressions().stream()).toList(); + } +} diff --git a/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java b/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java index b7aad5676321e..42bfd234f5344 100644 --- a/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java +++ b/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java @@ -9,6 +9,8 @@ package org.elasticsearch.cluster.metadata; +import org.elasticsearch.action.ResolvedIndexExpression; +import org.elasticsearch.action.ResolvedIndexExpressions; import org.elasticsearch.action.support.IndexComponentSelector; import org.elasticsearch.action.support.IndicesOptions; import org.elasticsearch.action.support.UnsupportedSelectorException; @@ -20,8 +22,10 @@ import org.elasticsearch.indices.SystemIndices.SystemIndexAccessLevel; import java.util.ArrayList; -import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.function.BiPredicate; import java.util.function.Function; @@ -34,7 +38,7 @@ public IndexAbstractionResolver(IndexNameExpressionResolver indexNameExpressionR this.indexNameExpressionResolver = indexNameExpressionResolver; } - public List resolveIndexAbstractions( + public ResolvedIndexExpressions resolveIndexAbstractions( Iterable indices, IndicesOptions indicesOptions, ProjectMetadata projectMetadata, @@ -42,33 +46,38 @@ public List resolveIndexAbstractions( BiPredicate isAuthorized, boolean includeDataStreams ) { - List finalIndices = new ArrayList<>(); + final Map replaced = new LinkedHashMap<>(); + boolean wildcardSeen = false; - for (String index : indices) { + + for (String originalIndexExpression : indices) { String indexAbstraction; boolean minus = false; - if (index.charAt(0) == '-' && wildcardSeen) { - indexAbstraction = index.substring(1); + + if (originalIndexExpression.charAt(0) == '-' && wildcardSeen) { + indexAbstraction = originalIndexExpression.substring(1); minus = true; } else { - indexAbstraction = index; + indexAbstraction = originalIndexExpression; } - // Always check to see if there's a selector on the index expression - Tuple expressionAndSelector = IndexNameExpressionResolver.splitSelectorExpression(indexAbstraction); - String selectorString = expressionAndSelector.v2(); + final Tuple expressionAndSelector = IndexNameExpressionResolver.splitSelectorExpression(indexAbstraction); + final String selectorString = expressionAndSelector.v2(); if (indicesOptions.allowSelectors() == false && selectorString != null) { throw new UnsupportedSelectorException(indexAbstraction); } + indexAbstraction = expressionAndSelector.v1(); - IndexComponentSelector selector = IndexComponentSelector.getByKeyOrThrow(selectorString); + final IndexComponentSelector selector = IndexComponentSelector.getByKeyOrThrow(selectorString); - // we always need to check for date math expressions indexAbstraction = IndexNameExpressionResolver.resolveDateMathExpression(indexAbstraction); + final Set resolvedForThisInput = new LinkedHashSet<>(); + if (indicesOptions.expandWildcardExpressions() && Regex.isSimpleMatchPattern(indexAbstraction)) { wildcardSeen = true; - Set resolvedIndices = new HashSet<>(); + + final Set resolvedIndices = new LinkedHashSet<>(); for (String authorizedIndex : allAuthorizedAndAvailableBySelector.apply(selector)) { if (Regex.simpleMatch(indexAbstraction, authorizedIndex) && isIndexVisible( @@ -83,32 +92,68 @@ && isIndexVisible( resolveSelectorsAndCollect(authorizedIndex, selectorString, indicesOptions, resolvedIndices, projectMetadata); } } + if (resolvedIndices.isEmpty()) { - // es core honours allow_no_indices for each wildcard expression, we do the same here by throwing index not found. if (indicesOptions.allowNoIndices() == false) { throw new IndexNotFoundException(indexAbstraction); } - } else { - if (minus) { - finalIndices.removeAll(resolvedIndices); - } else { - finalIndices.addAll(resolvedIndices); + } + + if (minus) { + if (resolvedIndices.isEmpty() == false) { + for (ResolvedIndexExpression prior : replaced.values()) { + if (prior.localExpressions().expressions().isEmpty()) { + continue; + } + prior.localExpressions().expressions().removeAll(resolvedIndices); + } } + } else { + resolvedForThisInput.addAll(resolvedIndices); + replaced.put( + originalIndexExpression, + new ResolvedIndexExpression(originalIndexExpression, new ArrayList<>(resolvedForThisInput)) + ); } } else { - Set resolvedIndices = new HashSet<>(); + final Set resolvedIndices = new LinkedHashSet<>(); resolveSelectorsAndCollect(indexAbstraction, selectorString, indicesOptions, resolvedIndices, projectMetadata); + if (minus) { - finalIndices.removeAll(resolvedIndices); - } else if (indicesOptions.ignoreUnavailable() == false || isAuthorized.test(indexAbstraction, selector)) { - // Unauthorized names are considered unavailable, so if `ignoreUnavailable` is `true` they should be silently - // discarded from the `finalIndices` list. Other "ways of unavailable" must be handled by the action - // handler, see: https://github.com/elastic/elasticsearch/issues/90215 - finalIndices.addAll(resolvedIndices); + if (resolvedIndices.isEmpty() == false) { + for (ResolvedIndexExpression prior : replaced.values()) { + if (prior.localExpressions().expressions().isEmpty()) { + continue; + } + prior.localExpressions().expressions().removeAll(resolvedIndices); + } + } + } else { + // We should consider if this needs to be optimized to avoid checking authorization and existence here + final boolean authorized = isAuthorized.test(indexAbstraction, selector); + final boolean existsAndVisible = authorized + && existsAndVisible(indicesOptions, projectMetadata, includeDataStreams, indexAbstraction, selectorString); + final ResolvedIndexExpression.LocalIndexResolutionResult resolutionResult = existsAndVisible + ? ResolvedIndexExpression.LocalIndexResolutionResult.SUCCESS + : (authorized + ? ResolvedIndexExpression.LocalIndexResolutionResult.CONCRETE_RESOURCE_MISSING + : ResolvedIndexExpression.LocalIndexResolutionResult.CONCRETE_RESOURCE_UNAUTHORIZED); + if (indicesOptions.ignoreUnavailable() == false || authorized) { + resolvedForThisInput.addAll(resolvedIndices); + } + replaced.put( + originalIndexExpression, + new ResolvedIndexExpression( + originalIndexExpression, + new ResolvedIndexExpression.LocalExpressions(new ArrayList<>(resolvedForThisInput), resolutionResult, null), + List.of() + ) + ); } } } - return finalIndices; + + return new ResolvedIndexExpressions(replaced); } private static void resolveSelectorsAndCollect( @@ -260,4 +305,24 @@ private static boolean isSystemIndexVisible(IndexNameExpressionResolver resolver private static boolean isVisibleDueToImplicitHidden(String expression, String index) { return index.startsWith(".") && expression.startsWith(".") && Regex.isSimpleMatchPattern(expression); } + + private boolean existsAndVisible( + IndicesOptions indicesOptions, + ProjectMetadata projectMetadata, + boolean includeDataStreams, + String indexAbstraction, + String selectorString + ) { + var abstraction = projectMetadata.getIndicesLookup().get(indexAbstraction); + return abstraction != null + && isIndexVisible( + indexAbstraction, + selectorString, + indexAbstraction, + indicesOptions, + projectMetadata, + indexNameExpressionResolver, + includeDataStreams + ); + } } diff --git a/server/src/test/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolverTests.java b/server/src/test/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolverTests.java index 43e7f2575384e..14fb42463136d 100644 --- a/server/src/test/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolverTests.java +++ b/server/src/test/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolverTests.java @@ -369,9 +369,9 @@ private List resolveAbstractions(List expressions, IndicesOption indicesOptions, projectMetadata, (ignored) -> mask, - (ignored, nothing) -> true, + (ignored1, ignored2) -> true, true - ); + ).getLocalIndicesList(); } private boolean isIndexVisible(String index, String selector) { diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java index 8ff69db0b2cf8..8537aa4428683 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java @@ -8,6 +8,7 @@ import org.elasticsearch.action.AliasesRequest; import org.elasticsearch.action.IndicesRequest; +import org.elasticsearch.action.ResolvedIndexExpressions; import org.elasticsearch.action.admin.indices.alias.IndicesAliasesRequest; import org.elasticsearch.action.admin.indices.alias.get.GetAliasesRequest; import org.elasticsearch.action.admin.indices.mapping.put.PutMappingRequest; @@ -348,7 +349,7 @@ ResolvedIndices resolveIndicesAndAliases( } else { split = new ResolvedIndices(Arrays.asList(indicesRequest.indices()), Collections.emptyList()); } - List replaced = indexAbstractionResolver.resolveIndexAbstractions( + final ResolvedIndexExpressions resolved = indexAbstractionResolver.resolveIndexAbstractions( split.getLocal(), indicesOptions, projectMetadata, @@ -356,7 +357,8 @@ ResolvedIndices resolveIndicesAndAliases( authorizedIndices::check, indicesRequest.includeDataStreams() ); - resolvedIndicesBuilder.addLocal(replaced); + replaceable.setResolvedIndexExpressions(resolved); + resolvedIndicesBuilder.addLocal(resolved.getLocalIndicesList()); resolvedIndicesBuilder.addRemote(split.getRemote()); } From dd1dbe056a8c99ab32572c18dbcc5ad930748d21 Mon Sep 17 00:00:00 2001 From: Nikolaj Volgushev Date: Fri, 19 Sep 2025 14:24:51 +0200 Subject: [PATCH 04/89] Smaller diff --- .../metadata/IndexAbstractionResolver.java | 42 +++++++++---------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java b/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java index 42bfd234f5344..139e3d6160928 100644 --- a/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java +++ b/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java @@ -22,8 +22,8 @@ import org.elasticsearch.indices.SystemIndices.SystemIndexAccessLevel; import java.util.ArrayList; +import java.util.HashSet; import java.util.LinkedHashMap; -import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Set; @@ -46,38 +46,40 @@ public ResolvedIndexExpressions resolveIndexAbstractions( BiPredicate isAuthorized, boolean includeDataStreams ) { - final Map replaced = new LinkedHashMap<>(); + Map replaced = new LinkedHashMap<>(); boolean wildcardSeen = false; - for (String originalIndexExpression : indices) { + for (String index : indices) { String indexAbstraction; boolean minus = false; - if (originalIndexExpression.charAt(0) == '-' && wildcardSeen) { - indexAbstraction = originalIndexExpression.substring(1); + if (index.charAt(0) == '-' && wildcardSeen) { + indexAbstraction = index.substring(1); minus = true; } else { - indexAbstraction = originalIndexExpression; + indexAbstraction = index; } - final Tuple expressionAndSelector = IndexNameExpressionResolver.splitSelectorExpression(indexAbstraction); - final String selectorString = expressionAndSelector.v2(); + // Always check to see if there's a selector on the index expression + Tuple expressionAndSelector = IndexNameExpressionResolver.splitSelectorExpression(indexAbstraction); + String selectorString = expressionAndSelector.v2(); if (indicesOptions.allowSelectors() == false && selectorString != null) { throw new UnsupportedSelectorException(indexAbstraction); } indexAbstraction = expressionAndSelector.v1(); - final IndexComponentSelector selector = IndexComponentSelector.getByKeyOrThrow(selectorString); + IndexComponentSelector selector = IndexComponentSelector.getByKeyOrThrow(selectorString); + // we always need to check for date math expressions indexAbstraction = IndexNameExpressionResolver.resolveDateMathExpression(indexAbstraction); - final Set resolvedForThisInput = new LinkedHashSet<>(); + Set resolvedForThisInput = new HashSet<>(); if (indicesOptions.expandWildcardExpressions() && Regex.isSimpleMatchPattern(indexAbstraction)) { wildcardSeen = true; + Set resolvedIndices = new HashSet<>(); - final Set resolvedIndices = new LinkedHashSet<>(); for (String authorizedIndex : allAuthorizedAndAvailableBySelector.apply(selector)) { if (Regex.simpleMatch(indexAbstraction, authorizedIndex) && isIndexVisible( @@ -94,6 +96,7 @@ && isIndexVisible( } if (resolvedIndices.isEmpty()) { + // es core honours allow_no_indices for each wildcard expression, we do the same here by throwing index not found. if (indicesOptions.allowNoIndices() == false) { throw new IndexNotFoundException(indexAbstraction); } @@ -110,13 +113,10 @@ && isIndexVisible( } } else { resolvedForThisInput.addAll(resolvedIndices); - replaced.put( - originalIndexExpression, - new ResolvedIndexExpression(originalIndexExpression, new ArrayList<>(resolvedForThisInput)) - ); + replaced.put(index, new ResolvedIndexExpression(index, new ArrayList<>(resolvedForThisInput))); } } else { - final Set resolvedIndices = new LinkedHashSet<>(); + Set resolvedIndices = new HashSet<>(); resolveSelectorsAndCollect(indexAbstraction, selectorString, indicesOptions, resolvedIndices, projectMetadata); if (minus) { @@ -130,10 +130,10 @@ && isIndexVisible( } } else { // We should consider if this needs to be optimized to avoid checking authorization and existence here - final boolean authorized = isAuthorized.test(indexAbstraction, selector); - final boolean existsAndVisible = authorized + boolean authorized = isAuthorized.test(indexAbstraction, selector); + boolean existsAndVisible = authorized && existsAndVisible(indicesOptions, projectMetadata, includeDataStreams, indexAbstraction, selectorString); - final ResolvedIndexExpression.LocalIndexResolutionResult resolutionResult = existsAndVisible + ResolvedIndexExpression.LocalIndexResolutionResult resolutionResult = existsAndVisible ? ResolvedIndexExpression.LocalIndexResolutionResult.SUCCESS : (authorized ? ResolvedIndexExpression.LocalIndexResolutionResult.CONCRETE_RESOURCE_MISSING @@ -142,9 +142,9 @@ && isIndexVisible( resolvedForThisInput.addAll(resolvedIndices); } replaced.put( - originalIndexExpression, + index, new ResolvedIndexExpression( - originalIndexExpression, + index, new ResolvedIndexExpression.LocalExpressions(new ArrayList<>(resolvedForThisInput), resolutionResult, null), List.of() ) From a363c19073918218a9df1cc058b33f15a0e2f582 Mon Sep 17 00:00:00 2001 From: Nikolaj Volgushev Date: Fri, 19 Sep 2025 14:35:21 +0200 Subject: [PATCH 05/89] Clean up --- .../metadata/IndexAbstractionResolver.java | 60 ++++++++----------- 1 file changed, 26 insertions(+), 34 deletions(-) diff --git a/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java b/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java index 139e3d6160928..d17cb15446e09 100644 --- a/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java +++ b/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java @@ -46,7 +46,7 @@ public ResolvedIndexExpressions resolveIndexAbstractions( BiPredicate isAuthorized, boolean includeDataStreams ) { - Map replaced = new LinkedHashMap<>(); + Map resolvedIndexExpressions = new LinkedHashMap<>(); boolean wildcardSeen = false; @@ -67,19 +67,16 @@ public ResolvedIndexExpressions resolveIndexAbstractions( if (indicesOptions.allowSelectors() == false && selectorString != null) { throw new UnsupportedSelectorException(indexAbstraction); } - indexAbstraction = expressionAndSelector.v1(); IndexComponentSelector selector = IndexComponentSelector.getByKeyOrThrow(selectorString); // we always need to check for date math expressions indexAbstraction = IndexNameExpressionResolver.resolveDateMathExpression(indexAbstraction); - Set resolvedForThisInput = new HashSet<>(); - + Set resolvedForThisExpression = new HashSet<>(); if (indicesOptions.expandWildcardExpressions() && Regex.isSimpleMatchPattern(indexAbstraction)) { wildcardSeen = true; Set resolvedIndices = new HashSet<>(); - for (String authorizedIndex : allAuthorizedAndAvailableBySelector.apply(selector)) { if (Regex.simpleMatch(indexAbstraction, authorizedIndex) && isIndexVisible( @@ -94,42 +91,25 @@ && isIndexVisible( resolveSelectorsAndCollect(authorizedIndex, selectorString, indicesOptions, resolvedIndices, projectMetadata); } } - if (resolvedIndices.isEmpty()) { // es core honours allow_no_indices for each wildcard expression, we do the same here by throwing index not found. if (indicesOptions.allowNoIndices() == false) { throw new IndexNotFoundException(indexAbstraction); } - } - - if (minus) { - if (resolvedIndices.isEmpty() == false) { - for (ResolvedIndexExpression prior : replaced.values()) { - if (prior.localExpressions().expressions().isEmpty()) { - continue; - } - prior.localExpressions().expressions().removeAll(resolvedIndices); - } - } } else { - resolvedForThisInput.addAll(resolvedIndices); - replaced.put(index, new ResolvedIndexExpression(index, new ArrayList<>(resolvedForThisInput))); + if (minus) { + exclude(resolvedIndices, resolvedIndexExpressions); + } else { + resolvedForThisExpression.addAll(resolvedIndices); + resolvedIndexExpressions.put(index, new ResolvedIndexExpression(index, new ArrayList<>(resolvedForThisExpression))); + } } } else { Set resolvedIndices = new HashSet<>(); resolveSelectorsAndCollect(indexAbstraction, selectorString, indicesOptions, resolvedIndices, projectMetadata); - if (minus) { - if (resolvedIndices.isEmpty() == false) { - for (ResolvedIndexExpression prior : replaced.values()) { - if (prior.localExpressions().expressions().isEmpty()) { - continue; - } - prior.localExpressions().expressions().removeAll(resolvedIndices); - } - } + exclude(resolvedIndices, resolvedIndexExpressions); } else { - // We should consider if this needs to be optimized to avoid checking authorization and existence here boolean authorized = isAuthorized.test(indexAbstraction, selector); boolean existsAndVisible = authorized && existsAndVisible(indicesOptions, projectMetadata, includeDataStreams, indexAbstraction, selectorString); @@ -139,13 +119,17 @@ && isIndexVisible( ? ResolvedIndexExpression.LocalIndexResolutionResult.CONCRETE_RESOURCE_MISSING : ResolvedIndexExpression.LocalIndexResolutionResult.CONCRETE_RESOURCE_UNAUTHORIZED); if (indicesOptions.ignoreUnavailable() == false || authorized) { - resolvedForThisInput.addAll(resolvedIndices); + resolvedForThisExpression.addAll(resolvedIndices); } - replaced.put( + resolvedIndexExpressions.put( index, new ResolvedIndexExpression( index, - new ResolvedIndexExpression.LocalExpressions(new ArrayList<>(resolvedForThisInput), resolutionResult, null), + new ResolvedIndexExpression.LocalExpressions( + new ArrayList<>(resolvedForThisExpression), + resolutionResult, + null + ), List.of() ) ); @@ -153,7 +137,7 @@ && isIndexVisible( } } - return new ResolvedIndexExpressions(replaced); + return new ResolvedIndexExpressions(resolvedIndexExpressions); } private static void resolveSelectorsAndCollect( @@ -313,7 +297,7 @@ private boolean existsAndVisible( String indexAbstraction, String selectorString ) { - var abstraction = projectMetadata.getIndicesLookup().get(indexAbstraction); + final IndexAbstraction abstraction = projectMetadata.getIndicesLookup().get(indexAbstraction); return abstraction != null && isIndexVisible( indexAbstraction, @@ -325,4 +309,12 @@ && isIndexVisible( includeDataStreams ); } + + private static void exclude(Set expressionsToExclude, Map replaced) { + if (expressionsToExclude.isEmpty() == false) { + for (ResolvedIndexExpression prior : replaced.values()) { + prior.localExpressions().expressions().removeAll(expressionsToExclude); + } + } + } } From 103e0448a7a74a383441cb4f4971520290446ca6 Mon Sep 17 00:00:00 2001 From: Nikolaj Volgushev Date: Fri, 19 Sep 2025 14:46:15 +0200 Subject: [PATCH 06/89] Clean up --- .../cluster/metadata/IndexAbstractionResolver.java | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java b/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java index d17cb15446e09..7cede6ceb72f6 100644 --- a/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java +++ b/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java @@ -73,7 +73,6 @@ public ResolvedIndexExpressions resolveIndexAbstractions( // we always need to check for date math expressions indexAbstraction = IndexNameExpressionResolver.resolveDateMathExpression(indexAbstraction); - Set resolvedForThisExpression = new HashSet<>(); if (indicesOptions.expandWildcardExpressions() && Regex.isSimpleMatchPattern(indexAbstraction)) { wildcardSeen = true; Set resolvedIndices = new HashSet<>(); @@ -100,8 +99,7 @@ && isIndexVisible( if (minus) { exclude(resolvedIndices, resolvedIndexExpressions); } else { - resolvedForThisExpression.addAll(resolvedIndices); - resolvedIndexExpressions.put(index, new ResolvedIndexExpression(index, new ArrayList<>(resolvedForThisExpression))); + resolvedIndexExpressions.put(index, new ResolvedIndexExpression(index, new ArrayList<>(resolvedIndices))); } } } else { @@ -118,25 +116,21 @@ && isIndexVisible( : (authorized ? ResolvedIndexExpression.LocalIndexResolutionResult.CONCRETE_RESOURCE_MISSING : ResolvedIndexExpression.LocalIndexResolutionResult.CONCRETE_RESOURCE_UNAUTHORIZED); + List finalIndices = new ArrayList<>(); if (indicesOptions.ignoreUnavailable() == false || authorized) { - resolvedForThisExpression.addAll(resolvedIndices); + finalIndices.addAll(resolvedIndices); } resolvedIndexExpressions.put( index, new ResolvedIndexExpression( index, - new ResolvedIndexExpression.LocalExpressions( - new ArrayList<>(resolvedForThisExpression), - resolutionResult, - null - ), + new ResolvedIndexExpression.LocalExpressions(finalIndices, resolutionResult, null), List.of() ) ); } } } - return new ResolvedIndexExpressions(resolvedIndexExpressions); } From 17c50c7e0c3e76c4a83109a3b8328a7fa82dcdbf Mon Sep 17 00:00:00 2001 From: Nikolaj Volgushev Date: Fri, 19 Sep 2025 14:56:39 +0200 Subject: [PATCH 07/89] Another clean up round --- .../metadata/IndexAbstractionResolver.java | 32 ++++++++++--------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java b/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java index 7cede6ceb72f6..abd71819013c9 100644 --- a/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java +++ b/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java @@ -10,6 +10,7 @@ package org.elasticsearch.cluster.metadata; import org.elasticsearch.action.ResolvedIndexExpression; +import org.elasticsearch.action.ResolvedIndexExpression.LocalIndexResolutionResult; import org.elasticsearch.action.ResolvedIndexExpressions; import org.elasticsearch.action.support.IndexComponentSelector; import org.elasticsearch.action.support.IndicesOptions; @@ -30,6 +31,10 @@ import java.util.function.BiPredicate; import java.util.function.Function; +import static org.elasticsearch.action.ResolvedIndexExpression.LocalIndexResolutionResult.CONCRETE_RESOURCE_MISSING; +import static org.elasticsearch.action.ResolvedIndexExpression.LocalIndexResolutionResult.CONCRETE_RESOURCE_UNAUTHORIZED; +import static org.elasticsearch.action.ResolvedIndexExpression.LocalIndexResolutionResult.SUCCESS; + public class IndexAbstractionResolver { private final IndexNameExpressionResolver indexNameExpressionResolver; @@ -49,11 +54,9 @@ public ResolvedIndexExpressions resolveIndexAbstractions( Map resolvedIndexExpressions = new LinkedHashMap<>(); boolean wildcardSeen = false; - for (String index : indices) { String indexAbstraction; boolean minus = false; - if (index.charAt(0) == '-' && wildcardSeen) { indexAbstraction = index.substring(1); minus = true; @@ -109,22 +112,21 @@ && isIndexVisible( exclude(resolvedIndices, resolvedIndexExpressions); } else { boolean authorized = isAuthorized.test(indexAbstraction, selector); - boolean existsAndVisible = authorized + boolean visible = authorized && existsAndVisible(indicesOptions, projectMetadata, includeDataStreams, indexAbstraction, selectorString); - ResolvedIndexExpression.LocalIndexResolutionResult resolutionResult = existsAndVisible - ? ResolvedIndexExpression.LocalIndexResolutionResult.SUCCESS - : (authorized - ? ResolvedIndexExpression.LocalIndexResolutionResult.CONCRETE_RESOURCE_MISSING - : ResolvedIndexExpression.LocalIndexResolutionResult.CONCRETE_RESOURCE_UNAUTHORIZED); - List finalIndices = new ArrayList<>(); - if (indicesOptions.ignoreUnavailable() == false || authorized) { - finalIndices.addAll(resolvedIndices); - } + + LocalIndexResolutionResult result = authorized + ? (visible ? SUCCESS : CONCRETE_RESOURCE_MISSING) + : CONCRETE_RESOURCE_UNAUTHORIZED; + + boolean includeIndices = authorized || (indicesOptions.ignoreUnavailable() == false); + List finalIndices = includeIndices ? new ArrayList<>(resolvedIndices) : new ArrayList<>(); + resolvedIndexExpressions.put( index, new ResolvedIndexExpression( index, - new ResolvedIndexExpression.LocalExpressions(finalIndices, resolutionResult, null), + new ResolvedIndexExpression.LocalExpressions(finalIndices, result, null), List.of() ) ); @@ -304,9 +306,9 @@ && isIndexVisible( ); } - private static void exclude(Set expressionsToExclude, Map replaced) { + private static void exclude(Set expressionsToExclude, Map resolvedIndexExpressions) { if (expressionsToExclude.isEmpty() == false) { - for (ResolvedIndexExpression prior : replaced.values()) { + for (ResolvedIndexExpression prior : resolvedIndexExpressions.values()) { prior.localExpressions().expressions().removeAll(expressionsToExclude); } } From 8a5861c5993b2d4735cc6350991bab4d5c0c6b46 Mon Sep 17 00:00:00 2001 From: Nikolaj Volgushev Date: Fri, 19 Sep 2025 15:21:26 +0200 Subject: [PATCH 08/89] Use a builder --- .../action/ResolvedIndexExpressions.java | 43 +++++++++++++++++++ .../metadata/IndexAbstractionResolver.java | 25 +++-------- 2 files changed, 50 insertions(+), 18 deletions(-) diff --git a/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpressions.java b/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpressions.java index 2e1b3ba18546f..49bea31968fa7 100644 --- a/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpressions.java +++ b/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpressions.java @@ -9,8 +9,11 @@ package org.elasticsearch.action; +import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Set; /** * A collection of {@link ResolvedIndexExpression}, keyed by the original expression. @@ -39,7 +42,47 @@ * } */ public record ResolvedIndexExpressions(Map expressions) { + public List getLocalIndicesList() { return expressions.values().stream().flatMap(e -> e.localExpressions().expressions().stream()).toList(); } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + private final Map expressions = new LinkedHashMap<>(); + + public void putSuccessfulLocalExpression(String original, Set localExpression) { + putLocalExpression(original, localExpression, ResolvedIndexExpression.LocalIndexResolutionResult.SUCCESS); + } + + public void putLocalExpression( + String original, + Set localExpression, + ResolvedIndexExpression.LocalIndexResolutionResult resolutionResult + ) { + expressions.put( + original, + new ResolvedIndexExpression( + original, + new ResolvedIndexExpression.LocalExpressions(new ArrayList<>(localExpression), resolutionResult, null), + new ArrayList<>() + ) + ); + } + + public void excludeAll(Set expressionsToExclude) { + if (expressionsToExclude.isEmpty() == false) { + for (ResolvedIndexExpression prior : expressions.values()) { + prior.localExpressions().expressions().removeAll(expressionsToExclude); + } + } + } + + public ResolvedIndexExpressions build() { + return new ResolvedIndexExpressions(Map.copyOf(expressions)); + } + } } diff --git a/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java b/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java index abd71819013c9..caecf29705322 100644 --- a/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java +++ b/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java @@ -22,10 +22,7 @@ import org.elasticsearch.index.IndexNotFoundException; import org.elasticsearch.indices.SystemIndices.SystemIndexAccessLevel; -import java.util.ArrayList; import java.util.HashSet; -import java.util.LinkedHashMap; -import java.util.List; import java.util.Map; import java.util.Set; import java.util.function.BiPredicate; @@ -51,7 +48,7 @@ public ResolvedIndexExpressions resolveIndexAbstractions( BiPredicate isAuthorized, boolean includeDataStreams ) { - Map resolvedIndexExpressions = new LinkedHashMap<>(); + ResolvedIndexExpressions.Builder resolvedIndexExpressions = ResolvedIndexExpressions.builder(); boolean wildcardSeen = false; for (String index : indices) { @@ -100,16 +97,16 @@ && isIndexVisible( } } else { if (minus) { - exclude(resolvedIndices, resolvedIndexExpressions); + resolvedIndexExpressions.excludeAll(resolvedIndices); } else { - resolvedIndexExpressions.put(index, new ResolvedIndexExpression(index, new ArrayList<>(resolvedIndices))); + resolvedIndexExpressions.putSuccessfulLocalExpression(index, resolvedIndices); } } } else { Set resolvedIndices = new HashSet<>(); resolveSelectorsAndCollect(indexAbstraction, selectorString, indicesOptions, resolvedIndices, projectMetadata); if (minus) { - exclude(resolvedIndices, resolvedIndexExpressions); + resolvedIndexExpressions.excludeAll(resolvedIndices); } else { boolean authorized = isAuthorized.test(indexAbstraction, selector); boolean visible = authorized @@ -120,20 +117,12 @@ && isIndexVisible( : CONCRETE_RESOURCE_UNAUTHORIZED; boolean includeIndices = authorized || (indicesOptions.ignoreUnavailable() == false); - List finalIndices = includeIndices ? new ArrayList<>(resolvedIndices) : new ArrayList<>(); - - resolvedIndexExpressions.put( - index, - new ResolvedIndexExpression( - index, - new ResolvedIndexExpression.LocalExpressions(finalIndices, result, null), - List.of() - ) - ); + Set finalIndices = includeIndices ? resolvedIndices : Set.of(); + resolvedIndexExpressions.putLocalExpression(index, finalIndices, result); } } } - return new ResolvedIndexExpressions(resolvedIndexExpressions); + return resolvedIndexExpressions.build(); } private static void resolveSelectorsAndCollect( From 777a8bcf80a67e224e2a80db3633a87cd2467d40 Mon Sep 17 00:00:00 2001 From: Nikolaj Volgushev Date: Fri, 19 Sep 2025 15:24:40 +0200 Subject: [PATCH 09/89] Remove unused exclude --- .../cluster/metadata/IndexAbstractionResolver.java | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java b/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java index caecf29705322..4bb44cd27a331 100644 --- a/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java +++ b/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java @@ -9,7 +9,6 @@ package org.elasticsearch.cluster.metadata; -import org.elasticsearch.action.ResolvedIndexExpression; import org.elasticsearch.action.ResolvedIndexExpression.LocalIndexResolutionResult; import org.elasticsearch.action.ResolvedIndexExpressions; import org.elasticsearch.action.support.IndexComponentSelector; @@ -23,7 +22,6 @@ import org.elasticsearch.indices.SystemIndices.SystemIndexAccessLevel; import java.util.HashSet; -import java.util.Map; import java.util.Set; import java.util.function.BiPredicate; import java.util.function.Function; @@ -294,12 +292,4 @@ && isIndexVisible( includeDataStreams ); } - - private static void exclude(Set expressionsToExclude, Map resolvedIndexExpressions) { - if (expressionsToExclude.isEmpty() == false) { - for (ResolvedIndexExpression prior : resolvedIndexExpressions.values()) { - prior.localExpressions().expressions().removeAll(expressionsToExclude); - } - } - } } From 047467132e00f35a3d36f32070c7c0f152f40cb3 Mon Sep 17 00:00:00 2001 From: Nikolaj Volgushev Date: Fri, 19 Sep 2025 15:27:00 +0200 Subject: [PATCH 10/89] Nits --- .../org/elasticsearch/action/ResolvedIndexExpressions.java | 4 ---- .../cluster/metadata/IndexAbstractionResolver.java | 4 ++-- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpressions.java b/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpressions.java index 49bea31968fa7..6556eb76bca59 100644 --- a/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpressions.java +++ b/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpressions.java @@ -54,10 +54,6 @@ public static Builder builder() { public static final class Builder { private final Map expressions = new LinkedHashMap<>(); - public void putSuccessfulLocalExpression(String original, Set localExpression) { - putLocalExpression(original, localExpression, ResolvedIndexExpression.LocalIndexResolutionResult.SUCCESS); - } - public void putLocalExpression( String original, Set localExpression, diff --git a/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java b/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java index 4bb44cd27a331..a1fa3ea9a974a 100644 --- a/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java +++ b/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java @@ -97,7 +97,7 @@ && isIndexVisible( if (minus) { resolvedIndexExpressions.excludeAll(resolvedIndices); } else { - resolvedIndexExpressions.putSuccessfulLocalExpression(index, resolvedIndices); + resolvedIndexExpressions.putLocalExpression(index, resolvedIndices, SUCCESS); } } } else { @@ -114,7 +114,7 @@ && isIndexVisible( ? (visible ? SUCCESS : CONCRETE_RESOURCE_MISSING) : CONCRETE_RESOURCE_UNAUTHORIZED; - boolean includeIndices = authorized || (indicesOptions.ignoreUnavailable() == false); + boolean includeIndices = indicesOptions.ignoreUnavailable() == false || authorized; Set finalIndices = includeIndices ? resolvedIndices : Set.of(); resolvedIndexExpressions.putLocalExpression(index, finalIndices, result); } From cbe0bd466844d42079d6bafe3b5617eea24466e0 Mon Sep 17 00:00:00 2001 From: Nikolaj Volgushev Date: Fri, 19 Sep 2025 15:30:10 +0200 Subject: [PATCH 11/89] Bring back comment --- .../cluster/metadata/IndexAbstractionResolver.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java b/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java index a1fa3ea9a974a..2ddf9998811b5 100644 --- a/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java +++ b/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java @@ -114,6 +114,9 @@ && isIndexVisible( ? (visible ? SUCCESS : CONCRETE_RESOURCE_MISSING) : CONCRETE_RESOURCE_UNAUTHORIZED; + // Unauthorized names are considered unavailable, so if `ignoreUnavailable` is `true` they should be silently + // discarded from the `finalIndices` list. Other "ways of unavailable" must be handled by the action + // handler, see: https://github.com/elastic/elasticsearch/issues/90215 boolean includeIndices = indicesOptions.ignoreUnavailable() == false || authorized; Set finalIndices = includeIndices ? resolvedIndices : Set.of(); resolvedIndexExpressions.putLocalExpression(index, finalIndices, result); From 8789cc438e3bbf79080c971ed4435f9159f76840 Mon Sep 17 00:00:00 2001 From: Nikolaj Volgushev Date: Fri, 19 Sep 2025 17:01:00 +0200 Subject: [PATCH 12/89] Test ordering --- .../xpack/security/authz/IndicesAndAliasesResolverTests.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolverTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolverTests.java index 0462679a5ff18..16698f25ed03c 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolverTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolverTests.java @@ -924,7 +924,7 @@ public void testResolveMissingIndexStrict() { assertThat(indices, hasSize(expectedIndices.length)); assertThat(request.indices().length, equalTo(expectedIndices.length)); assertThat(indices, hasItems(expectedIndices)); - assertThat(request.indices(), equalTo(expectedIndices)); + assertThat(request.indices(), arrayContainingInAnyOrder(expectedIndices)); } public void testResolveMissingIndexIgnoreUnavailable() { From 820303b9eff46855dd0897dd6da1c2605968b24f Mon Sep 17 00:00:00 2001 From: Nikolaj Volgushev Date: Fri, 19 Sep 2025 17:04:37 +0200 Subject: [PATCH 13/89] Nits --- .../action/ResolvedIndexExpression.java | 7 +------ .../metadata/IndexAbstractionResolver.java | 15 ++++++++------- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpression.java b/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpression.java index 36ba5e37d222c..ce26d711e8483 100644 --- a/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpression.java +++ b/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpression.java @@ -41,11 +41,6 @@ * @param remoteExpressions the remote expressions that replace the original */ public record ResolvedIndexExpression(String original, LocalExpressions localExpressions, List remoteExpressions) { - - public ResolvedIndexExpression(String original, List localExpressions) { - this(original, new LocalExpressions(localExpressions, LocalIndexResolutionResult.SUCCESS, null), List.of()); - } - /** * Indicates if a local index resolution attempt was successful or failed. * Failures can be due to missing concrete resources or unauthorized concrete resources. @@ -58,7 +53,7 @@ public enum LocalIndexResolutionResult { } /** - * Represents local (non-remote) resolution results, including expanded indices, and the resolution result. + * Represents local (non-remote) resolution results, including expanded indices, and a {@link LocalIndexResolutionResult}. */ public record LocalExpressions( List expressions, diff --git a/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java b/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java index 2ddf9998811b5..d3c1c4e77f746 100644 --- a/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java +++ b/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java @@ -22,6 +22,7 @@ import org.elasticsearch.indices.SystemIndices.SystemIndexAccessLevel; import java.util.HashSet; +import java.util.List; import java.util.Set; import java.util.function.BiPredicate; import java.util.function.Function; @@ -39,14 +40,14 @@ public IndexAbstractionResolver(IndexNameExpressionResolver indexNameExpressionR } public ResolvedIndexExpressions resolveIndexAbstractions( - Iterable indices, + List indices, IndicesOptions indicesOptions, ProjectMetadata projectMetadata, Function> allAuthorizedAndAvailableBySelector, BiPredicate isAuthorized, boolean includeDataStreams ) { - ResolvedIndexExpressions.Builder resolvedIndexExpressions = ResolvedIndexExpressions.builder(); + ResolvedIndexExpressions.Builder resolvedExpressionsBuilder = ResolvedIndexExpressions.builder(); boolean wildcardSeen = false; for (String index : indices) { @@ -95,16 +96,16 @@ && isIndexVisible( } } else { if (minus) { - resolvedIndexExpressions.excludeAll(resolvedIndices); + resolvedExpressionsBuilder.excludeAll(resolvedIndices); } else { - resolvedIndexExpressions.putLocalExpression(index, resolvedIndices, SUCCESS); + resolvedExpressionsBuilder.putLocalExpression(index, resolvedIndices, SUCCESS); } } } else { Set resolvedIndices = new HashSet<>(); resolveSelectorsAndCollect(indexAbstraction, selectorString, indicesOptions, resolvedIndices, projectMetadata); if (minus) { - resolvedIndexExpressions.excludeAll(resolvedIndices); + resolvedExpressionsBuilder.excludeAll(resolvedIndices); } else { boolean authorized = isAuthorized.test(indexAbstraction, selector); boolean visible = authorized @@ -119,11 +120,11 @@ && isIndexVisible( // handler, see: https://github.com/elastic/elasticsearch/issues/90215 boolean includeIndices = indicesOptions.ignoreUnavailable() == false || authorized; Set finalIndices = includeIndices ? resolvedIndices : Set.of(); - resolvedIndexExpressions.putLocalExpression(index, finalIndices, result); + resolvedExpressionsBuilder.putLocalExpression(index, finalIndices, result); } } } - return resolvedIndexExpressions.build(); + return resolvedExpressionsBuilder.build(); } private static void resolveSelectorsAndCollect( From 7ba1eecbdcefd28662ff89326e9e39f91f046bb0 Mon Sep 17 00:00:00 2001 From: Nikolaj Volgushev Date: Fri, 19 Sep 2025 19:01:10 +0200 Subject: [PATCH 14/89] Order and TODO --- .../org/elasticsearch/action/ResolvedIndexExpressions.java | 6 ++++-- .../cluster/metadata/IndexAbstractionResolver.java | 7 ++++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpressions.java b/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpressions.java index 6556eb76bca59..fc8e71289123b 100644 --- a/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpressions.java +++ b/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpressions.java @@ -56,14 +56,16 @@ public static final class Builder { public void putLocalExpression( String original, - Set localExpression, + Set localExpressions, ResolvedIndexExpression.LocalIndexResolutionResult resolutionResult ) { + // TODO is it always safe to overwrite an existing entry? exclusions can cause multiple calls for the same original + // expression but with different local expressions expressions.put( original, new ResolvedIndexExpression( original, - new ResolvedIndexExpression.LocalExpressions(new ArrayList<>(localExpression), resolutionResult, null), + new ResolvedIndexExpression.LocalExpressions(new ArrayList<>(localExpressions), resolutionResult, null), new ArrayList<>() ) ); diff --git a/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java b/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java index d3c1c4e77f746..9a6420f2a3042 100644 --- a/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java +++ b/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java @@ -21,7 +21,7 @@ import org.elasticsearch.index.IndexNotFoundException; import org.elasticsearch.indices.SystemIndices.SystemIndexAccessLevel; -import java.util.HashSet; +import java.util.LinkedHashSet; import java.util.List; import java.util.Set; import java.util.function.BiPredicate; @@ -74,7 +74,8 @@ public ResolvedIndexExpressions resolveIndexAbstractions( if (indicesOptions.expandWildcardExpressions() && Regex.isSimpleMatchPattern(indexAbstraction)) { wildcardSeen = true; - Set resolvedIndices = new HashSet<>(); + Set resolvedIndices = new LinkedHashSet<>(); + ; for (String authorizedIndex : allAuthorizedAndAvailableBySelector.apply(selector)) { if (Regex.simpleMatch(indexAbstraction, authorizedIndex) && isIndexVisible( @@ -102,7 +103,7 @@ && isIndexVisible( } } } else { - Set resolvedIndices = new HashSet<>(); + Set resolvedIndices = new LinkedHashSet<>(); resolveSelectorsAndCollect(indexAbstraction, selectorString, indicesOptions, resolvedIndices, projectMetadata); if (minus) { resolvedExpressionsBuilder.excludeAll(resolvedIndices); From bb79a5c02f0a1a1e7af474e5c3ba9eb7b922a1c6 Mon Sep 17 00:00:00 2001 From: Nikolaj Volgushev Date: Fri, 19 Sep 2025 22:46:36 +0200 Subject: [PATCH 15/89] Debug order --- .../action/ResolvedIndexExpressions.java | 17 ++++++----------- .../metadata/IndexAbstractionResolver.java | 1 - 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpressions.java b/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpressions.java index fc8e71289123b..8b1cd96c501a0 100644 --- a/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpressions.java +++ b/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpressions.java @@ -10,9 +10,7 @@ package org.elasticsearch.action; import java.util.ArrayList; -import java.util.LinkedHashMap; import java.util.List; -import java.util.Map; import java.util.Set; /** @@ -41,10 +39,10 @@ * } * } */ -public record ResolvedIndexExpressions(Map expressions) { +public record ResolvedIndexExpressions(List expressions) { public List getLocalIndicesList() { - return expressions.values().stream().flatMap(e -> e.localExpressions().expressions().stream()).toList(); + return expressions.stream().flatMap(e -> e.localExpressions().expressions().stream()).toList(); } public static Builder builder() { @@ -52,17 +50,14 @@ public static Builder builder() { } public static final class Builder { - private final Map expressions = new LinkedHashMap<>(); + private final List expressions = new ArrayList<>(); public void putLocalExpression( String original, Set localExpressions, ResolvedIndexExpression.LocalIndexResolutionResult resolutionResult ) { - // TODO is it always safe to overwrite an existing entry? exclusions can cause multiple calls for the same original - // expression but with different local expressions - expressions.put( - original, + expressions.add( new ResolvedIndexExpression( original, new ResolvedIndexExpression.LocalExpressions(new ArrayList<>(localExpressions), resolutionResult, null), @@ -73,14 +68,14 @@ public void putLocalExpression( public void excludeAll(Set expressionsToExclude) { if (expressionsToExclude.isEmpty() == false) { - for (ResolvedIndexExpression prior : expressions.values()) { + for (ResolvedIndexExpression prior : expressions) { prior.localExpressions().expressions().removeAll(expressionsToExclude); } } } public ResolvedIndexExpressions build() { - return new ResolvedIndexExpressions(Map.copyOf(expressions)); + return new ResolvedIndexExpressions(expressions); } } } diff --git a/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java b/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java index 9a6420f2a3042..0fa5b0a6bbcba 100644 --- a/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java +++ b/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java @@ -75,7 +75,6 @@ public ResolvedIndexExpressions resolveIndexAbstractions( if (indicesOptions.expandWildcardExpressions() && Regex.isSimpleMatchPattern(indexAbstraction)) { wildcardSeen = true; Set resolvedIndices = new LinkedHashSet<>(); - ; for (String authorizedIndex : allAuthorizedAndAvailableBySelector.apply(selector)) { if (Regex.simpleMatch(indexAbstraction, authorizedIndex) && isIndexVisible( From aad05d6e8726e91ae2981eb231d50beb40099ff1 Mon Sep 17 00:00:00 2001 From: Nikolaj Volgushev Date: Fri, 19 Sep 2025 23:01:52 +0200 Subject: [PATCH 16/89] Sets --- .../elasticsearch/action/ResolvedIndexExpression.java | 3 ++- .../elasticsearch/action/ResolvedIndexExpressions.java | 4 ++-- .../cluster/metadata/IndexAbstractionResolver.java | 10 +++++----- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpression.java b/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpression.java index ce26d711e8483..5adaf0b73f7c6 100644 --- a/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpression.java +++ b/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpression.java @@ -13,6 +13,7 @@ import org.elasticsearch.core.Nullable; import java.util.List; +import java.util.Set; /** * This class allows capturing context about index expression replacements performed on an {@link IndicesRequest.Replaceable} during @@ -56,7 +57,7 @@ public enum LocalIndexResolutionResult { * Represents local (non-remote) resolution results, including expanded indices, and a {@link LocalIndexResolutionResult}. */ public record LocalExpressions( - List expressions, + Set expressions, LocalIndexResolutionResult localIndexResolutionResult, @Nullable ElasticsearchException exception ) { diff --git a/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpressions.java b/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpressions.java index 8b1cd96c501a0..8fda3a9ad4d08 100644 --- a/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpressions.java +++ b/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpressions.java @@ -52,7 +52,7 @@ public static Builder builder() { public static final class Builder { private final List expressions = new ArrayList<>(); - public void putLocalExpression( + public void putLocalExpressions( String original, Set localExpressions, ResolvedIndexExpression.LocalIndexResolutionResult resolutionResult @@ -60,7 +60,7 @@ public void putLocalExpression( expressions.add( new ResolvedIndexExpression( original, - new ResolvedIndexExpression.LocalExpressions(new ArrayList<>(localExpressions), resolutionResult, null), + new ResolvedIndexExpression.LocalExpressions(localExpressions, resolutionResult, null), new ArrayList<>() ) ); diff --git a/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java b/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java index 0fa5b0a6bbcba..4c02064aae469 100644 --- a/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java +++ b/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java @@ -21,7 +21,7 @@ import org.elasticsearch.index.IndexNotFoundException; import org.elasticsearch.indices.SystemIndices.SystemIndexAccessLevel; -import java.util.LinkedHashSet; +import java.util.HashSet; import java.util.List; import java.util.Set; import java.util.function.BiPredicate; @@ -74,7 +74,7 @@ public ResolvedIndexExpressions resolveIndexAbstractions( if (indicesOptions.expandWildcardExpressions() && Regex.isSimpleMatchPattern(indexAbstraction)) { wildcardSeen = true; - Set resolvedIndices = new LinkedHashSet<>(); + Set resolvedIndices = new HashSet<>(); for (String authorizedIndex : allAuthorizedAndAvailableBySelector.apply(selector)) { if (Regex.simpleMatch(indexAbstraction, authorizedIndex) && isIndexVisible( @@ -98,11 +98,11 @@ && isIndexVisible( if (minus) { resolvedExpressionsBuilder.excludeAll(resolvedIndices); } else { - resolvedExpressionsBuilder.putLocalExpression(index, resolvedIndices, SUCCESS); + resolvedExpressionsBuilder.putLocalExpressions(index, resolvedIndices, SUCCESS); } } } else { - Set resolvedIndices = new LinkedHashSet<>(); + Set resolvedIndices = new HashSet<>(); resolveSelectorsAndCollect(indexAbstraction, selectorString, indicesOptions, resolvedIndices, projectMetadata); if (minus) { resolvedExpressionsBuilder.excludeAll(resolvedIndices); @@ -120,7 +120,7 @@ && isIndexVisible( // handler, see: https://github.com/elastic/elasticsearch/issues/90215 boolean includeIndices = indicesOptions.ignoreUnavailable() == false || authorized; Set finalIndices = includeIndices ? resolvedIndices : Set.of(); - resolvedExpressionsBuilder.putLocalExpression(index, finalIndices, result); + resolvedExpressionsBuilder.putLocalExpressions(index, finalIndices, result); } } } From 457b45bce5b7b092db30703bd72e84cac8f3d288 Mon Sep 17 00:00:00 2001 From: Nikolaj Volgushev Date: Fri, 19 Sep 2025 23:07:42 +0200 Subject: [PATCH 17/89] Remote also sets --- .../java/org/elasticsearch/action/ResolvedIndexExpression.java | 3 +-- .../org/elasticsearch/action/ResolvedIndexExpressions.java | 3 ++- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpression.java b/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpression.java index 5adaf0b73f7c6..22231d6699722 100644 --- a/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpression.java +++ b/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpression.java @@ -12,7 +12,6 @@ import org.elasticsearch.ElasticsearchException; import org.elasticsearch.core.Nullable; -import java.util.List; import java.util.Set; /** @@ -41,7 +40,7 @@ * and failure info * @param remoteExpressions the remote expressions that replace the original */ -public record ResolvedIndexExpression(String original, LocalExpressions localExpressions, List remoteExpressions) { +public record ResolvedIndexExpression(String original, LocalExpressions localExpressions, Set remoteExpressions) { /** * Indicates if a local index resolution attempt was successful or failed. * Failures can be due to missing concrete resources or unauthorized concrete resources. diff --git a/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpressions.java b/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpressions.java index 8fda3a9ad4d08..0f8837baf0356 100644 --- a/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpressions.java +++ b/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpressions.java @@ -10,6 +10,7 @@ package org.elasticsearch.action; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; import java.util.Set; @@ -61,7 +62,7 @@ public void putLocalExpressions( new ResolvedIndexExpression( original, new ResolvedIndexExpression.LocalExpressions(localExpressions, resolutionResult, null), - new ArrayList<>() + new HashSet<>() ) ); } From cb346e0f9e6ec2d5c9169b94c7fd790dcdf2c15f Mon Sep 17 00:00:00 2001 From: Nikolaj Volgushev Date: Mon, 22 Sep 2025 20:47:16 +0200 Subject: [PATCH 18/89] Tests --- .../elasticsearch/action/IndicesRequest.java | 1 + .../action/ResolvedIndexExpressions.java | 27 +-- .../action/search/SearchRequest.java | 15 ++ .../metadata/IndexAbstractionResolver.java | 1 + .../authz/IndicesAndAliasesResolverTests.java | 205 +++++++++++++++++- 5 files changed, 214 insertions(+), 35 deletions(-) diff --git a/server/src/main/java/org/elasticsearch/action/IndicesRequest.java b/server/src/main/java/org/elasticsearch/action/IndicesRequest.java index 125636feb7079..9a36bfa15d802 100644 --- a/server/src/main/java/org/elasticsearch/action/IndicesRequest.java +++ b/server/src/main/java/org/elasticsearch/action/IndicesRequest.java @@ -53,6 +53,7 @@ interface Replaceable extends IndicesRequest { * Record the results of index resolution. See {@link ResolvedIndexExpressions} for details. * Note: this method does not replace {@link #indices(String...)}. {@link #indices(String...)} must still be called to update * the actual list of indices the request relates to. + * Note: the field is transient and not serialized. */ default void setResolvedIndexExpressions(ResolvedIndexExpressions expressions) {} diff --git a/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpressions.java b/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpressions.java index 0f8837baf0356..96d97e8283017 100644 --- a/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpressions.java +++ b/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpressions.java @@ -15,30 +15,7 @@ import java.util.Set; /** - * A collection of {@link ResolvedIndexExpression}, keyed by the original expression. - * - *

An example structure is:

- * - *
{@code
- * {
- *   "my-index-*": {
- *      "original": "my-index-*",
- *      "localExpressions": {
- *          "expressions": ["my-index-000001", "my-index-000002"],
- *          "localIndexResolutionResult": "SUCCESS"
- *      },
- *      "remoteExpressions": ["remote1:my-index-*", "remote2:my-index-*"]
- *   },
- *   "my-index-000001": {
- *      "original": "my-index-000001",
- *      "localExpressions": {
- *          "expressions": ["my-index-000001"],
- *          "localIndexResolutionResult": "SUCCESS"
- *      },
- *      "remoteExpressions": ["remote1:my-index-000001", "remote2:my-index-000001"]
- *   }
- * }
- * }
+ * A collection of {@link ResolvedIndexExpression}. */ public record ResolvedIndexExpressions(List expressions) { @@ -61,7 +38,7 @@ public void putLocalExpressions( expressions.add( new ResolvedIndexExpression( original, - new ResolvedIndexExpression.LocalExpressions(localExpressions, resolutionResult, null), + new ResolvedIndexExpression.LocalExpressions(new HashSet<>(localExpressions), resolutionResult, null), new HashSet<>() ) ); diff --git a/server/src/main/java/org/elasticsearch/action/search/SearchRequest.java b/server/src/main/java/org/elasticsearch/action/search/SearchRequest.java index 5ab9e142eb943..f84a3578264f8 100644 --- a/server/src/main/java/org/elasticsearch/action/search/SearchRequest.java +++ b/server/src/main/java/org/elasticsearch/action/search/SearchRequest.java @@ -14,6 +14,7 @@ import org.elasticsearch.action.ActionRequestValidationException; import org.elasticsearch.action.IndicesRequest; import org.elasticsearch.action.LegacyActionRequest; +import org.elasticsearch.action.ResolvedIndexExpressions; import org.elasticsearch.action.support.IndicesOptions; import org.elasticsearch.client.internal.Client; import org.elasticsearch.common.Strings; @@ -70,6 +71,9 @@ public class SearchRequest extends LegacyActionRequest implements IndicesRequest private String[] indices = Strings.EMPTY_ARRAY; + @Nullable + private ResolvedIndexExpressions resolvedIndexExpressions = null; + @Nullable private String routing; @Nullable @@ -400,6 +404,17 @@ public SearchRequest indices(String... indices) { return this; } + @Override + public void setResolvedIndexExpressions(ResolvedIndexExpressions expressions) { + this.resolvedIndexExpressions = expressions; + } + + @Override + @Nullable + public ResolvedIndexExpressions getResolvedIndexExpressions() { + return resolvedIndexExpressions; + } + private static void validateIndices(String... indices) { Objects.requireNonNull(indices, "indices must not be null"); for (String index : indices) { diff --git a/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java b/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java index 4c02064aae469..9da469090c129 100644 --- a/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java +++ b/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java @@ -94,6 +94,7 @@ && isIndexVisible( if (indicesOptions.allowNoIndices() == false) { throw new IndexNotFoundException(indexAbstraction); } + resolvedExpressionsBuilder.putLocalExpressions(index, Set.of(), SUCCESS); } else { if (minus) { resolvedExpressionsBuilder.excludeAll(resolvedIndices); diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolverTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolverTests.java index 16698f25ed03c..6fd37a3f8e8cc 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolverTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolverTests.java @@ -8,6 +8,8 @@ import org.elasticsearch.action.ActionListener; import org.elasticsearch.action.IndicesRequest; +import org.elasticsearch.action.ResolvedIndexExpression; +import org.elasticsearch.action.ResolvedIndexExpressions; import org.elasticsearch.action.admin.indices.alias.IndicesAliasesRequest; import org.elasticsearch.action.admin.indices.alias.IndicesAliasesRequest.AliasActions; import org.elasticsearch.action.admin.indices.alias.TransportIndicesAliasesAction; @@ -104,6 +106,9 @@ import java.util.Map; import java.util.Set; +import static org.elasticsearch.action.ResolvedIndexExpression.LocalIndexResolutionResult.CONCRETE_RESOURCE_MISSING; +import static org.elasticsearch.action.ResolvedIndexExpression.LocalIndexResolutionResult.CONCRETE_RESOURCE_UNAUTHORIZED; +import static org.elasticsearch.action.ResolvedIndexExpression.LocalIndexResolutionResult.SUCCESS; import static org.elasticsearch.cluster.metadata.DataStreamTestHelper.newInstance; import static org.elasticsearch.test.ActionListenerUtils.anyActionListener; import static org.elasticsearch.test.TestMatchers.throwableWithMessage; @@ -113,10 +118,12 @@ import static org.hamcrest.CoreMatchers.containsString; import static org.hamcrest.CoreMatchers.instanceOf; import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.notNullValue; import static org.hamcrest.Matchers.arrayContaining; import static org.hamcrest.Matchers.arrayContainingInAnyOrder; import static org.hamcrest.Matchers.contains; import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.empty; import static org.hamcrest.Matchers.emptyArray; import static org.hamcrest.Matchers.emptyIterable; import static org.hamcrest.Matchers.equalTo; @@ -622,15 +629,20 @@ public void testExplicitDashIndices() { assertThat(request.indices().length, equalTo(expectedIndices.length)); assertThat(indices, hasItems(expectedIndices)); assertThat(request.indices(), arrayContainingInAnyOrder(expectedIndices)); + ResolvedIndexExpressions actual = request.getResolvedIndexExpressions(); + assertThat(actual, is(notNullValue())); + assertThat( + actual.expressions(), + contains( + resolvedIndexExpression("-index10", Set.of("-index10"), SUCCESS), + resolvedIndexExpression("-index20", Set.of("-index20"), SUCCESS) + ) + ); } public void testWildcardDashIndices() { - SearchRequest request; - if (randomBoolean()) { - request = new SearchRequest("-index*", "--index20"); - } else { - request = new SearchRequest("*", "--index20"); - } + String original = randomBoolean() ? "-index*" : "*"; + SearchRequest request = new SearchRequest(original, "--index20"); List indices = resolveIndices(request, buildAuthorizedIndices(userDashIndices, TransportSearchAction.TYPE.name())) .getLocal(); String[] expectedIndices = new String[] { "-index10", "-index11", "-index21" }; @@ -638,6 +650,9 @@ public void testWildcardDashIndices() { assertThat(request.indices().length, equalTo(expectedIndices.length)); assertThat(indices, hasItems(expectedIndices)); assertThat(request.indices(), arrayContainingInAnyOrder(expectedIndices)); + ResolvedIndexExpressions actual = request.getResolvedIndexExpressions(); + assertThat(actual, is(notNullValue())); + assertThat(actual.expressions(), contains(resolvedIndexExpression(original, Set.of("-index10", "-index11", "-index21"), SUCCESS))); } public void testExplicitMixedWildcardDashIndices() { @@ -649,6 +664,16 @@ public void testExplicitMixedWildcardDashIndices() { assertThat(request.indices().length, equalTo(expectedIndices.length)); assertThat(indices, hasItems(expectedIndices)); assertThat(request.indices(), arrayContainingInAnyOrder(expectedIndices)); + ResolvedIndexExpressions actual = request.getResolvedIndexExpressions(); + assertThat(actual, is(notNullValue())); + assertThat( + actual.expressions(), + contains( + resolvedIndexExpression("-index21", Set.of("-index21"), SUCCESS), + resolvedIndexExpression("-does_not_exist", Set.of("-does_not_exist"), CONCRETE_RESOURCE_UNAUTHORIZED), + resolvedIndexExpression("-index1*", Set.of("-index10"), SUCCESS) + ) + ); } public void testDashIndicesNoExpandWildcard() { @@ -661,11 +686,21 @@ public void testDashIndicesNoExpandWildcard() { assertThat(request.indices().length, equalTo(expectedIndices.length)); assertThat(indices, hasItems(expectedIndices)); assertThat(request.indices(), arrayContainingInAnyOrder(expectedIndices)); + ResolvedIndexExpressions actual = request.getResolvedIndexExpressions(); + assertThat(actual, is(notNullValue())); + assertThat( + actual.expressions(), + contains( + resolvedIndexExpression("-index1*", Set.of("-index1*"), CONCRETE_RESOURCE_UNAUTHORIZED), + resolvedIndexExpression("--index11", Set.of("--index11"), CONCRETE_RESOURCE_UNAUTHORIZED) + ) + ); } public void testDashIndicesMinus() { SearchRequest request = new SearchRequest("-index10", "-index11", "--index11", "-index20"); - request.indicesOptions(IndicesOptions.fromOptions(false, randomBoolean(), randomBoolean(), randomBoolean())); + boolean expandToOpenIndices = randomBoolean(); + request.indicesOptions(IndicesOptions.fromOptions(false, randomBoolean(), expandToOpenIndices, randomBoolean())); List indices = resolveIndices(request, buildAuthorizedIndices(userDashIndices, TransportSearchAction.TYPE.name())) .getLocal(); String[] expectedIndices = new String[] { "-index10", "-index11", "--index11", "-index20" }; @@ -673,6 +708,17 @@ public void testDashIndicesMinus() { assertThat(request.indices().length, equalTo(expectedIndices.length)); assertThat(indices, hasItems(expectedIndices)); assertThat(request.indices(), arrayContainingInAnyOrder(expectedIndices)); + ResolvedIndexExpressions actual = request.getResolvedIndexExpressions(); + assertThat(actual, is(notNullValue())); + assertThat( + actual.expressions(), + contains( + resolvedIndexExpression("-index10", Set.of("-index10"), expandToOpenIndices ? SUCCESS : CONCRETE_RESOURCE_MISSING), + resolvedIndexExpression("-index11", Set.of("-index11"), expandToOpenIndices ? SUCCESS : CONCRETE_RESOURCE_MISSING), + resolvedIndexExpression("--index11", Set.of("--index11"), CONCRETE_RESOURCE_UNAUTHORIZED), + resolvedIndexExpression("-index20", Set.of("-index20"), expandToOpenIndices ? SUCCESS : CONCRETE_RESOURCE_MISSING) + ) + ); } public void testDashIndicesPlus() { @@ -694,6 +740,12 @@ public void testDashNotExistingIndex() { assertThat(request.indices().length, equalTo(expectedIndices.length)); assertThat(indices, hasItems(expectedIndices)); assertThat(request.indices(), arrayContainingInAnyOrder(expectedIndices)); + ResolvedIndexExpressions actual = request.getResolvedIndexExpressions(); + assertThat(actual, is(notNullValue())); + assertThat( + actual.expressions(), + contains(resolvedIndexExpression("-does_not_exist", Set.of("-does_not_exist"), CONCRETE_RESOURCE_UNAUTHORIZED)) + ); } public void testResolveEmptyIndicesExpandWilcardsOpenAndClosed() { @@ -747,6 +799,15 @@ public void testResolveWildcardsStrictExpand() { assertThat(request.indices().length, equalTo(replacedIndices.length)); assertThat(indices, hasItems(replacedIndices)); assertThat(request.indices(), arrayContainingInAnyOrder(replacedIndices)); + ResolvedIndexExpressions actual = request.getResolvedIndexExpressions(); + assertThat(actual, is(notNullValue())); + assertThat( + actual.expressions(), + contains( + resolvedIndexExpression("barbaz", Set.of("barbaz"), CONCRETE_RESOURCE_UNAUTHORIZED), + resolvedIndexExpression("foofoo*", Set.of("foofoobar", "foofoo", "foofoo-closed"), SUCCESS) + ) + ); } public void testResolveWildcardsExpandOpenAndClosedIgnoreUnavailable() { @@ -758,6 +819,15 @@ public void testResolveWildcardsExpandOpenAndClosedIgnoreUnavailable() { assertThat(request.indices().length, equalTo(replacedIndices.length)); assertThat(indices, hasItems(replacedIndices)); assertThat(request.indices(), arrayContainingInAnyOrder(replacedIndices)); + ResolvedIndexExpressions actual = request.getResolvedIndexExpressions(); + assertThat(actual, is(notNullValue())); + assertThat( + actual.expressions(), + contains( + resolvedIndexExpression("barbaz", Set.of(), CONCRETE_RESOURCE_UNAUTHORIZED), + resolvedIndexExpression("foofoo*", Set.of("foofoobar", "foofoo", "foofoo-closed"), SUCCESS) + ) + ); } public void testResolveWildcardsStrictExpandOpen() { @@ -769,6 +839,15 @@ public void testResolveWildcardsStrictExpandOpen() { assertThat(request.indices().length, equalTo(replacedIndices.length)); assertThat(indices, hasItems(replacedIndices)); assertThat(request.indices(), arrayContainingInAnyOrder(replacedIndices)); + ResolvedIndexExpressions actual = request.getResolvedIndexExpressions(); + assertThat(actual, is(notNullValue())); + assertThat( + actual.expressions(), + contains( + resolvedIndexExpression("barbaz", Set.of("barbaz"), CONCRETE_RESOURCE_UNAUTHORIZED), + resolvedIndexExpression("foofoo*", Set.of("foofoobar", "foofoo"), SUCCESS) + ) + ); } public void testResolveWildcardsLenientExpandOpen() { @@ -780,6 +859,15 @@ public void testResolveWildcardsLenientExpandOpen() { assertThat(request.indices().length, equalTo(replacedIndices.length)); assertThat(indices, hasItems(replacedIndices)); assertThat(request.indices(), arrayContainingInAnyOrder(replacedIndices)); + ResolvedIndexExpressions actual = request.getResolvedIndexExpressions(); + assertThat(actual, is(notNullValue())); + assertThat( + actual.expressions(), + contains( + resolvedIndexExpression("barbaz", Set.of(), CONCRETE_RESOURCE_UNAUTHORIZED), + resolvedIndexExpression("foofoo*", Set.of("foofoobar", "foofoo"), SUCCESS) + ) + ); } public void testResolveWildcardsMinusExpandWilcardsOpen() { @@ -791,6 +879,9 @@ public void testResolveWildcardsMinusExpandWilcardsOpen() { assertThat(request.indices().length, equalTo(replacedIndices.length)); assertThat(indices, hasItems(replacedIndices)); assertThat(request.indices(), arrayContainingInAnyOrder(replacedIndices)); + ResolvedIndexExpressions actual = request.getResolvedIndexExpressions(); + assertThat(actual, is(notNullValue())); + assertThat(actual.expressions(), contains(resolvedIndexExpression("*", Set.of("bar", "foobarfoo"), SUCCESS))); } public void testResolveWildcardsMinusExpandWilcardsOpenAndClosed() { @@ -802,6 +893,9 @@ public void testResolveWildcardsMinusExpandWilcardsOpenAndClosed() { assertThat(request.indices().length, equalTo(replacedIndices.length)); assertThat(indices, hasItems(replacedIndices)); assertThat(request.indices(), arrayContainingInAnyOrder(replacedIndices)); + ResolvedIndexExpressions actual = request.getResolvedIndexExpressions(); + assertThat(actual, is(notNullValue())); + assertThat(actual.expressions(), contains(resolvedIndexExpression("*", Set.of("bar", "foobarfoo", "bar-closed"), SUCCESS))); } public void testResolveWildcardsNoExpand() { @@ -812,6 +906,15 @@ public void testResolveWildcardsNoExpand() { String[] replacedIndices = new String[] { "*", "-foofoo*" }; assertThat(indices.getLocal(), containsInAnyOrder(replacedIndices)); assertThat(request.indices(), arrayContainingInAnyOrder(replacedIndices)); + ResolvedIndexExpressions actual = request.getResolvedIndexExpressions(); + assertThat(actual, is(notNullValue())); + assertThat( + actual.expressions(), + contains( + resolvedIndexExpression("*", Set.of("*"), CONCRETE_RESOURCE_UNAUTHORIZED), + resolvedIndexExpression("-foofoo*", Set.of("-foofoo*"), CONCRETE_RESOURCE_UNAUTHORIZED) + ) + ); // no wildcard expand but ignore unavailable request = new SearchRequest("*", "-foofoo*"); request.indicesOptions(IndicesOptions.fromOptions(true, true, false, false)); @@ -833,6 +936,16 @@ public void testResolveWildcardsExclusionsExpandWilcardsOpenStrict() { String[] replacedIndices = new String[] { "bar", "foobarfoo", "barbaz" }; assertSameValues(indices, replacedIndices); assertThat(request.indices(), arrayContainingInAnyOrder("bar", "foobarfoo", "barbaz", "foobarfoo")); + ResolvedIndexExpressions actual = request.getResolvedIndexExpressions(); + assertThat(actual, is(notNullValue())); + assertThat( + actual.expressions(), + contains( + resolvedIndexExpression("*", Set.of("bar", "foobarfoo"), SUCCESS), + resolvedIndexExpression("barbaz", Set.of("barbaz"), CONCRETE_RESOURCE_UNAUTHORIZED), + resolvedIndexExpression("foob*", Set.of("foobarfoo"), SUCCESS) + ) + ); } public void testResolveWildcardsPlusAndMinusExpandWilcardsOpenIgnoreUnavailable() { @@ -844,6 +957,16 @@ public void testResolveWildcardsPlusAndMinusExpandWilcardsOpenIgnoreUnavailable( assertThat(request.indices().length, equalTo(replacedIndices.length)); assertThat(indices, hasItems(replacedIndices)); assertThat(request.indices(), arrayContainingInAnyOrder(replacedIndices)); + ResolvedIndexExpressions actual = request.getResolvedIndexExpressions(); + assertThat(actual, is(notNullValue())); + assertThat( + actual.expressions(), + contains( + resolvedIndexExpression("*", Set.of("bar", "foobarfoo"), SUCCESS), + resolvedIndexExpression("+barbaz", Set.of(), CONCRETE_RESOURCE_UNAUTHORIZED), + resolvedIndexExpression("+foob*", Set.of(), SUCCESS) + ) + ); } public void testResolveWildcardsExclusionExpandWilcardsOpenAndClosedStrict() { @@ -853,6 +976,15 @@ public void testResolveWildcardsExclusionExpandWilcardsOpenAndClosedStrict() { String[] replacedIndices = new String[] { "bar", "bar-closed", "barbaz", "foobarfoo" }; assertSameValues(indices, replacedIndices); assertThat(request.indices(), arrayContainingInAnyOrder(replacedIndices)); + ResolvedIndexExpressions actual = request.getResolvedIndexExpressions(); + assertThat(actual, is(notNullValue())); + assertThat( + actual.expressions(), + contains( + resolvedIndexExpression("*", Set.of("bar", "foobarfoo", "bar-closed"), SUCCESS), + resolvedIndexExpression("barbaz", Set.of("barbaz"), CONCRETE_RESOURCE_UNAUTHORIZED) + ) + ); } public void testResolveWildcardsExclusionExpandWilcardsOpenAndClosedIgnoreUnavailable() { @@ -933,6 +1065,16 @@ public void testResolveMissingIndexIgnoreUnavailable() { List indices = resolveIndices(request, buildAuthorizedIndices(user, TransportSearchAction.TYPE.name())).getLocal(); assertThat(indices, containsInAnyOrder("bar", "missing")); assertThat(request.indices(), arrayContainingInAnyOrder("bar", "missing")); + ResolvedIndexExpressions actual = request.getResolvedIndexExpressions(); + assertThat(actual, is(notNullValue())); + assertThat( + actual.expressions(), + contains( + resolvedIndexExpression("bar*", Set.of("bar"), SUCCESS), + resolvedIndexExpression("missing", Set.of("missing"), CONCRETE_RESOURCE_MISSING), + resolvedIndexExpression("missing-and-unauthorized", Set.of(), CONCRETE_RESOURCE_UNAUTHORIZED) + ) + ); } public void testResolveNonMatchingIndicesAndExplicit() { @@ -940,8 +1082,14 @@ public void testResolveNonMatchingIndicesAndExplicit() { request.indicesOptions(IndicesOptions.fromOptions(randomBoolean(), true, true, randomBoolean())); List indices = resolveIndices(request, buildAuthorizedIndices(user, TransportSearchAction.TYPE.name())).getLocal(); String[] expectedIndices = new String[] { "bar" }; - assertThat(indices.toArray(new String[indices.size()]), equalTo(expectedIndices)); + assertThat(indices.toArray(new String[0]), equalTo(expectedIndices)); assertThat(request.indices(), equalTo(expectedIndices)); + ResolvedIndexExpressions actual = request.getResolvedIndexExpressions(); + assertThat(actual, is(notNullValue())); + assertThat( + actual.expressions(), + contains(resolvedIndexExpression("missing*", Set.of(), SUCCESS), resolvedIndexExpression("bar", Set.of("bar"), SUCCESS)) + ); } public void testResolveNoExpandStrict() { @@ -949,14 +1097,20 @@ public void testResolveNoExpandStrict() { request.indicesOptions(IndicesOptions.fromOptions(false, randomBoolean(), false, false)); List indices = resolveIndices(request, buildAuthorizedIndices(user, TransportSearchAction.TYPE.name())).getLocal(); String[] expectedIndices = new String[] { "missing*" }; - assertThat(indices.toArray(new String[indices.size()]), equalTo(expectedIndices)); + assertThat(indices.toArray(new String[0]), equalTo(expectedIndices)); assertThat(request.indices(), equalTo(expectedIndices)); + ResolvedIndexExpressions actual = request.getResolvedIndexExpressions(); + assertThat(actual, is(notNullValue())); + assertThat(actual.expressions(), contains(resolvedIndexExpression("missing*", Set.of("missing*"), CONCRETE_RESOURCE_UNAUTHORIZED))); } public void testResolveNoExpandIgnoreUnavailable() { SearchRequest request = new SearchRequest("missing*"); request.indicesOptions(IndicesOptions.fromOptions(true, true, false, false)); assertNoIndices(request, resolveIndices(request, buildAuthorizedIndices(user, TransportSearchAction.TYPE.name()))); + ResolvedIndexExpressions actual = request.getResolvedIndexExpressions(); + assertThat(actual, is(notNullValue())); + assertThat(actual.expressions(), contains(resolvedIndexExpression("missing*", Set.of(), CONCRETE_RESOURCE_UNAUTHORIZED))); } public void testSearchWithRemoteIndex() { @@ -966,15 +1120,28 @@ public void testSearchWithRemoteIndex() { assertThat(resolved.getLocal(), emptyIterable()); assertThat(resolved.getRemote(), containsInAnyOrder("remote:indexName")); assertThat(request.indices(), arrayContaining("remote:indexName")); + ResolvedIndexExpressions actual = request.getResolvedIndexExpressions(); + assertThat(actual, is(notNullValue())); + assertThat(actual.expressions(), is(empty())); } public void testSearchWithRemoteAndLocalIndices() { SearchRequest request = new SearchRequest("remote:indexName", "bar", "bar2"); - request.indicesOptions(IndicesOptions.fromOptions(true, randomBoolean(), randomBoolean(), randomBoolean())); + boolean expandToOpenIndices = randomBoolean(); + request.indicesOptions(IndicesOptions.fromOptions(true, randomBoolean(), expandToOpenIndices, randomBoolean())); final ResolvedIndices resolved = resolveIndices(request, buildAuthorizedIndices(user, TransportSearchAction.TYPE.name())); assertThat(resolved.getLocal(), containsInAnyOrder("bar")); assertThat(resolved.getRemote(), containsInAnyOrder("remote:indexName")); assertThat(request.indices(), arrayContainingInAnyOrder("remote:indexName", "bar")); + ResolvedIndexExpressions actual = request.getResolvedIndexExpressions(); + assertThat(actual, is(notNullValue())); + assertThat( + actual.expressions(), + contains( + resolvedIndexExpression("bar", Set.of("bar"), expandToOpenIndices ? SUCCESS : CONCRETE_RESOURCE_MISSING), + resolvedIndexExpression("bar2", Set.of(), CONCRETE_RESOURCE_UNAUTHORIZED) + ) + ); } public void testSearchWithRemoteAndLocalWildcards() { @@ -988,6 +1155,12 @@ public void testSearchWithRemoteAndLocalWildcards() { request.indices(), arrayContainingInAnyOrder("remote:foo", "other_remote:foo", "remote:bar*", "remote:baz*", "bar", "foofoo") ); + ResolvedIndexExpressions actual = request.getResolvedIndexExpressions(); + assertThat(actual, is(notNullValue())); + assertThat( + actual.expressions(), + contains(resolvedIndexExpression("bar*", Set.of("bar"), SUCCESS), resolvedIndexExpression("foofoo", Set.of("foofoo"), SUCCESS)) + ); } public void testResolveIndicesAliasesRequest() { @@ -2688,6 +2861,18 @@ private void assertSameValues(List indices, String[] expectedIndices) { assertThat(indices, hasItems(expectedIndices)); } + private static ResolvedIndexExpression resolvedIndexExpression( + String original, + Set localExpressions, + ResolvedIndexExpression.LocalIndexResolutionResult localIndexResolutionResult + ) { + return new ResolvedIndexExpression( + original, + new ResolvedIndexExpression.LocalExpressions(localExpressions, localIndexResolutionResult, null), + Set.of() + ); + } + private boolean runFailureStore() { return randomBoolean(); } From 665bbec96ad013211b3cc41fb35186d012b103c1 Mon Sep 17 00:00:00 2001 From: elasticsearchmachine Date: Mon, 22 Sep 2025 19:40:39 +0000 Subject: [PATCH 19/89] [CI] Update transport version definitions --- server/src/main/resources/transport/upper_bounds/9.2.csv | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/main/resources/transport/upper_bounds/9.2.csv b/server/src/main/resources/transport/upper_bounds/9.2.csv index bf1a90e5be4e9..6e7d51d3d3020 100644 --- a/server/src/main/resources/transport/upper_bounds/9.2.csv +++ b/server/src/main/resources/transport/upper_bounds/9.2.csv @@ -1 +1 @@ -index_request_include_tsid,9167000 +security_stats_endpoint,9168000 From 5ad94e45225f537d33039b6efae87cc4147822ec Mon Sep 17 00:00:00 2001 From: Nikolaj Volgushev Date: Mon, 22 Sep 2025 21:50:47 +0200 Subject: [PATCH 20/89] Tweaks --- .../action/ResolvedIndexExpression.java | 2 +- .../action/ResolvedIndexExpressions.java | 2 +- .../cluster/metadata/IndexAbstractionResolver.java | 10 +++++----- .../authz/IndicesAndAliasesResolverTests.java | 12 ++++++------ 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpression.java b/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpression.java index 22231d6699722..11d7261686ca6 100644 --- a/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpression.java +++ b/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpression.java @@ -48,7 +48,7 @@ public record ResolvedIndexExpression(String original, LocalExpressions localExp */ public enum LocalIndexResolutionResult { SUCCESS, - CONCRETE_RESOURCE_MISSING, + CONCRETE_RESOURCE_NOT_VISIBLE, CONCRETE_RESOURCE_UNAUTHORIZED, } diff --git a/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpressions.java b/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpressions.java index 96d97e8283017..7dbc05264ed05 100644 --- a/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpressions.java +++ b/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpressions.java @@ -30,7 +30,7 @@ public static Builder builder() { public static final class Builder { private final List expressions = new ArrayList<>(); - public void putLocalExpressions( + public void addLocalExpressions( String original, Set localExpressions, ResolvedIndexExpression.LocalIndexResolutionResult resolutionResult diff --git a/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java b/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java index 9da469090c129..1914256f8aefa 100644 --- a/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java +++ b/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java @@ -27,7 +27,7 @@ import java.util.function.BiPredicate; import java.util.function.Function; -import static org.elasticsearch.action.ResolvedIndexExpression.LocalIndexResolutionResult.CONCRETE_RESOURCE_MISSING; +import static org.elasticsearch.action.ResolvedIndexExpression.LocalIndexResolutionResult.CONCRETE_RESOURCE_NOT_VISIBLE; import static org.elasticsearch.action.ResolvedIndexExpression.LocalIndexResolutionResult.CONCRETE_RESOURCE_UNAUTHORIZED; import static org.elasticsearch.action.ResolvedIndexExpression.LocalIndexResolutionResult.SUCCESS; @@ -94,12 +94,12 @@ && isIndexVisible( if (indicesOptions.allowNoIndices() == false) { throw new IndexNotFoundException(indexAbstraction); } - resolvedExpressionsBuilder.putLocalExpressions(index, Set.of(), SUCCESS); + resolvedExpressionsBuilder.addLocalExpressions(index, new HashSet<>(), SUCCESS); } else { if (minus) { resolvedExpressionsBuilder.excludeAll(resolvedIndices); } else { - resolvedExpressionsBuilder.putLocalExpressions(index, resolvedIndices, SUCCESS); + resolvedExpressionsBuilder.addLocalExpressions(index, resolvedIndices, SUCCESS); } } } else { @@ -113,7 +113,7 @@ && isIndexVisible( && existsAndVisible(indicesOptions, projectMetadata, includeDataStreams, indexAbstraction, selectorString); LocalIndexResolutionResult result = authorized - ? (visible ? SUCCESS : CONCRETE_RESOURCE_MISSING) + ? (visible ? SUCCESS : CONCRETE_RESOURCE_NOT_VISIBLE) : CONCRETE_RESOURCE_UNAUTHORIZED; // Unauthorized names are considered unavailable, so if `ignoreUnavailable` is `true` they should be silently @@ -121,7 +121,7 @@ && isIndexVisible( // handler, see: https://github.com/elastic/elasticsearch/issues/90215 boolean includeIndices = indicesOptions.ignoreUnavailable() == false || authorized; Set finalIndices = includeIndices ? resolvedIndices : Set.of(); - resolvedExpressionsBuilder.putLocalExpressions(index, finalIndices, result); + resolvedExpressionsBuilder.addLocalExpressions(index, finalIndices, result); } } } diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolverTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolverTests.java index 6fd37a3f8e8cc..b3244676ce3c5 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolverTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolverTests.java @@ -106,7 +106,7 @@ import java.util.Map; import java.util.Set; -import static org.elasticsearch.action.ResolvedIndexExpression.LocalIndexResolutionResult.CONCRETE_RESOURCE_MISSING; +import static org.elasticsearch.action.ResolvedIndexExpression.LocalIndexResolutionResult.CONCRETE_RESOURCE_NOT_VISIBLE; import static org.elasticsearch.action.ResolvedIndexExpression.LocalIndexResolutionResult.CONCRETE_RESOURCE_UNAUTHORIZED; import static org.elasticsearch.action.ResolvedIndexExpression.LocalIndexResolutionResult.SUCCESS; import static org.elasticsearch.cluster.metadata.DataStreamTestHelper.newInstance; @@ -713,10 +713,10 @@ public void testDashIndicesMinus() { assertThat( actual.expressions(), contains( - resolvedIndexExpression("-index10", Set.of("-index10"), expandToOpenIndices ? SUCCESS : CONCRETE_RESOURCE_MISSING), - resolvedIndexExpression("-index11", Set.of("-index11"), expandToOpenIndices ? SUCCESS : CONCRETE_RESOURCE_MISSING), + resolvedIndexExpression("-index10", Set.of("-index10"), expandToOpenIndices ? SUCCESS : CONCRETE_RESOURCE_NOT_VISIBLE), + resolvedIndexExpression("-index11", Set.of("-index11"), expandToOpenIndices ? SUCCESS : CONCRETE_RESOURCE_NOT_VISIBLE), resolvedIndexExpression("--index11", Set.of("--index11"), CONCRETE_RESOURCE_UNAUTHORIZED), - resolvedIndexExpression("-index20", Set.of("-index20"), expandToOpenIndices ? SUCCESS : CONCRETE_RESOURCE_MISSING) + resolvedIndexExpression("-index20", Set.of("-index20"), expandToOpenIndices ? SUCCESS : CONCRETE_RESOURCE_NOT_VISIBLE) ) ); } @@ -1071,7 +1071,7 @@ public void testResolveMissingIndexIgnoreUnavailable() { actual.expressions(), contains( resolvedIndexExpression("bar*", Set.of("bar"), SUCCESS), - resolvedIndexExpression("missing", Set.of("missing"), CONCRETE_RESOURCE_MISSING), + resolvedIndexExpression("missing", Set.of("missing"), CONCRETE_RESOURCE_NOT_VISIBLE), resolvedIndexExpression("missing-and-unauthorized", Set.of(), CONCRETE_RESOURCE_UNAUTHORIZED) ) ); @@ -1138,7 +1138,7 @@ public void testSearchWithRemoteAndLocalIndices() { assertThat( actual.expressions(), contains( - resolvedIndexExpression("bar", Set.of("bar"), expandToOpenIndices ? SUCCESS : CONCRETE_RESOURCE_MISSING), + resolvedIndexExpression("bar", Set.of("bar"), expandToOpenIndices ? SUCCESS : CONCRETE_RESOURCE_NOT_VISIBLE), resolvedIndexExpression("bar2", Set.of(), CONCRETE_RESOURCE_UNAUTHORIZED) ) ); From 97c6efaa8b0d5df424b12d36ecd06b7d0f3b9a7c Mon Sep 17 00:00:00 2001 From: Nikolaj Volgushev Date: Mon, 22 Sep 2025 22:03:01 +0200 Subject: [PATCH 21/89] More --- .../metadata/IndexAbstractionResolver.java | 33 ++++++++----------- 1 file changed, 13 insertions(+), 20 deletions(-) diff --git a/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java b/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java index 1914256f8aefa..bf8591d28ad3c 100644 --- a/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java +++ b/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java @@ -110,7 +110,16 @@ && isIndexVisible( } else { boolean authorized = isAuthorized.test(indexAbstraction, selector); boolean visible = authorized - && existsAndVisible(indicesOptions, projectMetadata, includeDataStreams, indexAbstraction, selectorString); + && indexExists(projectMetadata, indexAbstraction) + && isIndexVisible( + indexAbstraction, + selectorString, + indexAbstraction, + indicesOptions, + projectMetadata, + indexNameExpressionResolver, + includeDataStreams + ); LocalIndexResolutionResult result = authorized ? (visible ? SUCCESS : CONCRETE_RESOURCE_NOT_VISIBLE) @@ -120,7 +129,7 @@ && isIndexVisible( // discarded from the `finalIndices` list. Other "ways of unavailable" must be handled by the action // handler, see: https://github.com/elastic/elasticsearch/issues/90215 boolean includeIndices = indicesOptions.ignoreUnavailable() == false || authorized; - Set finalIndices = includeIndices ? resolvedIndices : Set.of(); + Set finalIndices = includeIndices ? resolvedIndices : new HashSet<>(); resolvedExpressionsBuilder.addLocalExpressions(index, finalIndices, result); } } @@ -278,23 +287,7 @@ private static boolean isVisibleDueToImplicitHidden(String expression, String in return index.startsWith(".") && expression.startsWith(".") && Regex.isSimpleMatchPattern(expression); } - private boolean existsAndVisible( - IndicesOptions indicesOptions, - ProjectMetadata projectMetadata, - boolean includeDataStreams, - String indexAbstraction, - String selectorString - ) { - final IndexAbstraction abstraction = projectMetadata.getIndicesLookup().get(indexAbstraction); - return abstraction != null - && isIndexVisible( - indexAbstraction, - selectorString, - indexAbstraction, - indicesOptions, - projectMetadata, - indexNameExpressionResolver, - includeDataStreams - ); + private static boolean indexExists(ProjectMetadata projectMetadata, String indexAbstraction) { + return projectMetadata.getIndicesLookup().get(indexAbstraction) != null; } } From add29c3f1320a054a79ec11064c0a452a65df758 Mon Sep 17 00:00:00 2001 From: Nikolaj Volgushev Date: Tue, 23 Sep 2025 09:10:51 +0200 Subject: [PATCH 22/89] Toggle storage --- .../xpack/security/authz/AuthorizationService.java | 2 +- .../security/authz/IndicesAndAliasesResolver.java | 11 +++++++++-- .../authz/IndicesAndAliasesResolverTests.java | 3 ++- 3 files changed, 12 insertions(+), 4 deletions(-) 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 0d0ffc43229e4..ce73c4603d6bc 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 @@ -171,7 +171,7 @@ public AuthorizationService( this.clusterService = clusterService; this.auditTrailService = auditTrailService; this.restrictedIndices = restrictedIndices; - this.indicesAndAliasesResolver = new IndicesAndAliasesResolver(settings, linkedProjectConfigService, resolver); + this.indicesAndAliasesResolver = new IndicesAndAliasesResolver(settings, linkedProjectConfigService, resolver, false); this.authcFailureHandler = authcFailureHandler; this.threadContext = threadPool.getThreadContext(); this.securityContext = new SecurityContext(settings, this.threadContext); diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java index 8537aa4428683..d78fe16f8bc09 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java @@ -58,15 +58,18 @@ class IndicesAndAliasesResolver { private final IndexNameExpressionResolver nameExpressionResolver; private final IndexAbstractionResolver indexAbstractionResolver; private final RemoteClusterResolver remoteClusterResolver; + private final boolean recordResolvedIndexExpressions; IndicesAndAliasesResolver( Settings settings, LinkedProjectConfigService linkedProjectConfigService, - IndexNameExpressionResolver resolver + IndexNameExpressionResolver resolver, + boolean recordResolvedIndexExpressions ) { this.nameExpressionResolver = resolver; this.indexAbstractionResolver = new IndexAbstractionResolver(resolver); this.remoteClusterResolver = new RemoteClusterResolver(settings, linkedProjectConfigService); + this.recordResolvedIndexExpressions = recordResolvedIndexExpressions; } /** @@ -357,7 +360,11 @@ ResolvedIndices resolveIndicesAndAliases( authorizedIndices::check, indicesRequest.includeDataStreams() ); - replaceable.setResolvedIndexExpressions(resolved); + // only store resolved expressions if configured to avoid unnecessary memory usage + // once we've migrated from `indices()` to using resolved expressions, we will always store them as part of the request + if (recordResolvedIndexExpressions) { + replaceable.setResolvedIndexExpressions(resolved); + } resolvedIndicesBuilder.addLocal(resolved.getLocalIndicesList()); resolvedIndicesBuilder.addRemote(split.getRemote()); } diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolverTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolverTests.java index b3244676ce3c5..8674cf533a4cd 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolverTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolverTests.java @@ -429,7 +429,8 @@ public void setup() { defaultIndicesResolver = new IndicesAndAliasesResolver( settings, new ClusterSettingsLinkedProjectConfigService(settings, clusterService.getClusterSettings(), projectResolver), - indexNameExpressionResolver + indexNameExpressionResolver, + true ); } From 1d0be73f5ed32b3ad3d197f61f589a57c418e594 Mon Sep 17 00:00:00 2001 From: Nikolaj Volgushev Date: Tue, 23 Sep 2025 10:46:10 +0200 Subject: [PATCH 23/89] Test --- .../action/ResolvedIndexExpression.java | 3 +- .../action/ResolvedIndexExpressions.java | 4 +- .../authz/IndicesAndAliasesResolver.java | 5 ++- .../authz/IndicesAndAliasesResolverTests.java | 37 +++++++++++++++++++ 4 files changed, 45 insertions(+), 4 deletions(-) diff --git a/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpression.java b/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpression.java index 11d7261686ca6..5180af78d22b2 100644 --- a/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpression.java +++ b/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpression.java @@ -43,7 +43,8 @@ public record ResolvedIndexExpression(String original, LocalExpressions localExpressions, Set remoteExpressions) { /** * Indicates if a local index resolution attempt was successful or failed. - * Failures can be due to missing concrete resources or unauthorized concrete resources. + * Failures can be due to concrete resources not being visible (either missing or not visible due to indices options) + * or unauthorized concrete resources. * A wildcard expression resolving to nothing is still considered a successful resolution. */ public enum LocalIndexResolutionResult { diff --git a/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpressions.java b/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpressions.java index 7dbc05264ed05..d24c08098ac25 100644 --- a/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpressions.java +++ b/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpressions.java @@ -9,6 +9,8 @@ package org.elasticsearch.action; +import org.elasticsearch.action.ResolvedIndexExpression.LocalExpressions; + import java.util.ArrayList; import java.util.HashSet; import java.util.List; @@ -38,7 +40,7 @@ public void addLocalExpressions( expressions.add( new ResolvedIndexExpression( original, - new ResolvedIndexExpression.LocalExpressions(new HashSet<>(localExpressions), resolutionResult, null), + new LocalExpressions(new HashSet<>(localExpressions), resolutionResult, null), new HashSet<>() ) ); diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java index d78fe16f8bc09..bb786e84f2dca 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java @@ -360,8 +360,9 @@ ResolvedIndices resolveIndicesAndAliases( authorizedIndices::check, indicesRequest.includeDataStreams() ); - // only store resolved expressions if configured to avoid unnecessary memory usage - // once we've migrated from `indices()` to using resolved expressions, we will always store them as part of the request + // only store resolved expressions if configured, to avoid unnecessary memory usage + // once we've migrated from `indices()` to using resolved expressions holistically, + // we will always store them if (recordResolvedIndexExpressions) { replaceable.setResolvedIndexExpressions(resolved); } diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolverTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolverTests.java index 8674cf533a4cd..027b99973b67f 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolverTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolverTests.java @@ -949,6 +949,43 @@ public void testResolveWildcardsExclusionsExpandWilcardsOpenStrict() { ); } + public void testResolveMultipleWildcardsExclusions() { + SearchRequest request = new SearchRequest("*", "-foo*", "foo*", "-foo*", "foo*", "bar"); + request.indicesOptions(IndicesOptions.fromOptions(false, true, true, false)); + List indices = resolveIndices(request, buildAuthorizedIndices(user, TransportSearchAction.TYPE.name())).getLocal(); + System.out.println(indices); + String[] replacedIndices = new String[] { "bar", "foobarfoo", "foofoobar", "foofoo" }; + assertSameValues(indices, replacedIndices); + assertThat(request.indices(), arrayContainingInAnyOrder("bar", "foobarfoo", "foofoobar", "foofoo", "bar")); + ResolvedIndexExpressions actual = request.getResolvedIndexExpressions(); + assertThat(actual, is(notNullValue())); + assertThat( + actual.expressions(), + contains( + resolvedIndexExpression("*", Set.of("bar"), SUCCESS), + resolvedIndexExpression("foo*", Set.of(), SUCCESS), + resolvedIndexExpression("foo*", Set.of("foobarfoo", "foofoobar", "foofoo"), SUCCESS), + resolvedIndexExpression("bar", Set.of("bar"), SUCCESS) + ) + ); + } + + public void testResolveMultipleDuplicateResources() { + SearchRequest request = new SearchRequest("bar", "bar"); + request.indicesOptions(IndicesOptions.fromOptions(false, true, true, false)); + List indices = resolveIndices(request, buildAuthorizedIndices(user, TransportSearchAction.TYPE.name())).getLocal(); + System.out.println(indices); + String[] replacedIndices = new String[] { "bar" }; + assertSameValues(indices, replacedIndices); + assertThat(request.indices(), arrayContainingInAnyOrder("bar", "bar")); + ResolvedIndexExpressions actual = request.getResolvedIndexExpressions(); + assertThat(actual, is(notNullValue())); + assertThat( + actual.expressions(), + contains(resolvedIndexExpression("bar", Set.of("bar"), SUCCESS), resolvedIndexExpression("bar", Set.of("bar"), SUCCESS)) + ); + } + public void testResolveWildcardsPlusAndMinusExpandWilcardsOpenIgnoreUnavailable() { SearchRequest request = new SearchRequest("*", "-foofoo*", "+barbaz", "+foob*"); request.indicesOptions(IndicesOptions.fromOptions(true, true, true, false)); From c15b0d7f2bf962805d59e01141a730067bf249c0 Mon Sep 17 00:00:00 2001 From: Nikolaj Volgushev Date: Tue, 23 Sep 2025 11:05:52 +0200 Subject: [PATCH 24/89] Rm sysout --- .../xpack/security/authz/IndicesAndAliasesResolverTests.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolverTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolverTests.java index 027b99973b67f..4945bb3cf99e1 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolverTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolverTests.java @@ -953,7 +953,6 @@ public void testResolveMultipleWildcardsExclusions() { SearchRequest request = new SearchRequest("*", "-foo*", "foo*", "-foo*", "foo*", "bar"); request.indicesOptions(IndicesOptions.fromOptions(false, true, true, false)); List indices = resolveIndices(request, buildAuthorizedIndices(user, TransportSearchAction.TYPE.name())).getLocal(); - System.out.println(indices); String[] replacedIndices = new String[] { "bar", "foobarfoo", "foofoobar", "foofoo" }; assertSameValues(indices, replacedIndices); assertThat(request.indices(), arrayContainingInAnyOrder("bar", "foobarfoo", "foofoobar", "foofoo", "bar")); @@ -974,7 +973,6 @@ public void testResolveMultipleDuplicateResources() { SearchRequest request = new SearchRequest("bar", "bar"); request.indicesOptions(IndicesOptions.fromOptions(false, true, true, false)); List indices = resolveIndices(request, buildAuthorizedIndices(user, TransportSearchAction.TYPE.name())).getLocal(); - System.out.println(indices); String[] replacedIndices = new String[] { "bar" }; assertSameValues(indices, replacedIndices); assertThat(request.indices(), arrayContainingInAnyOrder("bar", "bar")); From e2a0dffd532b7acfe5f4e52279a917313b799514 Mon Sep 17 00:00:00 2001 From: Nikolaj Volgushev Date: Wed, 24 Sep 2025 11:15:03 +0200 Subject: [PATCH 25/89] Use hashset as optimization --- .../action/ResolvedIndexExpressions.java | 11 +++++------ .../cluster/metadata/IndexAbstractionResolver.java | 6 +++--- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpressions.java b/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpressions.java index d24c08098ac25..b3c126ca2977b 100644 --- a/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpressions.java +++ b/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpressions.java @@ -32,17 +32,16 @@ public static Builder builder() { public static final class Builder { private final List expressions = new ArrayList<>(); + /** + * @param localExpressions is a HashSet as an optimization -- the set needs to be mutable, and we want to avoid copying it + */ public void addLocalExpressions( String original, - Set localExpressions, + HashSet localExpressions, ResolvedIndexExpression.LocalIndexResolutionResult resolutionResult ) { expressions.add( - new ResolvedIndexExpression( - original, - new LocalExpressions(new HashSet<>(localExpressions), resolutionResult, null), - new HashSet<>() - ) + new ResolvedIndexExpression(original, new LocalExpressions(localExpressions, resolutionResult, null), new HashSet<>()) ); } diff --git a/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java b/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java index bf8591d28ad3c..479bb4694c838 100644 --- a/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java +++ b/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java @@ -74,7 +74,7 @@ public ResolvedIndexExpressions resolveIndexAbstractions( if (indicesOptions.expandWildcardExpressions() && Regex.isSimpleMatchPattern(indexAbstraction)) { wildcardSeen = true; - Set resolvedIndices = new HashSet<>(); + HashSet resolvedIndices = new HashSet<>(); for (String authorizedIndex : allAuthorizedAndAvailableBySelector.apply(selector)) { if (Regex.simpleMatch(indexAbstraction, authorizedIndex) && isIndexVisible( @@ -103,7 +103,7 @@ && isIndexVisible( } } } else { - Set resolvedIndices = new HashSet<>(); + HashSet resolvedIndices = new HashSet<>(); resolveSelectorsAndCollect(indexAbstraction, selectorString, indicesOptions, resolvedIndices, projectMetadata); if (minus) { resolvedExpressionsBuilder.excludeAll(resolvedIndices); @@ -129,7 +129,7 @@ && isIndexVisible( // discarded from the `finalIndices` list. Other "ways of unavailable" must be handled by the action // handler, see: https://github.com/elastic/elasticsearch/issues/90215 boolean includeIndices = indicesOptions.ignoreUnavailable() == false || authorized; - Set finalIndices = includeIndices ? resolvedIndices : new HashSet<>(); + HashSet finalIndices = includeIndices ? resolvedIndices : new HashSet<>(); resolvedExpressionsBuilder.addLocalExpressions(index, finalIndices, result); } } From 177a8acd4bde3627b19d99b30f68363a40080e08 Mon Sep 17 00:00:00 2001 From: Nikolaj Volgushev Date: Wed, 24 Sep 2025 11:32:14 +0200 Subject: [PATCH 26/89] Naming nits --- .../org/elasticsearch/action/ResolvedIndexExpressions.java | 2 +- .../cluster/metadata/IndexAbstractionResolver.java | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpressions.java b/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpressions.java index b3c126ca2977b..4f68792ad7193 100644 --- a/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpressions.java +++ b/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpressions.java @@ -45,7 +45,7 @@ public void addLocalExpressions( ); } - public void excludeAll(Set expressionsToExclude) { + public void excludeFromLocalExpressions(Set expressionsToExclude) { if (expressionsToExclude.isEmpty() == false) { for (ResolvedIndexExpression prior : expressions) { prior.localExpressions().expressions().removeAll(expressionsToExclude); diff --git a/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java b/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java index 479bb4694c838..37ae7580d2602 100644 --- a/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java +++ b/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java @@ -97,7 +97,7 @@ && isIndexVisible( resolvedExpressionsBuilder.addLocalExpressions(index, new HashSet<>(), SUCCESS); } else { if (minus) { - resolvedExpressionsBuilder.excludeAll(resolvedIndices); + resolvedExpressionsBuilder.excludeFromLocalExpressions(resolvedIndices); } else { resolvedExpressionsBuilder.addLocalExpressions(index, resolvedIndices, SUCCESS); } @@ -106,7 +106,7 @@ && isIndexVisible( HashSet resolvedIndices = new HashSet<>(); resolveSelectorsAndCollect(indexAbstraction, selectorString, indicesOptions, resolvedIndices, projectMetadata); if (minus) { - resolvedExpressionsBuilder.excludeAll(resolvedIndices); + resolvedExpressionsBuilder.excludeFromLocalExpressions(resolvedIndices); } else { boolean authorized = isAuthorized.test(indexAbstraction, selector); boolean visible = authorized From f55765588a1e6e1d33f2cfa03d9c93d6ce59835c Mon Sep 17 00:00:00 2001 From: Nikolaj Volgushev Date: Wed, 24 Sep 2025 12:27:53 +0200 Subject: [PATCH 27/89] Feedback --- .../action/ResolvedIndexExpressions.java | 12 +++- .../metadata/IndexAbstractionResolver.java | 55 +++++++++---------- 2 files changed, 38 insertions(+), 29 deletions(-) diff --git a/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpressions.java b/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpressions.java index 4f68792ad7193..c02f8dfca3b24 100644 --- a/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpressions.java +++ b/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpressions.java @@ -14,6 +14,7 @@ import java.util.ArrayList; import java.util.HashSet; import java.util.List; +import java.util.Objects; import java.util.Set; /** @@ -33,19 +34,28 @@ public static final class Builder { private final List expressions = new ArrayList<>(); /** - * @param localExpressions is a HashSet as an optimization -- the set needs to be mutable, and we want to avoid copying it + * @param original the original expression that was resolved -- may be blank for "access all" cases + * @param localExpressions is a HashSet as an optimization -- the set needs to be mutable, and we want to avoid copying it. + * May be empty. */ public void addLocalExpressions( String original, HashSet localExpressions, ResolvedIndexExpression.LocalIndexResolutionResult resolutionResult ) { + Objects.requireNonNull(original); + Objects.requireNonNull(localExpressions); + Objects.requireNonNull(resolutionResult); expressions.add( new ResolvedIndexExpression(original, new LocalExpressions(localExpressions, resolutionResult, null), new HashSet<>()) ); } + /** + * Exclude the given expressions from the local expressions of all prior added {@link ResolvedIndexExpression}. + */ public void excludeFromLocalExpressions(Set expressionsToExclude) { + Objects.requireNonNull(expressionsToExclude); if (expressionsToExclude.isEmpty() == false) { for (ResolvedIndexExpression prior : expressions) { prior.localExpressions().expressions().removeAll(expressionsToExclude); diff --git a/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java b/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java index 37ae7580d2602..6e6d9844c1154 100644 --- a/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java +++ b/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java @@ -47,7 +47,7 @@ public ResolvedIndexExpressions resolveIndexAbstractions( BiPredicate isAuthorized, boolean includeDataStreams ) { - ResolvedIndexExpressions.Builder resolvedExpressionsBuilder = ResolvedIndexExpressions.builder(); + final ResolvedIndexExpressions.Builder resolvedExpressionsBuilder = ResolvedIndexExpressions.builder(); boolean wildcardSeen = false; for (String index : indices) { @@ -61,8 +61,8 @@ public ResolvedIndexExpressions resolveIndexAbstractions( } // Always check to see if there's a selector on the index expression - Tuple expressionAndSelector = IndexNameExpressionResolver.splitSelectorExpression(indexAbstraction); - String selectorString = expressionAndSelector.v2(); + final Tuple expressionAndSelector = IndexNameExpressionResolver.splitSelectorExpression(indexAbstraction); + final String selectorString = expressionAndSelector.v2(); if (indicesOptions.allowSelectors() == false && selectorString != null) { throw new UnsupportedSelectorException(indexAbstraction); } @@ -74,7 +74,7 @@ public ResolvedIndexExpressions resolveIndexAbstractions( if (indicesOptions.expandWildcardExpressions() && Regex.isSimpleMatchPattern(indexAbstraction)) { wildcardSeen = true; - HashSet resolvedIndices = new HashSet<>(); + final HashSet resolvedIndices = new HashSet<>(); for (String authorizedIndex : allAuthorizedAndAvailableBySelector.apply(selector)) { if (Regex.simpleMatch(indexAbstraction, authorizedIndex) && isIndexVisible( @@ -103,34 +103,33 @@ && isIndexVisible( } } } else { - HashSet resolvedIndices = new HashSet<>(); + final HashSet resolvedIndices = new HashSet<>(); resolveSelectorsAndCollect(indexAbstraction, selectorString, indicesOptions, resolvedIndices, projectMetadata); if (minus) { resolvedExpressionsBuilder.excludeFromLocalExpressions(resolvedIndices); } else { - boolean authorized = isAuthorized.test(indexAbstraction, selector); - boolean visible = authorized - && indexExists(projectMetadata, indexAbstraction) - && isIndexVisible( - indexAbstraction, - selectorString, - indexAbstraction, - indicesOptions, - projectMetadata, - indexNameExpressionResolver, - includeDataStreams - ); - - LocalIndexResolutionResult result = authorized - ? (visible ? SUCCESS : CONCRETE_RESOURCE_NOT_VISIBLE) - : CONCRETE_RESOURCE_UNAUTHORIZED; - - // Unauthorized names are considered unavailable, so if `ignoreUnavailable` is `true` they should be silently - // discarded from the `finalIndices` list. Other "ways of unavailable" must be handled by the action - // handler, see: https://github.com/elastic/elasticsearch/issues/90215 - boolean includeIndices = indicesOptions.ignoreUnavailable() == false || authorized; - HashSet finalIndices = includeIndices ? resolvedIndices : new HashSet<>(); - resolvedExpressionsBuilder.addLocalExpressions(index, finalIndices, result); + final boolean authorized = isAuthorized.test(indexAbstraction, selector); + if (authorized) { + final boolean visible = indexExists(projectMetadata, indexAbstraction) + && isIndexVisible( + indexAbstraction, + selectorString, + indexAbstraction, + indicesOptions, + projectMetadata, + indexNameExpressionResolver, + includeDataStreams + ); + final LocalIndexResolutionResult result = visible ? SUCCESS : CONCRETE_RESOURCE_NOT_VISIBLE; + resolvedExpressionsBuilder.addLocalExpressions(index, resolvedIndices, result); + } else if (indicesOptions.ignoreUnavailable()) { + // ignoreUnavailable implies that the request should not fail if an index is not authorized + // so we map this expression to an empty list, + resolvedExpressionsBuilder.addLocalExpressions(index, new HashSet<>(), CONCRETE_RESOURCE_UNAUTHORIZED); + } else { + // store the calculated expansion as unauthorized, it will be rejected later + resolvedExpressionsBuilder.addLocalExpressions(index, resolvedIndices, CONCRETE_RESOURCE_UNAUTHORIZED); + } } } } From 617d7d4a1b84e945ddeda962681dab021ecb47b9 Mon Sep 17 00:00:00 2001 From: Nikolaj Volgushev Date: Wed, 24 Sep 2025 14:11:29 +0200 Subject: [PATCH 28/89] load project --- server/src/main/java/module-info.java | 1 + .../crossproject/AuthorizedProjects.java | 16 +++++ .../crossproject/ProjectRoutingInfo.java | 27 ++++++++ .../search/crossproject/ProjectTags.java | 62 +++++++++++++++++++ .../core/security/SecurityExtension.java | 4 +- ...rossProjectSearchAuthorizationService.java | 22 +++++++ ...ProjectSearchIndexExpressionsRewriter.java | 22 ------- .../xpack/security/Security.java | 8 +-- .../security/authz/AuthorizationService.java | 19 +++--- .../authz/IndicesAndAliasesResolver.java | 1 - 10 files changed, 144 insertions(+), 38 deletions(-) create mode 100644 server/src/main/java/org/elasticsearch/search/crossproject/AuthorizedProjects.java create mode 100644 server/src/main/java/org/elasticsearch/search/crossproject/ProjectRoutingInfo.java create mode 100644 server/src/main/java/org/elasticsearch/search/crossproject/ProjectTags.java create mode 100644 x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/CrossProjectSearchAuthorizationService.java delete mode 100644 x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/CrossProjectSearchIndexExpressionsRewriter.java diff --git a/server/src/main/java/module-info.java b/server/src/main/java/module-info.java index cfe6345d7e590..68dc8da3b7d15 100644 --- a/server/src/main/java/module-info.java +++ b/server/src/main/java/module-info.java @@ -491,4 +491,5 @@ exports org.elasticsearch.inference.telemetry; exports org.elasticsearch.index.codec.vectors.diskbbq to org.elasticsearch.test.knn; exports org.elasticsearch.index.codec.vectors.cluster to org.elasticsearch.test.knn; + exports org.elasticsearch.search.crossproject; } diff --git a/server/src/main/java/org/elasticsearch/search/crossproject/AuthorizedProjects.java b/server/src/main/java/org/elasticsearch/search/crossproject/AuthorizedProjects.java new file mode 100644 index 0000000000000..6cd3702678632 --- /dev/null +++ b/server/src/main/java/org/elasticsearch/search/crossproject/AuthorizedProjects.java @@ -0,0 +1,16 @@ +/* + * 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.search.crossproject; + +import java.util.List; + +public record AuthorizedProjects(ProjectRoutingInfo originProject, List linkedProjects) { + public static AuthorizedProjects NOT_CROSS_PROJECT = new AuthorizedProjects(null, List.of()); +} diff --git a/server/src/main/java/org/elasticsearch/search/crossproject/ProjectRoutingInfo.java b/server/src/main/java/org/elasticsearch/search/crossproject/ProjectRoutingInfo.java new file mode 100644 index 0000000000000..e8c52be5ea86d --- /dev/null +++ b/server/src/main/java/org/elasticsearch/search/crossproject/ProjectRoutingInfo.java @@ -0,0 +1,27 @@ +/* + * 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.search.crossproject; + +import org.elasticsearch.cluster.metadata.ProjectId; + +/** + * Information about a project used for routing in cross-project search. + */ +public record ProjectRoutingInfo( + ProjectId projectId, + String projectType, + String projectAlias, + String organizationId, + ProjectTags projectTags +) { + public ProjectRoutingInfo(ProjectId projectId, ProjectTags projectTags) { + this(projectId, projectTags.projectType(), projectTags.projectAlias(), projectTags.organizationId(), projectTags); + } +} diff --git a/server/src/main/java/org/elasticsearch/search/crossproject/ProjectTags.java b/server/src/main/java/org/elasticsearch/search/crossproject/ProjectTags.java new file mode 100644 index 0000000000000..6db280e6f7d22 --- /dev/null +++ b/server/src/main/java/org/elasticsearch/search/crossproject/ProjectTags.java @@ -0,0 +1,62 @@ +/* + * 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.search.crossproject; + +import java.util.Map; + +/** + * Project tags used for cross-project search routing. + * @param tags the map of tags -- contains both built-in (Elastic-supplied) and custom user-defined tags. + * All built-in tags are prefixed with an underscore (_). + */ +public record ProjectTags(Map tags) { + public static final String PROJECT_ID_TAG = "_id"; + public static final String PROJECT_ALIAS = "_alias"; + public static final String PROJECT_TYPE_TAG = "_type"; + public static final String ORGANIZATION_ID_TAG = "_organization"; + + public String projectId() { + return tags.get(PROJECT_ID_TAG); + } + + public String organizationId() { + return tags.get(ORGANIZATION_ID_TAG); + } + + public String projectType() { + return tags.get(PROJECT_TYPE_TAG); + } + + public String projectAlias() { + return tags.get(PROJECT_ALIAS); + } + + /** + * Validate that all required tags are present. + */ + public static void validateTags(String projectId, Map tags) { + if (false == tags.containsKey(PROJECT_ID_TAG)) { + throw missingTagException(projectId, PROJECT_ID_TAG); + } + if (false == tags.containsKey(PROJECT_TYPE_TAG)) { + throw missingTagException(projectId, PROJECT_TYPE_TAG); + } + if (false == tags.containsKey(ORGANIZATION_ID_TAG)) { + throw missingTagException(projectId, ORGANIZATION_ID_TAG); + } + if (false == tags.containsKey(PROJECT_ALIAS)) { + throw missingTagException(projectId, PROJECT_ALIAS); + } + } + + private static IllegalStateException missingTagException(String projectId, String tagKey) { + return new IllegalStateException("Project configuration for [" + projectId + "] is missing required tag [" + tagKey + "]"); + } +} diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/SecurityExtension.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/SecurityExtension.java index 4debb0e1236b1..008b5c7cb7697 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/SecurityExtension.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/SecurityExtension.java @@ -21,7 +21,7 @@ import org.elasticsearch.xpack.core.security.authc.service.ServiceAccountTokenStore; import org.elasticsearch.xpack.core.security.authc.support.UserRoleMapper; import org.elasticsearch.xpack.core.security.authz.AuthorizationEngine; -import org.elasticsearch.xpack.core.security.authz.CrossProjectSearchIndexExpressionsRewriter; +import org.elasticsearch.xpack.core.security.authz.CrossProjectSearchAuthorizationService; import org.elasticsearch.xpack.core.security.authz.RoleDescriptor; import org.elasticsearch.xpack.core.security.authz.store.RoleRetrievalResult; @@ -150,7 +150,7 @@ default String extensionName() { return getClass().getName(); } - default CrossProjectSearchIndexExpressionsRewriter getCrossProjectSearchIndexExpressionsRewriter(SecurityComponents components) { + default CrossProjectSearchAuthorizationService getCrossProjectSearchIndexExpressionsRewriter(SecurityComponents components) { return null; } } diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/CrossProjectSearchAuthorizationService.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/CrossProjectSearchAuthorizationService.java new file mode 100644 index 0000000000000..8aae3ac4c2e83 --- /dev/null +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/CrossProjectSearchAuthorizationService.java @@ -0,0 +1,22 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.core.security.authz; + +import org.elasticsearch.action.ActionListener; +import org.elasticsearch.search.crossproject.AuthorizedProjects; + +public interface CrossProjectSearchAuthorizationService { + void loadAuthorizedProjects(ActionListener listener); + + class Default implements CrossProjectSearchAuthorizationService { + @Override + public void loadAuthorizedProjects(ActionListener listener) { + listener.onResponse(AuthorizedProjects.NOT_CROSS_PROJECT); + } + } +} diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/CrossProjectSearchIndexExpressionsRewriter.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/CrossProjectSearchIndexExpressionsRewriter.java deleted file mode 100644 index 19fb51e8d118b..0000000000000 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/CrossProjectSearchIndexExpressionsRewriter.java +++ /dev/null @@ -1,22 +0,0 @@ -/* - * 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; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -package org.elasticsearch.xpack.core.security.authz; - -import org.elasticsearch.action.ActionListener; -import org.elasticsearch.action.IndicesRequest; - -public interface CrossProjectSearchIndexExpressionsRewriter { - void rewriteIndexExpressions(IndicesRequest.Replaceable request, ActionListener listener); - - class Default implements CrossProjectSearchIndexExpressionsRewriter { - @Override - public void rewriteIndexExpressions(IndicesRequest.Replaceable request, ActionListener listener) { - listener.onResponse(null); - } - } -} diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java index 851967712c595..014fcc1559087 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java @@ -209,7 +209,7 @@ import org.elasticsearch.xpack.core.security.authc.support.UserRoleMapper; import org.elasticsearch.xpack.core.security.authc.support.UsernamePasswordToken; import org.elasticsearch.xpack.core.security.authz.AuthorizationEngine; -import org.elasticsearch.xpack.core.security.authz.CrossProjectSearchIndexExpressionsRewriter; +import org.elasticsearch.xpack.core.security.authz.CrossProjectSearchAuthorizationService; import org.elasticsearch.xpack.core.security.authz.RestrictedIndices; import org.elasticsearch.xpack.core.security.authz.RoleDescriptor; import org.elasticsearch.xpack.core.security.authz.accesscontrol.DocumentSubsetBitsetCache; @@ -1142,7 +1142,7 @@ Collection createComponents( authorizationDenialMessages.set(new AuthorizationDenialMessages.Default()); } - final CrossProjectSearchIndexExpressionsRewriter crossProjectSearchIndexExpressionsRewriter = + final CrossProjectSearchAuthorizationService crossProjectSearchIndexExpressionsRewriter = getCrossProjectSearchIndexExpressionsRewriter(extensionComponents); final AuthorizationService authzService = new AuthorizationService( settings, @@ -1163,7 +1163,7 @@ Collection createComponents( linkedProjectConfigService, projectResolver, crossProjectSearchIndexExpressionsRewriter == null - ? new CrossProjectSearchIndexExpressionsRewriter.Default() + ? new CrossProjectSearchAuthorizationService.Default() : crossProjectSearchIndexExpressionsRewriter ); @@ -1318,7 +1318,7 @@ private List getCustomAuthenticatorFromExtensions(SecurityE } } - private CrossProjectSearchIndexExpressionsRewriter getCrossProjectSearchIndexExpressionsRewriter( + private CrossProjectSearchAuthorizationService getCrossProjectSearchIndexExpressionsRewriter( SecurityExtension.SecurityComponents extensionComponents ) { return findValueFromExtensions( 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 7dd4470dd1930..319070c5b763a 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 @@ -49,6 +49,7 @@ import org.elasticsearch.index.IndexNotFoundException; import org.elasticsearch.indices.InvalidIndexNameException; import org.elasticsearch.license.XPackLicenseState; +import org.elasticsearch.search.crossproject.AuthorizedProjects; import org.elasticsearch.threadpool.ThreadPool; import org.elasticsearch.transport.LinkedProjectConfigService; import org.elasticsearch.transport.TransportActionProxy; @@ -71,7 +72,7 @@ import org.elasticsearch.xpack.core.security.authz.AuthorizationEngine.ParentActionAuthorization; import org.elasticsearch.xpack.core.security.authz.AuthorizationEngine.RequestInfo; import org.elasticsearch.xpack.core.security.authz.AuthorizationServiceField; -import org.elasticsearch.xpack.core.security.authz.CrossProjectSearchIndexExpressionsRewriter; +import org.elasticsearch.xpack.core.security.authz.CrossProjectSearchAuthorizationService; import org.elasticsearch.xpack.core.security.authz.ResolvedIndices; import org.elasticsearch.xpack.core.security.authz.RestrictedIndices; import org.elasticsearch.xpack.core.security.authz.RoleDescriptorsIntersection; @@ -150,7 +151,7 @@ public class AuthorizationService { private final boolean isAnonymousEnabled; private final boolean anonymousAuthzExceptionEnabled; private final DlsFlsFeatureTrackingIndicesAccessControlWrapper indicesAccessControlWrapper; - private final CrossProjectSearchIndexExpressionsRewriter crossProjectSearchIndexExpressionsRewriter; + private final CrossProjectSearchAuthorizationService crossProjectSearchAuthzService; public AuthorizationService( Settings settings, @@ -189,7 +190,7 @@ public AuthorizationService( authorizationDenialMessages, linkedProjectConfigService, projectResolver, - new CrossProjectSearchIndexExpressionsRewriter.Default() + new CrossProjectSearchAuthorizationService.Default() ); } @@ -211,7 +212,7 @@ public AuthorizationService( AuthorizationDenialMessages authorizationDenialMessages, LinkedProjectConfigService linkedProjectConfigService, ProjectResolver projectResolver, - CrossProjectSearchIndexExpressionsRewriter crossProjectSearchIndexExpressionsRewriter + CrossProjectSearchAuthorizationService crossProjectSearchIndexExpressionsRewriter ) { this.clusterService = clusterService; this.auditTrailService = auditTrailService; @@ -237,7 +238,7 @@ public AuthorizationService( this.indicesAccessControlWrapper = new DlsFlsFeatureTrackingIndicesAccessControlWrapper(settings, licenseState); this.authorizationDenialMessages = authorizationDenialMessages; this.projectResolver = projectResolver; - this.crossProjectSearchIndexExpressionsRewriter = crossProjectSearchIndexExpressionsRewriter; + this.crossProjectSearchAuthzService = crossProjectSearchIndexExpressionsRewriter; } public void checkPrivileges( @@ -549,11 +550,11 @@ private void authorizeAction( authzInfo, projectMetadata.getIndicesLookup(), ActionListener.wrap(authorizedIndices -> { - if (request instanceof IndicesRequest.Replaceable replaceable) { - crossProjectSearchIndexExpressionsRewriter.rewriteIndexExpressions(replaceable, new ActionListener() { + if (request instanceof IndicesRequest.Replaceable replaceable && replaceable.allowsCrossProjectSearch()) { + crossProjectSearchAuthzService.loadAuthorizedProjects(new ActionListener<>() { @Override - public void onResponse(Void unused) { - indicesAndAliasesResolver.resolve(action, request, projectMetadata, authorizedIndices); + public void onResponse(AuthorizedProjects authorizedProjects) { + } @Override diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java index bb786e84f2dca..84da1b9bf5059 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java @@ -110,7 +110,6 @@ class IndicesAndAliasesResolver { * resolving wildcards. *

*/ - ResolvedIndices resolve( String action, TransportRequest request, From e2e6236519f17653074ab56f8bb7f58ac6afcd15 Mon Sep 17 00:00:00 2001 From: Nikolaj Volgushev Date: Wed, 24 Sep 2025 14:21:16 +0200 Subject: [PATCH 29/89] Tweaks --- .../xpack/core/security/SecurityExtension.java | 2 +- .../elasticsearch/xpack/security/Security.java | 15 ++++++++------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/SecurityExtension.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/SecurityExtension.java index 008b5c7cb7697..db31445001e3b 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/SecurityExtension.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/SecurityExtension.java @@ -150,7 +150,7 @@ default String extensionName() { return getClass().getName(); } - default CrossProjectSearchAuthorizationService getCrossProjectSearchIndexExpressionsRewriter(SecurityComponents components) { + default CrossProjectSearchAuthorizationService getCrossProjectSearchAuthorizationService(SecurityComponents components) { return null; } } diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java index 014fcc1559087..ddddc67e6a6e5 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java @@ -1142,8 +1142,9 @@ Collection createComponents( authorizationDenialMessages.set(new AuthorizationDenialMessages.Default()); } - final CrossProjectSearchAuthorizationService crossProjectSearchIndexExpressionsRewriter = - getCrossProjectSearchIndexExpressionsRewriter(extensionComponents); + final CrossProjectSearchAuthorizationService crossProjectSearchAuthorizationService = getCrossProjectSearchAuthorizationService( + extensionComponents + ); final AuthorizationService authzService = new AuthorizationService( settings, allRolesStore, @@ -1162,9 +1163,9 @@ Collection createComponents( authorizationDenialMessages.get(), linkedProjectConfigService, projectResolver, - crossProjectSearchIndexExpressionsRewriter == null + crossProjectSearchAuthorizationService == null ? new CrossProjectSearchAuthorizationService.Default() - : crossProjectSearchIndexExpressionsRewriter + : crossProjectSearchAuthorizationService ); components.add(nativeRolesStore); // used by roles actions @@ -1318,12 +1319,12 @@ private List getCustomAuthenticatorFromExtensions(SecurityE } } - private CrossProjectSearchAuthorizationService getCrossProjectSearchIndexExpressionsRewriter( + private CrossProjectSearchAuthorizationService getCrossProjectSearchAuthorizationService( SecurityExtension.SecurityComponents extensionComponents ) { return findValueFromExtensions( - "cross-project search index expressions rewriter", - extension -> extension.getCrossProjectSearchIndexExpressionsRewriter(extensionComponents) + "cross-project search authorization service", + extension -> extension.getCrossProjectSearchAuthorizationService(extensionComponents) ); } From 635492823b7ec2cd0078eb30a2b4307134d013be Mon Sep 17 00:00:00 2001 From: Nikolaj Volgushev Date: Wed, 24 Sep 2025 14:26:02 +0200 Subject: [PATCH 30/89] WIP --- .../xpack/security/authz/AuthorizationService.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 319070c5b763a..471e990d2b72b 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 @@ -554,7 +554,10 @@ private void authorizeAction( crossProjectSearchAuthzService.loadAuthorizedProjects(new ActionListener<>() { @Override public void onResponse(AuthorizedProjects authorizedProjects) { - + logger.debug("Loaded authorized projects: [{}]", authorizedProjects); + resolvedIndicesListener.onResponse( + indicesAndAliasesResolver.resolve(action, request, projectMetadata, authorizedIndices) + ); } @Override From 3be77dafb645e90b172edfdde35ff18e19c311d4 Mon Sep 17 00:00:00 2001 From: Nikolaj Volgushev Date: Wed, 24 Sep 2025 16:14:26 +0200 Subject: [PATCH 31/89] Rename --- .../main/java/org/elasticsearch/action/IndicesRequest.java | 2 +- .../java/org/elasticsearch/action/search/SearchRequest.java | 5 +++++ .../xpack/security/authz/AuthorizationService.java | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/server/src/main/java/org/elasticsearch/action/IndicesRequest.java b/server/src/main/java/org/elasticsearch/action/IndicesRequest.java index 37457cb341621..7b6535e042610 100644 --- a/server/src/main/java/org/elasticsearch/action/IndicesRequest.java +++ b/server/src/main/java/org/elasticsearch/action/IndicesRequest.java @@ -81,7 +81,7 @@ default boolean allowsRemoteIndices() { return false; } - default boolean allowsCrossProjectSearch() { + default boolean supportsCrossProjectSearch() { return false; } } diff --git a/server/src/main/java/org/elasticsearch/action/search/SearchRequest.java b/server/src/main/java/org/elasticsearch/action/search/SearchRequest.java index f84a3578264f8..14c10ed35a7a6 100644 --- a/server/src/main/java/org/elasticsearch/action/search/SearchRequest.java +++ b/server/src/main/java/org/elasticsearch/action/search/SearchRequest.java @@ -158,6 +158,11 @@ public boolean allowsRemoteIndices() { return true; } + @Override + public boolean supportsCrossProjectSearch() { + return true; + } + /** * Creates a new sub-search request starting from the original search request that is provided. * For internal use only, allows to fork a search request into multiple search requests that will be executed independently. 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 471e990d2b72b..a3f80f92e36e1 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 @@ -550,7 +550,7 @@ private void authorizeAction( authzInfo, projectMetadata.getIndicesLookup(), ActionListener.wrap(authorizedIndices -> { - if (request instanceof IndicesRequest.Replaceable replaceable && replaceable.allowsCrossProjectSearch()) { + if (request instanceof IndicesRequest.Replaceable replaceable && replaceable.supportsCrossProjectSearch()) { crossProjectSearchAuthzService.loadAuthorizedProjects(new ActionListener<>() { @Override public void onResponse(AuthorizedProjects authorizedProjects) { From 339ccea447e37c81191d3ea613f8ecaa73332255 Mon Sep 17 00:00:00 2001 From: Nikolaj Volgushev Date: Thu, 25 Sep 2025 11:31:13 +0200 Subject: [PATCH 32/89] Project filterting sort of works --- ...rizedProjects.java => TargetProjects.java} | 4 +- ...rossProjectSearchAuthorizationService.java | 8 +- .../security/authz/AuthorizationService.java | 17 +- .../authz/IndicesAndAliasesResolver.java | 26 ++- .../CrossProjectIndexExpressionsRewriter.java | 174 ++++++++++++++++++ .../NoMatchingProjectException.java | 21 +++ 6 files changed, 237 insertions(+), 13 deletions(-) rename server/src/main/java/org/elasticsearch/search/crossproject/{AuthorizedProjects.java => TargetProjects.java} (72%) create mode 100644 x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/crossproject/CrossProjectIndexExpressionsRewriter.java create mode 100644 x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/crossproject/NoMatchingProjectException.java diff --git a/server/src/main/java/org/elasticsearch/search/crossproject/AuthorizedProjects.java b/server/src/main/java/org/elasticsearch/search/crossproject/TargetProjects.java similarity index 72% rename from server/src/main/java/org/elasticsearch/search/crossproject/AuthorizedProjects.java rename to server/src/main/java/org/elasticsearch/search/crossproject/TargetProjects.java index 6cd3702678632..76df1fab70a21 100644 --- a/server/src/main/java/org/elasticsearch/search/crossproject/AuthorizedProjects.java +++ b/server/src/main/java/org/elasticsearch/search/crossproject/TargetProjects.java @@ -11,6 +11,6 @@ import java.util.List; -public record AuthorizedProjects(ProjectRoutingInfo originProject, List linkedProjects) { - public static AuthorizedProjects NOT_CROSS_PROJECT = new AuthorizedProjects(null, List.of()); +public record TargetProjects(ProjectRoutingInfo originProject, List linkedProjects) { + public static TargetProjects NOT_CROSS_PROJECT = new TargetProjects(null, List.of()); } diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/CrossProjectSearchAuthorizationService.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/CrossProjectSearchAuthorizationService.java index 8aae3ac4c2e83..a8de82c857416 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/CrossProjectSearchAuthorizationService.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/CrossProjectSearchAuthorizationService.java @@ -8,15 +8,15 @@ package org.elasticsearch.xpack.core.security.authz; import org.elasticsearch.action.ActionListener; -import org.elasticsearch.search.crossproject.AuthorizedProjects; +import org.elasticsearch.search.crossproject.TargetProjects; public interface CrossProjectSearchAuthorizationService { - void loadAuthorizedProjects(ActionListener listener); + void loadAuthorizedProjects(ActionListener listener); class Default implements CrossProjectSearchAuthorizationService { @Override - public void loadAuthorizedProjects(ActionListener listener) { - listener.onResponse(AuthorizedProjects.NOT_CROSS_PROJECT); + public void loadAuthorizedProjects(ActionListener listener) { + listener.onResponse(TargetProjects.NOT_CROSS_PROJECT); } } } 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 a3f80f92e36e1..13831ca0356fc 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 @@ -49,7 +49,7 @@ import org.elasticsearch.index.IndexNotFoundException; import org.elasticsearch.indices.InvalidIndexNameException; import org.elasticsearch.license.XPackLicenseState; -import org.elasticsearch.search.crossproject.AuthorizedProjects; +import org.elasticsearch.search.crossproject.TargetProjects; import org.elasticsearch.threadpool.ThreadPool; import org.elasticsearch.transport.LinkedProjectConfigService; import org.elasticsearch.transport.TransportActionProxy; @@ -553,15 +553,22 @@ private void authorizeAction( if (request instanceof IndicesRequest.Replaceable replaceable && replaceable.supportsCrossProjectSearch()) { crossProjectSearchAuthzService.loadAuthorizedProjects(new ActionListener<>() { @Override - public void onResponse(AuthorizedProjects authorizedProjects) { - logger.debug("Loaded authorized projects: [{}]", authorizedProjects); + public void onResponse(TargetProjects authorizedProjects) { + logger.info("Loaded authorized projects: [{}]", authorizedProjects); resolvedIndicesListener.onResponse( - indicesAndAliasesResolver.resolve(action, request, projectMetadata, authorizedIndices) + indicesAndAliasesResolver.resolve( + action, + request, + projectMetadata, + authorizedIndices, + authorizedProjects + ) ); } @Override public void onFailure(Exception e) { + logger.info("Failed to load authorized projects", e); resolvedIndicesListener.onFailure(e); } }); @@ -574,7 +581,7 @@ public void onFailure(Exception e) { if (e instanceof InvalidIndexNameException || e instanceof InvalidSelectorException || e instanceof UnsupportedSelectorException) { - logger.debug( + logger.info( () -> Strings.format( "failed [%s] action authorization for [%s] due to [%s] exception", action, diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java index 84da1b9bf5059..6e6edbc56c6d6 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java @@ -30,6 +30,7 @@ import org.elasticsearch.core.Tuple; import org.elasticsearch.index.Index; import org.elasticsearch.index.IndexNotFoundException; +import org.elasticsearch.search.crossproject.TargetProjects; import org.elasticsearch.transport.LinkedProjectConfig; import org.elasticsearch.transport.LinkedProjectConfigService; import org.elasticsearch.transport.NoSuchRemoteClusterException; @@ -72,6 +73,15 @@ class IndicesAndAliasesResolver { this.recordResolvedIndexExpressions = recordResolvedIndexExpressions; } + ResolvedIndices resolve( + String action, + TransportRequest request, + ProjectMetadata projectMetadata, + AuthorizationEngine.AuthorizedIndices authorizedIndices + ) { + return resolve(action, request, projectMetadata, authorizedIndices, TargetProjects.NOT_CROSS_PROJECT); + } + /** * Resolves, and if necessary updates, the list of index names in the provided request in accordance with the user's * authorizedIndices. @@ -114,7 +124,8 @@ ResolvedIndices resolve( String action, TransportRequest request, ProjectMetadata projectMetadata, - AuthorizationEngine.AuthorizedIndices authorizedIndices + AuthorizationEngine.AuthorizedIndices authorizedIndices, + TargetProjects authorizedProjects ) { if (request instanceof IndicesAliasesRequest indicesAliasesRequest) { ResolvedIndices.Builder resolvedIndicesBuilder = new ResolvedIndices.Builder(); @@ -130,7 +141,7 @@ ResolvedIndices resolve( if (request instanceof IndicesRequest == false) { throw new IllegalStateException("Request [" + request + "] is not an Indices request, but should be."); } - return resolveIndicesAndAliases(action, (IndicesRequest) request, projectMetadata, authorizedIndices); + return resolveIndicesAndAliases(action, (IndicesRequest) request, projectMetadata, authorizedIndices, authorizedProjects); } /** @@ -282,6 +293,16 @@ ResolvedIndices resolveIndicesAndAliases( IndicesRequest indicesRequest, ProjectMetadata projectMetadata, AuthorizationEngine.AuthorizedIndices authorizedIndices + ) { + return resolveIndicesAndAliases(action, indicesRequest, projectMetadata, authorizedIndices, TargetProjects.NOT_CROSS_PROJECT); + } + + ResolvedIndices resolveIndicesAndAliases( + String action, + IndicesRequest indicesRequest, + ProjectMetadata projectMetadata, + AuthorizationEngine.AuthorizedIndices authorizedIndices, + TargetProjects authorizedProjects ) { final ResolvedIndices.Builder resolvedIndicesBuilder = new ResolvedIndices.Builder(); boolean indicesReplacedWithNoIndices = false; @@ -345,6 +366,7 @@ ResolvedIndices resolveIndicesAndAliases( // if we cannot replace wildcards the indices list stays empty. Same if there are no authorized indices. // we honour allow_no_indices like es core does. } else { + final ResolvedIndices split; if (replaceable.allowsRemoteIndices()) { split = remoteClusterResolver.splitLocalAndRemoteIndexNames(indicesRequest.indices()); diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/crossproject/CrossProjectIndexExpressionsRewriter.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/crossproject/CrossProjectIndexExpressionsRewriter.java new file mode 100644 index 0000000000000..b9188c0fd24e4 --- /dev/null +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/crossproject/CrossProjectIndexExpressionsRewriter.java @@ -0,0 +1,174 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.security.authz.crossproject; + +import org.elasticsearch.cluster.metadata.ClusterNameExpressionResolver; +import org.elasticsearch.cluster.metadata.IndexNameExpressionResolver; +import org.elasticsearch.core.Nullable; +import org.elasticsearch.logging.LogManager; +import org.elasticsearch.logging.Logger; +import org.elasticsearch.search.crossproject.ProjectRoutingInfo; +import org.elasticsearch.transport.NoSuchRemoteClusterException; +import org.elasticsearch.transport.RemoteClusterAware; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * Utility class for rewriting cross-project index expressions. + * Provides methods that can rewrite qualified and unqualified index expressions to canonical CCS. + */ +public class CrossProjectIndexExpressionsRewriter { + private static final Logger logger = LogManager.getLogger(CrossProjectIndexExpressionsRewriter.class); + private static final String ORIGIN_PROJECT_KEY = "_origin"; + private static final String WILDCARD = "*"; + private static final String[] MATCH_ALL = new String[] { WILDCARD }; + private static final String EXCLUSION = "-"; + private static final String DATE_MATH = "<"; + + /** + * Rewrites index expressions for cross-project search requests. + * Handles qualified and unqualified expressions and match-all cases will also hand exclusions in the future. + * + * @param originProject the _origin project with its alias + * @param linkedProjects the list of linked and available projects to consider for a request + * @param originalIndices the array of index expressions to be rewritten to canonical CCS + * @return a map from original index expressions to lists of canonical index expressions + * @throws IllegalArgumentException if exclusions, date math or selectors are present in the index expressions + * @throws NoMatchingProjectException if a qualified resource cannot be resolved because a project is missing + */ + public static Map> rewriteIndexExpressions( + ProjectRoutingInfo originProject, + List linkedProjects, + final String[] originalIndices + ) { + final String[] indices; + if (originalIndices == null || originalIndices.length == 0) { // handling of match all cases besides _all and `*` + // TODO this should be Metadata.ALL + indices = MATCH_ALL; + } else { + indices = originalIndices; + } + assert false == IndexNameExpressionResolver.isNoneExpression(indices) + : "expression list is *,-* which effectively means a request that requests no indices"; + assert originProject != null || linkedProjects.isEmpty() == false + : "either origin project or linked projects must be in project target set"; + + Set linkedProjectNames = linkedProjects.stream().map(ProjectRoutingInfo::projectAlias).collect(Collectors.toSet()); + Map> canonicalExpressionsMap = new LinkedHashMap<>(indices.length); + for (String resource : indices) { + if (canonicalExpressionsMap.containsKey(resource)) { + continue; + } + maybeThrowOnUnsupportedResource(resource); + + boolean isQualified = RemoteClusterAware.isRemoteIndexName(resource); + if (isQualified) { + String[] splitResource = RemoteClusterAware.splitIndexName(resource); + assert splitResource.length == 2 + : "Expected two strings (project and indexExpression) for a qualified resource [" + + resource + + "], but found [" + + splitResource.length + + "]"; + String projectAlias = splitResource[0]; + assert projectAlias != null : "Expected a project alias for a qualified resource but was null"; + String indexExpression = splitResource[1]; + maybeThrowOnUnsupportedResource(indexExpression); + + List canonicalExpressions = rewriteQualified(projectAlias, indexExpression, originProject, linkedProjectNames); + + canonicalExpressionsMap.put(resource, canonicalExpressions); + logger.debug("Rewrote qualified expression [{}] to [{}]", resource, canonicalExpressions); + } else { + List canonicalExpressions = rewriteUnqualified(resource, originProject, linkedProjects); + canonicalExpressionsMap.put(resource, canonicalExpressions); + logger.debug("Rewrote unqualified expression [{}] to [{}]", resource, canonicalExpressions); + } + } + return canonicalExpressionsMap; + } + + private static List rewriteUnqualified( + String indexExpression, + @Nullable ProjectRoutingInfo origin, + List projects + ) { + List canonicalExpressions = new ArrayList<>(); + if (origin != null) { + canonicalExpressions.add(indexExpression); // adding the original indexExpression for the _origin cluster. + } + for (ProjectRoutingInfo targetProject : projects) { + canonicalExpressions.add(RemoteClusterAware.buildRemoteIndexName(targetProject.projectAlias(), indexExpression)); + } + return canonicalExpressions; + } + + private static List rewriteQualified( + String requestedProjectAlias, + String indexExpression, + @Nullable ProjectRoutingInfo originProject, + Set allProjectAliases + ) { + if (originProject != null && ORIGIN_PROJECT_KEY.equals(requestedProjectAlias)) { + // handling case where we have a qualified expression like: _origin:indexName + return List.of(indexExpression); + } + + if (originProject == null && ORIGIN_PROJECT_KEY.equals(requestedProjectAlias)) { + // handling case where we have a qualified expression like: _origin:indexName but no _origin project is set + throw new NoMatchingProjectException(requestedProjectAlias); + } + + try { + if (originProject != null) { + allProjectAliases.add(originProject.projectAlias()); + } + List resourcesMatchingAliases = new ArrayList<>(); + List allProjectsMatchingAlias = ClusterNameExpressionResolver.resolveClusterNames( + allProjectAliases, + requestedProjectAlias + ); + + if (allProjectsMatchingAlias.isEmpty()) { + throw new NoMatchingProjectException(requestedProjectAlias); + } + + for (String project : allProjectsMatchingAlias) { + if (originProject != null && project.equals(originProject.projectAlias())) { + resourcesMatchingAliases.add(indexExpression); + } else { + resourcesMatchingAliases.add(RemoteClusterAware.buildRemoteIndexName(project, indexExpression)); + } + } + + return resourcesMatchingAliases; + } catch (NoSuchRemoteClusterException ex) { + logger.debug(ex.getMessage(), ex); + throw new NoMatchingProjectException(requestedProjectAlias); + } + } + + private static void maybeThrowOnUnsupportedResource(String resource) { + // TODO To be handled in future PR. + if (resource.startsWith(EXCLUSION)) { + throw new IllegalArgumentException("Exclusions are not currently supported but was found in the expression [" + resource + "]"); + } + if (resource.startsWith(DATE_MATH)) { + throw new IllegalArgumentException("Date math are not currently supported but was found in the expression [" + resource + "]"); + } + if (IndexNameExpressionResolver.hasSelectorSuffix(resource)) { + throw new IllegalArgumentException("Selectors are not currently supported but was found in the expression [" + resource + "]"); + + } + } +} diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/crossproject/NoMatchingProjectException.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/crossproject/NoMatchingProjectException.java new file mode 100644 index 0000000000000..411c519156197 --- /dev/null +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/crossproject/NoMatchingProjectException.java @@ -0,0 +1,21 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.security.authz.crossproject; + +import org.elasticsearch.ResourceNotFoundException; + +/** + * An exception that a project is missing + */ +public final class NoMatchingProjectException extends ResourceNotFoundException { + + public NoMatchingProjectException(String projectName) { + super("No such project: [" + projectName + "]"); + } + +} From eb3e45a8bce4767561e0b334cf754ea65f11eda0 Mon Sep 17 00:00:00 2001 From: Nikolaj Volgushev Date: Thu, 25 Sep 2025 12:21:43 +0200 Subject: [PATCH 33/89] WIP resolve index --- .../action/ResolvedIndexExpression.java | 37 +++++++++++- .../action/ResolvedIndexExpressions.java | 15 ++++- .../ResponseWithResolvedIndexExpressions.java | 15 +++++ .../indices/resolve/ResolveIndexAction.java | 56 ++++++++++++++++++- .../search/crossproject/TargetProjects.java | 4 ++ 5 files changed, 123 insertions(+), 4 deletions(-) create mode 100644 server/src/main/java/org/elasticsearch/action/ResponseWithResolvedIndexExpressions.java diff --git a/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpression.java b/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpression.java index 5180af78d22b2..9fbe7f5103cfa 100644 --- a/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpression.java +++ b/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpression.java @@ -10,8 +10,12 @@ 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.Set; /** @@ -40,7 +44,21 @@ * and failure info * @param remoteExpressions the remote expressions that replace the original */ -public record ResolvedIndexExpression(String original, LocalExpressions localExpressions, Set remoteExpressions) { +public record ResolvedIndexExpression(String original, LocalExpressions localExpressions, Set remoteExpressions) + implements + Writeable { + + public ResolvedIndexExpression(StreamInput in) throws IOException { + this(in.readString(), new LocalExpressions(in), in.readCollectionAsSet(StreamInput::readString)); + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + out.writeString(original); + localExpressions.writeTo(out); + out.writeStringCollection(remoteExpressions); + } + /** * Indicates if a local index resolution attempt was successful or failed. * Failures can be due to concrete resources not being visible (either missing or not visible due to indices options) @@ -60,10 +78,25 @@ public record LocalExpressions( Set expressions, LocalIndexResolutionResult localIndexResolutionResult, @Nullable ElasticsearchException exception - ) { + ) implements Writeable { public LocalExpressions { assert localIndexResolutionResult != LocalIndexResolutionResult.SUCCESS || exception == null : "If the local resolution result is SUCCESS, exception must be null"; } + + public LocalExpressions(StreamInput in) throws IOException { + this( + in.readCollectionAsSet(StreamInput::readString), + in.readEnum(LocalIndexResolutionResult.class), + ElasticsearchException.readException(in) + ); + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + out.writeStringCollection(expressions); + out.writeEnum(localIndexResolutionResult); + ElasticsearchException.writeException(exception, out); + } } } diff --git a/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpressions.java b/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpressions.java index c02f8dfca3b24..bcd0442fe463d 100644 --- a/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpressions.java +++ b/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpressions.java @@ -10,7 +10,11 @@ package org.elasticsearch.action; import org.elasticsearch.action.ResolvedIndexExpression.LocalExpressions; +import org.elasticsearch.common.io.stream.StreamInput; +import org.elasticsearch.common.io.stream.StreamOutput; +import org.elasticsearch.common.io.stream.Writeable; +import java.io.IOException; import java.util.ArrayList; import java.util.HashSet; import java.util.List; @@ -20,7 +24,11 @@ /** * A collection of {@link ResolvedIndexExpression}. */ -public record ResolvedIndexExpressions(List expressions) { +public record ResolvedIndexExpressions(List expressions) implements Writeable { + + public ResolvedIndexExpressions(StreamInput in) throws IOException { + this(in.readCollectionAsList(ResolvedIndexExpression::new)); + } public List getLocalIndicesList() { return expressions.stream().flatMap(e -> e.localExpressions().expressions().stream()).toList(); @@ -30,6 +38,11 @@ public static Builder builder() { return new Builder(); } + @Override + public void writeTo(StreamOutput out) throws IOException { + out.writeCollection(expressions); + } + public static final class Builder { private final List expressions = new ArrayList<>(); diff --git a/server/src/main/java/org/elasticsearch/action/ResponseWithResolvedIndexExpressions.java b/server/src/main/java/org/elasticsearch/action/ResponseWithResolvedIndexExpressions.java new file mode 100644 index 0000000000000..c7ab869d139c4 --- /dev/null +++ b/server/src/main/java/org/elasticsearch/action/ResponseWithResolvedIndexExpressions.java @@ -0,0 +1,15 @@ +/* + * 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; + +public interface ResponseWithResolvedIndexExpressions { + + ResolvedIndexExpressions getResolvedIndexExpressions(); +} diff --git a/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java b/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java index 58ee074a0b951..a1ca30822da18 100644 --- a/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java +++ b/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java @@ -18,6 +18,8 @@ import org.elasticsearch.action.LegacyActionRequest; import org.elasticsearch.action.OriginalIndices; import org.elasticsearch.action.RemoteClusterActionType; +import org.elasticsearch.action.ResolvedIndexExpressions; +import org.elasticsearch.action.ResponseWithResolvedIndexExpressions; import org.elasticsearch.action.support.ActionFilters; import org.elasticsearch.action.support.HandledTransportAction; import org.elasticsearch.action.support.IndicesOptions; @@ -78,6 +80,9 @@ public class ResolveIndexAction extends ActionType private static final TransportVersion RESOLVE_INDEX_MODE_ADDED = TransportVersion.fromName("resolve_index_mode_added"); private static final TransportVersion RESOLVE_INDEX_MODE_FILTER = TransportVersion.fromName("resolve_index_mode_filter"); + private static final TransportVersion RESOLVE_INDEX_INCLUDE_RESOLVED_FLAG = TransportVersion.fromName( + "resolve_index_include_resolved_flag" + ); private ResolveIndexAction() { super(NAME); @@ -90,6 +95,8 @@ public static class Request extends LegacyActionRequest implements IndicesReques private String[] names; private IndicesOptions indicesOptions = DEFAULT_INDICES_OPTIONS; private EnumSet indexModes = EnumSet.noneOf(IndexMode.class); + private ResolvedIndexExpressions resolvedIndexExpressions = null; + private boolean includeResolvedExpressions = false; public Request(String[] names) { this.names = names; @@ -108,6 +115,20 @@ public Request(String[] names, IndicesOptions indicesOptions, @Nullable EnumSet< } } + public Request( + String[] names, + IndicesOptions indicesOptions, + @Nullable EnumSet indexModes, + boolean includeResolvedExpressions + ) { + this.names = names; + this.indicesOptions = indicesOptions; + if (indexModes != null) { + this.indexModes = indexModes; + } + this.includeResolvedExpressions = includeResolvedExpressions; + } + @Override public ActionRequestValidationException validate() { return null; @@ -122,6 +143,11 @@ public Request(StreamInput in) throws IOException { } else { this.indexModes = EnumSet.noneOf(IndexMode.class); } + if (in.getTransportVersion().supports(RESOLVE_INDEX_INCLUDE_RESOLVED_FLAG)) { + this.includeResolvedExpressions = in.readBoolean(); + } else { + this.includeResolvedExpressions = false; + } } @Override @@ -132,6 +158,9 @@ public void writeTo(StreamOutput out) throws IOException { if (out.getTransportVersion().supports(RESOLVE_INDEX_MODE_FILTER)) { out.writeEnumSet(indexModes); } + if (out.getTransportVersion().supports(RESOLVE_INDEX_INCLUDE_RESOLVED_FLAG)) { + out.writeBoolean(includeResolvedExpressions); + } } @Override @@ -168,6 +197,21 @@ public boolean allowsRemoteIndices() { return true; } + @Override + public boolean supportsCrossProjectSearch() { + return true; + } + + @Override + public void setResolvedIndexExpressions(ResolvedIndexExpressions expressions) { + this.resolvedIndexExpressions = expressions; + } + + @Override + public ResolvedIndexExpressions getResolvedIndexExpressions() { + return resolvedIndexExpressions; + } + @Override public boolean includeDataStreams() { // request must allow data streams because the index name expression resolver for the action handler assumes it @@ -452,7 +496,7 @@ public String toString() { } } - public static class Response extends ActionResponse implements ToXContentObject { + public static class Response extends ActionResponse implements ToXContentObject, ResponseWithResolvedIndexExpressions { static final ParseField INDICES_FIELD = new ParseField("indices"); static final ParseField ALIASES_FIELD = new ParseField("aliases"); @@ -461,17 +505,21 @@ public static class Response extends ActionResponse implements ToXContentObject private final List indices; private final List aliases; private final List dataStreams; + @Nullable + private final ResolvedIndexExpressions resolvedIndexExpressions; public Response(List indices, List aliases, List dataStreams) { this.indices = indices; this.aliases = aliases; this.dataStreams = dataStreams; + this.resolvedIndexExpressions = null; } public Response(StreamInput in) throws IOException { this.indices = in.readCollectionAsList(ResolvedIndex::new); this.aliases = in.readCollectionAsList(ResolvedAlias::new); this.dataStreams = in.readCollectionAsList(ResolvedDataStream::new); + this.resolvedIndexExpressions = in.readOptionalWriteable(ResolvedIndexExpressions::new); } public List getIndices() { @@ -491,6 +539,7 @@ public void writeTo(StreamOutput out) throws IOException { out.writeCollection(indices); out.writeCollection(aliases); out.writeCollection(dataStreams); + out.writeOptionalWriteable(resolvedIndexExpressions); } @Override @@ -515,6 +564,11 @@ public boolean equals(Object o) { public int hashCode() { return Objects.hash(indices, aliases, dataStreams); } + + @Override + public ResolvedIndexExpressions getResolvedIndexExpressions() { + return resolvedIndexExpressions; + } } public static class TransportAction extends HandledTransportAction { diff --git a/server/src/main/java/org/elasticsearch/search/crossproject/TargetProjects.java b/server/src/main/java/org/elasticsearch/search/crossproject/TargetProjects.java index 76df1fab70a21..78aa4b321b5cd 100644 --- a/server/src/main/java/org/elasticsearch/search/crossproject/TargetProjects.java +++ b/server/src/main/java/org/elasticsearch/search/crossproject/TargetProjects.java @@ -13,4 +13,8 @@ public record TargetProjects(ProjectRoutingInfo originProject, List linkedProjects) { public static TargetProjects NOT_CROSS_PROJECT = new TargetProjects(null, List.of()); + + public TargetProjects(ProjectRoutingInfo originProject) { + this(originProject, List.of()); + } } From cfe91efd6442f87e8394535825f948c2e3b1f489 Mon Sep 17 00:00:00 2001 From: elasticsearchmachine Date: Thu, 25 Sep 2025 10:43:52 +0000 Subject: [PATCH 34/89] [CI] Update transport version definitions --- .../referable/resolve_index_include_resolved_flag.csv | 1 + server/src/main/resources/transport/upper_bounds/9.2.csv | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 server/src/main/resources/transport/definitions/referable/resolve_index_include_resolved_flag.csv diff --git a/server/src/main/resources/transport/definitions/referable/resolve_index_include_resolved_flag.csv b/server/src/main/resources/transport/definitions/referable/resolve_index_include_resolved_flag.csv new file mode 100644 index 0000000000000..833feabb55a10 --- /dev/null +++ b/server/src/main/resources/transport/definitions/referable/resolve_index_include_resolved_flag.csv @@ -0,0 +1 @@ +9171000 diff --git a/server/src/main/resources/transport/upper_bounds/9.2.csv b/server/src/main/resources/transport/upper_bounds/9.2.csv index 2c15e0254cbe8..2672f98fc63ee 100644 --- a/server/src/main/resources/transport/upper_bounds/9.2.csv +++ b/server/src/main/resources/transport/upper_bounds/9.2.csv @@ -1 +1 @@ -transform_check_for_dangling_tasks,9170000 +resolve_index_include_resolved_flag,9171000 From d39b249aef16f507970317cb25eb3823c9d8baa2 Mon Sep 17 00:00:00 2001 From: Nikolaj Volgushev Date: Thu, 25 Sep 2025 12:48:09 +0200 Subject: [PATCH 35/89] More --- .../elasticsearch/action/IndicesRequest.java | 2 +- .../indices/resolve/ResolveIndexAction.java | 17 +++++-- .../action/search/SearchRequest.java | 5 -- .../admin/indices/RestResolveIndexAction.java | 7 ++- .../resolve_index_include_resolved_flag.csv | 1 + .../resources/transport/upper_bounds/9.2.csv | 2 +- .../security/authz/AuthorizationService.java | 46 +++++++++---------- .../authz/IndicesAndAliasesResolver.java | 1 - 8 files changed, 45 insertions(+), 36 deletions(-) create mode 100644 server/src/main/resources/transport/definitions/referable/resolve_index_include_resolved_flag.csv diff --git a/server/src/main/java/org/elasticsearch/action/IndicesRequest.java b/server/src/main/java/org/elasticsearch/action/IndicesRequest.java index 7b6535e042610..81ecdf176b565 100644 --- a/server/src/main/java/org/elasticsearch/action/IndicesRequest.java +++ b/server/src/main/java/org/elasticsearch/action/IndicesRequest.java @@ -81,7 +81,7 @@ default boolean allowsRemoteIndices() { return false; } - default boolean supportsCrossProjectSearch() { + default boolean resolveCrossProject() { return false; } } diff --git a/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java b/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java index a1ca30822da18..261f4bf252e00 100644 --- a/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java +++ b/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java @@ -97,6 +97,7 @@ public static class Request extends LegacyActionRequest implements IndicesReques private EnumSet indexModes = EnumSet.noneOf(IndexMode.class); private ResolvedIndexExpressions resolvedIndexExpressions = null; private boolean includeResolvedExpressions = false; + private boolean resolveCrossProject = false; public Request(String[] names) { this.names = names; @@ -119,7 +120,8 @@ public Request( String[] names, IndicesOptions indicesOptions, @Nullable EnumSet indexModes, - boolean includeResolvedExpressions + boolean includeResolvedExpressions, + boolean resolveCrossProject ) { this.names = names; this.indicesOptions = indicesOptions; @@ -127,6 +129,7 @@ public Request( this.indexModes = indexModes; } this.includeResolvedExpressions = includeResolvedExpressions; + this.resolveCrossProject = resolveCrossProject; } @Override @@ -148,6 +151,11 @@ public Request(StreamInput in) throws IOException { } else { this.includeResolvedExpressions = false; } + if (in.getTransportVersion().supports(RESOLVE_INDEX_INCLUDE_RESOLVED_FLAG)) { + this.resolveCrossProject = in.readBoolean(); + } else { + this.resolveCrossProject = false; + } } @Override @@ -161,6 +169,9 @@ public void writeTo(StreamOutput out) throws IOException { if (out.getTransportVersion().supports(RESOLVE_INDEX_INCLUDE_RESOLVED_FLAG)) { out.writeBoolean(includeResolvedExpressions); } + if (out.getTransportVersion().supports(RESOLVE_INDEX_INCLUDE_RESOLVED_FLAG)) { + out.writeBoolean(resolveCrossProject); + } } @Override @@ -198,8 +209,8 @@ public boolean allowsRemoteIndices() { } @Override - public boolean supportsCrossProjectSearch() { - return true; + public boolean resolveCrossProject() { + return resolveCrossProject; } @Override diff --git a/server/src/main/java/org/elasticsearch/action/search/SearchRequest.java b/server/src/main/java/org/elasticsearch/action/search/SearchRequest.java index 14c10ed35a7a6..f84a3578264f8 100644 --- a/server/src/main/java/org/elasticsearch/action/search/SearchRequest.java +++ b/server/src/main/java/org/elasticsearch/action/search/SearchRequest.java @@ -158,11 +158,6 @@ public boolean allowsRemoteIndices() { return true; } - @Override - public boolean supportsCrossProjectSearch() { - return true; - } - /** * Creates a new sub-search request starting from the original search request that is provided. * For internal use only, allows to fork a search request into multiple search requests that will be executed independently. diff --git a/server/src/main/java/org/elasticsearch/rest/action/admin/indices/RestResolveIndexAction.java b/server/src/main/java/org/elasticsearch/rest/action/admin/indices/RestResolveIndexAction.java index 0146fe6697ae0..09308d7034dff 100644 --- a/server/src/main/java/org/elasticsearch/rest/action/admin/indices/RestResolveIndexAction.java +++ b/server/src/main/java/org/elasticsearch/rest/action/admin/indices/RestResolveIndexAction.java @@ -57,7 +57,8 @@ public Set supportedCapabilities() { protected BaseRestHandler.RestChannelConsumer prepareRequest(RestRequest request, NodeClient client) throws IOException { String[] indices = Strings.splitStringByCommaToArray(request.param("name")); String modeParam = request.param("mode"); - if (settings != null && settings.getAsBoolean("serverless.cross_project.enabled", false)) { + boolean crossProjectMode = settings != null && settings.getAsBoolean("serverless.cross_project.enabled", false); + if (crossProjectMode) { // accept but drop project_routing param until fully supported request.param("project_routing"); } @@ -68,7 +69,9 @@ protected BaseRestHandler.RestChannelConsumer prepareRequest(RestRequest request ? null : Arrays.stream(modeParam.split(",")) .map(IndexMode::fromString) - .collect(() -> EnumSet.noneOf(IndexMode.class), EnumSet::add, EnumSet::addAll) + .collect(() -> EnumSet.noneOf(IndexMode.class), EnumSet::add, EnumSet::addAll), + false, + crossProjectMode ); return channel -> client.admin().indices().resolveIndex(resolveRequest, new RestToXContentListener<>(channel)); } diff --git a/server/src/main/resources/transport/definitions/referable/resolve_index_include_resolved_flag.csv b/server/src/main/resources/transport/definitions/referable/resolve_index_include_resolved_flag.csv new file mode 100644 index 0000000000000..833feabb55a10 --- /dev/null +++ b/server/src/main/resources/transport/definitions/referable/resolve_index_include_resolved_flag.csv @@ -0,0 +1 @@ +9171000 diff --git a/server/src/main/resources/transport/upper_bounds/9.2.csv b/server/src/main/resources/transport/upper_bounds/9.2.csv index 2c15e0254cbe8..2672f98fc63ee 100644 --- a/server/src/main/resources/transport/upper_bounds/9.2.csv +++ b/server/src/main/resources/transport/upper_bounds/9.2.csv @@ -1 +1 @@ -transform_check_for_dangling_tasks,9170000 +resolve_index_include_resolved_flag,9171000 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 13831ca0356fc..5c811c56b1060 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 @@ -550,31 +550,31 @@ private void authorizeAction( authzInfo, projectMetadata.getIndicesLookup(), ActionListener.wrap(authorizedIndices -> { - if (request instanceof IndicesRequest.Replaceable replaceable && replaceable.supportsCrossProjectSearch()) { - crossProjectSearchAuthzService.loadAuthorizedProjects(new ActionListener<>() { - @Override - public void onResponse(TargetProjects authorizedProjects) { - logger.info("Loaded authorized projects: [{}]", authorizedProjects); - resolvedIndicesListener.onResponse( - indicesAndAliasesResolver.resolve( - action, - request, - projectMetadata, - authorizedIndices, - authorizedProjects - ) - ); - } - - @Override - public void onFailure(Exception e) { - logger.info("Failed to load authorized projects", e); - resolvedIndicesListener.onFailure(e); - } - }); + if (request instanceof IndicesRequest.Replaceable replaceable && replaceable.resolveCrossProject()) { + crossProjectSearchAuthzService.loadAuthorizedProjects(ActionListener.wrap(authorizedProjects -> { + logger.info("Loaded authorized projects: [{}]", authorizedProjects); + resolvedIndicesListener.onResponse( + indicesAndAliasesResolver.resolve( + action, + request, + projectMetadata, + authorizedIndices, + authorizedProjects + ) + ); + }, e -> { + logger.error("Failed to load authorized projects", e); + resolvedIndicesListener.onFailure(e); + })); } else { resolvedIndicesListener.onResponse( - indicesAndAliasesResolver.resolve(action, request, projectMetadata, authorizedIndices) + indicesAndAliasesResolver.resolve( + action, + request, + projectMetadata, + authorizedIndices, + TargetProjects.NOT_CROSS_PROJECT + ) ); } }, e -> { diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java index 6e6edbc56c6d6..b141103e51d01 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java @@ -366,7 +366,6 @@ ResolvedIndices resolveIndicesAndAliases( // if we cannot replace wildcards the indices list stays empty. Same if there are no authorized indices. // we honour allow_no_indices like es core does. } else { - final ResolvedIndices split; if (replaceable.allowsRemoteIndices()) { split = remoteClusterResolver.splitLocalAndRemoteIndexNames(indicesRequest.indices()); From f1be6b4c24b6234cca50fae175914a8f8c7e44f5 Mon Sep 17 00:00:00 2001 From: Nikolaj Volgushev Date: Thu, 25 Sep 2025 14:13:16 +0200 Subject: [PATCH 36/89] Moar --- .../ResponseWithResolvedIndexExpressions.java | 15 --------------- .../admin/indices/resolve/ResolveIndexAction.java | 4 +--- 2 files changed, 1 insertion(+), 18 deletions(-) delete mode 100644 server/src/main/java/org/elasticsearch/action/ResponseWithResolvedIndexExpressions.java diff --git a/server/src/main/java/org/elasticsearch/action/ResponseWithResolvedIndexExpressions.java b/server/src/main/java/org/elasticsearch/action/ResponseWithResolvedIndexExpressions.java deleted file mode 100644 index c7ab869d139c4..0000000000000 --- a/server/src/main/java/org/elasticsearch/action/ResponseWithResolvedIndexExpressions.java +++ /dev/null @@ -1,15 +0,0 @@ -/* - * 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; - -public interface ResponseWithResolvedIndexExpressions { - - ResolvedIndexExpressions getResolvedIndexExpressions(); -} diff --git a/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java b/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java index 261f4bf252e00..2fb8fea6366fd 100644 --- a/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java +++ b/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java @@ -19,7 +19,6 @@ import org.elasticsearch.action.OriginalIndices; import org.elasticsearch.action.RemoteClusterActionType; import org.elasticsearch.action.ResolvedIndexExpressions; -import org.elasticsearch.action.ResponseWithResolvedIndexExpressions; import org.elasticsearch.action.support.ActionFilters; import org.elasticsearch.action.support.HandledTransportAction; import org.elasticsearch.action.support.IndicesOptions; @@ -507,7 +506,7 @@ public String toString() { } } - public static class Response extends ActionResponse implements ToXContentObject, ResponseWithResolvedIndexExpressions { + public static class Response extends ActionResponse implements ToXContentObject { static final ParseField INDICES_FIELD = new ParseField("indices"); static final ParseField ALIASES_FIELD = new ParseField("aliases"); @@ -576,7 +575,6 @@ public int hashCode() { return Objects.hash(indices, aliases, dataStreams); } - @Override public ResolvedIndexExpressions getResolvedIndexExpressions() { return resolvedIndexExpressions; } From 515eb37860b1d694566cd4f25fbdc7a33ac4aa94 Mon Sep 17 00:00:00 2001 From: Nikolaj Volgushev Date: Thu, 25 Sep 2025 15:26:58 +0200 Subject: [PATCH 37/89] WIP plug in rewriting --- .../authz/IndicesAndAliasesResolver.java | 10 ++++++++ .../CrossProjectIndexExpressionsRewriter.java | 25 +++++++++---------- 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java index b141103e51d01..c02ff8ae344b6 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java @@ -39,6 +39,7 @@ import org.elasticsearch.xpack.core.security.authz.AuthorizationEngine; import org.elasticsearch.xpack.core.security.authz.IndicesAndAliasesResolverField; import org.elasticsearch.xpack.core.security.authz.ResolvedIndices; +import org.elasticsearch.xpack.security.authz.crossproject.CrossProjectIndexExpressionsRewriter; import java.util.ArrayList; import java.util.Arrays; @@ -366,6 +367,15 @@ ResolvedIndices resolveIndicesAndAliases( // if we cannot replace wildcards the indices list stays empty. Same if there are no authorized indices. // we honour allow_no_indices like es core does. } else { + if (replaceable.resolveCrossProject() && authorizedProjects != TargetProjects.NOT_CROSS_PROJECT) { + assert replaceable.allowsRemoteIndices() : "cross-project requests must allow remote indices"; + Map> rewritten = CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions( + authorizedProjects.originProject(), + authorizedProjects.linkedProjects(), + replaceable.indices() + ); + } + final ResolvedIndices split; if (replaceable.allowsRemoteIndices()) { split = remoteClusterResolver.splitLocalAndRemoteIndexNames(indicesRequest.indices()); diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/crossproject/CrossProjectIndexExpressionsRewriter.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/crossproject/CrossProjectIndexExpressionsRewriter.java index b9188c0fd24e4..3d2d5f221f400 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/crossproject/CrossProjectIndexExpressionsRewriter.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/crossproject/CrossProjectIndexExpressionsRewriter.java @@ -9,6 +9,7 @@ import org.elasticsearch.cluster.metadata.ClusterNameExpressionResolver; import org.elasticsearch.cluster.metadata.IndexNameExpressionResolver; +import org.elasticsearch.cluster.metadata.Metadata; import org.elasticsearch.core.Nullable; import org.elasticsearch.logging.LogManager; import org.elasticsearch.logging.Logger; @@ -16,8 +17,8 @@ import org.elasticsearch.transport.NoSuchRemoteClusterException; import org.elasticsearch.transport.RemoteClusterAware; -import java.util.ArrayList; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Set; @@ -30,8 +31,7 @@ public class CrossProjectIndexExpressionsRewriter { private static final Logger logger = LogManager.getLogger(CrossProjectIndexExpressionsRewriter.class); private static final String ORIGIN_PROJECT_KEY = "_origin"; - private static final String WILDCARD = "*"; - private static final String[] MATCH_ALL = new String[] { WILDCARD }; + private static final String[] MATCH_ALL = new String[] { Metadata.ALL }; private static final String EXCLUSION = "-"; private static final String DATE_MATH = "<"; @@ -46,14 +46,13 @@ public class CrossProjectIndexExpressionsRewriter { * @throws IllegalArgumentException if exclusions, date math or selectors are present in the index expressions * @throws NoMatchingProjectException if a qualified resource cannot be resolved because a project is missing */ - public static Map> rewriteIndexExpressions( + public static Map> rewriteIndexExpressions( ProjectRoutingInfo originProject, List linkedProjects, final String[] originalIndices ) { final String[] indices; if (originalIndices == null || originalIndices.length == 0) { // handling of match all cases besides _all and `*` - // TODO this should be Metadata.ALL indices = MATCH_ALL; } else { indices = originalIndices; @@ -64,7 +63,7 @@ public static Map> rewriteIndexExpressions( : "either origin project or linked projects must be in project target set"; Set linkedProjectNames = linkedProjects.stream().map(ProjectRoutingInfo::projectAlias).collect(Collectors.toSet()); - Map> canonicalExpressionsMap = new LinkedHashMap<>(indices.length); + Map> canonicalExpressionsMap = new LinkedHashMap<>(indices.length); for (String resource : indices) { if (canonicalExpressionsMap.containsKey(resource)) { continue; @@ -85,12 +84,12 @@ public static Map> rewriteIndexExpressions( String indexExpression = splitResource[1]; maybeThrowOnUnsupportedResource(indexExpression); - List canonicalExpressions = rewriteQualified(projectAlias, indexExpression, originProject, linkedProjectNames); + Set canonicalExpressions = rewriteQualified(projectAlias, indexExpression, originProject, linkedProjectNames); canonicalExpressionsMap.put(resource, canonicalExpressions); logger.debug("Rewrote qualified expression [{}] to [{}]", resource, canonicalExpressions); } else { - List canonicalExpressions = rewriteUnqualified(resource, originProject, linkedProjects); + Set canonicalExpressions = rewriteUnqualified(resource, originProject, linkedProjects); canonicalExpressionsMap.put(resource, canonicalExpressions); logger.debug("Rewrote unqualified expression [{}] to [{}]", resource, canonicalExpressions); } @@ -98,12 +97,12 @@ public static Map> rewriteIndexExpressions( return canonicalExpressionsMap; } - private static List rewriteUnqualified( + private static Set rewriteUnqualified( String indexExpression, @Nullable ProjectRoutingInfo origin, List projects ) { - List canonicalExpressions = new ArrayList<>(); + Set canonicalExpressions = new LinkedHashSet<>(); if (origin != null) { canonicalExpressions.add(indexExpression); // adding the original indexExpression for the _origin cluster. } @@ -113,7 +112,7 @@ private static List rewriteUnqualified( return canonicalExpressions; } - private static List rewriteQualified( + private static Set rewriteQualified( String requestedProjectAlias, String indexExpression, @Nullable ProjectRoutingInfo originProject, @@ -121,7 +120,7 @@ private static List rewriteQualified( ) { if (originProject != null && ORIGIN_PROJECT_KEY.equals(requestedProjectAlias)) { // handling case where we have a qualified expression like: _origin:indexName - return List.of(indexExpression); + return Set.of(indexExpression); } if (originProject == null && ORIGIN_PROJECT_KEY.equals(requestedProjectAlias)) { @@ -133,7 +132,7 @@ private static List rewriteQualified( if (originProject != null) { allProjectAliases.add(originProject.projectAlias()); } - List resourcesMatchingAliases = new ArrayList<>(); + Set resourcesMatchingAliases = new LinkedHashSet<>(); List allProjectsMatchingAlias = ClusterNameExpressionResolver.resolveClusterNames( allProjectAliases, requestedProjectAlias From 6f79ff0e655e73155108759d0c723a280d119d44 Mon Sep 17 00:00:00 2001 From: elasticsearchmachine Date: Thu, 25 Sep 2025 13:44:20 +0000 Subject: [PATCH 38/89] [CI] Update transport version definitions --- .../referable/resolve_index_include_resolved_flag.csv | 2 +- server/src/main/resources/transport/upper_bounds/9.2.csv | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/server/src/main/resources/transport/definitions/referable/resolve_index_include_resolved_flag.csv b/server/src/main/resources/transport/definitions/referable/resolve_index_include_resolved_flag.csv index 833feabb55a10..42d1df167e3b9 100644 --- a/server/src/main/resources/transport/definitions/referable/resolve_index_include_resolved_flag.csv +++ b/server/src/main/resources/transport/definitions/referable/resolve_index_include_resolved_flag.csv @@ -1 +1 @@ -9171000 +9172000 diff --git a/server/src/main/resources/transport/upper_bounds/9.2.csv b/server/src/main/resources/transport/upper_bounds/9.2.csv index 2672f98fc63ee..c561d237e6a78 100644 --- a/server/src/main/resources/transport/upper_bounds/9.2.csv +++ b/server/src/main/resources/transport/upper_bounds/9.2.csv @@ -1 +1 @@ -resolve_index_include_resolved_flag,9171000 +resolve_index_include_resolved_flag,9172000 From cf76a8f5ca037e71922c4078e299b214a46b64ba Mon Sep 17 00:00:00 2001 From: Nikolaj Volgushev Date: Fri, 26 Sep 2025 09:48:30 +0200 Subject: [PATCH 39/89] Plug in CPS rewrites --- server/src/main/java/module-info.java | 1 + .../action/ResolvedIndexExpression.java | 1 + .../action/ResolvedIndexExpressions.java | 25 ++- .../metadata/IndexAbstractionResolver.java | 202 ++++++++++++------ .../CrossProjectIndexExpressionsRewriter.java | 145 +++++++------ .../NoMatchingProjectException.java | 11 +- .../search/crossproject/TargetProjects.java | 10 + ...rossProjectSearchAuthorizationService.java | 7 + .../security/authz/AuthorizationService.java | 11 +- .../authz/IndicesAndAliasesResolver.java | 63 +++--- 10 files changed, 302 insertions(+), 174 deletions(-) rename {x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz => server/src/main/java/org/elasticsearch/search}/crossproject/CrossProjectIndexExpressionsRewriter.java (51%) rename {x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz => server/src/main/java/org/elasticsearch/search}/crossproject/NoMatchingProjectException.java (52%) diff --git a/server/src/main/java/module-info.java b/server/src/main/java/module-info.java index 68dc8da3b7d15..856633f0f8350 100644 --- a/server/src/main/java/module-info.java +++ b/server/src/main/java/module-info.java @@ -55,6 +55,7 @@ requires org.apache.lucene.queryparser; requires org.apache.lucene.sandbox; requires org.apache.lucene.suggest; + requires org.jruby.jcodings; exports org.elasticsearch; exports org.elasticsearch.action; diff --git a/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpression.java b/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpression.java index 9fbe7f5103cfa..e0624d9370e46 100644 --- a/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpression.java +++ b/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpression.java @@ -66,6 +66,7 @@ public void writeTo(StreamOutput out) throws IOException { * A wildcard expression resolving to nothing is still considered a successful resolution. */ public enum LocalIndexResolutionResult { + NONE, SUCCESS, CONCRETE_RESOURCE_NOT_VISIBLE, CONCRETE_RESOURCE_UNAUTHORIZED, diff --git a/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpressions.java b/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpressions.java index bcd0442fe463d..c149c0d0649b1 100644 --- a/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpressions.java +++ b/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpressions.java @@ -34,6 +34,10 @@ public List getLocalIndicesList() { return expressions.stream().flatMap(e -> e.localExpressions().expressions().stream()).toList(); } + public List getRemoteIndicesList() { + return expressions.stream().flatMap(e -> e.remoteExpressions().stream()).toList(); + } + public static Builder builder() { return new Builder(); } @@ -47,20 +51,35 @@ public static final class Builder { private final List expressions = new ArrayList<>(); /** + * Add a new resolved expression. * @param original the original expression that was resolved -- may be blank for "access all" cases * @param localExpressions is a HashSet as an optimization -- the set needs to be mutable, and we want to avoid copying it. * May be empty. */ - public void addLocalExpressions( + public void addExpressions( String original, HashSet localExpressions, - ResolvedIndexExpression.LocalIndexResolutionResult resolutionResult + ResolvedIndexExpression.LocalIndexResolutionResult resolutionResult, + HashSet remoteExpressions ) { Objects.requireNonNull(original); Objects.requireNonNull(localExpressions); Objects.requireNonNull(resolutionResult); + Objects.requireNonNull(remoteExpressions); + expressions.add( + new ResolvedIndexExpression(original, new LocalExpressions(localExpressions, resolutionResult, null), remoteExpressions) + ); + } + + public void addRemoteExpressions(String original, HashSet remoteExpressions) { + Objects.requireNonNull(original); + Objects.requireNonNull(remoteExpressions); expressions.add( - new ResolvedIndexExpression(original, new LocalExpressions(localExpressions, resolutionResult, null), new HashSet<>()) + new ResolvedIndexExpression( + original, + new LocalExpressions(new HashSet<>(), ResolvedIndexExpression.LocalIndexResolutionResult.NONE, null), + remoteExpressions + ) ); } diff --git a/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java b/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java index 6e6d9844c1154..52cde7b95eab8 100644 --- a/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java +++ b/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java @@ -20,6 +20,8 @@ import org.elasticsearch.index.Index; import org.elasticsearch.index.IndexNotFoundException; import org.elasticsearch.indices.SystemIndices.SystemIndexAccessLevel; +import org.elasticsearch.search.crossproject.CrossProjectIndexExpressionsRewriter; +import org.elasticsearch.search.crossproject.TargetProjects; import java.util.HashSet; import java.util.List; @@ -48,92 +50,156 @@ public ResolvedIndexExpressions resolveIndexAbstractions( boolean includeDataStreams ) { final ResolvedIndexExpressions.Builder resolvedExpressionsBuilder = ResolvedIndexExpressions.builder(); + boolean wildcardSeen = false; + for (String index : indices) { + wildcardSeen = resolveIndexAbstraction( + index, + indicesOptions, + projectMetadata, + allAuthorizedAndAvailableBySelector, + isAuthorized, + includeDataStreams, + wildcardSeen, + resolvedExpressionsBuilder, + new HashSet<>() + ); + } + return resolvedExpressionsBuilder.build(); + } + public ResolvedIndexExpressions resolveIndexAbstractions( + List indices, + IndicesOptions indicesOptions, + ProjectMetadata projectMetadata, + Function> allAuthorizedAndAvailableBySelector, + BiPredicate isAuthorized, + TargetProjects targetProjects, + boolean includeDataStreams + ) { + final String originProjectAlias = targetProjects.originProjectAlias(); + final Set linkedProjectAliases = targetProjects.linkedProjectAliases(); + final ResolvedIndexExpressions.Builder resolvedExpressionsBuilder = ResolvedIndexExpressions.builder(); boolean wildcardSeen = false; for (String index : indices) { - String indexAbstraction; - boolean minus = false; - if (index.charAt(0) == '-' && wildcardSeen) { - indexAbstraction = index.substring(1); - minus = true; - } else { - indexAbstraction = index; + CrossProjectIndexExpressionsRewriter.Result rewritten = CrossProjectIndexExpressionsRewriter.rewrite( + index, + originProjectAlias, + linkedProjectAliases + ); + if (rewritten.local() == null) { + resolvedExpressionsBuilder.addRemoteExpressions(index, rewritten.remote()); + continue; } - // Always check to see if there's a selector on the index expression - final Tuple expressionAndSelector = IndexNameExpressionResolver.splitSelectorExpression(indexAbstraction); - final String selectorString = expressionAndSelector.v2(); - if (indicesOptions.allowSelectors() == false && selectorString != null) { - throw new UnsupportedSelectorException(indexAbstraction); - } - indexAbstraction = expressionAndSelector.v1(); - IndexComponentSelector selector = IndexComponentSelector.getByKeyOrThrow(selectorString); + wildcardSeen = resolveIndexAbstraction( + index, + indicesOptions, + projectMetadata, + allAuthorizedAndAvailableBySelector, + isAuthorized, + includeDataStreams, + wildcardSeen, + resolvedExpressionsBuilder, + rewritten.remote() + ); + } - // we always need to check for date math expressions - indexAbstraction = IndexNameExpressionResolver.resolveDateMathExpression(indexAbstraction); + return resolvedExpressionsBuilder.build(); + } + + private boolean resolveIndexAbstraction( + String index, + IndicesOptions indicesOptions, + ProjectMetadata projectMetadata, + Function> allAuthorizedAndAvailableBySelector, + BiPredicate isAuthorized, + boolean includeDataStreams, + boolean wildcardSeen, + ResolvedIndexExpressions.Builder resolvedExpressionsBuilder, + HashSet remoteIndices + ) { + String indexAbstraction; + boolean minus = false; + if (index.charAt(0) == '-' && wildcardSeen) { + indexAbstraction = index.substring(1); + minus = true; + } else { + indexAbstraction = index; + } - if (indicesOptions.expandWildcardExpressions() && Regex.isSimpleMatchPattern(indexAbstraction)) { - wildcardSeen = true; - final HashSet resolvedIndices = new HashSet<>(); - for (String authorizedIndex : allAuthorizedAndAvailableBySelector.apply(selector)) { - if (Regex.simpleMatch(indexAbstraction, authorizedIndex) + // Always check to see if there's a selector on the index expression + final Tuple expressionAndSelector = IndexNameExpressionResolver.splitSelectorExpression(indexAbstraction); + final String selectorString = expressionAndSelector.v2(); + if (indicesOptions.allowSelectors() == false && selectorString != null) { + throw new UnsupportedSelectorException(indexAbstraction); + } + indexAbstraction = expressionAndSelector.v1(); + IndexComponentSelector selector = IndexComponentSelector.getByKeyOrThrow(selectorString); + + // we always need to check for date math expressions + indexAbstraction = IndexNameExpressionResolver.resolveDateMathExpression(indexAbstraction); + + if (indicesOptions.expandWildcardExpressions() && Regex.isSimpleMatchPattern(indexAbstraction)) { + wildcardSeen = true; + final HashSet resolvedIndices = new HashSet<>(); + for (String authorizedIndex : allAuthorizedAndAvailableBySelector.apply(selector)) { + if (Regex.simpleMatch(indexAbstraction, authorizedIndex) + && isIndexVisible( + indexAbstraction, + selectorString, + authorizedIndex, + indicesOptions, + projectMetadata, + indexNameExpressionResolver, + includeDataStreams + )) { + resolveSelectorsAndCollect(authorizedIndex, selectorString, indicesOptions, resolvedIndices, projectMetadata); + } + } + if (resolvedIndices.isEmpty()) { + // es core honours allow_no_indices for each wildcard expression, we do the same here by throwing index not found. + if (indicesOptions.allowNoIndices() == false) { + throw new IndexNotFoundException(indexAbstraction); + } + resolvedExpressionsBuilder.addExpressions(index, new HashSet<>(), SUCCESS, remoteIndices); + } else { + if (minus) { + resolvedExpressionsBuilder.excludeFromLocalExpressions(resolvedIndices); + } else { + resolvedExpressionsBuilder.addExpressions(index, resolvedIndices, SUCCESS, remoteIndices); + } + } + } else { + final HashSet resolvedIndices = new HashSet<>(); + resolveSelectorsAndCollect(indexAbstraction, selectorString, indicesOptions, resolvedIndices, projectMetadata); + if (minus) { + resolvedExpressionsBuilder.excludeFromLocalExpressions(resolvedIndices); + } else { + final boolean authorized = isAuthorized.test(indexAbstraction, selector); + if (authorized) { + final boolean visible = indexExists(projectMetadata, indexAbstraction) && isIndexVisible( indexAbstraction, selectorString, - authorizedIndex, + indexAbstraction, indicesOptions, projectMetadata, indexNameExpressionResolver, includeDataStreams - )) { - resolveSelectorsAndCollect(authorizedIndex, selectorString, indicesOptions, resolvedIndices, projectMetadata); - } - } - if (resolvedIndices.isEmpty()) { - // es core honours allow_no_indices for each wildcard expression, we do the same here by throwing index not found. - if (indicesOptions.allowNoIndices() == false) { - throw new IndexNotFoundException(indexAbstraction); - } - resolvedExpressionsBuilder.addLocalExpressions(index, new HashSet<>(), SUCCESS); - } else { - if (minus) { - resolvedExpressionsBuilder.excludeFromLocalExpressions(resolvedIndices); - } else { - resolvedExpressionsBuilder.addLocalExpressions(index, resolvedIndices, SUCCESS); - } - } - } else { - final HashSet resolvedIndices = new HashSet<>(); - resolveSelectorsAndCollect(indexAbstraction, selectorString, indicesOptions, resolvedIndices, projectMetadata); - if (minus) { - resolvedExpressionsBuilder.excludeFromLocalExpressions(resolvedIndices); + ); + final LocalIndexResolutionResult result = visible ? SUCCESS : CONCRETE_RESOURCE_NOT_VISIBLE; + resolvedExpressionsBuilder.addExpressions(index, resolvedIndices, result, remoteIndices); + } else if (indicesOptions.ignoreUnavailable()) { + // ignoreUnavailable implies that the request should not fail if an index is not authorized + // so we map this expression to an empty list, + resolvedExpressionsBuilder.addExpressions(index, new HashSet<>(), CONCRETE_RESOURCE_UNAUTHORIZED, remoteIndices); } else { - final boolean authorized = isAuthorized.test(indexAbstraction, selector); - if (authorized) { - final boolean visible = indexExists(projectMetadata, indexAbstraction) - && isIndexVisible( - indexAbstraction, - selectorString, - indexAbstraction, - indicesOptions, - projectMetadata, - indexNameExpressionResolver, - includeDataStreams - ); - final LocalIndexResolutionResult result = visible ? SUCCESS : CONCRETE_RESOURCE_NOT_VISIBLE; - resolvedExpressionsBuilder.addLocalExpressions(index, resolvedIndices, result); - } else if (indicesOptions.ignoreUnavailable()) { - // ignoreUnavailable implies that the request should not fail if an index is not authorized - // so we map this expression to an empty list, - resolvedExpressionsBuilder.addLocalExpressions(index, new HashSet<>(), CONCRETE_RESOURCE_UNAUTHORIZED); - } else { - // store the calculated expansion as unauthorized, it will be rejected later - resolvedExpressionsBuilder.addLocalExpressions(index, resolvedIndices, CONCRETE_RESOURCE_UNAUTHORIZED); - } + // store the calculated expansion as unauthorized, it will be rejected later + resolvedExpressionsBuilder.addExpressions(index, resolvedIndices, CONCRETE_RESOURCE_UNAUTHORIZED, remoteIndices); } } } - return resolvedExpressionsBuilder.build(); + return wildcardSeen; } private static void resolveSelectorsAndCollect( diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/crossproject/CrossProjectIndexExpressionsRewriter.java b/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectIndexExpressionsRewriter.java similarity index 51% rename from x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/crossproject/CrossProjectIndexExpressionsRewriter.java rename to server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectIndexExpressionsRewriter.java index 3d2d5f221f400..f472ab9060fb6 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/crossproject/CrossProjectIndexExpressionsRewriter.java +++ b/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectIndexExpressionsRewriter.java @@ -1,11 +1,13 @@ /* * 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; you may not use this file except in compliance with the Elastic License - * 2.0. + * 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.xpack.security.authz.crossproject; +package org.elasticsearch.search.crossproject; import org.elasticsearch.cluster.metadata.ClusterNameExpressionResolver; import org.elasticsearch.cluster.metadata.IndexNameExpressionResolver; @@ -13,14 +15,13 @@ import org.elasticsearch.core.Nullable; import org.elasticsearch.logging.LogManager; import org.elasticsearch.logging.Logger; -import org.elasticsearch.search.crossproject.ProjectRoutingInfo; import org.elasticsearch.transport.NoSuchRemoteClusterException; import org.elasticsearch.transport.RemoteClusterAware; -import java.util.LinkedHashMap; +import java.util.ArrayList; +import java.util.HashSet; import java.util.LinkedHashSet; import java.util.List; -import java.util.Map; import java.util.Set; import java.util.stream.Collectors; @@ -35,6 +36,8 @@ public class CrossProjectIndexExpressionsRewriter { private static final String EXCLUSION = "-"; private static final String DATE_MATH = "<"; + public record Result(@Nullable String local, HashSet remote) {} + /** * Rewrites index expressions for cross-project search requests. * Handles qualified and unqualified expressions and match-all cases will also hand exclusions in the future. @@ -46,7 +49,7 @@ public class CrossProjectIndexExpressionsRewriter { * @throws IllegalArgumentException if exclusions, date math or selectors are present in the index expressions * @throws NoMatchingProjectException if a qualified resource cannot be resolved because a project is missing */ - public static Map> rewriteIndexExpressions( + public static List rewriteIndexExpressions( ProjectRoutingInfo originProject, List linkedProjects, final String[] originalIndices @@ -63,100 +66,97 @@ public static Map> rewriteIndexExpressions( : "either origin project or linked projects must be in project target set"; Set linkedProjectNames = linkedProjects.stream().map(ProjectRoutingInfo::projectAlias).collect(Collectors.toSet()); - Map> canonicalExpressionsMap = new LinkedHashMap<>(indices.length); + List results = new ArrayList<>(indices.length); for (String resource : indices) { - if (canonicalExpressionsMap.containsKey(resource)) { - continue; - } - maybeThrowOnUnsupportedResource(resource); - - boolean isQualified = RemoteClusterAware.isRemoteIndexName(resource); - if (isQualified) { - String[] splitResource = RemoteClusterAware.splitIndexName(resource); - assert splitResource.length == 2 - : "Expected two strings (project and indexExpression) for a qualified resource [" - + resource - + "], but found [" - + splitResource.length - + "]"; - String projectAlias = splitResource[0]; - assert projectAlias != null : "Expected a project alias for a qualified resource but was null"; - String indexExpression = splitResource[1]; - maybeThrowOnUnsupportedResource(indexExpression); - - Set canonicalExpressions = rewriteQualified(projectAlias, indexExpression, originProject, linkedProjectNames); - - canonicalExpressionsMap.put(resource, canonicalExpressions); - logger.debug("Rewrote qualified expression [{}] to [{}]", resource, canonicalExpressions); - } else { - Set canonicalExpressions = rewriteUnqualified(resource, originProject, linkedProjects); - canonicalExpressionsMap.put(resource, canonicalExpressions); - logger.debug("Rewrote unqualified expression [{}] to [{}]", resource, canonicalExpressions); - } + results.add(rewrite(resource, originProject == null ? null : originProject.projectAlias(), linkedProjectNames)); } - return canonicalExpressionsMap; + return results; } - private static Set rewriteUnqualified( - String indexExpression, - @Nullable ProjectRoutingInfo origin, - List projects - ) { - Set canonicalExpressions = new LinkedHashSet<>(); - if (origin != null) { - canonicalExpressions.add(indexExpression); // adding the original indexExpression for the _origin cluster. - } - for (ProjectRoutingInfo targetProject : projects) { - canonicalExpressions.add(RemoteClusterAware.buildRemoteIndexName(targetProject.projectAlias(), indexExpression)); + /** + * Rewrites a single index expression for cross-project search requests. + * Handles qualified and unqualified expressions and match-all cases will also hand exclusions in the future. + * + * @param originProjectAlias the _origin project with its alias + * @return a map from original index expressions to lists of canonical index expressions + * @throws IllegalArgumentException if exclusions, date math or selectors are present in the index expressions + * @throws NoMatchingProjectException if a qualified resource cannot be resolved because a project is missing + */ + public static Result rewrite(String resource, @Nullable String originProjectAlias, Set linkedProjectAliases) { + maybeThrowOnUnsupportedResource(resource); + + boolean isQualified = RemoteClusterAware.isRemoteIndexName(resource); + + Result result; + if (isQualified) { + result = rewriteQualified(resource, originProjectAlias, linkedProjectAliases); + logger.debug("Rewrote qualified expression [{}] to [{}]", resource, result); + } else { + result = rewriteUnqualified(resource, originProjectAlias, linkedProjectAliases); + logger.debug("Rewrote unqualified expression [{}] to [{}]", resource, result); } - return canonicalExpressions; + return result; } - private static Set rewriteQualified( - String requestedProjectAlias, - String indexExpression, - @Nullable ProjectRoutingInfo originProject, - Set allProjectAliases - ) { - if (originProject != null && ORIGIN_PROJECT_KEY.equals(requestedProjectAlias)) { + public static Result rewriteQualified(String resource, @Nullable String originProjectAlias, Set linkedProjectNames) { + String[] splitResource = RemoteClusterAware.splitIndexName(resource); + assert splitResource.length == 2 + : "Expected two strings (project and indexExpression) for a qualified resource [" + + resource + + "], but found [" + + splitResource.length + + "]"; + String projectAlias = splitResource[0]; + assert projectAlias != null : "Expected a project alias for a qualified resource but was null"; + String indexExpression = splitResource[1]; + maybeThrowOnUnsupportedResource(indexExpression); + + if (originProjectAlias != null && ORIGIN_PROJECT_KEY.equals(projectAlias)) { // handling case where we have a qualified expression like: _origin:indexName - return Set.of(indexExpression); + return new Result(indexExpression, new HashSet<>()); } - if (originProject == null && ORIGIN_PROJECT_KEY.equals(requestedProjectAlias)) { + if (originProjectAlias == null && ORIGIN_PROJECT_KEY.equals(projectAlias)) { // handling case where we have a qualified expression like: _origin:indexName but no _origin project is set - throw new NoMatchingProjectException(requestedProjectAlias); + throw new NoMatchingProjectException(projectAlias); } try { - if (originProject != null) { - allProjectAliases.add(originProject.projectAlias()); + if (originProjectAlias != null) { + linkedProjectNames.add(originProjectAlias); } - Set resourcesMatchingAliases = new LinkedHashSet<>(); - List allProjectsMatchingAlias = ClusterNameExpressionResolver.resolveClusterNames( - allProjectAliases, - requestedProjectAlias - ); + HashSet resourcesMatchingAliases = new LinkedHashSet<>(); + List allProjectsMatchingAlias = ClusterNameExpressionResolver.resolveClusterNames(linkedProjectNames, projectAlias); if (allProjectsMatchingAlias.isEmpty()) { - throw new NoMatchingProjectException(requestedProjectAlias); + throw new NoMatchingProjectException(projectAlias); } + boolean includeOrigin = false; for (String project : allProjectsMatchingAlias) { - if (originProject != null && project.equals(originProject.projectAlias())) { - resourcesMatchingAliases.add(indexExpression); + if (project.equals(originProjectAlias)) { + includeOrigin = true; } else { resourcesMatchingAliases.add(RemoteClusterAware.buildRemoteIndexName(project, indexExpression)); } } - return resourcesMatchingAliases; + return new Result(includeOrigin ? indexExpression : null, resourcesMatchingAliases); } catch (NoSuchRemoteClusterException ex) { logger.debug(ex.getMessage(), ex); - throw new NoMatchingProjectException(requestedProjectAlias); + throw new NoMatchingProjectException(projectAlias); } } + public static Result rewriteUnqualified(String indexExpression, @Nullable String originProjectAlias, Set linkedProjects) { + HashSet remoteExpressions = new LinkedHashSet<>(); + for (String targetProject : linkedProjects) { + remoteExpressions.add(RemoteClusterAware.buildRemoteIndexName(targetProject, indexExpression)); + } + boolean includeOrigin = originProjectAlias != null; + return new Result(includeOrigin ? indexExpression : null, remoteExpressions); + } + private static void maybeThrowOnUnsupportedResource(String resource) { // TODO To be handled in future PR. if (resource.startsWith(EXCLUSION)) { @@ -167,7 +167,6 @@ private static void maybeThrowOnUnsupportedResource(String resource) { } if (IndexNameExpressionResolver.hasSelectorSuffix(resource)) { throw new IllegalArgumentException("Selectors are not currently supported but was found in the expression [" + resource + "]"); - } } } diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/crossproject/NoMatchingProjectException.java b/server/src/main/java/org/elasticsearch/search/crossproject/NoMatchingProjectException.java similarity index 52% rename from x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/crossproject/NoMatchingProjectException.java rename to server/src/main/java/org/elasticsearch/search/crossproject/NoMatchingProjectException.java index 411c519156197..7b3932caf0f0f 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/crossproject/NoMatchingProjectException.java +++ b/server/src/main/java/org/elasticsearch/search/crossproject/NoMatchingProjectException.java @@ -1,3 +1,12 @@ +/* + * 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". + */ + /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one * or more contributor license agreements. Licensed under the Elastic License @@ -5,7 +14,7 @@ * 2.0. */ -package org.elasticsearch.xpack.security.authz.crossproject; +package org.elasticsearch.search.crossproject; import org.elasticsearch.ResourceNotFoundException; diff --git a/server/src/main/java/org/elasticsearch/search/crossproject/TargetProjects.java b/server/src/main/java/org/elasticsearch/search/crossproject/TargetProjects.java index 78aa4b321b5cd..536dac2f2a17f 100644 --- a/server/src/main/java/org/elasticsearch/search/crossproject/TargetProjects.java +++ b/server/src/main/java/org/elasticsearch/search/crossproject/TargetProjects.java @@ -10,6 +10,8 @@ package org.elasticsearch.search.crossproject; import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; public record TargetProjects(ProjectRoutingInfo originProject, List linkedProjects) { public static TargetProjects NOT_CROSS_PROJECT = new TargetProjects(null, List.of()); @@ -17,4 +19,12 @@ public record TargetProjects(ProjectRoutingInfo originProject, List linkedProjectAliases() { + return linkedProjects.stream().map(ProjectRoutingInfo::projectAlias).collect(Collectors.toSet()); + } } diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/CrossProjectSearchAuthorizationService.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/CrossProjectSearchAuthorizationService.java index a8de82c857416..746ac2196815e 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/CrossProjectSearchAuthorizationService.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/CrossProjectSearchAuthorizationService.java @@ -13,10 +13,17 @@ public interface CrossProjectSearchAuthorizationService { void loadAuthorizedProjects(ActionListener listener); + boolean enabled(); + class Default implements CrossProjectSearchAuthorizationService { @Override public void loadAuthorizedProjects(ActionListener listener) { listener.onResponse(TargetProjects.NOT_CROSS_PROJECT); } + + @Override + public boolean enabled() { + return false; + } } } 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 5c811c56b1060..424d18febf4c6 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 @@ -212,12 +212,17 @@ public AuthorizationService( AuthorizationDenialMessages authorizationDenialMessages, LinkedProjectConfigService linkedProjectConfigService, ProjectResolver projectResolver, - CrossProjectSearchAuthorizationService crossProjectSearchIndexExpressionsRewriter + CrossProjectSearchAuthorizationService crossProjectSearchAuthorizationService ) { this.clusterService = clusterService; this.auditTrailService = auditTrailService; this.restrictedIndices = restrictedIndices; - this.indicesAndAliasesResolver = new IndicesAndAliasesResolver(settings, linkedProjectConfigService, resolver, false); + this.indicesAndAliasesResolver = new IndicesAndAliasesResolver( + settings, + linkedProjectConfigService, + resolver, + crossProjectSearchAuthorizationService.enabled() + ); this.authcFailureHandler = authcFailureHandler; this.threadContext = threadPool.getThreadContext(); this.securityContext = new SecurityContext(settings, this.threadContext); @@ -238,7 +243,7 @@ public AuthorizationService( this.indicesAccessControlWrapper = new DlsFlsFeatureTrackingIndicesAccessControlWrapper(settings, licenseState); this.authorizationDenialMessages = authorizationDenialMessages; this.projectResolver = projectResolver; - this.crossProjectSearchAuthzService = crossProjectSearchIndexExpressionsRewriter; + this.crossProjectSearchAuthzService = crossProjectSearchAuthorizationService; } public void checkPrivileges( diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java index c02ff8ae344b6..0744c5f0c2d0e 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java @@ -39,7 +39,6 @@ import org.elasticsearch.xpack.core.security.authz.AuthorizationEngine; import org.elasticsearch.xpack.core.security.authz.IndicesAndAliasesResolverField; import org.elasticsearch.xpack.core.security.authz.ResolvedIndices; -import org.elasticsearch.xpack.security.authz.crossproject.CrossProjectIndexExpressionsRewriter; import java.util.ArrayList; import java.util.Arrays; @@ -369,35 +368,47 @@ ResolvedIndices resolveIndicesAndAliases( } else { if (replaceable.resolveCrossProject() && authorizedProjects != TargetProjects.NOT_CROSS_PROJECT) { assert replaceable.allowsRemoteIndices() : "cross-project requests must allow remote indices"; - Map> rewritten = CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions( - authorizedProjects.originProject(), - authorizedProjects.linkedProjects(), - replaceable.indices() + assert recordResolvedIndexExpressions : "cross-project requests must record resolved index expressions"; + + final ResolvedIndexExpressions resolved = indexAbstractionResolver.resolveIndexAbstractions( + Arrays.asList(replaceable.indices()), + // TODO make lenient + indicesOptions, + projectMetadata, + authorizedIndices::all, + authorizedIndices::check, + authorizedProjects, + indicesRequest.includeDataStreams() ); - } - final ResolvedIndices split; - if (replaceable.allowsRemoteIndices()) { - split = remoteClusterResolver.splitLocalAndRemoteIndexNames(indicesRequest.indices()); - } else { - split = new ResolvedIndices(Arrays.asList(indicesRequest.indices()), Collections.emptyList()); - } - final ResolvedIndexExpressions resolved = indexAbstractionResolver.resolveIndexAbstractions( - split.getLocal(), - indicesOptions, - projectMetadata, - authorizedIndices::all, - authorizedIndices::check, - indicesRequest.includeDataStreams() - ); - // only store resolved expressions if configured, to avoid unnecessary memory usage - // once we've migrated from `indices()` to using resolved expressions holistically, - // we will always store them - if (recordResolvedIndexExpressions) { replaceable.setResolvedIndexExpressions(resolved); + + resolvedIndicesBuilder.addLocal(resolved.getLocalIndicesList()); + resolvedIndicesBuilder.addRemote(resolved.getRemoteIndicesList()); + } else { + final ResolvedIndices split; + if (replaceable.allowsRemoteIndices()) { + split = remoteClusterResolver.splitLocalAndRemoteIndexNames(indicesRequest.indices()); + } else { + split = new ResolvedIndices(Arrays.asList(indicesRequest.indices()), Collections.emptyList()); + } + final ResolvedIndexExpressions resolved = indexAbstractionResolver.resolveIndexAbstractions( + split.getLocal(), + indicesOptions, + projectMetadata, + authorizedIndices::all, + authorizedIndices::check, + indicesRequest.includeDataStreams() + ); + // only store resolved expressions if configured, to avoid unnecessary memory usage + // once we've migrated from `indices()` to using resolved expressions holistically, + // we will always store them + if (recordResolvedIndexExpressions) { + replaceable.setResolvedIndexExpressions(resolved); + } + resolvedIndicesBuilder.addLocal(resolved.getLocalIndicesList()); + resolvedIndicesBuilder.addRemote(split.getRemote()); } - resolvedIndicesBuilder.addLocal(resolved.getLocalIndicesList()); - resolvedIndicesBuilder.addRemote(split.getRemote()); } if (resolvedIndicesBuilder.isEmpty()) { From c41eebce76030dec2d9d5c58231f31fe2a4ef337 Mon Sep 17 00:00:00 2001 From: elasticsearchmachine Date: Fri, 26 Sep 2025 08:03:32 +0000 Subject: [PATCH 40/89] [CI] Update transport version definitions --- .../referable/resolve_index_include_resolved_flag.csv | 4 ---- server/src/main/resources/transport/upper_bounds/9.2.csv | 4 ---- 2 files changed, 8 deletions(-) diff --git a/server/src/main/resources/transport/definitions/referable/resolve_index_include_resolved_flag.csv b/server/src/main/resources/transport/definitions/referable/resolve_index_include_resolved_flag.csv index 807d5c5b1548f..a4676dc2fe444 100644 --- a/server/src/main/resources/transport/definitions/referable/resolve_index_include_resolved_flag.csv +++ b/server/src/main/resources/transport/definitions/referable/resolve_index_include_resolved_flag.csv @@ -1,5 +1 @@ -<<<<<<< HEAD 9175000 -======= -9172000 ->>>>>>> 6f79ff0e655e73155108759d0c723a280d119d44 diff --git a/server/src/main/resources/transport/upper_bounds/9.2.csv b/server/src/main/resources/transport/upper_bounds/9.2.csv index 2232f2fe44f86..2a4fb1db74d97 100644 --- a/server/src/main/resources/transport/upper_bounds/9.2.csv +++ b/server/src/main/resources/transport/upper_bounds/9.2.csv @@ -1,5 +1 @@ -<<<<<<< HEAD resolve_index_include_resolved_flag,9175000 -======= -resolve_index_include_resolved_flag,9172000 ->>>>>>> 6f79ff0e655e73155108759d0c723a280d119d44 From ae36d5594e59d3ca2a16ea196a744c3bc67c08f2 Mon Sep 17 00:00:00 2001 From: Nikolaj Volgushev Date: Fri, 26 Sep 2025 10:14:19 +0200 Subject: [PATCH 41/89] Transport will not go away --- .../referable/resolve_index_include_resolved_flag.csv | 4 ---- server/src/main/resources/transport/upper_bounds/9.2.csv | 4 ---- 2 files changed, 8 deletions(-) diff --git a/server/src/main/resources/transport/definitions/referable/resolve_index_include_resolved_flag.csv b/server/src/main/resources/transport/definitions/referable/resolve_index_include_resolved_flag.csv index 807d5c5b1548f..a4676dc2fe444 100644 --- a/server/src/main/resources/transport/definitions/referable/resolve_index_include_resolved_flag.csv +++ b/server/src/main/resources/transport/definitions/referable/resolve_index_include_resolved_flag.csv @@ -1,5 +1 @@ -<<<<<<< HEAD 9175000 -======= -9172000 ->>>>>>> 6f79ff0e655e73155108759d0c723a280d119d44 diff --git a/server/src/main/resources/transport/upper_bounds/9.2.csv b/server/src/main/resources/transport/upper_bounds/9.2.csv index 2232f2fe44f86..2a4fb1db74d97 100644 --- a/server/src/main/resources/transport/upper_bounds/9.2.csv +++ b/server/src/main/resources/transport/upper_bounds/9.2.csv @@ -1,5 +1 @@ -<<<<<<< HEAD resolve_index_include_resolved_flag,9175000 -======= -resolve_index_include_resolved_flag,9172000 ->>>>>>> 6f79ff0e655e73155108759d0c723a280d119d44 From 0fe6504edc6101857c23ada90ae98e612b6ab344 Mon Sep 17 00:00:00 2001 From: Nikolaj Volgushev Date: Fri, 26 Sep 2025 10:17:02 +0200 Subject: [PATCH 42/89] Module info --- server/src/main/java/module-info.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/server/src/main/java/module-info.java b/server/src/main/java/module-info.java index 856633f0f8350..c5f69f4a5b701 100644 --- a/server/src/main/java/module-info.java +++ b/server/src/main/java/module-info.java @@ -55,8 +55,7 @@ requires org.apache.lucene.queryparser; requires org.apache.lucene.sandbox; requires org.apache.lucene.suggest; - requires org.jruby.jcodings; - + exports org.elasticsearch; exports org.elasticsearch.action; exports org.elasticsearch.action.admin.cluster.allocation; From 9be4eba6f450ffa91f5691a46817a699b8ec1307 Mon Sep 17 00:00:00 2001 From: Nikolaj Volgushev Date: Fri, 26 Sep 2025 10:18:34 +0200 Subject: [PATCH 43/89] Still module info --- server/src/main/java/module-info.java | 1 - 1 file changed, 1 deletion(-) diff --git a/server/src/main/java/module-info.java b/server/src/main/java/module-info.java index c5f69f4a5b701..b94fa28157d13 100644 --- a/server/src/main/java/module-info.java +++ b/server/src/main/java/module-info.java @@ -55,7 +55,6 @@ requires org.apache.lucene.queryparser; requires org.apache.lucene.sandbox; requires org.apache.lucene.suggest; - exports org.elasticsearch; exports org.elasticsearch.action; exports org.elasticsearch.action.admin.cluster.allocation; From dcd3169c6762fb782725115b0be649c506a7d6db Mon Sep 17 00:00:00 2001 From: elasticsearchmachine Date: Fri, 26 Sep 2025 08:27:10 +0000 Subject: [PATCH 44/89] [CI] Auto commit changes from spotless --- server/src/main/java/module-info.java | 1 + 1 file changed, 1 insertion(+) diff --git a/server/src/main/java/module-info.java b/server/src/main/java/module-info.java index b94fa28157d13..68dc8da3b7d15 100644 --- a/server/src/main/java/module-info.java +++ b/server/src/main/java/module-info.java @@ -55,6 +55,7 @@ requires org.apache.lucene.queryparser; requires org.apache.lucene.sandbox; requires org.apache.lucene.suggest; + exports org.elasticsearch; exports org.elasticsearch.action; exports org.elasticsearch.action.admin.cluster.allocation; From 0338477a80617a0d88087bbce2e8b866c84adc88 Mon Sep 17 00:00:00 2001 From: Nikolaj Volgushev Date: Fri, 26 Sep 2025 11:34:11 +0200 Subject: [PATCH 45/89] Fix --- server/src/main/java/module-info.java | 1 + .../indices/resolve/ResolveIndexAction.java | 41 +++++++++++++++---- .../NoMatchingProjectException.java | 7 ---- 3 files changed, 33 insertions(+), 16 deletions(-) diff --git a/server/src/main/java/module-info.java b/server/src/main/java/module-info.java index b94fa28157d13..68dc8da3b7d15 100644 --- a/server/src/main/java/module-info.java +++ b/server/src/main/java/module-info.java @@ -55,6 +55,7 @@ requires org.apache.lucene.queryparser; requires org.apache.lucene.sandbox; requires org.apache.lucene.suggest; + exports org.elasticsearch; exports org.elasticsearch.action; exports org.elasticsearch.action.admin.cluster.allocation; diff --git a/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java b/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java index 2fb8fea6366fd..aecce6a17ba69 100644 --- a/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java +++ b/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java @@ -147,12 +147,9 @@ public Request(StreamInput in) throws IOException { } if (in.getTransportVersion().supports(RESOLVE_INDEX_INCLUDE_RESOLVED_FLAG)) { this.includeResolvedExpressions = in.readBoolean(); - } else { - this.includeResolvedExpressions = false; - } - if (in.getTransportVersion().supports(RESOLVE_INDEX_INCLUDE_RESOLVED_FLAG)) { this.resolveCrossProject = in.readBoolean(); } else { + this.includeResolvedExpressions = false; this.resolveCrossProject = false; } } @@ -167,8 +164,6 @@ public void writeTo(StreamOutput out) throws IOException { } if (out.getTransportVersion().supports(RESOLVE_INDEX_INCLUDE_RESOLVED_FLAG)) { out.writeBoolean(includeResolvedExpressions); - } - if (out.getTransportVersion().supports(RESOLVE_INDEX_INCLUDE_RESOLVED_FLAG)) { out.writeBoolean(resolveCrossProject); } } @@ -519,10 +514,19 @@ public static class Response extends ActionResponse implements ToXContentObject private final ResolvedIndexExpressions resolvedIndexExpressions; public Response(List indices, List aliases, List dataStreams) { + this(indices, aliases, dataStreams, null); + } + + public Response( + List indices, + List aliases, + List dataStreams, + ResolvedIndexExpressions resolvedIndexExpressions + ) { this.indices = indices; this.aliases = aliases; this.dataStreams = dataStreams; - this.resolvedIndexExpressions = null; + this.resolvedIndexExpressions = resolvedIndexExpressions; } public Response(StreamInput in) throws IOException { @@ -575,6 +579,7 @@ public int hashCode() { return Objects.hash(indices, aliases, dataStreams); } + @Nullable public ResolvedIndexExpressions getResolvedIndexExpressions() { return resolvedIndexExpressions; } @@ -620,12 +625,16 @@ protected void doExecute(Task task, Request request, final ActionListener dataStreams = new ArrayList<>(); resolveIndices(localIndices, projectState, indexNameExpressionResolver, indices, aliases, dataStreams, request.indexModes); + final ResolvedIndexExpressions resolvedExpressions = request.getResolvedIndexExpressions(); if (remoteClusterIndices.size() > 0) { final int remoteRequests = remoteClusterIndices.size(); final CountDown completionCounter = new CountDown(remoteRequests); final SortedMap remoteResponses = Collections.synchronizedSortedMap(new TreeMap<>()); final Runnable terminalHandler = () -> { if (completionCounter.countDown()) { + if (request.resolveCrossProject) { + // TODO error handling + } mergeResults(remoteResponses, indices, aliases, dataStreams, request.indexModes); listener.onResponse(new Response(indices, aliases, dataStreams)); } @@ -640,14 +649,28 @@ protected void doExecute(Task task, Request request, final ActionListener { remoteResponses.put(clusterAlias, response); terminalHandler.run(); }, failure -> terminalHandler.run())); } } else { - listener.onResponse(new Response(indices, aliases, dataStreams)); + listener.onResponse( + new Response( + indices, + aliases, + dataStreams, + request.includeResolvedExpressions ? request.getResolvedIndexExpressions() : null + ) + ); } } diff --git a/server/src/main/java/org/elasticsearch/search/crossproject/NoMatchingProjectException.java b/server/src/main/java/org/elasticsearch/search/crossproject/NoMatchingProjectException.java index 7b3932caf0f0f..c9be3a517c37c 100644 --- a/server/src/main/java/org/elasticsearch/search/crossproject/NoMatchingProjectException.java +++ b/server/src/main/java/org/elasticsearch/search/crossproject/NoMatchingProjectException.java @@ -7,13 +7,6 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -/* - * 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; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - package org.elasticsearch.search.crossproject; import org.elasticsearch.ResourceNotFoundException; From 6f91def6a446bcbd1e54ff4894fa33b8d5980c02 Mon Sep 17 00:00:00 2001 From: Nikolaj Volgushev Date: Fri, 26 Sep 2025 14:15:33 +0200 Subject: [PATCH 46/89] Add error handling --- .../elasticsearch/ElasticsearchException.java | 1 + .../indices/resolve/ResolveIndexAction.java | 28 ++- .../metadata/IndexAbstractionResolver.java | 2 +- .../CrossProjectIndexExpressionsRewriter.java | 4 +- .../CrossProjectSearchErrorHandler.java | 215 ++++++++++++++++++ .../LinkedProjectExpressions.java | 47 ++++ .../crossproject/RemoteIndexExpressions.java | 40 ++++ .../search/crossproject/TargetProjects.java | 7 +- 8 files changed, 336 insertions(+), 8 deletions(-) create mode 100644 server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectSearchErrorHandler.java create mode 100644 server/src/main/java/org/elasticsearch/search/crossproject/LinkedProjectExpressions.java create mode 100644 server/src/main/java/org/elasticsearch/search/crossproject/RemoteIndexExpressions.java diff --git a/server/src/main/java/org/elasticsearch/ElasticsearchException.java b/server/src/main/java/org/elasticsearch/ElasticsearchException.java index 79d1df6a09be4..9d2c2d58ba4ad 100644 --- a/server/src/main/java/org/elasticsearch/ElasticsearchException.java +++ b/server/src/main/java/org/elasticsearch/ElasticsearchException.java @@ -2023,6 +2023,7 @@ private enum ElasticsearchExceptionHandle { TransportVersions.REMOTE_EXCEPTION, TransportVersions.REMOTE_EXCEPTION_8_19 ); + // TODO register NoMatchingProjectException final Class exceptionClass; final CheckedFunction constructor; diff --git a/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java b/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java index aecce6a17ba69..d8f7ef827e60f 100644 --- a/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java +++ b/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java @@ -44,6 +44,9 @@ import org.elasticsearch.index.IndexMode; import org.elasticsearch.injection.guice.Inject; import org.elasticsearch.search.SearchService; +import org.elasticsearch.search.crossproject.CrossProjectSearchErrorHandler; +import org.elasticsearch.search.crossproject.LinkedProjectExpressions; +import org.elasticsearch.search.crossproject.RemoteIndexExpressions; import org.elasticsearch.tasks.Task; import org.elasticsearch.transport.RemoteClusterAware; import org.elasticsearch.transport.RemoteClusterService; @@ -70,6 +73,7 @@ import java.util.stream.Stream; import static org.elasticsearch.action.search.TransportSearchHelper.checkCCSVersionCompatibility; +import static org.elasticsearch.search.crossproject.CrossProjectSearchErrorHandler.lenientIndicesOptionsForFanout; public class ResolveIndexAction extends ActionType { @@ -614,15 +618,16 @@ protected void doExecute(Task task, Request request, final ActionListener remoteClusterIndices = remoteClusterService.groupIndices( - request.indicesOptions(), + request.resolveCrossProject ? lenientIndicesOptionsForFanout(originalIndicesOptions) : originalIndicesOptions, request.indices() ); final OriginalIndices localIndices = remoteClusterIndices.remove(RemoteClusterAware.LOCAL_CLUSTER_GROUP_KEY); List indices = new ArrayList<>(); List aliases = new ArrayList<>(); List dataStreams = new ArrayList<>(); + final ProjectState projectState = projectResolver.getProjectState(clusterService.state()); resolveIndices(localIndices, projectState, indexNameExpressionResolver, indices, aliases, dataStreams, request.indexModes); final ResolvedIndexExpressions resolvedExpressions = request.getResolvedIndexExpressions(); @@ -633,7 +638,24 @@ protected void doExecute(Task task, Request request, final ActionListener { if (completionCounter.countDown()) { if (request.resolveCrossProject) { - // TODO error handling + Map linkedProjectExpressions = remoteResponses.entrySet() + .stream() + .collect( + Collectors.toMap( + Map.Entry::getKey, + e -> LinkedProjectExpressions.fromResolvedExpressions(e.getValue().getResolvedIndexExpressions()) + ) + ); + try { + CrossProjectSearchErrorHandler.crossProjectFanoutErrorHandling( + request.indicesOptions, + resolvedExpressions, + new RemoteIndexExpressions(linkedProjectExpressions) + ); + } catch (Exception ex) { + listener.onFailure(ex); + return; + } } mergeResults(remoteResponses, indices, aliases, dataStreams, request.indexModes); listener.onResponse(new Response(indices, aliases, dataStreams)); diff --git a/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java b/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java index b8e7e5543b74b..68b63a1b2ee0c 100644 --- a/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java +++ b/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java @@ -82,7 +82,7 @@ public ResolvedIndexExpressions resolveIndexAbstractions( final ResolvedIndexExpressions.Builder resolvedExpressionsBuilder = ResolvedIndexExpressions.builder(); boolean wildcardSeen = false; for (String index : indices) { - CrossProjectIndexExpressionsRewriter.Result rewritten = CrossProjectIndexExpressionsRewriter.rewrite( + CrossProjectIndexExpressionsRewriter.Result rewritten = CrossProjectIndexExpressionsRewriter.rewriteIndexExpression( index, originProjectAlias, linkedProjectAliases diff --git a/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectIndexExpressionsRewriter.java b/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectIndexExpressionsRewriter.java index f472ab9060fb6..a9a8537ceeb2b 100644 --- a/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectIndexExpressionsRewriter.java +++ b/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectIndexExpressionsRewriter.java @@ -68,7 +68,7 @@ public static List rewriteIndexExpressions( Set linkedProjectNames = linkedProjects.stream().map(ProjectRoutingInfo::projectAlias).collect(Collectors.toSet()); List results = new ArrayList<>(indices.length); for (String resource : indices) { - results.add(rewrite(resource, originProject == null ? null : originProject.projectAlias(), linkedProjectNames)); + results.add(rewriteIndexExpression(resource, originProject == null ? null : originProject.projectAlias(), linkedProjectNames)); } return results; } @@ -82,7 +82,7 @@ public static List rewriteIndexExpressions( * @throws IllegalArgumentException if exclusions, date math or selectors are present in the index expressions * @throws NoMatchingProjectException if a qualified resource cannot be resolved because a project is missing */ - public static Result rewrite(String resource, @Nullable String originProjectAlias, Set linkedProjectAliases) { + public static Result rewriteIndexExpression(String resource, @Nullable String originProjectAlias, Set linkedProjectAliases) { maybeThrowOnUnsupportedResource(resource); boolean isQualified = RemoteClusterAware.isRemoteIndexName(resource); diff --git a/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectSearchErrorHandler.java b/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectSearchErrorHandler.java new file mode 100644 index 0000000000000..e3e759b6a2d4e --- /dev/null +++ b/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectSearchErrorHandler.java @@ -0,0 +1,215 @@ +/* + * 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.search.crossproject; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.elasticsearch.ElasticsearchException; +import org.elasticsearch.ElasticsearchSecurityException; +import org.elasticsearch.action.ResolvedIndexExpression; +import org.elasticsearch.action.ResolvedIndexExpressions; +import org.elasticsearch.action.support.IndicesOptions; +import org.elasticsearch.index.IndexNotFoundException; +import org.elasticsearch.rest.RestStatus; +import org.elasticsearch.transport.RemoteClusterAware; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +import static org.elasticsearch.action.ResolvedIndexExpression.LocalIndexResolutionResult.SUCCESS; + +/** + * Utility class for handling errors in cross-project index operations. + *

+ * This class provides consistent error handling for scenarios where index resolution + * spans multiple projects, taking into account the provided {@link IndicesOptions}. + * It handles: + *

    + *
  • Validation of index existence in the merged project view based on IndicesOptions (ignoreUnavailable, + * allowNoIndices)
  • + *
  • Authorization issues during cross-project index resolution
  • + *
  • Both flat (unqualified) and qualified index expressions
  • + *
  • Wildcard index patterns that may resolve differently across projects
  • + *
+ *

+ * The utility examines both local and remote resolution results to determine the appropriate + * error response, throwing {@link IndexNotFoundException} for missing indices or + * {@link ElasticsearchSecurityException} for authorization failures. + */ +public class CrossProjectSearchErrorHandler { + private static final Logger logger = LogManager.getLogger(CrossProjectSearchErrorHandler.class); + private static final String WILDCARD = "*"; + + /** + * Validates the results of cross-project index resolution and throws appropriate exceptions based on the provided + * {@link IndicesOptions}. + *

+ * This method handles error scenarios when resolving indices across multiple projects: + *

    + *
  • If both {@code ignoreUnavailable} and {@code allowNoIndices} are true, the method returns without validation + * (lenient mode)
  • + *
  • For wildcard patterns that resolve to no indices, validates against {@code allowNoIndices}
  • + *
  • For concrete indices that don't exist, validates against {@code ignoreUnavailable}
  • + *
  • For indices with authorization issues, throws security exceptions
  • + *
+ *

+ * The method considers both flat (unqualified) and qualified index expressions, as well as + * local and linked project resolution results when determining whether to throw exceptions. + * + * @param indicesOptions Controls error behavior for missing indices + * @param localResolvedExpressions Resolution results from the origin project + * @param remoteResolvedExpressions Resolution results from linked projects + * @throws IndexNotFoundException If indices are missing and the {@code IndicesOptions} do not allow it + * @throws ElasticsearchSecurityException If authorization errors occurred during index resolution + */ + public static void crossProjectFanoutErrorHandling( + IndicesOptions indicesOptions, + ResolvedIndexExpressions localResolvedExpressions, + RemoteIndexExpressions remoteResolvedExpressions + ) { + if (indicesOptions.allowNoIndices() && indicesOptions.ignoreUnavailable()) { + // nothing to do since we're in lenient mode + logger.debug("Skipping index existence check in lenient mode"); + return; + } + + logger.info( + "Checking index existence for [{}] and [{}] with indices options [{}]", + localResolvedExpressions, + remoteResolvedExpressions, + indicesOptions + ); + + for (ResolvedIndexExpression localResolvedIndices : localResolvedExpressions.expressions()) { + String originalExpression = localResolvedIndices.original(); + + logger.info("Checking replaced expression for original expression [{}]", originalExpression); + + String resource = originalExpression; + boolean isQualifiedResource = RemoteClusterAware.isRemoteIndexName(resource); + if (isQualifiedResource) { + // handle qualified resource eg. P1:logs* + String[] splitResource = RemoteClusterAware.splitIndexName(resource); + assert splitResource.length == 2 + : "Expected two strings (project and indexExpression) for a qualified resource [" + + resource + + "], but found [" + + splitResource.length + + "]"; + resource = splitResource[1]; + } + if (false == indicesOptions.allowNoIndices()) { + checkAllowNoIndices( + resource, + originalExpression, + localResolvedIndices, + remoteResolvedExpressions, + isQualifiedResource == false + ); + } else if (false == indicesOptions.ignoreUnavailable()) { + checkIndicesOptions(originalExpression, localResolvedIndices, remoteResolvedExpressions, isQualifiedResource == false); + } + } + } + + public static IndicesOptions lenientIndicesOptionsForFanout(IndicesOptions indicesOptions) { + return IndicesOptions.builder(indicesOptions) + .concreteTargetOptions(new IndicesOptions.ConcreteTargetOptions(true)) + .wildcardOptions(IndicesOptions.WildcardOptions.builder(indicesOptions.wildcardOptions()).allowEmptyExpressions(true).build()) + .build(); + } + + private static void checkAllowNoIndices( + String indexAlias, + String originalExpression, + ResolvedIndexExpression localResolvedIndices, + RemoteIndexExpressions remoteResolvedExpressions, + boolean isFlatWorldResource + ) { + // strict behaviour of allowNoIndices checks if a wildcard expression resolves to no concrete indices. + if (false == indexAlias.contains(WILDCARD)) { + return; + } + checkIndicesOptions(originalExpression, localResolvedIndices, remoteResolvedExpressions, isFlatWorldResource); + } + + private static void checkIndicesOptions( + String originalExpression, + ResolvedIndexExpression localResolvedIndices, + RemoteIndexExpressions remoteResolvedExpressions, + boolean isFlatWorldResource + ) { + ResolvedIndexExpression.LocalExpressions localExpressions = localResolvedIndices.localExpressions(); + boolean resourceFound = false == localExpressions.expressions().isEmpty() + && localExpressions.localIndexResolutionResult() == SUCCESS; + + if (resourceFound && (isFlatWorldResource || localExpressions.expressions().size() == 1)) { + // a concrete index locally and either was a flat expression or was the only thing we needed to search + logger.info( + "Local cluster has canonical expression for original expression [{}], skipping remote existence check", + originalExpression + ); + return; + } + List exceptions = new ArrayList<>(); + ElasticsearchException localException = localExpressions.exception(); + if (localException != null) { + exceptions.add(localException); + } + int numberOfQualifiedFound = resourceFound ? 1 : 0; + for (var linkedProjectExpressions : remoteResolvedExpressions.expressions().values()) { + // for each linked project we check if the resolved expressions contains the original expression and check for resolution status + ResolvedIndexExpression.LocalExpressions resolvedRemoteExpression = linkedProjectExpressions.resolvedExpressions() + .get(originalExpression); + assert resolvedRemoteExpression != null : "we should always have resolved expressions from remote"; + + Set remoteExpressions = resolvedRemoteExpression.expressions(); + assert remoteExpressions != null : "we should always have replaced expressions"; + + logger.debug("Replaced indices from remote response resolved: [{}]", remoteExpressions); + boolean existsRemotely = false == remoteExpressions.isEmpty() + && resolvedRemoteExpression.localIndexResolutionResult() == SUCCESS; + if (existsRemotely) { + if (isFlatWorldResource) { + logger.debug( + "Remote project has resolved entries for [{}], skipping further remote existence check", + originalExpression + ); + resourceFound = true; + break; + } else { + numberOfQualifiedFound++; + } + } else if (resolvedRemoteExpression.exception() != null) { + exceptions.add(resolvedRemoteExpression.exception()); + } + } + boolean missingFlatResource = isFlatWorldResource && false == resourceFound; + boolean missingQualifiedResource = false == isFlatWorldResource + && numberOfQualifiedFound < localResolvedIndices.remoteExpressions().size(); + + if (missingFlatResource || missingQualifiedResource) { + 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 [" + originalExpression + "]", + RestStatus.FORBIDDEN + ); + exceptions.forEach(e::addSuppressed); + throw e; + } else { + throw new IndexNotFoundException(originalExpression); + } + } + } +} diff --git a/server/src/main/java/org/elasticsearch/search/crossproject/LinkedProjectExpressions.java b/server/src/main/java/org/elasticsearch/search/crossproject/LinkedProjectExpressions.java new file mode 100644 index 0000000000000..ac01f33db6c69 --- /dev/null +++ b/server/src/main/java/org/elasticsearch/search/crossproject/LinkedProjectExpressions.java @@ -0,0 +1,47 @@ +/* + * 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.search.crossproject; + +import org.elasticsearch.action.ResolvedIndexExpression; +import org.elasticsearch.action.ResolvedIndexExpressions; + +import java.util.HashMap; +import java.util.Map; + +/** + * This class allows capturing context about index expression replacements performed on a linked project. + *

+ * The replacements are keyed by the original index expression and have as value {@link ResolvedIndexExpression.LocalExpressions} that + * contains the set of expression (if any) was found on the remote, the result of the resolution and possibly the exception thrown. + * + *

An example structure is:

+ * + *
{@code
+ * {
+ *   "P*:my-index-*": {
+ *     "expressions": ["my-index-000001", "my-index-000002"],
+ *     "localIndexResolutionResult": "SUCCESS"
+ *   }
+ * }
+ * }
+ * + * @param resolvedExpressions a map keyed by the original expression and having as value the remote resolution for that expression. + */ +public record LinkedProjectExpressions(Map resolvedExpressions) { + public static LinkedProjectExpressions fromResolvedExpressions(ResolvedIndexExpressions resolvedExpressions) { + Map map = new HashMap<>(); + for (ResolvedIndexExpression e : resolvedExpressions.expressions()) { + if (map.put(e.original(), e.localExpressions()) != null) { + throw new IllegalStateException("duplicate key"); + } + } + return new LinkedProjectExpressions(map); + } +} diff --git a/server/src/main/java/org/elasticsearch/search/crossproject/RemoteIndexExpressions.java b/server/src/main/java/org/elasticsearch/search/crossproject/RemoteIndexExpressions.java new file mode 100644 index 0000000000000..6b5908d2ab20b --- /dev/null +++ b/server/src/main/java/org/elasticsearch/search/crossproject/RemoteIndexExpressions.java @@ -0,0 +1,40 @@ +/* + * 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.search.crossproject; + +import java.util.Map; + +/** + * A set of {@link LinkedProjectExpressions}, keyed by the project alias. + * + *

An example structure is:

+ * + *
{@code
+ * {
+ *   "P1": {
+ *      "P1:my-index-*": { //example qualified
+ *             "expressions": ["my-index-000001", "my-index-000002"],
+ *             "localIndexResolutionResult": "SUCCESS"
+ *       },
+ *       "my-metrics-*": { //example flat
+ *             "expressions": ["my-metrics-000001", "my-metrics-000002"],
+ *             "localIndexResolutionResult": "SUCCESS"
+ *       }
+ *   },
+ *   "P2": {
+ *      "my-index-*": {
+ *          "expressions": ["my-index-000001", "my-index-000002"],
+ *          "localIndexResolutionResult": "SUCCESS"
+ *      }
+ *   }
+ * }
+ * }
+ */ +public record RemoteIndexExpressions(Map expressions) {} diff --git a/server/src/main/java/org/elasticsearch/search/crossproject/TargetProjects.java b/server/src/main/java/org/elasticsearch/search/crossproject/TargetProjects.java index 536dac2f2a17f..9ecdbcbb41eaf 100644 --- a/server/src/main/java/org/elasticsearch/search/crossproject/TargetProjects.java +++ b/server/src/main/java/org/elasticsearch/search/crossproject/TargetProjects.java @@ -9,17 +9,20 @@ package org.elasticsearch.search.crossproject; +import org.elasticsearch.core.Nullable; + import java.util.List; import java.util.Set; import java.util.stream.Collectors; -public record TargetProjects(ProjectRoutingInfo originProject, List linkedProjects) { - public static TargetProjects NOT_CROSS_PROJECT = new TargetProjects(null, List.of()); +public record TargetProjects(@Nullable ProjectRoutingInfo originProject, List linkedProjects) { + public static final TargetProjects NOT_CROSS_PROJECT = new TargetProjects(null, List.of()); public TargetProjects(ProjectRoutingInfo originProject) { this(originProject, List.of()); } + @Nullable public String originProjectAlias() { return originProject != null ? originProject.projectAlias() : null; } From ddc9e8570387aacd74785b08d1a611356fb6278c Mon Sep 17 00:00:00 2001 From: elasticsearchmachine Date: Fri, 26 Sep 2025 12:23:24 +0000 Subject: [PATCH 47/89] [CI] Update transport version definitions --- .../referable/resolve_index_include_resolved_flag.csv | 2 +- server/src/main/resources/transport/upper_bounds/9.2.csv | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/server/src/main/resources/transport/definitions/referable/resolve_index_include_resolved_flag.csv b/server/src/main/resources/transport/definitions/referable/resolve_index_include_resolved_flag.csv index a4676dc2fe444..958f4e9c30d79 100644 --- a/server/src/main/resources/transport/definitions/referable/resolve_index_include_resolved_flag.csv +++ b/server/src/main/resources/transport/definitions/referable/resolve_index_include_resolved_flag.csv @@ -1 +1 @@ -9175000 +9176000 diff --git a/server/src/main/resources/transport/upper_bounds/9.2.csv b/server/src/main/resources/transport/upper_bounds/9.2.csv index 2a4fb1db74d97..9064e6faf610d 100644 --- a/server/src/main/resources/transport/upper_bounds/9.2.csv +++ b/server/src/main/resources/transport/upper_bounds/9.2.csv @@ -1 +1 @@ -resolve_index_include_resolved_flag,9175000 +resolve_index_include_resolved_flag,9176000 From 6506e485d7e7f19bb9e5f4640636058b42dc6ad9 Mon Sep 17 00:00:00 2001 From: Nikolaj Volgushev Date: Fri, 26 Sep 2025 14:43:42 +0200 Subject: [PATCH 48/89] Tweak error handling --- .../search/crossproject/CrossProjectSearchErrorHandler.java | 5 +++-- .../xpack/security/authz/IndicesAndAliasesResolver.java | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectSearchErrorHandler.java b/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectSearchErrorHandler.java index e3e759b6a2d4e..c6592bfa44c3e 100644 --- a/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectSearchErrorHandler.java +++ b/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectSearchErrorHandler.java @@ -115,7 +115,7 @@ public static void crossProjectFanoutErrorHandling( isQualifiedResource == false ); } else if (false == indicesOptions.ignoreUnavailable()) { - checkIndicesOptions(originalExpression, localResolvedIndices, remoteResolvedExpressions, isQualifiedResource == false); + checkIndicesOptions(resource, localResolvedIndices, remoteResolvedExpressions, isQualifiedResource == false); } } } @@ -169,7 +169,8 @@ private static void checkIndicesOptions( // for each linked project we check if the resolved expressions contains the original expression and check for resolution status ResolvedIndexExpression.LocalExpressions resolvedRemoteExpression = linkedProjectExpressions.resolvedExpressions() .get(originalExpression); - assert resolvedRemoteExpression != null : "we should always have resolved expressions from remote"; + assert resolvedRemoteExpression != null + : "we should always have resolved expressions from remote. missing resolved expressions for [" + originalExpression + "]"; Set remoteExpressions = resolvedRemoteExpression.expressions(); assert remoteExpressions != null : "we should always have replaced expressions"; diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java index d86e097e648fe..7c1850895ef53 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java @@ -52,6 +52,7 @@ import java.util.concurrent.CopyOnWriteArraySet; import java.util.function.BiPredicate; +import static org.elasticsearch.search.crossproject.CrossProjectSearchErrorHandler.lenientIndicesOptionsForFanout; import static org.elasticsearch.xpack.core.security.authz.IndicesAndAliasesResolverField.NO_INDEX_PLACEHOLDER; class IndicesAndAliasesResolver { @@ -372,8 +373,7 @@ ResolvedIndices resolveIndicesAndAliases( final ResolvedIndexExpressions resolved = indexAbstractionResolver.resolveIndexAbstractions( Arrays.asList(replaceable.indices()), - // TODO make lenient - indicesOptions, + lenientIndicesOptionsForFanout(indicesOptions), projectMetadata, authorizedIndices::all, authorizedIndices::check, From 55dc1c9bc5a060c5e7fdef2d6ae4701c9f6285df Mon Sep 17 00:00:00 2001 From: Nikolaj Volgushev Date: Fri, 26 Sep 2025 17:59:25 +0200 Subject: [PATCH 49/89] Fix up local index resolution --- .../metadata/IndexAbstractionResolver.java | 53 ++++++-- .../CrossProjectIndexExpressionsRewriter.java | 4 +- .../CrossProjectSearchErrorHandler.java | 120 +++++++++--------- 3 files changed, 108 insertions(+), 69 deletions(-) diff --git a/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java b/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java index 68b63a1b2ee0c..06ba6270738c5 100644 --- a/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java +++ b/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java @@ -94,6 +94,7 @@ public ResolvedIndexExpressions resolveIndexAbstractions( wildcardSeen = resolveIndexAbstraction( index, + rewritten.local(), indicesOptions, projectMetadata, allAuthorizedAndAvailableBySelector, @@ -118,14 +119,40 @@ private boolean resolveIndexAbstraction( boolean wildcardSeen, ResolvedIndexExpressions.Builder resolvedExpressionsBuilder, HashSet remoteIndices + ) { + return resolveIndexAbstraction( + index, + index, + indicesOptions, + projectMetadata, + allAuthorizedAndAvailableBySelector, + isAuthorized, + includeDataStreams, + wildcardSeen, + resolvedExpressionsBuilder, + remoteIndices + ); + } + + private boolean resolveIndexAbstraction( + String originalExpression, + String localIndex, + IndicesOptions indicesOptions, + ProjectMetadata projectMetadata, + Function> allAuthorizedAndAvailableBySelector, + BiPredicate isAuthorized, + boolean includeDataStreams, + boolean wildcardSeen, + ResolvedIndexExpressions.Builder resolvedExpressionsBuilder, + HashSet remoteIndices ) { String indexAbstraction; boolean minus = false; - if (index.charAt(0) == '-' && wildcardSeen) { - indexAbstraction = index.substring(1); + if (localIndex.charAt(0) == '-' && wildcardSeen) { + indexAbstraction = localIndex.substring(1); minus = true; } else { - indexAbstraction = index; + indexAbstraction = localIndex; } // Always check to see if there's a selector on the index expression @@ -162,12 +189,12 @@ && isIndexVisible( if (indicesOptions.allowNoIndices() == false) { throw new IndexNotFoundException(indexAbstraction); } - resolvedExpressionsBuilder.addExpressions(index, new HashSet<>(), SUCCESS, remoteIndices); + resolvedExpressionsBuilder.addExpressions(originalExpression, new HashSet<>(), SUCCESS, remoteIndices); } else { if (minus) { resolvedExpressionsBuilder.excludeFromLocalExpressions(resolvedIndices); } else { - resolvedExpressionsBuilder.addExpressions(index, resolvedIndices, SUCCESS, remoteIndices); + resolvedExpressionsBuilder.addExpressions(originalExpression, resolvedIndices, SUCCESS, remoteIndices); } } } else { @@ -189,14 +216,24 @@ && isIndexVisible( includeDataStreams ); final LocalIndexResolutionResult result = visible ? SUCCESS : CONCRETE_RESOURCE_NOT_VISIBLE; - resolvedExpressionsBuilder.addExpressions(index, resolvedIndices, result, remoteIndices); + resolvedExpressionsBuilder.addExpressions(originalExpression, resolvedIndices, result, remoteIndices); } else if (indicesOptions.ignoreUnavailable()) { // ignoreUnavailable implies that the request should not fail if an index is not authorized // so we map this expression to an empty list, - resolvedExpressionsBuilder.addExpressions(index, new HashSet<>(), CONCRETE_RESOURCE_UNAUTHORIZED, remoteIndices); + resolvedExpressionsBuilder.addExpressions( + originalExpression, + new HashSet<>(), + CONCRETE_RESOURCE_UNAUTHORIZED, + remoteIndices + ); } else { // store the calculated expansion as unauthorized, it will be rejected later - resolvedExpressionsBuilder.addExpressions(index, resolvedIndices, CONCRETE_RESOURCE_UNAUTHORIZED, remoteIndices); + resolvedExpressionsBuilder.addExpressions( + originalExpression, + resolvedIndices, + CONCRETE_RESOURCE_UNAUTHORIZED, + remoteIndices + ); } } } diff --git a/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectIndexExpressionsRewriter.java b/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectIndexExpressionsRewriter.java index a9a8537ceeb2b..6fe2017e4fb7d 100644 --- a/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectIndexExpressionsRewriter.java +++ b/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectIndexExpressionsRewriter.java @@ -90,10 +90,10 @@ public static Result rewriteIndexExpression(String resource, @Nullable String or Result result; if (isQualified) { result = rewriteQualified(resource, originProjectAlias, linkedProjectAliases); - logger.debug("Rewrote qualified expression [{}] to [{}]", resource, result); + logger.info("Rewrote qualified expression [{}] to [{}]", resource, result); } else { result = rewriteUnqualified(resource, originProjectAlias, linkedProjectAliases); - logger.debug("Rewrote unqualified expression [{}] to [{}]", resource, result); + logger.info("Rewrote unqualified expression [{}] to [{}]", resource, result); } return result; } diff --git a/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectSearchErrorHandler.java b/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectSearchErrorHandler.java index c6592bfa44c3e..ff16a458f19df 100644 --- a/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectSearchErrorHandler.java +++ b/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectSearchErrorHandler.java @@ -22,8 +22,10 @@ import java.util.ArrayList; import java.util.List; -import java.util.Set; +import static org.elasticsearch.action.ResolvedIndexExpression.LocalIndexResolutionResult.CONCRETE_RESOURCE_NOT_VISIBLE; +import static org.elasticsearch.action.ResolvedIndexExpression.LocalIndexResolutionResult.CONCRETE_RESOURCE_UNAUTHORIZED; +import static org.elasticsearch.action.ResolvedIndexExpression.LocalIndexResolutionResult.NONE; import static org.elasticsearch.action.ResolvedIndexExpression.LocalIndexResolutionResult.SUCCESS; /** @@ -64,10 +66,10 @@ public class CrossProjectSearchErrorHandler { * The method considers both flat (unqualified) and qualified index expressions, as well as * local and linked project resolution results when determining whether to throw exceptions. * - * @param indicesOptions Controls error behavior for missing indices - * @param localResolvedExpressions Resolution results from the origin project + * @param indicesOptions Controls error behavior for missing indices + * @param localResolvedExpressions Resolution results from the origin project * @param remoteResolvedExpressions Resolution results from linked projects - * @throws IndexNotFoundException If indices are missing and the {@code IndicesOptions} do not allow it + * @throws IndexNotFoundException If indices are missing and the {@code IndicesOptions} do not allow it * @throws ElasticsearchSecurityException If authorization errors occurred during index resolution */ public static void crossProjectFanoutErrorHandling( @@ -75,23 +77,22 @@ public static void crossProjectFanoutErrorHandling( ResolvedIndexExpressions localResolvedExpressions, RemoteIndexExpressions remoteResolvedExpressions ) { + logger.info( + "Checking cross-project index resolution results with indices options [{}] for [{}] and [{}]", + indicesOptions, + localResolvedExpressions, + remoteResolvedExpressions + ); + if (indicesOptions.allowNoIndices() && indicesOptions.ignoreUnavailable()) { - // nothing to do since we're in lenient mode logger.debug("Skipping index existence check in lenient mode"); return; } - logger.info( - "Checking index existence for [{}] and [{}] with indices options [{}]", - localResolvedExpressions, - remoteResolvedExpressions, - indicesOptions - ); - for (ResolvedIndexExpression localResolvedIndices : localResolvedExpressions.expressions()) { String originalExpression = localResolvedIndices.original(); - logger.info("Checking replaced expression for original expression [{}]", originalExpression); + logger.debug("Checking replaced expression for original expression [{}]", originalExpression); String resource = originalExpression; boolean isQualifiedResource = RemoteClusterAware.isRemoteIndexName(resource); @@ -115,7 +116,7 @@ public static void crossProjectFanoutErrorHandling( isQualifiedResource == false ); } else if (false == indicesOptions.ignoreUnavailable()) { - checkIndicesOptions(resource, localResolvedIndices, remoteResolvedExpressions, isQualifiedResource == false); + checkIndicesOptions(originalExpression, localResolvedIndices, remoteResolvedExpressions, isQualifiedResource == false); } } } @@ -149,10 +150,9 @@ private static void checkIndicesOptions( ) { ResolvedIndexExpression.LocalExpressions localExpressions = localResolvedIndices.localExpressions(); boolean resourceFound = false == localExpressions.expressions().isEmpty() - && localExpressions.localIndexResolutionResult() == SUCCESS; + && (localExpressions.localIndexResolutionResult() == SUCCESS || localExpressions.localIndexResolutionResult() == NONE); if (resourceFound && (isFlatWorldResource || localExpressions.expressions().size() == 1)) { - // a concrete index locally and either was a flat expression or was the only thing we needed to search logger.info( "Local cluster has canonical expression for original expression [{}], skipping remote existence check", originalExpression @@ -164,52 +164,54 @@ private static void checkIndicesOptions( if (localException != null) { exceptions.add(localException); } - int numberOfQualifiedFound = resourceFound ? 1 : 0; - for (var linkedProjectExpressions : remoteResolvedExpressions.expressions().values()) { - // for each linked project we check if the resolved expressions contains the original expression and check for resolution status - ResolvedIndexExpression.LocalExpressions resolvedRemoteExpression = linkedProjectExpressions.resolvedExpressions() - .get(originalExpression); - assert resolvedRemoteExpression != null - : "we should always have resolved expressions from remote. missing resolved expressions for [" + originalExpression + "]"; - - Set remoteExpressions = resolvedRemoteExpression.expressions(); - assert remoteExpressions != null : "we should always have replaced expressions"; - - logger.debug("Replaced indices from remote response resolved: [{}]", remoteExpressions); - boolean existsRemotely = false == remoteExpressions.isEmpty() - && resolvedRemoteExpression.localIndexResolutionResult() == SUCCESS; - if (existsRemotely) { - if (isFlatWorldResource) { - logger.debug( - "Remote project has resolved entries for [{}], skipping further remote existence check", - originalExpression + + for (String remoteExpression : localResolvedIndices.remoteExpressions()) { + boolean isQualifiedResource = RemoteClusterAware.isRemoteIndexName(remoteExpression); + if (isQualifiedResource) { + // handle qualified resource eg. P1:logs* + String[] splitResource = RemoteClusterAware.splitIndexName(remoteExpression); + assert splitResource.length == 2 + : "Expected two strings (project and indexExpression) for a qualified resource [" + + remoteExpression + + "], but found [" + + splitResource.length + + "]"; + String projectAlias = splitResource[0]; + String resource = splitResource[1]; + LinkedProjectExpressions linkedProjectExpressions = remoteResolvedExpressions.expressions().get(projectAlias); + assert linkedProjectExpressions != null : "we should always have linked expressions from remote"; + + ResolvedIndexExpression.LocalExpressions resolvedRemoteExpression = linkedProjectExpressions.resolvedExpressions() + .get(resource); + assert resolvedRemoteExpression != null : "we should always have resolved expressions from remote"; + + if (resolvedRemoteExpression.localIndexResolutionResult() == CONCRETE_RESOURCE_NOT_VISIBLE) { + throw new IndexNotFoundException(remoteExpression); + } + if (resolvedRemoteExpression.localIndexResolutionResult() == CONCRETE_RESOURCE_UNAUTHORIZED) { + // 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 [" + remoteExpression + "]", + RestStatus.FORBIDDEN ); - resourceFound = true; - break; - } else { - numberOfQualifiedFound++; + exceptions.forEach(e::addSuppressed); + throw e; } - } else if (resolvedRemoteExpression.exception() != null) { - exceptions.add(resolvedRemoteExpression.exception()); - } - } - boolean missingFlatResource = isFlatWorldResource && false == resourceFound; - boolean missingQualifiedResource = false == isFlatWorldResource - && numberOfQualifiedFound < localResolvedIndices.remoteExpressions().size(); - - if (missingFlatResource || missingQualifiedResource) { - 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 [" + originalExpression + "]", - RestStatus.FORBIDDEN - ); - exceptions.forEach(e::addSuppressed); - throw e; } else { - throw new IndexNotFoundException(originalExpression); + boolean foundFlat = false; + for (var linkedProjectExpressions : remoteResolvedExpressions.expressions().values()) { + ResolvedIndexExpression.LocalExpressions resolvedRemoteExpression = linkedProjectExpressions.resolvedExpressions() + .get(remoteExpression); + if (resolvedRemoteExpression != null) { + foundFlat = true; + break; + } + } + if (false == foundFlat) { + throw new IndexNotFoundException(remoteExpression); + } } } } From 4e656681566a5a1f1e93679fee75c3859d29e427 Mon Sep 17 00:00:00 2001 From: Nikolaj Volgushev Date: Mon, 29 Sep 2025 14:09:43 +0200 Subject: [PATCH 50/89] Tweaks --- .../src/main/java/org/elasticsearch/ElasticsearchException.java | 1 - .../src/main/java/org/elasticsearch/action/IndicesRequest.java | 2 +- .../action/admin/indices/resolve/ResolveIndexAction.java | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/server/src/main/java/org/elasticsearch/ElasticsearchException.java b/server/src/main/java/org/elasticsearch/ElasticsearchException.java index 1a9713e0dd5eb..98a0846ad9fd1 100644 --- a/server/src/main/java/org/elasticsearch/ElasticsearchException.java +++ b/server/src/main/java/org/elasticsearch/ElasticsearchException.java @@ -2031,7 +2031,6 @@ private enum ElasticsearchExceptionHandle { 185, NO_MATCHING_PROJECT_EXCEPTION_VERSION ); - // TODO register NoMatchingProjectException final Class exceptionClass; final CheckedFunction constructor; diff --git a/server/src/main/java/org/elasticsearch/action/IndicesRequest.java b/server/src/main/java/org/elasticsearch/action/IndicesRequest.java index d43a36b71d35e..2562c13030e01 100644 --- a/server/src/main/java/org/elasticsearch/action/IndicesRequest.java +++ b/server/src/main/java/org/elasticsearch/action/IndicesRequest.java @@ -81,7 +81,7 @@ default boolean allowsRemoteIndices() { return false; } - default boolean allowCrossProjectResolution() { + default boolean allowsCrossProjectResolution() { return false; } } diff --git a/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java b/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java index be68f4bed6486..dacb4d2a9eeb9 100644 --- a/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java +++ b/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java @@ -207,7 +207,7 @@ public boolean allowsRemoteIndices() { } @Override - public boolean allowCrossProjectResolution() { + public boolean allowsCrossProjectResolution() { return resolveCrossProject; } From f75d46519698df87984bfd05b7e325a7a2ca83c9 Mon Sep 17 00:00:00 2001 From: Nikolaj Volgushev Date: Mon, 29 Sep 2025 14:09:56 +0200 Subject: [PATCH 51/89] Also xpack --- .../xpack/security/authz/AuthorizationService.java | 2 +- .../xpack/security/authz/IndicesAndAliasesResolver.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 ab7ff385bb91d..ca1522a6ae6b5 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 @@ -555,7 +555,7 @@ private void authorizeAction( authzInfo, projectMetadata.getIndicesLookup(), ActionListener.wrap(authorizedIndices -> { - if (request instanceof IndicesRequest.Replaceable replaceable && replaceable.allowCrossProjectResolution()) { + if (request instanceof IndicesRequest.Replaceable replaceable && replaceable.allowsCrossProjectResolution()) { crossProjectSearchAuthzService.loadAuthorizedProjects(ActionListener.wrap(authorizedProjects -> { logger.info("Loaded authorized projects: [{}]", authorizedProjects); resolvedIndicesListener.onResponse( diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java index 4c68ce88ca011..507e364179d1c 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java @@ -367,7 +367,7 @@ ResolvedIndices resolveIndicesAndAliases( // if we cannot replace wildcards the indices list stays empty. Same if there are no authorized indices. // we honour allow_no_indices like es core does. } else { - if (replaceable.allowCrossProjectResolution() && authorizedProjects != TargetProjects.NOT_CROSS_PROJECT) { + if (replaceable.allowsCrossProjectResolution() && authorizedProjects != TargetProjects.NOT_CROSS_PROJECT) { assert replaceable.allowsRemoteIndices() : "cross-project requests must allow remote indices"; assert recordResolvedIndexExpressions : "cross-project requests must record resolved index expressions"; assert false == IndexNameExpressionResolver.isNoneExpression(replaceable.indices()) From a3bd85a507afd66b36afecc8a72d7c632b60e056 Mon Sep 17 00:00:00 2001 From: Nikolaj Volgushev Date: Mon, 29 Sep 2025 16:36:30 +0200 Subject: [PATCH 52/89] WIP debug wildcard weirdness --- .../indices/resolve/ResolveIndexAction.java | 1 + .../metadata/IndexAbstractionResolver.java | 28 ++++++++++++----- .../CrossProjectIndexExpressionsRewriter.java | 4 +-- .../CrossProjectSearchErrorHandler.java | 3 +- .../security/authz/AuthorizationService.java | 31 ++++++++++++++++--- 5 files changed, 51 insertions(+), 16 deletions(-) diff --git a/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java b/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java index dacb4d2a9eeb9..cf176843cd1ee 100644 --- a/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java +++ b/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java @@ -631,6 +631,7 @@ protected void doExecute(Task task, Request request, final ActionListener 0) { final int remoteRequests = remoteClusterIndices.size(); final CountDown completionCounter = new CountDown(remoteRequests); diff --git a/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java b/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java index 282bca01d0e4a..172e14ef59cab 100644 --- a/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java +++ b/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java @@ -9,6 +9,8 @@ package org.elasticsearch.cluster.metadata; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import org.elasticsearch.action.ResolvedIndexExpression.LocalIndexResolutionResult; import org.elasticsearch.action.ResolvedIndexExpressions; import org.elasticsearch.action.support.IndexComponentSelector; @@ -36,6 +38,8 @@ public class IndexAbstractionResolver { + private static final Logger logger = LogManager.getLogger(IndexAbstractionResolver.class); + private final IndexNameExpressionResolver indexNameExpressionResolver; public IndexAbstractionResolver(IndexNameExpressionResolver indexNameExpressionResolver) { @@ -89,6 +93,14 @@ public ResolvedIndexExpressions resolveIndexAbstractions( originProjectAlias, linkedProjectAliases ); + logger.info( + "[{}] rewritten index expression [{}] to local [{}] and remote [{}]", + originProjectAlias, + index, + rewrittenIndexExpression.localExpression(), + rewrittenIndexExpression.remoteExpressions() + ); + if (rewrittenIndexExpression.localExpression() == null) { resolvedExpressionsBuilder.addRemoteExpressions(index, new HashSet<>(rewrittenIndexExpression.remoteExpressions())); continue; @@ -112,14 +124,14 @@ public ResolvedIndexExpressions resolveIndexAbstractions( } private boolean resolveIndexAbstraction( - ResolvedIndexExpressions.Builder resolvedExpressionsBuilder, - String originalIndexExpression, - String localIndexExpression, - IndicesOptions indicesOptions, - ProjectMetadata projectMetadata, - Function> allAuthorizedAndAvailableBySelector, - BiPredicate isAuthorized, - boolean includeDataStreams, + final ResolvedIndexExpressions.Builder resolvedExpressionsBuilder, + final String originalIndexExpression, + final String localIndexExpression, + final IndicesOptions indicesOptions, + final ProjectMetadata projectMetadata, + final Function> allAuthorizedAndAvailableBySelector, + final BiPredicate isAuthorized, + final boolean includeDataStreams, boolean wildcardSeen, HashSet remoteExpressions ) { diff --git a/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectIndexExpressionsRewriter.java b/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectIndexExpressionsRewriter.java index b27711463ceda..c648a47209467 100644 --- a/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectIndexExpressionsRewriter.java +++ b/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectIndexExpressionsRewriter.java @@ -105,10 +105,10 @@ public static LocalWithRemoteExpressions rewriteIndexExpression( final LocalWithRemoteExpressions rewrittenExpression; if (isQualified) { rewrittenExpression = rewriteQualifiedExpression(indexExpression, originProjectAlias, allProjectAliases); - logger.debug("Rewrote qualified expression [{}] to [{}]", indexExpression, rewrittenExpression); + logger.info("Rewrote qualified expression [{}] to [{}]", indexExpression, rewrittenExpression); } else { rewrittenExpression = rewriteUnqualifiedExpression(indexExpression, originProjectAlias, allProjectAliases); - logger.debug("Rewrote unqualified expression [{}] to [{}]", indexExpression, rewrittenExpression); + logger.info("Rewrote unqualified expression [{}] to [{}]", indexExpression, rewrittenExpression); } return rewrittenExpression; } diff --git a/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectSearchErrorHandler.java b/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectSearchErrorHandler.java index ff16a458f19df..d28b3e858d2a8 100644 --- a/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectSearchErrorHandler.java +++ b/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectSearchErrorHandler.java @@ -78,8 +78,7 @@ public static void crossProjectFanoutErrorHandling( RemoteIndexExpressions remoteResolvedExpressions ) { logger.info( - "Checking cross-project index resolution results with indices options [{}] for [{}] and [{}]", - indicesOptions, + "Checking cross-project index resolution results for [{}] and [{}]", localResolvedExpressions, remoteResolvedExpressions ); 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 ca1522a6ae6b5..893f220ed659a 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 @@ -49,6 +49,7 @@ import org.elasticsearch.index.IndexNotFoundException; import org.elasticsearch.indices.InvalidIndexNameException; import org.elasticsearch.license.XPackLicenseState; +import org.elasticsearch.search.crossproject.NoMatchingProjectException; import org.elasticsearch.search.crossproject.TargetProjects; import org.elasticsearch.threadpool.ThreadPool; import org.elasticsearch.transport.LinkedProjectConfigService; @@ -568,8 +569,28 @@ private void authorizeAction( ) ); }, e -> { - logger.error("Failed to load authorized projects", e); - resolvedIndicesListener.onFailure(e); + // TODO avoid duplicating this + if (e instanceof InvalidIndexNameException + || e instanceof InvalidSelectorException + || e instanceof UnsupportedSelectorException) { + logger.info( + () -> Strings.format( + "failed [%s] action authorization for [%s] due to [%s] exception", + action, + authentication, + e.getClass().getSimpleName() + ), + e + ); + listener.onFailure(e); + return; + } + auditTrail.accessDenied(requestId, authentication, action, request, authzInfo); + if (e instanceof IndexNotFoundException || e instanceof NoMatchingProjectException) { + listener.onFailure(e); + } else { + listener.onFailure(actionDenied(authentication, authzInfo, action, request, e)); + } })); } else { resolvedIndicesListener.onResponse( @@ -599,7 +620,7 @@ private void authorizeAction( return; } auditTrail.accessDenied(requestId, authentication, action, request, authzInfo); - if (e instanceof IndexNotFoundException) { + if (e instanceof IndexNotFoundException || e instanceof NoMatchingProjectException) { listener.onFailure(e); } else { listener.onFailure(actionDenied(authentication, authzInfo, action, request, e)); @@ -631,7 +652,9 @@ private void authorizeAction( threadContext ) ); - } else { + } else + + { logger.warn("denying access for [{}] as action [{}] is not an index or cluster action", authentication, action); auditTrail.accessDenied(requestId, authentication, action, request, authzInfo); listener.onFailure(actionDenied(authentication, authzInfo, action, request)); From 426832c30cdba308dbfffba0d6b28cba96b8865b Mon Sep 17 00:00:00 2001 From: Nikolaj Volgushev Date: Mon, 29 Sep 2025 17:59:06 +0200 Subject: [PATCH 53/89] Dont overlog --- .../indices/resolve/ResolveIndexAction.java | 28 +++++++++++++------ .../security/authz/AuthorizationService.java | 4 +-- .../authz/IndicesAndAliasesResolver.java | 6 ++++ 3 files changed, 26 insertions(+), 12 deletions(-) diff --git a/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java b/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java index cf176843cd1ee..0f6bdf4028c0f 100644 --- a/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java +++ b/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java @@ -9,6 +9,8 @@ package org.elasticsearch.action.admin.indices.resolve; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import org.elasticsearch.TransportVersion; import org.elasticsearch.action.ActionListener; import org.elasticsearch.action.ActionRequestValidationException; @@ -77,6 +79,8 @@ public class ResolveIndexAction extends ActionType { + private static final Logger LOGGER = LogManager.getLogger(ResolveIndexAction.class); + public static final ResolveIndexAction INSTANCE = new ResolveIndexAction(); public static final String NAME = "indices:admin/resolve/index"; public static final RemoteClusterActionType REMOTE_TYPE = new RemoteClusterActionType<>(NAME, Response::new); @@ -213,7 +217,10 @@ public boolean allowsCrossProjectResolution() { @Override public void setResolvedIndexExpressions(ResolvedIndexExpressions expressions) { - this.resolvedIndexExpressions = expressions; + // TODO this is obviously not right + if (resolvedIndexExpressions == null) { + this.resolvedIndexExpressions = expressions; + } } @Override @@ -631,7 +638,7 @@ protected void doExecute(Task task, Request request, final ActionListener 0) { final int remoteRequests = remoteClusterIndices.size(); final CountDown completionCounter = new CountDown(remoteRequests); @@ -672,6 +679,11 @@ protected void doExecute(Task task, Request request, final ActionListener { remoteResponses.put(clusterAlias, response); terminalHandler.run(); - }, failure -> terminalHandler.run())); + }, failure -> { + LOGGER.error("failed to resolve indices on remote cluster [{}]", clusterAlias, failure); + terminalHandler.run(); + })); } } else { listener.onResponse( - new Response( - indices, - aliases, - dataStreams, - request.includeResolvedExpressions ? request.getResolvedIndexExpressions() : null - ) + new Response(indices, aliases, dataStreams, request.includeResolvedExpressions ? resolvedExpressions : null) ); } } 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 893f220ed659a..1ff97d002a66a 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 @@ -652,9 +652,7 @@ private void authorizeAction( threadContext ) ); - } else - - { + } else { logger.warn("denying access for [{}] as action [{}] is not an index or cluster action", authentication, action); auditTrail.accessDenied(requestId, authentication, action, request, authzInfo); listener.onFailure(actionDenied(authentication, authzInfo, action, request)); diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java index 507e364179d1c..10cb63a089385 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java @@ -6,6 +6,8 @@ */ package org.elasticsearch.xpack.security.authz; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import org.elasticsearch.action.AliasesRequest; import org.elasticsearch.action.IndicesRequest; import org.elasticsearch.action.ResolvedIndexExpressions; @@ -57,6 +59,8 @@ class IndicesAndAliasesResolver { + private static final Logger logger = LogManager.getLogger(IndicesAndAliasesResolver.class); + private final IndexNameExpressionResolver nameExpressionResolver; private final IndexAbstractionResolver indexAbstractionResolver; private final RemoteClusterResolver remoteClusterResolver; @@ -400,10 +404,12 @@ ResolvedIndices resolveIndicesAndAliases( authorizedIndices::check, indicesRequest.includeDataStreams() ); + logger.debug("resolved indices for request [{}]: [{}]", action, resolved); // only store resolved expressions if configured, to avoid unnecessary memory usage // once we've migrated from `indices()` to using resolved expressions holistically, // we will always store them if (recordResolvedIndexExpressions) { + logger.debug("setting resolved expressions [{}]: [{}]", action, resolved); replaceable.setResolvedIndexExpressions(resolved); } resolvedIndicesBuilder.addLocal(resolved.getLocalIndicesList()); From 6b8fc99caf372af0e4c33b32dfe0190dc0f24a12 Mon Sep 17 00:00:00 2001 From: Nikolaj Volgushev Date: Mon, 29 Sep 2025 23:26:07 +0200 Subject: [PATCH 54/89] Set once --- .../indices/resolve/ResolveIndexAction.java | 17 +++++++++++++---- .../authz/IndicesAndAliasesResolver.java | 19 +++++++++++++++---- 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java b/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java index 0f6bdf4028c0f..ddbf9700f66c0 100644 --- a/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java +++ b/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java @@ -217,10 +217,7 @@ public boolean allowsCrossProjectResolution() { @Override public void setResolvedIndexExpressions(ResolvedIndexExpressions expressions) { - // TODO this is obviously not right - if (resolvedIndexExpressions == null) { - this.resolvedIndexExpressions = expressions; - } + this.resolvedIndexExpressions = expressions; } @Override @@ -701,6 +698,18 @@ protected void doExecute(Task task, Request request, final ActionListener Date: Mon, 29 Sep 2025 23:39:40 +0200 Subject: [PATCH 55/89] Nit --- .../xpack/security/authz/IndicesAndAliasesResolver.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java index ecece14e7912e..7a9904b3055c6 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java @@ -485,7 +485,7 @@ private static void setResolvedIfNull(String action, IndicesRequest.Replaceable replaceable.setResolvedIndexExpressions(resolved); } else { logger.info( - "resolved index expressions {} already set on request [{}], not overwriting with {}", + "resolved index expressions [{}] already set on request [{}], not overwriting with [{}]", replaceable.getResolvedIndexExpressions(), action, resolved From 0390eca689a23f3fd62896e28b40984b42efd17d Mon Sep 17 00:00:00 2001 From: Nikolaj Volgushev Date: Tue, 30 Sep 2025 12:04:22 +0200 Subject: [PATCH 56/89] Tweaks and fixes --- .../elasticsearch/ElasticsearchException.java | 2 +- .../action/ResolvedIndexExpression.java | 2 + .../action/ResolvedIndexExpressions.java | 18 +- .../metadata/IndexAbstractionResolver.java | 19 +- .../CrossProjectSearchErrorHandler.java | 29 ++- ...ter.java => IndexExpressionsRewriter.java} | 40 +-- .../LocalWithRemoteExpressions.java | 34 --- ...ava => IndexExpressionsRewriterTests.java} | 230 ++++++++---------- 8 files changed, 164 insertions(+), 210 deletions(-) rename server/src/main/java/org/elasticsearch/search/crossproject/{CrossProjectIndexExpressionsRewriter.java => IndexExpressionsRewriter.java} (86%) delete mode 100644 server/src/main/java/org/elasticsearch/search/crossproject/LocalWithRemoteExpressions.java rename server/src/test/java/org/elasticsearch/search/crossproject/{CrossProjectIndexExpressionsRewriterTests.java => IndexExpressionsRewriterTests.java} (59%) diff --git a/server/src/main/java/org/elasticsearch/ElasticsearchException.java b/server/src/main/java/org/elasticsearch/ElasticsearchException.java index 98a0846ad9fd1..a46eac0c31a74 100644 --- a/server/src/main/java/org/elasticsearch/ElasticsearchException.java +++ b/server/src/main/java/org/elasticsearch/ElasticsearchException.java @@ -80,7 +80,7 @@ import static org.elasticsearch.cluster.metadata.IndexMetadata.INDEX_UUID_NA_VALUE; import static org.elasticsearch.common.xcontent.XContentParserUtils.ensureExpectedToken; import static org.elasticsearch.common.xcontent.XContentParserUtils.ensureFieldName; -import static org.elasticsearch.search.crossproject.CrossProjectIndexExpressionsRewriter.NO_MATCHING_PROJECT_EXCEPTION_VERSION; +import static org.elasticsearch.search.crossproject.IndexExpressionsRewriter.NO_MATCHING_PROJECT_EXCEPTION_VERSION; /** * A base class for all elasticsearch exceptions. diff --git a/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpression.java b/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpression.java index e0624d9370e46..d2afda08954cb 100644 --- a/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpression.java +++ b/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpression.java @@ -85,6 +85,8 @@ public record LocalExpressions( : "If the local resolution result is SUCCESS, exception must be null"; } + public static final LocalExpressions NONE = new LocalExpressions(Set.of(), LocalIndexResolutionResult.NONE, null); + public LocalExpressions(StreamInput in) throws IOException { this( in.readCollectionAsSet(StreamInput::readString), diff --git a/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpressions.java b/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpressions.java index c149c0d0649b1..2715d6705653a 100644 --- a/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpressions.java +++ b/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpressions.java @@ -60,7 +60,7 @@ public void addExpressions( String original, HashSet localExpressions, ResolvedIndexExpression.LocalIndexResolutionResult resolutionResult, - HashSet remoteExpressions + Set remoteExpressions ) { Objects.requireNonNull(original); Objects.requireNonNull(localExpressions); @@ -71,16 +71,10 @@ public void addExpressions( ); } - public void addRemoteExpressions(String original, HashSet remoteExpressions) { + public void addRemoteExpressions(String original, Set remoteExpressions) { Objects.requireNonNull(original); Objects.requireNonNull(remoteExpressions); - expressions.add( - new ResolvedIndexExpression( - original, - new LocalExpressions(new HashSet<>(), ResolvedIndexExpression.LocalIndexResolutionResult.NONE, null), - remoteExpressions - ) - ); + expressions.add(new ResolvedIndexExpression(original, LocalExpressions.NONE, remoteExpressions)); } /** @@ -90,7 +84,11 @@ public void excludeFromLocalExpressions(Set expressionsToExclude) { Objects.requireNonNull(expressionsToExclude); if (expressionsToExclude.isEmpty() == false) { for (ResolvedIndexExpression prior : expressions) { - prior.localExpressions().expressions().removeAll(expressionsToExclude); + final Set localExpressions = prior.localExpressions().expressions(); + if (localExpressions.isEmpty()) { + continue; + } + localExpressions.removeAll(expressionsToExclude); } } } diff --git a/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java b/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java index 172e14ef59cab..f949581a709f1 100644 --- a/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java +++ b/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java @@ -22,8 +22,7 @@ import org.elasticsearch.index.Index; import org.elasticsearch.index.IndexNotFoundException; import org.elasticsearch.indices.SystemIndices.SystemIndexAccessLevel; -import org.elasticsearch.search.crossproject.CrossProjectIndexExpressionsRewriter; -import org.elasticsearch.search.crossproject.LocalWithRemoteExpressions; +import org.elasticsearch.search.crossproject.IndexExpressionsRewriter; import org.elasticsearch.search.crossproject.TargetProjects; import java.util.HashSet; @@ -88,7 +87,7 @@ public ResolvedIndexExpressions resolveIndexAbstractions( final ResolvedIndexExpressions.Builder resolvedExpressionsBuilder = ResolvedIndexExpressions.builder(); boolean wildcardSeen = false; for (String index : indices) { - final LocalWithRemoteExpressions rewrittenIndexExpression = CrossProjectIndexExpressionsRewriter.rewriteIndexExpression( + final IndexExpressionsRewriter.IndexRewriteResult indexRewriteResult = IndexExpressionsRewriter.rewriteIndexExpression( index, originProjectAlias, linkedProjectAliases @@ -97,26 +96,26 @@ public ResolvedIndexExpressions resolveIndexAbstractions( "[{}] rewritten index expression [{}] to local [{}] and remote [{}]", originProjectAlias, index, - rewrittenIndexExpression.localExpression(), - rewrittenIndexExpression.remoteExpressions() + indexRewriteResult.localExpression(), + indexRewriteResult.remoteExpressions() ); - if (rewrittenIndexExpression.localExpression() == null) { - resolvedExpressionsBuilder.addRemoteExpressions(index, new HashSet<>(rewrittenIndexExpression.remoteExpressions())); + if (indexRewriteResult.localExpression() == null) { + resolvedExpressionsBuilder.addRemoteExpressions(index, Set.copyOf(indexRewriteResult.remoteExpressions())); continue; } wildcardSeen = resolveIndexAbstraction( resolvedExpressionsBuilder, index, - rewrittenIndexExpression.localExpression(), + indexRewriteResult.localExpression(), indicesOptions, projectMetadata, allAuthorizedAndAvailableBySelector, isAuthorized, includeDataStreams, wildcardSeen, - new HashSet<>(rewrittenIndexExpression.remoteExpressions()) + Set.copyOf(indexRewriteResult.remoteExpressions()) ); } @@ -133,7 +132,7 @@ private boolean resolveIndexAbstraction( final BiPredicate isAuthorized, final boolean includeDataStreams, boolean wildcardSeen, - HashSet remoteExpressions + Set remoteExpressions ) { String indexAbstraction; boolean minus = false; diff --git a/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectSearchErrorHandler.java b/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectSearchErrorHandler.java index d28b3e858d2a8..5103bfa554180 100644 --- a/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectSearchErrorHandler.java +++ b/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectSearchErrorHandler.java @@ -164,6 +164,23 @@ private static void checkIndicesOptions( exceptions.add(localException); } + if (localResolvedIndices.remoteExpressions().isEmpty()) { + if (localExpressions.localIndexResolutionResult() == CONCRETE_RESOURCE_NOT_VISIBLE) { + throw new IndexNotFoundException(originalExpression); + } + if (localExpressions.localIndexResolutionResult() == CONCRETE_RESOURCE_UNAUTHORIZED) { + // 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 [" + originalExpression + "]", + RestStatus.FORBIDDEN + ); + exceptions.forEach(e::addSuppressed); + throw e; + } + } + for (String remoteExpression : localResolvedIndices.remoteExpressions()) { boolean isQualifiedResource = RemoteClusterAware.isRemoteIndexName(remoteExpression); if (isQualifiedResource) { @@ -184,8 +201,12 @@ private static void checkIndicesOptions( .get(resource); assert resolvedRemoteExpression != null : "we should always have resolved expressions from remote"; + // TODO in wildcard case? + if (resolvedRemoteExpression.expressions().isEmpty()) { + throw new IndexNotFoundException(originalExpression); + } if (resolvedRemoteExpression.localIndexResolutionResult() == CONCRETE_RESOURCE_NOT_VISIBLE) { - throw new IndexNotFoundException(remoteExpression); + throw new IndexNotFoundException(originalExpression); } if (resolvedRemoteExpression.localIndexResolutionResult() == CONCRETE_RESOURCE_UNAUTHORIZED) { // we only ever get exceptions if they are security related @@ -203,13 +224,15 @@ private static void checkIndicesOptions( for (var linkedProjectExpressions : remoteResolvedExpressions.expressions().values()) { ResolvedIndexExpression.LocalExpressions resolvedRemoteExpression = linkedProjectExpressions.resolvedExpressions() .get(remoteExpression); - if (resolvedRemoteExpression != null) { + if (resolvedRemoteExpression != null + && resolvedRemoteExpression.expressions().isEmpty() == false + && resolvedRemoteExpression.localIndexResolutionResult() == SUCCESS) { foundFlat = true; break; } } if (false == foundFlat) { - throw new IndexNotFoundException(remoteExpression); + throw new IndexNotFoundException(originalExpression); } } } diff --git a/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectIndexExpressionsRewriter.java b/server/src/main/java/org/elasticsearch/search/crossproject/IndexExpressionsRewriter.java similarity index 86% rename from server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectIndexExpressionsRewriter.java rename to server/src/main/java/org/elasticsearch/search/crossproject/IndexExpressionsRewriter.java index c648a47209467..9d21cb01d0255 100644 --- a/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectIndexExpressionsRewriter.java +++ b/server/src/main/java/org/elasticsearch/search/crossproject/IndexExpressionsRewriter.java @@ -31,10 +31,10 @@ * Utility class for rewriting cross-project index expressions. * Provides methods that can rewrite qualified and unqualified index expressions to canonical CCS. */ -public class CrossProjectIndexExpressionsRewriter { +public class IndexExpressionsRewriter { public static TransportVersion NO_MATCHING_PROJECT_EXCEPTION_VERSION = TransportVersion.fromName("no_matching_project_exception"); - private static final Logger logger = LogManager.getLogger(CrossProjectIndexExpressionsRewriter.class); + private static final Logger logger = LogManager.getLogger(IndexExpressionsRewriter.class); private static final String ORIGIN_PROJECT_KEY = "_origin"; private static final String[] MATCH_ALL = new String[] { Metadata.ALL }; private static final String EXCLUSION = "-"; @@ -51,7 +51,7 @@ public class CrossProjectIndexExpressionsRewriter { * @throws IllegalArgumentException if exclusions, date math or selectors are present in the index expressions * @throws NoMatchingProjectException if a qualified resource cannot be resolved because a project is missing */ - public static Map> rewriteIndexExpressions( + public static Map rewriteIndexExpressions( ProjectRoutingInfo originProject, List linkedProjects, final String[] originalIndices @@ -67,15 +67,12 @@ public static Map> rewriteIndexExpressions( final Set allProjectAliases = getAllProjectAliases(originProject, linkedProjects); final String originProjectAlias = originProject != null ? originProject.projectAlias() : null; - final Map> canonicalExpressionsMap = new LinkedHashMap<>(indices.length); + final Map canonicalExpressionsMap = new LinkedHashMap<>(indices.length); for (String indexExpression : indices) { if (canonicalExpressionsMap.containsKey(indexExpression)) { continue; } - canonicalExpressionsMap.put( - indexExpression, - rewriteIndexExpression(indexExpression, originProjectAlias, allProjectAliases).all() - ); + canonicalExpressionsMap.put(indexExpression, rewriteIndexExpression(indexExpression, originProjectAlias, allProjectAliases)); } return canonicalExpressionsMap; } @@ -91,7 +88,7 @@ public static Map> rewriteIndexExpressions( * @throws IllegalArgumentException if exclusions, date math or selectors are present in the index expressions * @throws NoMatchingProjectException if a qualified resource cannot be resolved because a project is missing */ - public static LocalWithRemoteExpressions rewriteIndexExpression( + public static IndexRewriteResult rewriteIndexExpression( String indexExpression, @Nullable String originProjectAlias, Set allProjectAliases @@ -102,13 +99,13 @@ public static LocalWithRemoteExpressions rewriteIndexExpression( maybeThrowOnUnsupportedResource(indexExpression); final boolean isQualified = RemoteClusterAware.isRemoteIndexName(indexExpression); - final LocalWithRemoteExpressions rewrittenExpression; + final IndexRewriteResult rewrittenExpression; if (isQualified) { rewrittenExpression = rewriteQualifiedExpression(indexExpression, originProjectAlias, allProjectAliases); - logger.info("Rewrote qualified expression [{}] to [{}]", indexExpression, rewrittenExpression); + logger.debug("Rewrote qualified expression [{}] to [{}]", indexExpression, rewrittenExpression); } else { rewrittenExpression = rewriteUnqualifiedExpression(indexExpression, originProjectAlias, allProjectAliases); - logger.info("Rewrote unqualified expression [{}] to [{}]", indexExpression, rewrittenExpression); + logger.debug("Rewrote unqualified expression [{}] to [{}]", indexExpression, rewrittenExpression); } return rewrittenExpression; } @@ -124,7 +121,7 @@ private static Set getAllProjectAliases(@Nullable ProjectRoutingInfo ori return Collections.unmodifiableSet(allProjectAliases); } - private static LocalWithRemoteExpressions rewriteUnqualifiedExpression( + private static IndexRewriteResult rewriteUnqualifiedExpression( String indexExpression, @Nullable String originAlias, Set allProjectAliases @@ -139,10 +136,10 @@ private static LocalWithRemoteExpressions rewriteUnqualifiedExpression( rewrittenExpressions.add(RemoteClusterAware.buildRemoteIndexName(targetProjectAlias, indexExpression)); } } - return new LocalWithRemoteExpressions(localExpression, rewrittenExpressions); + return new IndexRewriteResult(localExpression, rewrittenExpressions); } - private static LocalWithRemoteExpressions rewriteQualifiedExpression( + private static IndexRewriteResult rewriteQualifiedExpression( String resource, @Nullable String originProjectAlias, Set allProjectAliases @@ -161,7 +158,7 @@ private static LocalWithRemoteExpressions rewriteQualifiedExpression( if (originProjectAlias != null && ORIGIN_PROJECT_KEY.equals(requestedProjectAlias)) { // handling case where we have a qualified expression like: _origin:indexName - return new LocalWithRemoteExpressions(indexExpression); + return new IndexRewriteResult(indexExpression); } if (originProjectAlias == null && ORIGIN_PROJECT_KEY.equals(requestedProjectAlias)) { @@ -189,7 +186,7 @@ private static LocalWithRemoteExpressions rewriteQualifiedExpression( } } - return new LocalWithRemoteExpressions(localExpression, resourcesMatchingLinkedProjectAliases); + return new IndexRewriteResult(localExpression, resourcesMatchingLinkedProjectAliases); } catch (NoSuchRemoteClusterException ex) { logger.debug(ex.getMessage(), ex); throw new NoMatchingProjectException(requestedProjectAlias); @@ -208,4 +205,13 @@ private static void maybeThrowOnUnsupportedResource(String resource) { throw new IllegalArgumentException("Selectors are not currently supported but was found in the expression [" + resource + "]"); } } + + /** + * A container for a local expression and a list of remote expressions. + */ + public record IndexRewriteResult(@Nullable String localExpression, List remoteExpressions) { + public IndexRewriteResult(String localExpression) { + this(localExpression, List.of()); + } + } } diff --git a/server/src/main/java/org/elasticsearch/search/crossproject/LocalWithRemoteExpressions.java b/server/src/main/java/org/elasticsearch/search/crossproject/LocalWithRemoteExpressions.java deleted file mode 100644 index faa77fa9aee02..0000000000000 --- a/server/src/main/java/org/elasticsearch/search/crossproject/LocalWithRemoteExpressions.java +++ /dev/null @@ -1,34 +0,0 @@ -/* - * 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.search.crossproject; - -import org.elasticsearch.core.Nullable; - -import java.util.ArrayList; -import java.util.List; - -/** - * A container for a local expression and a list of remote expressions. - */ -public record LocalWithRemoteExpressions(@Nullable String localExpression, List remoteExpressions) { - public LocalWithRemoteExpressions(String localExpression) { - this(localExpression, List.of()); - } - - List all() { - if (localExpression == null) { - return remoteExpressions; - } - List all = new ArrayList<>(); - all.add(localExpression); - all.addAll(remoteExpressions); - return List.copyOf(all); - } -} diff --git a/server/src/test/java/org/elasticsearch/search/crossproject/CrossProjectIndexExpressionsRewriterTests.java b/server/src/test/java/org/elasticsearch/search/crossproject/IndexExpressionsRewriterTests.java similarity index 59% rename from server/src/test/java/org/elasticsearch/search/crossproject/CrossProjectIndexExpressionsRewriterTests.java rename to server/src/test/java/org/elasticsearch/search/crossproject/IndexExpressionsRewriterTests.java index 95285c06d1a4f..41f0d02f66af5 100644 --- a/server/src/test/java/org/elasticsearch/search/crossproject/CrossProjectIndexExpressionsRewriterTests.java +++ b/server/src/test/java/org/elasticsearch/search/crossproject/IndexExpressionsRewriterTests.java @@ -12,13 +12,15 @@ import org.elasticsearch.ResourceNotFoundException; import org.elasticsearch.cluster.metadata.ProjectId; import org.elasticsearch.test.ESTestCase; +import org.hamcrest.Matcher; +import java.util.ArrayList; import java.util.List; import java.util.Map; import static org.hamcrest.Matchers.containsInAnyOrder; -public class CrossProjectIndexExpressionsRewriterTests extends ESTestCase { +public class IndexExpressionsRewriterTests extends ESTestCase { public void testFlatOnlyRewrite() { ProjectRoutingInfo origin = createRandomProjectWithAlias("P0"); @@ -29,15 +31,14 @@ public void testFlatOnlyRewrite() { ); String[] requestedResources = new String[] { "logs*", "metrics*" }; - Map> canonical = CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions( - origin, - linked, - requestedResources - ); + var actual = IndexExpressionsRewriter.rewriteIndexExpressions(origin, linked, requestedResources); - assertThat(canonical.keySet(), containsInAnyOrder("logs*", "metrics*")); - assertThat(canonical.get("logs*"), containsInAnyOrder("logs*", "P1:logs*", "P2:logs*", "P3:logs*")); - assertThat(canonical.get("metrics*"), containsInAnyOrder("metrics*", "P1:metrics*", "P2:metrics*", "P3:metrics*")); + assertThat(actual.keySet(), containsInAnyOrder("logs*", "metrics*")); + assertIndexRewriteResultsContains(actual.get("logs*"), containsInAnyOrder("logs*", "P1:logs*", "P2:logs*", "P3:logs*")); + assertIndexRewriteResultsContains( + actual.get("metrics*"), + containsInAnyOrder("metrics*", "P1:metrics*", "P2:metrics*", "P3:metrics*") + ); } public void testFlatAndQualifiedRewrite() { @@ -49,15 +50,14 @@ public void testFlatAndQualifiedRewrite() { ); String[] requestedResources = new String[] { "P1:logs*", "metrics*" }; - Map> canonical = CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions( - origin, - linked, - requestedResources - ); + var actual = IndexExpressionsRewriter.rewriteIndexExpressions(origin, linked, requestedResources); - assertThat(canonical.keySet(), containsInAnyOrder("P1:logs*", "metrics*")); - assertThat(canonical.get("P1:logs*"), containsInAnyOrder("P1:logs*")); - assertThat(canonical.get("metrics*"), containsInAnyOrder("metrics*", "P1:metrics*", "P2:metrics*", "P3:metrics*")); + assertThat(actual.keySet(), containsInAnyOrder("P1:logs*", "metrics*")); + assertIndexRewriteResultsContains(actual.get("P1:logs*"), containsInAnyOrder("P1:logs*")); + assertIndexRewriteResultsContains( + actual.get("metrics*"), + containsInAnyOrder("metrics*", "P1:metrics*", "P2:metrics*", "P3:metrics*") + ); } public void testQualifiedOnlyRewrite() { @@ -69,15 +69,11 @@ public void testQualifiedOnlyRewrite() { ); String[] requestedResources = new String[] { "P1:logs*", "P2:metrics*" }; - Map> canonical = CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions( - origin, - linked, - requestedResources - ); + var actual = IndexExpressionsRewriter.rewriteIndexExpressions(origin, linked, requestedResources); - assertThat(canonical.keySet(), containsInAnyOrder("P1:logs*", "P2:metrics*")); - assertThat(canonical.get("P1:logs*"), containsInAnyOrder("P1:logs*")); - assertThat(canonical.get("P2:metrics*"), containsInAnyOrder("P2:metrics*")); + assertThat(actual.keySet(), containsInAnyOrder("P1:logs*", "P2:metrics*")); + assertIndexRewriteResultsContains(actual.get("P1:logs*"), containsInAnyOrder("P1:logs*")); + assertIndexRewriteResultsContains(actual.get("P2:metrics*"), containsInAnyOrder("P2:metrics*")); } public void testOriginQualifiedOnlyRewrite() { @@ -89,15 +85,11 @@ public void testOriginQualifiedOnlyRewrite() { ); String[] requestedResources = new String[] { "_origin:logs*", "_origin:metrics*" }; - Map> canonical = CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions( - origin, - linked, - requestedResources - ); + var actual = IndexExpressionsRewriter.rewriteIndexExpressions(origin, linked, requestedResources); - assertThat(canonical.keySet(), containsInAnyOrder("_origin:logs*", "_origin:metrics*")); - assertThat(canonical.get("_origin:logs*"), containsInAnyOrder("logs*")); - assertThat(canonical.get("_origin:metrics*"), containsInAnyOrder("metrics*")); + assertThat(actual.keySet(), containsInAnyOrder("_origin:logs*", "_origin:metrics*")); + assertIndexRewriteResultsContains(actual.get("_origin:logs*"), containsInAnyOrder("logs*")); + assertIndexRewriteResultsContains(actual.get("_origin:metrics*"), containsInAnyOrder("metrics*")); } public void testOriginQualifiedOnlyRewriteWithNoLikedProjects() { @@ -105,15 +97,11 @@ public void testOriginQualifiedOnlyRewriteWithNoLikedProjects() { List linked = List.of(); String[] requestedResources = new String[] { "_origin:logs*", "_origin:metrics*" }; - Map> canonical = CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions( - origin, - linked, - requestedResources - ); + var actual = IndexExpressionsRewriter.rewriteIndexExpressions(origin, linked, requestedResources); - assertThat(canonical.keySet(), containsInAnyOrder("_origin:logs*", "_origin:metrics*")); - assertThat(canonical.get("_origin:logs*"), containsInAnyOrder("logs*")); - assertThat(canonical.get("_origin:metrics*"), containsInAnyOrder("metrics*")); + assertThat(actual.keySet(), containsInAnyOrder("_origin:logs*", "_origin:metrics*")); + assertIndexRewriteResultsContains(actual.get("_origin:logs*"), containsInAnyOrder("logs*")); + assertIndexRewriteResultsContains(actual.get("_origin:metrics*"), containsInAnyOrder("metrics*")); } public void testOriginWithDifferentAliasQualifiedOnlyRewrite() { @@ -130,15 +118,11 @@ public void testOriginWithDifferentAliasQualifiedOnlyRewrite() { String metricResource = aliasForOrigin + ":" + metricsIndexAlias; String[] requestedResources = new String[] { logResource, metricResource }; - Map> canonical = CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions( - origin, - linked, - requestedResources - ); + var actual = IndexExpressionsRewriter.rewriteIndexExpressions(origin, linked, requestedResources); - assertThat(canonical.keySet(), containsInAnyOrder(logResource, metricResource)); - assertThat(canonical.get(logResource), containsInAnyOrder(logIndexAlias)); - assertThat(canonical.get(metricResource), containsInAnyOrder(metricsIndexAlias)); + assertThat(actual.keySet(), containsInAnyOrder(logResource, metricResource)); + assertIndexRewriteResultsContains(actual.get(logResource), containsInAnyOrder(logIndexAlias)); + assertIndexRewriteResultsContains(actual.get(metricResource), containsInAnyOrder(metricsIndexAlias)); } public void testQualifiedLinkedAndOriginRewrite() { @@ -150,15 +134,11 @@ public void testQualifiedLinkedAndOriginRewrite() { ); String[] requestedResources = new String[] { "P1:logs*", "_origin:metrics*" }; - Map> canonical = CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions( - origin, - linked, - requestedResources - ); + var actual = IndexExpressionsRewriter.rewriteIndexExpressions(origin, linked, requestedResources); - assertThat(canonical.keySet(), containsInAnyOrder("P1:logs*", "_origin:metrics*")); - assertThat(canonical.get("P1:logs*"), containsInAnyOrder("P1:logs*")); - assertThat(canonical.get("_origin:metrics*"), containsInAnyOrder("metrics*")); + assertThat(actual.keySet(), containsInAnyOrder("P1:logs*", "_origin:metrics*")); + assertIndexRewriteResultsContains(actual.get("P1:logs*"), containsInAnyOrder("P1:logs*")); + assertIndexRewriteResultsContains(actual.get("_origin:metrics*"), containsInAnyOrder("metrics*")); } public void testQualifiedStartsWithProjectWildcardRewrite() { @@ -171,14 +151,10 @@ public void testQualifiedStartsWithProjectWildcardRewrite() { ); String[] requestedResources = new String[] { "Q*:metrics*" }; - Map> canonical = CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions( - origin, - linked, - requestedResources - ); + var actual = IndexExpressionsRewriter.rewriteIndexExpressions(origin, linked, requestedResources); - assertThat(canonical.keySet(), containsInAnyOrder("Q*:metrics*")); - assertThat(canonical.get("Q*:metrics*"), containsInAnyOrder("Q1:metrics*", "Q2:metrics*")); + assertThat(actual.keySet(), containsInAnyOrder("Q*:metrics*")); + assertIndexRewriteResultsContains(actual.get("Q*:metrics*"), containsInAnyOrder("Q1:metrics*", "Q2:metrics*")); } public void testQualifiedEndsWithProjectWildcardRewrite() { @@ -191,14 +167,10 @@ public void testQualifiedEndsWithProjectWildcardRewrite() { ); String[] requestedResources = new String[] { "*1:metrics*" }; - Map> canonical = CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions( - origin, - linked, - requestedResources - ); + var actual = IndexExpressionsRewriter.rewriteIndexExpressions(origin, linked, requestedResources); - assertThat(canonical.keySet(), containsInAnyOrder("*1:metrics*")); - assertThat(canonical.get("*1:metrics*"), containsInAnyOrder("P1:metrics*", "Q1:metrics*")); + assertThat(actual.keySet(), containsInAnyOrder("*1:metrics*")); + assertIndexRewriteResultsContains(actual.get("*1:metrics*"), containsInAnyOrder("P1:metrics*", "Q1:metrics*")); } public void testOriginProjectMatchingTwice() { @@ -206,15 +178,11 @@ public void testOriginProjectMatchingTwice() { List linked = List.of(createRandomProjectWithAlias("P1"), createRandomProjectWithAlias("P2")); String[] requestedResources = new String[] { "P0:metrics*", "_origin:metrics*" }; - Map> canonical = CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions( - origin, - linked, - requestedResources - ); + var actual = IndexExpressionsRewriter.rewriteIndexExpressions(origin, linked, requestedResources); - assertThat(canonical.keySet(), containsInAnyOrder("P0:metrics*", "_origin:metrics*")); - assertThat(canonical.get("P0:metrics*"), containsInAnyOrder("metrics*")); - assertThat(canonical.get("_origin:metrics*"), containsInAnyOrder("metrics*")); + assertThat(actual.keySet(), containsInAnyOrder("P0:metrics*", "_origin:metrics*")); + assertIndexRewriteResultsContains(actual.get("P0:metrics*"), containsInAnyOrder("metrics*")); + assertIndexRewriteResultsContains(actual.get("_origin:metrics*"), containsInAnyOrder("metrics*")); } public void testUnderscoreWildcardShouldNotMatchOrigin() { @@ -222,14 +190,10 @@ public void testUnderscoreWildcardShouldNotMatchOrigin() { List linked = List.of(createRandomProjectWithAlias("_P1"), createRandomProjectWithAlias("_P2")); String[] requestedResources = new String[] { "_*:metrics*" }; - Map> canonical = CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions( - origin, - linked, - requestedResources - ); + var actual = IndexExpressionsRewriter.rewriteIndexExpressions(origin, linked, requestedResources); - assertThat(canonical.keySet(), containsInAnyOrder("_*:metrics*")); - assertThat(canonical.get("_*:metrics*"), containsInAnyOrder("_P1:metrics*", "_P2:metrics*")); + assertThat(actual.keySet(), containsInAnyOrder("_*:metrics*")); + assertIndexRewriteResultsContains(actual.get("_*:metrics*"), containsInAnyOrder("_P1:metrics*", "_P2:metrics*")); } public void testDuplicateInputShouldProduceSingleOutput() { @@ -243,14 +207,10 @@ public void testDuplicateInputShouldProduceSingleOutput() { String indexPattern = "Q*:metrics*"; String[] requestedResources = new String[] { indexPattern, indexPattern }; - Map> canonical = CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions( - origin, - linked, - requestedResources - ); + var actual = IndexExpressionsRewriter.rewriteIndexExpressions(origin, linked, requestedResources); - assertThat(canonical.keySet(), containsInAnyOrder(indexPattern)); - assertThat(canonical.get(indexPattern), containsInAnyOrder("Q1:metrics*", "Q2:metrics*")); + assertThat(actual.keySet(), containsInAnyOrder(indexPattern)); + assertIndexRewriteResultsContains(actual.get(indexPattern), containsInAnyOrder("Q1:metrics*", "Q2:metrics*")); } public void testProjectWildcardNotMatchingAnythingShouldThrow() { @@ -265,7 +225,7 @@ public void testProjectWildcardNotMatchingAnythingShouldThrow() { expectThrows( ResourceNotFoundException.class, - () -> CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions(origin, linked, requestedResources) + () -> IndexExpressionsRewriter.rewriteIndexExpressions(origin, linked, requestedResources) ); } @@ -282,7 +242,7 @@ public void testRewritingShouldThrowOnIndexExclusions() { expectThrows( IllegalArgumentException.class, - () -> CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions(origin, linked, requestedResources) + () -> IndexExpressionsRewriter.rewriteIndexExpressions(origin, linked, requestedResources) ); } @@ -299,7 +259,7 @@ public void testRewritingShouldThrowOnIndexSelectors() { expectThrows( IllegalArgumentException.class, - () -> CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions(origin, linked, requestedResources) + () -> IndexExpressionsRewriter.rewriteIndexExpressions(origin, linked, requestedResources) ); } @@ -313,14 +273,13 @@ public void testWildcardOnlyProjectRewrite() { ); String[] requestedResources = new String[] { "*:metrics*" }; - Map> canonical = CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions( - origin, - linked, - requestedResources - ); + var actual = IndexExpressionsRewriter.rewriteIndexExpressions(origin, linked, requestedResources); - assertThat(canonical.keySet(), containsInAnyOrder("*:metrics*")); - assertThat(canonical.get("*:metrics*"), containsInAnyOrder("P1:metrics*", "P2:metrics*", "Q1:metrics*", "Q2:metrics*", "metrics*")); + assertThat(actual.keySet(), containsInAnyOrder("*:metrics*")); + assertIndexRewriteResultsContains( + actual.get("*:metrics*"), + containsInAnyOrder("P1:metrics*", "P2:metrics*", "Q1:metrics*", "Q2:metrics*", "metrics*") + ); } public void testWildcardMatchesOnlyOriginProject() { @@ -333,14 +292,10 @@ public void testWildcardMatchesOnlyOriginProject() { ); String[] requestedResources = new String[] { "alias*:metrics*" }; - Map> canonical = CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions( - origin, - linked, - requestedResources - ); + var actual = IndexExpressionsRewriter.rewriteIndexExpressions(origin, linked, requestedResources); - assertThat(canonical.keySet(), containsInAnyOrder("alias*:metrics*")); - assertThat(canonical.get("alias*:metrics*"), containsInAnyOrder("metrics*")); + assertThat(actual.keySet(), containsInAnyOrder("alias*:metrics*")); + assertIndexRewriteResultsContains(actual.get("alias*:metrics*"), containsInAnyOrder("metrics*")); } public void testEmptyExpressionShouldMatchAll() { @@ -348,24 +303,20 @@ public void testEmptyExpressionShouldMatchAll() { List linked = List.of(createRandomProjectWithAlias("P1"), createRandomProjectWithAlias("P2")); String[] requestedResources = new String[] {}; - Map> canonical = CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions( - origin, - linked, - requestedResources - ); + var actual = IndexExpressionsRewriter.rewriteIndexExpressions(origin, linked, requestedResources); - assertThat(canonical.keySet(), containsInAnyOrder("*")); - assertThat(canonical.get("*"), containsInAnyOrder("P1:*", "P2:*", "*")); + assertThat(actual.keySet(), containsInAnyOrder("_all")); + assertIndexRewriteResultsContains(actual.get("_all"), containsInAnyOrder("P1:_all", "P2:_all", "_all")); } public void testNullExpressionShouldMatchAll() { ProjectRoutingInfo origin = createRandomProjectWithAlias("P0"); List linked = List.of(createRandomProjectWithAlias("P1"), createRandomProjectWithAlias("P2")); - Map> canonical = CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions(origin, linked, null); + var actual = IndexExpressionsRewriter.rewriteIndexExpressions(origin, linked, null); - assertThat(canonical.keySet(), containsInAnyOrder("*")); - assertThat(canonical.get("*"), containsInAnyOrder("P1:*", "P2:*", "*")); + assertThat(actual.keySet(), containsInAnyOrder("_all")); + assertIndexRewriteResultsContains(actual.get("_all"), containsInAnyOrder("P1:_all", "P2:_all", "_all")); } public void testWildcardExpressionShouldMatchAll() { @@ -373,14 +324,10 @@ public void testWildcardExpressionShouldMatchAll() { List linked = List.of(createRandomProjectWithAlias("P1"), createRandomProjectWithAlias("P2")); String[] requestedResources = new String[] { "*" }; - Map> canonical = CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions( - origin, - linked, - requestedResources - ); + var actual = IndexExpressionsRewriter.rewriteIndexExpressions(origin, linked, requestedResources); - assertThat(canonical.keySet(), containsInAnyOrder("*")); - assertThat(canonical.get("*"), containsInAnyOrder("P1:*", "P2:*", "*")); + assertThat(actual.keySet(), containsInAnyOrder("*")); + assertIndexRewriteResultsContains(actual.get("*"), containsInAnyOrder("P1:*", "P2:*", "*")); } public void test_ALLExpressionShouldMatchAll() { @@ -389,14 +336,10 @@ public void test_ALLExpressionShouldMatchAll() { String all = randomBoolean() ? "_ALL" : "_all"; String[] requestedResources = new String[] { all }; - Map> canonical = CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions( - origin, - linked, - requestedResources - ); + var actual = IndexExpressionsRewriter.rewriteIndexExpressions(origin, linked, requestedResources); - assertThat(canonical.keySet(), containsInAnyOrder(all)); - assertThat(canonical.get(all), containsInAnyOrder("P1:" + all, "P2:" + all, all)); + assertThat(actual.keySet(), containsInAnyOrder(all)); + assertIndexRewriteResultsContains(actual.get(all), containsInAnyOrder("P1:" + all, "P2:" + all, all)); } public void testRewritingShouldThrowIfNotProjectMatchExpression() { @@ -411,7 +354,7 @@ public void testRewritingShouldThrowIfNotProjectMatchExpression() { expectThrows( NoMatchingProjectException.class, - () -> CrossProjectIndexExpressionsRewriter.rewriteIndexExpressions(origin, linked, requestedResources) + () -> IndexExpressionsRewriter.rewriteIndexExpressions(origin, linked, requestedResources) ); } @@ -424,4 +367,21 @@ private ProjectRoutingInfo createRandomProjectWithAlias(String alias) { ProjectTags projectTags = new ProjectTags(tags); return new ProjectRoutingInfo(projectId, type, alias, org, projectTags); } + + private static void assertIndexRewriteResultsContains( + IndexExpressionsRewriter.IndexRewriteResult actual, + Matcher> iterableMatcher + ) { + assertThat(resultAsList(actual), iterableMatcher); + } + + private static List resultAsList(IndexExpressionsRewriter.IndexRewriteResult result) { + if (result.localExpression() == null) { + return result.remoteExpressions(); + } + List all = new ArrayList<>(); + all.add(result.localExpression()); + all.addAll(result.remoteExpressions()); + return List.copyOf(all); + } } From 607f46594f6d358e0c7a9a5163b09fd5abda2c5c Mon Sep 17 00:00:00 2001 From: Nikolaj Volgushev Date: Tue, 30 Sep 2025 20:31:48 +0200 Subject: [PATCH 57/89] Renames --- .../indices/resolve/ResolveIndexAction.java | 57 ++++++++++++------- .../core/security/SecurityExtension.java | 4 +- ...e.java => AuthorizedProjectsSupplier.java} | 8 +-- .../xpack/security/Security.java | 12 ++-- .../security/authz/AuthorizationService.java | 14 ++--- 5 files changed, 56 insertions(+), 39 deletions(-) rename x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/{CrossProjectSearchAuthorizationService.java => AuthorizedProjectsSupplier.java} (68%) diff --git a/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java b/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java index ddbf9700f66c0..14a10f7f27d68 100644 --- a/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java +++ b/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java @@ -622,9 +622,10 @@ protected void doExecute(Task task, Request request, final ActionListener remoteClusterIndices = remoteClusterService.groupIndices( - request.resolveCrossProject ? lenientIndicesOptionsForFanout(originalIndicesOptions) : originalIndicesOptions, + resolveCrossProject ? lenientIndicesOptionsForFanout(originalIndicesOptions) : originalIndicesOptions, request.indices() ); final OriginalIndices localIndices = remoteClusterIndices.remove(RemoteClusterAware.LOCAL_CLUSTER_GROUP_KEY); @@ -642,7 +643,7 @@ protected void doExecute(Task task, Request request, final ActionListener remoteResponses = Collections.synchronizedSortedMap(new TreeMap<>()); final Runnable terminalHandler = () -> { if (completionCounter.countDown()) { - if (request.resolveCrossProject) { + if (resolveCrossProject) { Map linkedProjectExpressions = remoteResponses.entrySet() .stream() .collect( @@ -686,36 +687,52 @@ protected void doExecute(Task task, Request request, final ActionListener { remoteResponses.put(clusterAlias, response); terminalHandler.run(); - }, failure -> { - LOGGER.error("failed to resolve indices on remote cluster [{}]", clusterAlias, failure); - terminalHandler.run(); - })); + }, failure -> terminalHandler.run())); } } else { - if (request.resolveCrossProject) { - try { - CrossProjectSearchErrorHandler.crossProjectFanoutErrorHandling( - request.indicesOptions, - resolvedExpressions, - new RemoteIndexExpressions(Map.of()) - ); - } catch (Exception ex) { - listener.onFailure(ex); - return; - } - } - listener.onResponse( + onResponse( + request, + listener, + resolveCrossProject, + resolvedExpressions, new Response(indices, aliases, dataStreams, request.includeResolvedExpressions ? resolvedExpressions : null) ); } } + private void onResponse( + Request request, + ActionListener listener, + boolean resolveCrossProject, + ResolvedIndexExpressions resolvedExpressions, + Response response + ) { + if (resolveCrossProject) { + try { + CrossProjectSearchErrorHandler.crossProjectFanoutErrorHandling( + request.indicesOptions, + resolvedExpressions, + new RemoteIndexExpressions(Map.of()) + ); + } catch (Exception ex) { + listener.onFailure(ex); + return; + } + } + listener.onResponse(response); + } + + private boolean resolveCrossProject(Request request) { + // TODO use IndicesOptions instead + return request.resolveCrossProject; + } + /** * Resolves the specified names and/or wildcard expressions to index abstractions. Returns results in the supplied lists. * diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/SecurityExtension.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/SecurityExtension.java index db31445001e3b..1863c4de716f0 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/SecurityExtension.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/SecurityExtension.java @@ -21,7 +21,7 @@ import org.elasticsearch.xpack.core.security.authc.service.ServiceAccountTokenStore; import org.elasticsearch.xpack.core.security.authc.support.UserRoleMapper; import org.elasticsearch.xpack.core.security.authz.AuthorizationEngine; -import org.elasticsearch.xpack.core.security.authz.CrossProjectSearchAuthorizationService; +import org.elasticsearch.xpack.core.security.authz.AuthorizedProjectsSupplier; import org.elasticsearch.xpack.core.security.authz.RoleDescriptor; import org.elasticsearch.xpack.core.security.authz.store.RoleRetrievalResult; @@ -150,7 +150,7 @@ default String extensionName() { return getClass().getName(); } - default CrossProjectSearchAuthorizationService getCrossProjectSearchAuthorizationService(SecurityComponents components) { + default AuthorizedProjectsSupplier getCrossProjectSearchAuthorizationService(SecurityComponents components) { return null; } } diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/CrossProjectSearchAuthorizationService.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/AuthorizedProjectsSupplier.java similarity index 68% rename from x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/CrossProjectSearchAuthorizationService.java rename to x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/AuthorizedProjectsSupplier.java index 746ac2196815e..a9d12115d5d57 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/CrossProjectSearchAuthorizationService.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/AuthorizedProjectsSupplier.java @@ -10,14 +10,14 @@ import org.elasticsearch.action.ActionListener; import org.elasticsearch.search.crossproject.TargetProjects; -public interface CrossProjectSearchAuthorizationService { - void loadAuthorizedProjects(ActionListener listener); +public interface AuthorizedProjectsSupplier { + void getAuthorizedProjects(ActionListener listener); boolean enabled(); - class Default implements CrossProjectSearchAuthorizationService { + class Default implements AuthorizedProjectsSupplier { @Override - public void loadAuthorizedProjects(ActionListener listener) { + public void getAuthorizedProjects(ActionListener listener) { listener.onResponse(TargetProjects.NOT_CROSS_PROJECT); } diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java index 578bd10657eb3..08fae6a869b84 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java @@ -210,7 +210,7 @@ import org.elasticsearch.xpack.core.security.authc.support.UserRoleMapper; import org.elasticsearch.xpack.core.security.authc.support.UsernamePasswordToken; import org.elasticsearch.xpack.core.security.authz.AuthorizationEngine; -import org.elasticsearch.xpack.core.security.authz.CrossProjectSearchAuthorizationService; +import org.elasticsearch.xpack.core.security.authz.AuthorizedProjectsSupplier; import org.elasticsearch.xpack.core.security.authz.RestrictedIndices; import org.elasticsearch.xpack.core.security.authz.RoleDescriptor; import org.elasticsearch.xpack.core.security.authz.accesscontrol.DocumentSubsetBitsetCache; @@ -1144,7 +1144,7 @@ Collection createComponents( authorizationDenialMessages.set(new AuthorizationDenialMessages.Default()); } - final CrossProjectSearchAuthorizationService crossProjectSearchAuthorizationService = getCrossProjectSearchAuthorizationService( + final AuthorizedProjectsSupplier authorizedProjectsSupplier = getCrossProjectSearchAuthorizationService( extensionComponents ); final AuthorizationService authzService = new AuthorizationService( @@ -1165,9 +1165,9 @@ Collection createComponents( authorizationDenialMessages.get(), linkedProjectConfigService, projectResolver, - crossProjectSearchAuthorizationService == null - ? new CrossProjectSearchAuthorizationService.Default() - : crossProjectSearchAuthorizationService + authorizedProjectsSupplier == null + ? new AuthorizedProjectsSupplier.Default() + : authorizedProjectsSupplier ); components.add(nativeRolesStore); // used by roles actions @@ -1352,7 +1352,7 @@ private List getCustomAuthenticatorFromExtensions(SecurityE } } - private CrossProjectSearchAuthorizationService getCrossProjectSearchAuthorizationService( + private AuthorizedProjectsSupplier getCrossProjectSearchAuthorizationService( SecurityExtension.SecurityComponents extensionComponents ) { return findValueFromExtensions( 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 1ff97d002a66a..ccb161428fd31 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 @@ -73,7 +73,7 @@ import org.elasticsearch.xpack.core.security.authz.AuthorizationEngine.ParentActionAuthorization; import org.elasticsearch.xpack.core.security.authz.AuthorizationEngine.RequestInfo; import org.elasticsearch.xpack.core.security.authz.AuthorizationServiceField; -import org.elasticsearch.xpack.core.security.authz.CrossProjectSearchAuthorizationService; +import org.elasticsearch.xpack.core.security.authz.AuthorizedProjectsSupplier; import org.elasticsearch.xpack.core.security.authz.ResolvedIndices; import org.elasticsearch.xpack.core.security.authz.RestrictedIndices; import org.elasticsearch.xpack.core.security.authz.RoleDescriptorsIntersection; @@ -152,7 +152,7 @@ public class AuthorizationService { private final boolean isAnonymousEnabled; private final boolean anonymousAuthzExceptionEnabled; private final DlsFlsFeatureTrackingIndicesAccessControlWrapper indicesAccessControlWrapper; - private final CrossProjectSearchAuthorizationService crossProjectSearchAuthzService; + private final AuthorizedProjectsSupplier crossProjectSearchAuthzService; public AuthorizationService( Settings settings, @@ -191,7 +191,7 @@ public AuthorizationService( authorizationDenialMessages, linkedProjectConfigService, projectResolver, - new CrossProjectSearchAuthorizationService.Default() + new AuthorizedProjectsSupplier.Default() ); } @@ -213,7 +213,7 @@ public AuthorizationService( AuthorizationDenialMessages authorizationDenialMessages, LinkedProjectConfigService linkedProjectConfigService, ProjectResolver projectResolver, - CrossProjectSearchAuthorizationService crossProjectSearchAuthorizationService + AuthorizedProjectsSupplier authorizedProjectsSupplier ) { this.clusterService = clusterService; this.auditTrailService = auditTrailService; @@ -222,7 +222,7 @@ public AuthorizationService( settings, linkedProjectConfigService, resolver, - crossProjectSearchAuthorizationService.enabled() + authorizedProjectsSupplier.enabled() ); this.authcFailureHandler = authcFailureHandler; this.threadContext = threadPool.getThreadContext(); @@ -244,7 +244,7 @@ public AuthorizationService( this.indicesAccessControlWrapper = new DlsFlsFeatureTrackingIndicesAccessControlWrapper(settings, licenseState); this.authorizationDenialMessages = authorizationDenialMessages; this.projectResolver = projectResolver; - this.crossProjectSearchAuthzService = crossProjectSearchAuthorizationService; + this.crossProjectSearchAuthzService = authorizedProjectsSupplier; } public void checkPrivileges( @@ -557,7 +557,7 @@ private void authorizeAction( projectMetadata.getIndicesLookup(), ActionListener.wrap(authorizedIndices -> { if (request instanceof IndicesRequest.Replaceable replaceable && replaceable.allowsCrossProjectResolution()) { - crossProjectSearchAuthzService.loadAuthorizedProjects(ActionListener.wrap(authorizedProjects -> { + crossProjectSearchAuthzService.getAuthorizedProjects(ActionListener.wrap(authorizedProjects -> { logger.info("Loaded authorized projects: [{}]", authorizedProjects); resolvedIndicesListener.onResponse( indicesAndAliasesResolver.resolve( From c688e0a182fd90427c6be1813064f626dc53e5be Mon Sep 17 00:00:00 2001 From: Nikolaj Volgushev Date: Tue, 30 Sep 2025 21:39:52 +0200 Subject: [PATCH 58/89] Clean up authz project supplier --- .../core/security/SecurityExtension.java | 2 +- .../authz/AuthorizedProjectsSupplier.java | 8 +++- .../xpack/security/Security.java | 18 +++----- .../security/authz/AuthorizationService.java | 45 +------------------ .../authz/AuthorizationServiceTests.java | 16 ++++--- 5 files changed, 25 insertions(+), 64 deletions(-) diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/SecurityExtension.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/SecurityExtension.java index 1863c4de716f0..57bd4cad49e63 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/SecurityExtension.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/SecurityExtension.java @@ -150,7 +150,7 @@ default String extensionName() { return getClass().getName(); } - default AuthorizedProjectsSupplier getCrossProjectSearchAuthorizationService(SecurityComponents components) { + default AuthorizedProjectsSupplier getAuthorizedProjectsSupplier(SecurityComponents components) { return null; } } diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/AuthorizedProjectsSupplier.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/AuthorizedProjectsSupplier.java index a9d12115d5d57..9104e4cc7589b 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/AuthorizedProjectsSupplier.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/AuthorizedProjectsSupplier.java @@ -10,10 +10,14 @@ import org.elasticsearch.action.ActionListener; import org.elasticsearch.search.crossproject.TargetProjects; +/** + * A supplier of authorized projects for the current user. This includes the origin project and all linked projects the user has access to. + * If we are not in a cross-project search context, the supplier returns {@link TargetProjects#NOT_CROSS_PROJECT}. + */ public interface AuthorizedProjectsSupplier { void getAuthorizedProjects(ActionListener listener); - boolean enabled(); + boolean recordResolvedIndexExpressions(); class Default implements AuthorizedProjectsSupplier { @Override @@ -22,7 +26,7 @@ public void getAuthorizedProjects(ActionListener listener) { } @Override - public boolean enabled() { + public boolean recordResolvedIndexExpressions() { return false; } } diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java index 08fae6a869b84..31a3e8b5c0d1b 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java @@ -1144,9 +1144,6 @@ Collection createComponents( authorizationDenialMessages.set(new AuthorizationDenialMessages.Default()); } - final AuthorizedProjectsSupplier authorizedProjectsSupplier = getCrossProjectSearchAuthorizationService( - extensionComponents - ); final AuthorizationService authzService = new AuthorizationService( settings, allRolesStore, @@ -1165,9 +1162,7 @@ Collection createComponents( authorizationDenialMessages.get(), linkedProjectConfigService, projectResolver, - authorizedProjectsSupplier == null - ? new AuthorizedProjectsSupplier.Default() - : authorizedProjectsSupplier + getAuthorizedProjectsSupplier(extensionComponents) ); components.add(nativeRolesStore); // used by roles actions @@ -1352,13 +1347,12 @@ private List getCustomAuthenticatorFromExtensions(SecurityE } } - private AuthorizedProjectsSupplier getCrossProjectSearchAuthorizationService( - SecurityExtension.SecurityComponents extensionComponents - ) { - return findValueFromExtensions( - "cross-project search authorization service", - extension -> extension.getCrossProjectSearchAuthorizationService(extensionComponents) + private AuthorizedProjectsSupplier getAuthorizedProjectsSupplier(SecurityExtension.SecurityComponents extensionComponents) { + AuthorizedProjectsSupplier serverlesAuthorizedProjectsSupplier = findValueFromExtensions( + "serverles authorized projects supplier", + extension -> extension.getAuthorizedProjectsSupplier(extensionComponents) ); + return serverlesAuthorizedProjectsSupplier == null ? new AuthorizedProjectsSupplier.Default() : serverlesAuthorizedProjectsSupplier; } private ServiceAccountService createServiceAccountService( 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 ccb161428fd31..1046bc089110d 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 @@ -154,47 +154,6 @@ public class AuthorizationService { private final DlsFlsFeatureTrackingIndicesAccessControlWrapper indicesAccessControlWrapper; private final AuthorizedProjectsSupplier crossProjectSearchAuthzService; - public AuthorizationService( - Settings settings, - CompositeRolesStore rolesStore, - FieldPermissionsCache fieldPermissionsCache, - ClusterService clusterService, - AuditTrailService auditTrailService, - AuthenticationFailureHandler authcFailureHandler, - ThreadPool threadPool, - AnonymousUser anonymousUser, - @Nullable AuthorizationEngine authorizationEngine, - Set requestInterceptors, - XPackLicenseState licenseState, - IndexNameExpressionResolver resolver, - OperatorPrivilegesService operatorPrivilegesService, - RestrictedIndices restrictedIndices, - AuthorizationDenialMessages authorizationDenialMessages, - LinkedProjectConfigService linkedProjectConfigService, - ProjectResolver projectResolver - ) { - this( - settings, - rolesStore, - fieldPermissionsCache, - clusterService, - auditTrailService, - authcFailureHandler, - threadPool, - anonymousUser, - authorizationEngine, - requestInterceptors, - licenseState, - resolver, - operatorPrivilegesService, - restrictedIndices, - authorizationDenialMessages, - linkedProjectConfigService, - projectResolver, - new AuthorizedProjectsSupplier.Default() - ); - } - public AuthorizationService( Settings settings, CompositeRolesStore rolesStore, @@ -222,7 +181,7 @@ public AuthorizationService( settings, linkedProjectConfigService, resolver, - authorizedProjectsSupplier.enabled() + authorizedProjectsSupplier.recordResolvedIndexExpressions() ); this.authcFailureHandler = authcFailureHandler; this.threadContext = threadPool.getThreadContext(); @@ -558,7 +517,6 @@ private void authorizeAction( ActionListener.wrap(authorizedIndices -> { if (request instanceof IndicesRequest.Replaceable replaceable && replaceable.allowsCrossProjectResolution()) { crossProjectSearchAuthzService.getAuthorizedProjects(ActionListener.wrap(authorizedProjects -> { - logger.info("Loaded authorized projects: [{}]", authorizedProjects); resolvedIndicesListener.onResponse( indicesAndAliasesResolver.resolve( action, @@ -569,7 +527,6 @@ private void authorizeAction( ) ); }, e -> { - // TODO avoid duplicating this if (e instanceof InvalidIndexNameException || e instanceof InvalidSelectorException || e instanceof UnsupportedSelectorException) { 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 56d71e27353f9..4063ccf26941e 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 @@ -157,6 +157,7 @@ import org.elasticsearch.xpack.core.security.authc.Subject; import org.elasticsearch.xpack.core.security.authz.AuthorizationEngine; import org.elasticsearch.xpack.core.security.authz.AuthorizationEngine.AuthorizationInfo; +import org.elasticsearch.xpack.core.security.authz.AuthorizedProjectsSupplier; import org.elasticsearch.xpack.core.security.authz.IndicesAndAliasesResolverField; import org.elasticsearch.xpack.core.security.authz.ResolvedIndices; import org.elasticsearch.xpack.core.security.authz.RoleDescriptor; @@ -341,7 +342,8 @@ public void setup() { RESTRICTED_INDICES, new AuthorizationDenialMessages.Default(), linkedProjectConfigService, - projectResolver + projectResolver, + new AuthorizedProjectsSupplier.Default() ); } @@ -1775,7 +1777,8 @@ public void testDenialForAnonymousUser() { RESTRICTED_INDICES, new AuthorizationDenialMessages.Default(), linkedProjectConfigService, - projectResolver + projectResolver, + new AuthorizedProjectsSupplier.Default() ); RoleDescriptor role = new RoleDescriptor( @@ -1826,7 +1829,8 @@ public void testDenialForAnonymousUserAuthorizationExceptionDisabled() { RESTRICTED_INDICES, new AuthorizationDenialMessages.Default(), linkedProjectConfigService, - projectResolver + projectResolver, + new AuthorizedProjectsSupplier.Default() ); RoleDescriptor role = new RoleDescriptor( @@ -3365,7 +3369,8 @@ public void testAuthorizationEngineSelectionForCheckPrivileges() throws Exceptio RESTRICTED_INDICES, new AuthorizationDenialMessages.Default(), linkedProjectConfigService, - projectResolver + projectResolver, + new AuthorizedProjectsSupplier.Default() ); Subject subject = new Subject(new User("test", "a role"), mock(RealmRef.class)); @@ -3522,7 +3527,8 @@ public void getUserPrivileges(AuthorizationInfo authorizationInfo, ActionListene RESTRICTED_INDICES, new AuthorizationDenialMessages.Default(), linkedProjectConfigService, - projectResolver + projectResolver, + new AuthorizedProjectsSupplier.Default() ); Authentication authentication; try (StoredContext ignore = threadContext.stashContext()) { From f4cd4237eac169ba96a9ded9948db27c8c03f90e Mon Sep 17 00:00:00 2001 From: Nikolaj Volgushev Date: Tue, 30 Sep 2025 21:42:25 +0200 Subject: [PATCH 59/89] Merge --- .../referable/resolve_index_include_resolved_flag.csv | 2 +- server/src/main/resources/transport/upper_bounds/9.2.csv | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/server/src/main/resources/transport/definitions/referable/resolve_index_include_resolved_flag.csv b/server/src/main/resources/transport/definitions/referable/resolve_index_include_resolved_flag.csv index 2752f57900f3e..02acf4c3b415f 100644 --- a/server/src/main/resources/transport/definitions/referable/resolve_index_include_resolved_flag.csv +++ b/server/src/main/resources/transport/definitions/referable/resolve_index_include_resolved_flag.csv @@ -1 +1 @@ -9183000 +9184000 diff --git a/server/src/main/resources/transport/upper_bounds/9.2.csv b/server/src/main/resources/transport/upper_bounds/9.2.csv index 1c545880016f4..29697baeee92b 100644 --- a/server/src/main/resources/transport/upper_bounds/9.2.csv +++ b/server/src/main/resources/transport/upper_bounds/9.2.csv @@ -1 +1 @@ -resolve_index_include_resolved_flag,9183000 +resolve_index_include_resolved_flag,9184000 From 0af5abc4dd6a6417c170c8f786ae876a55df132d Mon Sep 17 00:00:00 2001 From: Nikolaj Volgushev Date: Tue, 30 Sep 2025 22:00:46 +0200 Subject: [PATCH 60/89] More clean up --- .../elasticsearch/action/IndicesRequest.java | 2 +- .../indices/resolve/ResolveIndexAction.java | 2 +- .../security/authz/AuthorizationService.java | 110 +++++++++--------- .../authz/IndicesAndAliasesResolver.java | 11 +- .../authz/IndicesAndAliasesResolverTests.java | 3 +- 5 files changed, 57 insertions(+), 71 deletions(-) diff --git a/server/src/main/java/org/elasticsearch/action/IndicesRequest.java b/server/src/main/java/org/elasticsearch/action/IndicesRequest.java index 2562c13030e01..3d08fcc088d05 100644 --- a/server/src/main/java/org/elasticsearch/action/IndicesRequest.java +++ b/server/src/main/java/org/elasticsearch/action/IndicesRequest.java @@ -81,7 +81,7 @@ default boolean allowsRemoteIndices() { return false; } - default boolean allowsCrossProjectResolution() { + default boolean crossProjectResolvable() { return false; } } diff --git a/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java b/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java index 14a10f7f27d68..d3118691cf456 100644 --- a/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java +++ b/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java @@ -211,7 +211,7 @@ public boolean allowsRemoteIndices() { } @Override - public boolean allowsCrossProjectResolution() { + public boolean crossProjectResolvable() { return resolveCrossProject; } 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 1046bc089110d..8e6209e09a3d2 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 @@ -152,7 +152,7 @@ public class AuthorizationService { private final boolean isAnonymousEnabled; private final boolean anonymousAuthzExceptionEnabled; private final DlsFlsFeatureTrackingIndicesAccessControlWrapper indicesAccessControlWrapper; - private final AuthorizedProjectsSupplier crossProjectSearchAuthzService; + private final AuthorizedProjectsSupplier authorizedProjectsSupplier; public AuthorizationService( Settings settings, @@ -203,7 +203,7 @@ public AuthorizationService( this.indicesAccessControlWrapper = new DlsFlsFeatureTrackingIndicesAccessControlWrapper(settings, licenseState); this.authorizationDenialMessages = authorizationDenialMessages; this.projectResolver = projectResolver; - this.crossProjectSearchAuthzService = authorizedProjectsSupplier; + this.authorizedProjectsSupplier = authorizedProjectsSupplier; } public void checkPrivileges( @@ -515,40 +515,21 @@ private void authorizeAction( authzInfo, projectMetadata.getIndicesLookup(), ActionListener.wrap(authorizedIndices -> { - if (request instanceof IndicesRequest.Replaceable replaceable && replaceable.allowsCrossProjectResolution()) { - crossProjectSearchAuthzService.getAuthorizedProjects(ActionListener.wrap(authorizedProjects -> { - resolvedIndicesListener.onResponse( - indicesAndAliasesResolver.resolve( - action, - request, - projectMetadata, - authorizedIndices, - authorizedProjects - ) - ); - }, e -> { - if (e instanceof InvalidIndexNameException - || e instanceof InvalidSelectorException - || e instanceof UnsupportedSelectorException) { - logger.info( - () -> Strings.format( - "failed [%s] action authorization for [%s] due to [%s] exception", + if (request instanceof IndicesRequest.Replaceable replaceable && replaceable.crossProjectResolvable()) { + authorizedProjectsSupplier.getAuthorizedProjects( + ActionListener.wrap( + authorizedProjects -> resolvedIndicesListener.onResponse( + indicesAndAliasesResolver.resolve( action, - authentication, - e.getClass().getSimpleName() - ), - e - ); - listener.onFailure(e); - return; - } - auditTrail.accessDenied(requestId, authentication, action, request, authzInfo); - if (e instanceof IndexNotFoundException || e instanceof NoMatchingProjectException) { - listener.onFailure(e); - } else { - listener.onFailure(actionDenied(authentication, authzInfo, action, request, e)); - } - })); + request, + projectMetadata, + authorizedIndices, + authorizedProjects + ) + ), + e -> authzResourceLoadFailure(requestId, requestInfo, authzInfo, auditTrail, e, listener) + ) + ); } else { resolvedIndicesListener.onResponse( indicesAndAliasesResolver.resolve( @@ -560,29 +541,7 @@ private void authorizeAction( ) ); } - }, e -> { - if (e instanceof InvalidIndexNameException - || e instanceof InvalidSelectorException - || e instanceof UnsupportedSelectorException) { - logger.info( - () -> Strings.format( - "failed [%s] action authorization for [%s] due to [%s] exception", - action, - authentication, - e.getClass().getSimpleName() - ), - e - ); - listener.onFailure(e); - return; - } - auditTrail.accessDenied(requestId, authentication, action, request, authzInfo); - if (e instanceof IndexNotFoundException || e instanceof NoMatchingProjectException) { - listener.onFailure(e); - } else { - listener.onFailure(actionDenied(authentication, authzInfo, action, request, e)); - } - }) + }, e -> authzResourceLoadFailure(requestId, requestInfo, authzInfo, auditTrail, e, listener)) ); return resolvedIndicesListener; } @@ -616,6 +575,41 @@ private void authorizeAction( } } + private void authzResourceLoadFailure( + final String requestId, + final RequestInfo requestInfo, + final AuthorizationInfo authzInfo, + final AuditTrail auditTrail, + final Exception ex, + final ActionListener listener + ) { + final String action = requestInfo.getAction(); + final TransportRequest request = requestInfo.getRequest(); + final Authentication authentication = requestInfo.getAuthentication(); + + if (ex instanceof InvalidIndexNameException + || ex instanceof InvalidSelectorException + || ex instanceof UnsupportedSelectorException) { + logger.info( + () -> Strings.format( + "failed [%s] action authorization for [%s] due to [%s] exception", + action, + authentication, + ex.getClass().getSimpleName() + ), + ex + ); + listener.onFailure(ex); + return; + } + auditTrail.accessDenied(requestId, authentication, action, request, authzInfo); + if (ex instanceof IndexNotFoundException || ex instanceof NoMatchingProjectException) { + listener.onFailure(ex); + } else { + listener.onFailure(actionDenied(authentication, authzInfo, action, request, ex)); + } + } + private void handleIndexActionAuthorizationResult( final IndexAuthorizationResult result, final RequestInfo requestInfo, diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java index 7a9904b3055c6..d38d7dbf9e12b 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java @@ -78,15 +78,6 @@ class IndicesAndAliasesResolver { this.recordResolvedIndexExpressions = recordResolvedIndexExpressions; } - ResolvedIndices resolve( - String action, - TransportRequest request, - ProjectMetadata projectMetadata, - AuthorizationEngine.AuthorizedIndices authorizedIndices - ) { - return resolve(action, request, projectMetadata, authorizedIndices, TargetProjects.NOT_CROSS_PROJECT); - } - /** * Resolves, and if necessary updates, the list of index names in the provided request in accordance with the user's * authorizedIndices. @@ -371,7 +362,7 @@ ResolvedIndices resolveIndicesAndAliases( // if we cannot replace wildcards the indices list stays empty. Same if there are no authorized indices. // we honour allow_no_indices like es core does. } else { - if (replaceable.allowsCrossProjectResolution() && authorizedProjects != TargetProjects.NOT_CROSS_PROJECT) { + if (replaceable.crossProjectResolvable() && authorizedProjects != TargetProjects.NOT_CROSS_PROJECT) { assert replaceable.allowsRemoteIndices() : "cross-project requests must allow remote indices"; assert recordResolvedIndexExpressions : "cross-project requests must record resolved index expressions"; assert false == IndexNameExpressionResolver.isNoneExpression(replaceable.indices()) diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolverTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolverTests.java index 4945bb3cf99e1..b982a92f8532f 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolverTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolverTests.java @@ -61,6 +61,7 @@ import org.elasticsearch.indices.TestIndexNameExpressionResolver; import org.elasticsearch.license.MockLicenseState; import org.elasticsearch.protocol.xpack.graph.GraphExploreRequest; +import org.elasticsearch.search.crossproject.TargetProjects; import org.elasticsearch.search.internal.ShardSearchRequest; import org.elasticsearch.test.ESTestCase; import org.elasticsearch.transport.ClusterSettingsLinkedProjectConfigService; @@ -2881,7 +2882,7 @@ private ResolvedIndices resolveIndices(TransportRequest request, AuthorizedIndic } private ResolvedIndices resolveIndices(String action, TransportRequest request, AuthorizedIndices authorizedIndices) { - return defaultIndicesResolver.resolve(action, request, this.projectMetadata, authorizedIndices); + return defaultIndicesResolver.resolve(action, request, this.projectMetadata, authorizedIndices, TargetProjects.NOT_CROSS_PROJECT); } private static void assertNoIndices(IndicesRequest.Replaceable request, ResolvedIndices resolvedIndices) { From 1be376473d1ca2cabd2bd0f1796d046ad1df031b Mon Sep 17 00:00:00 2001 From: Nikolaj Volgushev Date: Wed, 1 Oct 2025 10:58:02 +0200 Subject: [PATCH 61/89] Clean up index resolution code --- .../action/ResolvedIndexExpression.java | 2 + .../action/ResolvedIndexExpressions.java | 1 + .../metadata/IndexAbstractionResolver.java | 49 ++++++++----------- .../IndexExpressionsRewriter.java | 19 +++++-- .../search/crossproject/TargetProjects.java | 4 ++ 5 files changed, 41 insertions(+), 34 deletions(-) diff --git a/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpression.java b/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpression.java index d2afda08954cb..f285c7606ed22 100644 --- a/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpression.java +++ b/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpression.java @@ -64,6 +64,7 @@ public void writeTo(StreamOutput out) throws IOException { * Failures can be due to concrete resources not being visible (either missing or not visible due to indices options) * or unauthorized concrete resources. * A wildcard expression resolving to nothing is still considered a successful resolution. + * The NONE result indicates that no local resolution was attempted, because the expression is known to be remote-only. */ public enum LocalIndexResolutionResult { NONE, @@ -85,6 +86,7 @@ public record LocalExpressions( : "If the local resolution result is SUCCESS, exception must be null"; } + // Singleton for the case where all expressions in a ResolvedIndexExpression instance are remote public static final LocalExpressions NONE = new LocalExpressions(Set.of(), LocalIndexResolutionResult.NONE, null); public LocalExpressions(StreamInput in) throws IOException { diff --git a/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpressions.java b/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpressions.java index 2715d6705653a..975af6463a19c 100644 --- a/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpressions.java +++ b/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpressions.java @@ -94,6 +94,7 @@ public void excludeFromLocalExpressions(Set expressionsToExclude) { } public ResolvedIndexExpressions build() { + // TODO make all sets on `expressions` immutable return new ResolvedIndexExpressions(expressions); } } diff --git a/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java b/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java index f949581a709f1..a696ee375c026 100644 --- a/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java +++ b/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java @@ -9,8 +9,6 @@ package org.elasticsearch.cluster.metadata; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; import org.elasticsearch.action.ResolvedIndexExpression.LocalIndexResolutionResult; import org.elasticsearch.action.ResolvedIndexExpressions; import org.elasticsearch.action.support.IndexComponentSelector; @@ -36,9 +34,6 @@ import static org.elasticsearch.action.ResolvedIndexExpression.LocalIndexResolutionResult.SUCCESS; public class IndexAbstractionResolver { - - private static final Logger logger = LogManager.getLogger(IndexAbstractionResolver.class); - private final IndexNameExpressionResolver indexNameExpressionResolver; public IndexAbstractionResolver(IndexNameExpressionResolver indexNameExpressionResolver) { @@ -55,18 +50,18 @@ public ResolvedIndexExpressions resolveIndexAbstractions( ) { final ResolvedIndexExpressions.Builder resolvedExpressionsBuilder = ResolvedIndexExpressions.builder(); boolean wildcardSeen = false; - for (String index : indices) { + for (String originalIndexExpression : indices) { wildcardSeen = resolveIndexAbstraction( resolvedExpressionsBuilder, - index, - index, + originalIndexExpression, + originalIndexExpression, // in the case of local resolution, the local expression is alwasy the same as the original indicesOptions, projectMetadata, allAuthorizedAndAvailableBySelector, isAuthorized, includeDataStreams, - wildcardSeen, - new HashSet<>() + Set.of(), + wildcardSeen ); } return resolvedExpressionsBuilder.build(); @@ -81,44 +76,40 @@ public ResolvedIndexExpressions resolveIndexAbstractions( TargetProjects targetProjects, boolean includeDataStreams ) { - // TODO preflight checks and assertions (e.g., no *,-* expression) + assert targetProjects != TargetProjects.NOT_CROSS_PROJECT : "TargetProjects.NOT_CROSS_PROJECT is not allowed here"; + targetProjects.assertNonEmptyTargets(); + final String originProjectAlias = targetProjects.originProjectAlias(); final Set linkedProjectAliases = targetProjects.allProjectAliases(); final ResolvedIndexExpressions.Builder resolvedExpressionsBuilder = ResolvedIndexExpressions.builder(); boolean wildcardSeen = false; - for (String index : indices) { + for (String originalIndexExpression : indices) { final IndexExpressionsRewriter.IndexRewriteResult indexRewriteResult = IndexExpressionsRewriter.rewriteIndexExpression( - index, + originalIndexExpression, originProjectAlias, linkedProjectAliases ); - logger.info( - "[{}] rewritten index expression [{}] to local [{}] and remote [{}]", - originProjectAlias, - index, - indexRewriteResult.localExpression(), - indexRewriteResult.remoteExpressions() - ); - if (indexRewriteResult.localExpression() == null) { - resolvedExpressionsBuilder.addRemoteExpressions(index, Set.copyOf(indexRewriteResult.remoteExpressions())); + final String localIndexExpression = indexRewriteResult.localExpression(); + if (localIndexExpression == null) { + // nothing to resolve locally so skip resolve abstraction call + resolvedExpressionsBuilder.addRemoteExpressions(originalIndexExpression, indexRewriteResult.remoteExpressions()); continue; } wildcardSeen = resolveIndexAbstraction( resolvedExpressionsBuilder, - index, - indexRewriteResult.localExpression(), + originalIndexExpression, + localIndexExpression, indicesOptions, projectMetadata, allAuthorizedAndAvailableBySelector, isAuthorized, includeDataStreams, - wildcardSeen, - Set.copyOf(indexRewriteResult.remoteExpressions()) + indexRewriteResult.remoteExpressions(), + wildcardSeen ); } - return resolvedExpressionsBuilder.build(); } @@ -131,8 +122,8 @@ private boolean resolveIndexAbstraction( final Function> allAuthorizedAndAvailableBySelector, final BiPredicate isAuthorized, final boolean includeDataStreams, - boolean wildcardSeen, - Set remoteExpressions + final Set remoteExpressions, + boolean wildcardSeen ) { String indexAbstraction; boolean minus = false; diff --git a/server/src/main/java/org/elasticsearch/search/crossproject/IndexExpressionsRewriter.java b/server/src/main/java/org/elasticsearch/search/crossproject/IndexExpressionsRewriter.java index 9d21cb01d0255..1231d013c024b 100644 --- a/server/src/main/java/org/elasticsearch/search/crossproject/IndexExpressionsRewriter.java +++ b/server/src/main/java/org/elasticsearch/search/crossproject/IndexExpressionsRewriter.java @@ -19,9 +19,9 @@ import org.elasticsearch.transport.NoSuchRemoteClusterException; import org.elasticsearch.transport.RemoteClusterAware; -import java.util.ArrayList; import java.util.Collections; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Set; @@ -51,6 +51,7 @@ public class IndexExpressionsRewriter { * @throws IllegalArgumentException if exclusions, date math or selectors are present in the index expressions * @throws NoMatchingProjectException if a qualified resource cannot be resolved because a project is missing */ + // TODO remove me: only used in tests public static Map rewriteIndexExpressions( ProjectRoutingInfo originProject, List linkedProjects, @@ -127,7 +128,7 @@ private static IndexRewriteResult rewriteUnqualifiedExpression( Set allProjectAliases ) { String localExpression = null; - final List rewrittenExpressions = new ArrayList<>(); + final Set rewrittenExpressions = new LinkedHashSet<>(); if (originAlias != null) { localExpression = indexExpression; // adding the original indexExpression for the _origin cluster. } @@ -177,7 +178,7 @@ private static IndexRewriteResult rewriteQualifiedExpression( } String localExpression = null; - final List resourcesMatchingLinkedProjectAliases = new ArrayList<>(); + final Set resourcesMatchingLinkedProjectAliases = new LinkedHashSet<>(); for (String project : allProjectsMatchingAlias) { if (project.equals(originProjectAlias)) { localExpression = indexExpression; @@ -209,9 +210,17 @@ private static void maybeThrowOnUnsupportedResource(String resource) { /** * A container for a local expression and a list of remote expressions. */ - public record IndexRewriteResult(@Nullable String localExpression, List remoteExpressions) { + public record IndexRewriteResult(@Nullable String localExpression, Set remoteExpressions) { public IndexRewriteResult(String localExpression) { - this(localExpression, List.of()); + this(localExpression, Set.of()); + } + + public boolean hasLocalExpression() { + return localExpression != null; + } + + public boolean hasRemoteExpressions() { + return remoteExpressions.isEmpty() == false; } } } diff --git a/server/src/main/java/org/elasticsearch/search/crossproject/TargetProjects.java b/server/src/main/java/org/elasticsearch/search/crossproject/TargetProjects.java index cae218eb0439c..423257f3b2e09 100644 --- a/server/src/main/java/org/elasticsearch/search/crossproject/TargetProjects.java +++ b/server/src/main/java/org/elasticsearch/search/crossproject/TargetProjects.java @@ -35,4 +35,8 @@ public Set allProjectAliases() { } return Collections.unmodifiableSet(allProjectAliases); } + + public void assertNonEmptyTargets() { + assert originProject != null || false == linkedProjects.isEmpty() : "At least one target project must be specified"; + } } From 45bd9956a4dc488321ccf590669ec8c287a825f7 Mon Sep 17 00:00:00 2001 From: Nikolaj Volgushev Date: Wed, 1 Oct 2025 11:04:29 +0200 Subject: [PATCH 62/89] More clean up --- .../cluster/metadata/IndexAbstractionResolver.java | 8 ++++++-- .../search/crossproject/TargetProjects.java | 10 ++++++++-- .../security/authz/IndicesAndAliasesResolver.java | 14 ++++++++++---- 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java b/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java index a696ee375c026..3ec6d27d32bf7 100644 --- a/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java +++ b/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java @@ -76,8 +76,12 @@ public ResolvedIndexExpressions resolveIndexAbstractions( TargetProjects targetProjects, boolean includeDataStreams ) { - assert targetProjects != TargetProjects.NOT_CROSS_PROJECT : "TargetProjects.NOT_CROSS_PROJECT is not allowed here"; - targetProjects.assertNonEmptyTargets(); + assert targetProjects != TargetProjects.NOT_CROSS_PROJECT + : "cannot resolve indices cross project if target set is marked NOT_CROSS_PROJECT"; + if (targetProjects.isEmpty()) { + assert false : "cannot resolve indices cross project if target set is empty"; + throw new IllegalArgumentException("cannot resolve indices cross project if target set is empty"); + } final String originProjectAlias = targetProjects.originProjectAlias(); final Set linkedProjectAliases = targetProjects.allProjectAliases(); diff --git a/server/src/main/java/org/elasticsearch/search/crossproject/TargetProjects.java b/server/src/main/java/org/elasticsearch/search/crossproject/TargetProjects.java index 423257f3b2e09..9650685dbf7d2 100644 --- a/server/src/main/java/org/elasticsearch/search/crossproject/TargetProjects.java +++ b/server/src/main/java/org/elasticsearch/search/crossproject/TargetProjects.java @@ -29,6 +29,7 @@ public String originProjectAlias() { } public Set allProjectAliases() { + // TODO consider caching this final Set allProjectAliases = linkedProjects.stream().map(ProjectRoutingInfo::projectAlias).collect(Collectors.toSet()); if (originProject != null) { allProjectAliases.add(originProject.projectAlias()); @@ -36,7 +37,12 @@ public Set allProjectAliases() { return Collections.unmodifiableSet(allProjectAliases); } - public void assertNonEmptyTargets() { - assert originProject != null || false == linkedProjects.isEmpty() : "At least one target project must be specified"; + public boolean isEmpty() { + return originProject == null && linkedProjects.isEmpty(); + } + + public void assertNonEmpty() { + assert originProject != null || false == linkedProjects.isEmpty() + : "either origin project or linked projects must be in project target set"; } } diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java index d38d7dbf9e12b..85728a5309761 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java @@ -342,6 +342,7 @@ ResolvedIndices resolveIndicesAndAliases( throw new UnsupportedSelectorException(originalIndexExpression); } if (indicesOptions.expandWildcardExpressions()) { + // TODO implement CPS index rewriting for all indices requests IndexComponentSelector selector = IndexComponentSelector.getByKeyOrThrow(allIndicesPatternSelector); for (String authorizedIndex : authorizedIndices.all(selector)) { if (IndexAbstractionResolver.isIndexVisible( @@ -362,12 +363,11 @@ ResolvedIndices resolveIndicesAndAliases( // if we cannot replace wildcards the indices list stays empty. Same if there are no authorized indices. // we honour allow_no_indices like es core does. } else { - if (replaceable.crossProjectResolvable() && authorizedProjects != TargetProjects.NOT_CROSS_PROJECT) { + if (shouldResolveCrossProject(replaceable, authorizedProjects)) { assert replaceable.allowsRemoteIndices() : "cross-project requests must allow remote indices"; assert recordResolvedIndexExpressions : "cross-project requests must record resolved index expressions"; - assert false == IndexNameExpressionResolver.isNoneExpression(replaceable.indices()) - : "expression list is *,-* which effectively means a request that requests no indices"; - + assert IndexNameExpressionResolver.isNoneExpression(replaceable.indices()) + : "none expressions should be handled by local resolution logic"; final ResolvedIndexExpressions resolved = indexAbstractionResolver.resolveIndexAbstractions( Arrays.asList(replaceable.indices()), lenientIndicesOptionsForFanout(indicesOptions), @@ -471,6 +471,12 @@ ResolvedIndices resolveIndicesAndAliases( return resolvedIndicesBuilder.build(); } + private static boolean shouldResolveCrossProject(IndicesRequest.Replaceable replaceable, TargetProjects authorizedProjects) { + return replaceable.crossProjectResolvable() && authorizedProjects != TargetProjects.NOT_CROSS_PROJECT + // a none expression should not go through the cross-project resolution -- fall back to local resolution logic + && false == IndexNameExpressionResolver.isNoneExpression(replaceable.indices()); + } + private static void setResolvedIfNull(String action, IndicesRequest.Replaceable replaceable, ResolvedIndexExpressions resolved) { if (replaceable.getResolvedIndexExpressions() == null) { replaceable.setResolvedIndexExpressions(resolved); From 8f172a0398c92df3f3006bbe33fc5ffc6d019f83 Mon Sep 17 00:00:00 2001 From: Nikolaj Volgushev Date: Wed, 1 Oct 2025 14:32:56 +0200 Subject: [PATCH 63/89] Clean up resolve index action --- .../indices/resolve/ResolveIndexAction.java | 143 ++++++++---------- .../admin/indices/RestResolveIndexAction.java | 3 +- .../CrossProjectSearchErrorHandler.java | 32 +++- .../IndexExpressionsRewriter.java | 8 - .../crossproject/RemoteIndexExpressions.java | 40 ----- .../search/crossproject/TargetProjects.java | 5 - .../IndexExpressionsRewriterTests.java | 2 +- .../authz/IndicesAndAliasesResolver.java | 15 +- 8 files changed, 101 insertions(+), 147 deletions(-) delete mode 100644 server/src/main/java/org/elasticsearch/search/crossproject/RemoteIndexExpressions.java diff --git a/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java b/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java index d3118691cf456..5a3fef790ac22 100644 --- a/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java +++ b/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java @@ -9,8 +9,6 @@ package org.elasticsearch.action.admin.indices.resolve; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; import org.elasticsearch.TransportVersion; import org.elasticsearch.action.ActionListener; import org.elasticsearch.action.ActionRequestValidationException; @@ -47,8 +45,6 @@ import org.elasticsearch.injection.guice.Inject; import org.elasticsearch.search.SearchService; import org.elasticsearch.search.crossproject.CrossProjectSearchErrorHandler; -import org.elasticsearch.search.crossproject.LinkedProjectExpressions; -import org.elasticsearch.search.crossproject.RemoteIndexExpressions; import org.elasticsearch.tasks.Task; import org.elasticsearch.transport.RemoteClusterAware; import org.elasticsearch.transport.RemoteClusterService; @@ -75,21 +71,18 @@ import java.util.stream.Stream; import static org.elasticsearch.action.search.TransportSearchHelper.checkCCSVersionCompatibility; -import static org.elasticsearch.search.crossproject.CrossProjectSearchErrorHandler.lenientIndicesOptionsForFanout; +import static org.elasticsearch.search.crossproject.CrossProjectSearchErrorHandler.lenientIndicesOptionsForCrossProject; +import static org.elasticsearch.search.crossproject.CrossProjectSearchErrorHandler.resolveCrossProject; public class ResolveIndexAction extends ActionType { - private static final Logger LOGGER = LogManager.getLogger(ResolveIndexAction.class); - public static final ResolveIndexAction INSTANCE = new ResolveIndexAction(); public static final String NAME = "indices:admin/resolve/index"; public static final RemoteClusterActionType REMOTE_TYPE = new RemoteClusterActionType<>(NAME, Response::new); private static final TransportVersion RESOLVE_INDEX_MODE_ADDED = TransportVersion.fromName("resolve_index_mode_added"); private static final TransportVersion RESOLVE_INDEX_MODE_FILTER = TransportVersion.fromName("resolve_index_mode_filter"); - private static final TransportVersion RESOLVE_INDEX_INCLUDE_RESOLVED_FLAG = TransportVersion.fromName( - "resolve_index_include_resolved_flag" - ); + private static final TransportVersion RESOLVED_INDICES_EXPRESSIONS = TransportVersion.fromName("resolve_index_include_resolved_flag"); private ResolveIndexAction() { super(NAME); @@ -104,7 +97,6 @@ public static class Request extends LegacyActionRequest implements IndicesReques private EnumSet indexModes = EnumSet.noneOf(IndexMode.class); private ResolvedIndexExpressions resolvedIndexExpressions = null; private boolean includeResolvedExpressions = false; - private boolean resolveCrossProject = false; public Request(String[] names) { this.names = names; @@ -127,8 +119,7 @@ public Request( String[] names, IndicesOptions indicesOptions, @Nullable EnumSet indexModes, - boolean includeResolvedExpressions, - boolean resolveCrossProject + boolean includeResolvedExpressions ) { this.names = names; this.indicesOptions = indicesOptions; @@ -136,7 +127,6 @@ public Request( this.indexModes = indexModes; } this.includeResolvedExpressions = includeResolvedExpressions; - this.resolveCrossProject = resolveCrossProject; } @Override @@ -153,12 +143,10 @@ public Request(StreamInput in) throws IOException { } else { this.indexModes = EnumSet.noneOf(IndexMode.class); } - if (in.getTransportVersion().supports(RESOLVE_INDEX_INCLUDE_RESOLVED_FLAG)) { + if (in.getTransportVersion().supports(RESOLVED_INDICES_EXPRESSIONS)) { this.includeResolvedExpressions = in.readBoolean(); - this.resolveCrossProject = in.readBoolean(); } else { this.includeResolvedExpressions = false; - this.resolveCrossProject = false; } } @@ -170,9 +158,8 @@ public void writeTo(StreamOutput out) throws IOException { if (out.getTransportVersion().supports(RESOLVE_INDEX_MODE_FILTER)) { out.writeEnumSet(indexModes); } - if (out.getTransportVersion().supports(RESOLVE_INDEX_INCLUDE_RESOLVED_FLAG)) { + if (out.getTransportVersion().supports(RESOLVED_INDICES_EXPRESSIONS)) { out.writeBoolean(includeResolvedExpressions); - out.writeBoolean(resolveCrossProject); } } @@ -181,12 +168,14 @@ public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; Request request = (Request) o; - return Arrays.equals(names, request.names) && indexModes.equals(request.indexModes); + return Arrays.equals(names, request.names) + && indexModes.equals(request.indexModes) + && includeResolvedExpressions == request.includeResolvedExpressions; } @Override public int hashCode() { - return Objects.hash(Arrays.hashCode(names), indexModes); + return Objects.hash(Arrays.hashCode(names), indexModes, includeResolvedExpressions); } @Override @@ -212,7 +201,7 @@ public boolean allowsRemoteIndices() { @Override public boolean crossProjectResolvable() { - return resolveCrossProject; + return true; } @Override @@ -622,43 +611,33 @@ protected void doExecute(Task task, Request request, final ActionListener remoteClusterIndices = remoteClusterService.groupIndices( - resolveCrossProject ? lenientIndicesOptionsForFanout(originalIndicesOptions) : originalIndicesOptions, + resolvedCrossProject ? lenientIndicesOptionsForCrossProject(originalIndicesOptions) : originalIndicesOptions, request.indices() ); final OriginalIndices localIndices = remoteClusterIndices.remove(RemoteClusterAware.LOCAL_CLUSTER_GROUP_KEY); List indices = new ArrayList<>(); List aliases = new ArrayList<>(); List dataStreams = new ArrayList<>(); - final ProjectState projectState = projectResolver.getProjectState(clusterService.state()); resolveIndices(localIndices, projectState, indexNameExpressionResolver, indices, aliases, dataStreams, request.indexModes); - final ResolvedIndexExpressions resolvedExpressions = request.getResolvedIndexExpressions(); - LOGGER.info("Resolved expressions [{}] and indices [{}]", resolvedExpressions, Arrays.toString(request.indices())); + final ResolvedIndexExpressions primaryResolvedIndexExpressions = request.getResolvedIndexExpressions(); if (remoteClusterIndices.size() > 0) { final int remoteRequests = remoteClusterIndices.size(); final CountDown completionCounter = new CountDown(remoteRequests); final SortedMap remoteResponses = Collections.synchronizedSortedMap(new TreeMap<>()); final Runnable terminalHandler = () -> { if (completionCounter.countDown()) { - if (resolveCrossProject) { - Map linkedProjectExpressions = remoteResponses.entrySet() - .stream() - .collect( - Collectors.toMap( - Map.Entry::getKey, - e -> LinkedProjectExpressions.fromResolvedExpressions(e.getValue().getResolvedIndexExpressions()) - ) - ); - try { - CrossProjectSearchErrorHandler.crossProjectFanoutErrorHandling( - request.indicesOptions, - resolvedExpressions, - new RemoteIndexExpressions(linkedProjectExpressions) - ); - } catch (Exception ex) { + if (resolvedCrossProject) { + final Exception ex = validateCrossProjectResponses( + request.indicesOptions(), + primaryResolvedIndexExpressions, + remoteResponses + ); + if (ex != null) { listener.onFailure(ex); return; } @@ -677,18 +656,11 @@ protected void doExecute(Task task, Request request, final ActionListener { remoteResponses.put(clusterAlias, response); @@ -696,41 +668,56 @@ protected void doExecute(Task task, Request request, final ActionListener terminalHandler.run())); } } else { - onResponse( - request, - listener, - resolveCrossProject, - resolvedExpressions, - new Response(indices, aliases, dataStreams, request.includeResolvedExpressions ? resolvedExpressions : null) + if (resolvedCrossProject) { + final Exception ex = validateCrossProjectResponses(request.indicesOptions(), primaryResolvedIndexExpressions); + if (ex != null) { + listener.onFailure(ex); + return; + } + } + listener.onResponse( + new Response(indices, aliases, dataStreams, request.includeResolvedExpressions ? primaryResolvedIndexExpressions : null) ); } } - private void onResponse( - Request request, - ActionListener listener, - boolean resolveCrossProject, - ResolvedIndexExpressions resolvedExpressions, - Response response + @Nullable + private Exception validateCrossProjectResponses( + IndicesOptions indicesOptions, + ResolvedIndexExpressions primaryResolvedIndexExpressions ) { - if (resolveCrossProject) { - try { - CrossProjectSearchErrorHandler.crossProjectFanoutErrorHandling( - request.indicesOptions, - resolvedExpressions, - new RemoteIndexExpressions(Map.of()) - ); - } catch (Exception ex) { - listener.onFailure(ex); - return; - } + return validateCrossProjectResponses(indicesOptions, primaryResolvedIndexExpressions, Map.of()); + } + + @Nullable + private Exception validateCrossProjectResponses( + IndicesOptions indicesOptions, + ResolvedIndexExpressions primaryResolvedIndexExpressions, + Map remoteResponses + ) { + try { + CrossProjectSearchErrorHandler.crossProjectFanoutErrorHandling( + indicesOptions, + primaryResolvedIndexExpressions, + remoteResponses.isEmpty() ? Map.of() : getResolvedExpressionsByRemote(remoteResponses) + ); + return null; + } catch (Exception ex) { + return ex; } - listener.onResponse(response); } - private boolean resolveCrossProject(Request request) { - // TODO use IndicesOptions instead - return request.resolveCrossProject; + private Map getResolvedExpressionsByRemote(Map remoteResponses) { + return remoteResponses.entrySet() + .stream() + .collect( + Collectors.toMap( + Map.Entry::getKey, + e -> e.getValue().getResolvedIndexExpressions() == null + ? new ResolvedIndexExpressions(List.of()) + : e.getValue().getResolvedIndexExpressions() + ) + ); } /** diff --git a/server/src/main/java/org/elasticsearch/rest/action/admin/indices/RestResolveIndexAction.java b/server/src/main/java/org/elasticsearch/rest/action/admin/indices/RestResolveIndexAction.java index 09308d7034dff..d272c2a143d9c 100644 --- a/server/src/main/java/org/elasticsearch/rest/action/admin/indices/RestResolveIndexAction.java +++ b/server/src/main/java/org/elasticsearch/rest/action/admin/indices/RestResolveIndexAction.java @@ -70,8 +70,7 @@ protected BaseRestHandler.RestChannelConsumer prepareRequest(RestRequest request : Arrays.stream(modeParam.split(",")) .map(IndexMode::fromString) .collect(() -> EnumSet.noneOf(IndexMode.class), EnumSet::add, EnumSet::addAll), - false, - crossProjectMode + false ); return channel -> client.admin().indices().resolveIndex(resolveRequest, new RestToXContentListener<>(channel)); } diff --git a/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectSearchErrorHandler.java b/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectSearchErrorHandler.java index 5103bfa554180..f97a73f385012 100644 --- a/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectSearchErrorHandler.java +++ b/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectSearchErrorHandler.java @@ -22,6 +22,8 @@ import java.util.ArrayList; import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; import static org.elasticsearch.action.ResolvedIndexExpression.LocalIndexResolutionResult.CONCRETE_RESOURCE_NOT_VISIBLE; import static org.elasticsearch.action.ResolvedIndexExpression.LocalIndexResolutionResult.CONCRETE_RESOURCE_UNAUTHORIZED; @@ -50,6 +52,17 @@ public class CrossProjectSearchErrorHandler { private static final Logger logger = LogManager.getLogger(CrossProjectSearchErrorHandler.class); private static final String WILDCARD = "*"; + public static void crossProjectFanoutErrorHandling( + IndicesOptions indicesOptions, + ResolvedIndexExpressions primaryResolvedIndexExpressions, + Map resolvedIndexExpressionsByRemote + ) { + Map remoteLinkedProjectExpressions = resolvedIndexExpressionsByRemote.entrySet() + .stream() + .collect(Collectors.toMap(Map.Entry::getKey, e -> LinkedProjectExpressions.fromResolvedExpressions(e.getValue()))); + tempCrossProjectFanoutErrorHandling(indicesOptions, primaryResolvedIndexExpressions, remoteLinkedProjectExpressions); + } + /** * Validates the results of cross-project index resolution and throws appropriate exceptions based on the provided * {@link IndicesOptions}. @@ -72,10 +85,10 @@ public class CrossProjectSearchErrorHandler { * @throws IndexNotFoundException If indices are missing and the {@code IndicesOptions} do not allow it * @throws ElasticsearchSecurityException If authorization errors occurred during index resolution */ - public static void crossProjectFanoutErrorHandling( + private static void tempCrossProjectFanoutErrorHandling( IndicesOptions indicesOptions, ResolvedIndexExpressions localResolvedExpressions, - RemoteIndexExpressions remoteResolvedExpressions + Map remoteResolvedExpressions ) { logger.info( "Checking cross-project index resolution results for [{}] and [{}]", @@ -120,7 +133,12 @@ public static void crossProjectFanoutErrorHandling( } } - public static IndicesOptions lenientIndicesOptionsForFanout(IndicesOptions indicesOptions) { + public static boolean resolveCrossProject(IndicesOptions indicesOptions) { + // TODO this needs to be based on the IndicesOptions flag instead, once available + return Boolean.parseBoolean(System.getProperty("cps.resolve_cross_project", "false")); + } + + public static IndicesOptions lenientIndicesOptionsForCrossProject(IndicesOptions indicesOptions) { return IndicesOptions.builder(indicesOptions) .concreteTargetOptions(new IndicesOptions.ConcreteTargetOptions(true)) .wildcardOptions(IndicesOptions.WildcardOptions.builder(indicesOptions.wildcardOptions()).allowEmptyExpressions(true).build()) @@ -131,7 +149,7 @@ private static void checkAllowNoIndices( String indexAlias, String originalExpression, ResolvedIndexExpression localResolvedIndices, - RemoteIndexExpressions remoteResolvedExpressions, + Map remoteResolvedExpressions, boolean isFlatWorldResource ) { // strict behaviour of allowNoIndices checks if a wildcard expression resolves to no concrete indices. @@ -144,7 +162,7 @@ private static void checkAllowNoIndices( private static void checkIndicesOptions( String originalExpression, ResolvedIndexExpression localResolvedIndices, - RemoteIndexExpressions remoteResolvedExpressions, + Map remoteResolvedExpressions, boolean isFlatWorldResource ) { ResolvedIndexExpression.LocalExpressions localExpressions = localResolvedIndices.localExpressions(); @@ -194,7 +212,7 @@ private static void checkIndicesOptions( + "]"; String projectAlias = splitResource[0]; String resource = splitResource[1]; - LinkedProjectExpressions linkedProjectExpressions = remoteResolvedExpressions.expressions().get(projectAlias); + LinkedProjectExpressions linkedProjectExpressions = remoteResolvedExpressions.get(projectAlias); assert linkedProjectExpressions != null : "we should always have linked expressions from remote"; ResolvedIndexExpression.LocalExpressions resolvedRemoteExpression = linkedProjectExpressions.resolvedExpressions() @@ -221,7 +239,7 @@ private static void checkIndicesOptions( } } else { boolean foundFlat = false; - for (var linkedProjectExpressions : remoteResolvedExpressions.expressions().values()) { + for (var linkedProjectExpressions : remoteResolvedExpressions.values()) { ResolvedIndexExpression.LocalExpressions resolvedRemoteExpression = linkedProjectExpressions.resolvedExpressions() .get(remoteExpression); if (resolvedRemoteExpression != null diff --git a/server/src/main/java/org/elasticsearch/search/crossproject/IndexExpressionsRewriter.java b/server/src/main/java/org/elasticsearch/search/crossproject/IndexExpressionsRewriter.java index 1231d013c024b..1fcf542257853 100644 --- a/server/src/main/java/org/elasticsearch/search/crossproject/IndexExpressionsRewriter.java +++ b/server/src/main/java/org/elasticsearch/search/crossproject/IndexExpressionsRewriter.java @@ -214,13 +214,5 @@ public record IndexRewriteResult(@Nullable String localExpression, Set r public IndexRewriteResult(String localExpression) { this(localExpression, Set.of()); } - - public boolean hasLocalExpression() { - return localExpression != null; - } - - public boolean hasRemoteExpressions() { - return remoteExpressions.isEmpty() == false; - } } } diff --git a/server/src/main/java/org/elasticsearch/search/crossproject/RemoteIndexExpressions.java b/server/src/main/java/org/elasticsearch/search/crossproject/RemoteIndexExpressions.java deleted file mode 100644 index 6b5908d2ab20b..0000000000000 --- a/server/src/main/java/org/elasticsearch/search/crossproject/RemoteIndexExpressions.java +++ /dev/null @@ -1,40 +0,0 @@ -/* - * 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.search.crossproject; - -import java.util.Map; - -/** - * A set of {@link LinkedProjectExpressions}, keyed by the project alias. - * - *

An example structure is:

- * - *
{@code
- * {
- *   "P1": {
- *      "P1:my-index-*": { //example qualified
- *             "expressions": ["my-index-000001", "my-index-000002"],
- *             "localIndexResolutionResult": "SUCCESS"
- *       },
- *       "my-metrics-*": { //example flat
- *             "expressions": ["my-metrics-000001", "my-metrics-000002"],
- *             "localIndexResolutionResult": "SUCCESS"
- *       }
- *   },
- *   "P2": {
- *      "my-index-*": {
- *          "expressions": ["my-index-000001", "my-index-000002"],
- *          "localIndexResolutionResult": "SUCCESS"
- *      }
- *   }
- * }
- * }
- */ -public record RemoteIndexExpressions(Map expressions) {} diff --git a/server/src/main/java/org/elasticsearch/search/crossproject/TargetProjects.java b/server/src/main/java/org/elasticsearch/search/crossproject/TargetProjects.java index 9650685dbf7d2..c700e5e265325 100644 --- a/server/src/main/java/org/elasticsearch/search/crossproject/TargetProjects.java +++ b/server/src/main/java/org/elasticsearch/search/crossproject/TargetProjects.java @@ -40,9 +40,4 @@ public Set allProjectAliases() { public boolean isEmpty() { return originProject == null && linkedProjects.isEmpty(); } - - public void assertNonEmpty() { - assert originProject != null || false == linkedProjects.isEmpty() - : "either origin project or linked projects must be in project target set"; - } } diff --git a/server/src/test/java/org/elasticsearch/search/crossproject/IndexExpressionsRewriterTests.java b/server/src/test/java/org/elasticsearch/search/crossproject/IndexExpressionsRewriterTests.java index 41f0d02f66af5..37334ab1ff22a 100644 --- a/server/src/test/java/org/elasticsearch/search/crossproject/IndexExpressionsRewriterTests.java +++ b/server/src/test/java/org/elasticsearch/search/crossproject/IndexExpressionsRewriterTests.java @@ -377,7 +377,7 @@ private static void assertIndexRewriteResultsContains( private static List resultAsList(IndexExpressionsRewriter.IndexRewriteResult result) { if (result.localExpression() == null) { - return result.remoteExpressions(); + return List.copyOf(result.remoteExpressions()); } List all = new ArrayList<>(); all.add(result.localExpression()); diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java index 85728a5309761..be2a565d4dc8b 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java @@ -54,7 +54,8 @@ import java.util.concurrent.CopyOnWriteArraySet; import java.util.function.BiPredicate; -import static org.elasticsearch.search.crossproject.CrossProjectSearchErrorHandler.lenientIndicesOptionsForFanout; +import static org.elasticsearch.search.crossproject.CrossProjectSearchErrorHandler.lenientIndicesOptionsForCrossProject; +import static org.elasticsearch.search.crossproject.CrossProjectSearchErrorHandler.resolveCrossProject; import static org.elasticsearch.xpack.core.security.authz.IndicesAndAliasesResolverField.NO_INDEX_PLACEHOLDER; class IndicesAndAliasesResolver { @@ -342,7 +343,7 @@ ResolvedIndices resolveIndicesAndAliases( throw new UnsupportedSelectorException(originalIndexExpression); } if (indicesOptions.expandWildcardExpressions()) { - // TODO implement CPS index rewriting for all indices requests + // TODO implement CPS index rewriting for all-indices requests IndexComponentSelector selector = IndexComponentSelector.getByKeyOrThrow(allIndicesPatternSelector); for (String authorizedIndex : authorizedIndices.all(selector)) { if (IndexAbstractionResolver.isIndexVisible( @@ -366,11 +367,11 @@ ResolvedIndices resolveIndicesAndAliases( if (shouldResolveCrossProject(replaceable, authorizedProjects)) { assert replaceable.allowsRemoteIndices() : "cross-project requests must allow remote indices"; assert recordResolvedIndexExpressions : "cross-project requests must record resolved index expressions"; - assert IndexNameExpressionResolver.isNoneExpression(replaceable.indices()) + assert false == IndexNameExpressionResolver.isNoneExpression(replaceable.indices()) : "none expressions should be handled by local resolution logic"; final ResolvedIndexExpressions resolved = indexAbstractionResolver.resolveIndexAbstractions( Arrays.asList(replaceable.indices()), - lenientIndicesOptionsForFanout(indicesOptions), + lenientIndicesOptionsForCrossProject(indicesOptions), projectMetadata, authorizedIndices::all, authorizedIndices::check, @@ -472,8 +473,10 @@ ResolvedIndices resolveIndicesAndAliases( } private static boolean shouldResolveCrossProject(IndicesRequest.Replaceable replaceable, TargetProjects authorizedProjects) { - return replaceable.crossProjectResolvable() && authorizedProjects != TargetProjects.NOT_CROSS_PROJECT - // a none expression should not go through the cross-project resolution -- fall back to local resolution logic + return replaceable.crossProjectResolvable() + && authorizedProjects != TargetProjects.NOT_CROSS_PROJECT + && resolveCrossProject(replaceable.indicesOptions()) + // a none expression should not go through the cross-project resolution -- fall back to local resolution logic && false == IndexNameExpressionResolver.isNoneExpression(replaceable.indices()); } From c9d793ed8aed02d653ede2d0013855cd1a36e542 Mon Sep 17 00:00:00 2001 From: Nikolaj Volgushev Date: Wed, 1 Oct 2025 14:42:04 +0200 Subject: [PATCH 64/89] Smaller diff --- .../action/admin/indices/resolve/ResolveIndexAction.java | 2 +- .../rest/action/admin/indices/RestResolveIndexAction.java | 6 ++---- .../search/crossproject/NoMatchingProjectException.java | 1 + 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java b/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java index 5a3fef790ac22..593fc75c0d74a 100644 --- a/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java +++ b/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java @@ -612,8 +612,8 @@ protected void doExecute(Task task, Request request, final ActionListener remoteClusterIndices = remoteClusterService.groupIndices( resolvedCrossProject ? lenientIndicesOptionsForCrossProject(originalIndicesOptions) : originalIndicesOptions, request.indices() diff --git a/server/src/main/java/org/elasticsearch/rest/action/admin/indices/RestResolveIndexAction.java b/server/src/main/java/org/elasticsearch/rest/action/admin/indices/RestResolveIndexAction.java index d272c2a143d9c..0146fe6697ae0 100644 --- a/server/src/main/java/org/elasticsearch/rest/action/admin/indices/RestResolveIndexAction.java +++ b/server/src/main/java/org/elasticsearch/rest/action/admin/indices/RestResolveIndexAction.java @@ -57,8 +57,7 @@ public Set supportedCapabilities() { protected BaseRestHandler.RestChannelConsumer prepareRequest(RestRequest request, NodeClient client) throws IOException { String[] indices = Strings.splitStringByCommaToArray(request.param("name")); String modeParam = request.param("mode"); - boolean crossProjectMode = settings != null && settings.getAsBoolean("serverless.cross_project.enabled", false); - if (crossProjectMode) { + if (settings != null && settings.getAsBoolean("serverless.cross_project.enabled", false)) { // accept but drop project_routing param until fully supported request.param("project_routing"); } @@ -69,8 +68,7 @@ protected BaseRestHandler.RestChannelConsumer prepareRequest(RestRequest request ? null : Arrays.stream(modeParam.split(",")) .map(IndexMode::fromString) - .collect(() -> EnumSet.noneOf(IndexMode.class), EnumSet::add, EnumSet::addAll), - false + .collect(() -> EnumSet.noneOf(IndexMode.class), EnumSet::add, EnumSet::addAll) ); return channel -> client.admin().indices().resolveIndex(resolveRequest, new RestToXContentListener<>(channel)); } diff --git a/server/src/main/java/org/elasticsearch/search/crossproject/NoMatchingProjectException.java b/server/src/main/java/org/elasticsearch/search/crossproject/NoMatchingProjectException.java index 38bc65c149d1d..5c271e251795e 100644 --- a/server/src/main/java/org/elasticsearch/search/crossproject/NoMatchingProjectException.java +++ b/server/src/main/java/org/elasticsearch/search/crossproject/NoMatchingProjectException.java @@ -26,4 +26,5 @@ public NoMatchingProjectException(String projectName) { public NoMatchingProjectException(StreamInput in) throws IOException { super(in); } + } From 040512c01fcedbeb44e5117eadc662e60460222f Mon Sep 17 00:00:00 2001 From: Nikolaj Volgushev Date: Wed, 1 Oct 2025 16:33:04 +0200 Subject: [PATCH 65/89] More clean up --- .../action/ResolvedIndexExpressions.java | 2 + .../indices/resolve/ResolveIndexAction.java | 78 ++++++++----------- .../metadata/IndexAbstractionResolver.java | 4 +- ...ava => CrossProjectResponseValidator.java} | 24 +++++- .../resolve_index_include_resolved_flag.csv | 1 - .../resources/transport/upper_bounds/9.2.csv | 2 +- .../authz/IndicesAndAliasesResolver.java | 4 +- 7 files changed, 59 insertions(+), 56 deletions(-) rename server/src/main/java/org/elasticsearch/search/crossproject/{CrossProjectSearchErrorHandler.java => CrossProjectResponseValidator.java} (94%) delete mode 100644 server/src/main/resources/transport/definitions/referable/resolve_index_include_resolved_flag.csv diff --git a/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpressions.java b/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpressions.java index 975af6463a19c..45ceebf04d5d2 100644 --- a/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpressions.java +++ b/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpressions.java @@ -9,6 +9,7 @@ package org.elasticsearch.action; +import org.elasticsearch.TransportVersion; import org.elasticsearch.action.ResolvedIndexExpression.LocalExpressions; import org.elasticsearch.common.io.stream.StreamInput; import org.elasticsearch.common.io.stream.StreamOutput; @@ -25,6 +26,7 @@ * A collection of {@link ResolvedIndexExpression}. */ public record ResolvedIndexExpressions(List expressions) implements Writeable { + public static final TransportVersion RESOLVED_INDEX_EXPRESSIONS = TransportVersion.fromName("resolved_index_expressions"); public ResolvedIndexExpressions(StreamInput in) throws IOException { this(in.readCollectionAsList(ResolvedIndexExpression::new)); diff --git a/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java b/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java index 593fc75c0d74a..3a33d27878b30 100644 --- a/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java +++ b/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java @@ -44,7 +44,7 @@ import org.elasticsearch.index.IndexMode; import org.elasticsearch.injection.guice.Inject; import org.elasticsearch.search.SearchService; -import org.elasticsearch.search.crossproject.CrossProjectSearchErrorHandler; +import org.elasticsearch.search.crossproject.CrossProjectResponseValidator; import org.elasticsearch.tasks.Task; import org.elasticsearch.transport.RemoteClusterAware; import org.elasticsearch.transport.RemoteClusterService; @@ -71,8 +71,8 @@ import java.util.stream.Stream; import static org.elasticsearch.action.search.TransportSearchHelper.checkCCSVersionCompatibility; -import static org.elasticsearch.search.crossproject.CrossProjectSearchErrorHandler.lenientIndicesOptionsForCrossProject; -import static org.elasticsearch.search.crossproject.CrossProjectSearchErrorHandler.resolveCrossProject; +import static org.elasticsearch.search.crossproject.CrossProjectResponseValidator.lenientIndicesOptionsForCrossProject; +import static org.elasticsearch.search.crossproject.CrossProjectResponseValidator.resolveCrossProject; public class ResolveIndexAction extends ActionType { @@ -82,7 +82,6 @@ public class ResolveIndexAction extends ActionType private static final TransportVersion RESOLVE_INDEX_MODE_ADDED = TransportVersion.fromName("resolve_index_mode_added"); private static final TransportVersion RESOLVE_INDEX_MODE_FILTER = TransportVersion.fromName("resolve_index_mode_filter"); - private static final TransportVersion RESOLVED_INDICES_EXPRESSIONS = TransportVersion.fromName("resolve_index_include_resolved_flag"); private ResolveIndexAction() { super(NAME); @@ -143,7 +142,7 @@ public Request(StreamInput in) throws IOException { } else { this.indexModes = EnumSet.noneOf(IndexMode.class); } - if (in.getTransportVersion().supports(RESOLVED_INDICES_EXPRESSIONS)) { + if (in.getTransportVersion().supports(ResolvedIndexExpressions.RESOLVED_INDEX_EXPRESSIONS)) { this.includeResolvedExpressions = in.readBoolean(); } else { this.includeResolvedExpressions = false; @@ -158,7 +157,7 @@ public void writeTo(StreamOutput out) throws IOException { if (out.getTransportVersion().supports(RESOLVE_INDEX_MODE_FILTER)) { out.writeEnumSet(indexModes); } - if (out.getTransportVersion().supports(RESOLVED_INDICES_EXPRESSIONS)) { + if (out.getTransportVersion().supports(ResolvedIndexExpressions.RESOLVED_INDEX_EXPRESSIONS)) { out.writeBoolean(includeResolvedExpressions); } } @@ -632,10 +631,16 @@ protected void doExecute(Task task, Request request, final ActionListener { if (completionCounter.countDown()) { if (resolvedCrossProject) { - final Exception ex = validateCrossProjectResponses( - request.indicesOptions(), + // TODO temporary fix: we need to properly handle the case where a remote does not return a result due to + // a failure -- in the current version of resolve indices though, these are just silently ignored + if (remoteRequests != remoteResponses.size()) { + listener.onFailure(new IllegalStateException("not all remote clusters responded")); + return; + } + final Exception ex = CrossProjectResponseValidator.validate( + originalIndicesOptions, primaryResolvedIndexExpressions, - remoteResponses + getResolvedExpressionsByRemote(remoteResponses) ); if (ex != null) { listener.onFailure(ex); @@ -665,11 +670,20 @@ protected void doExecute(Task task, Request request, final ActionListener { remoteResponses.put(clusterAlias, response); terminalHandler.run(); - }, failure -> terminalHandler.run())); + }, failure -> { + logger.info("failed to resolve indices on remote cluster [" + clusterAlias + "]", failure); + terminalHandler.run(); + })); } } else { if (resolvedCrossProject) { - final Exception ex = validateCrossProjectResponses(request.indicesOptions(), primaryResolvedIndexExpressions); + // we still need to call response validation for local results, since qualified expressions like `_origin:index` or + // `:index` get deferred validation, also + final Exception ex = CrossProjectResponseValidator.validate( + originalIndicesOptions, + primaryResolvedIndexExpressions, + Map.of() + ); if (ex != null) { listener.onFailure(ex); return; @@ -681,43 +695,13 @@ protected void doExecute(Task task, Request request, final ActionListener remoteResponses - ) { - try { - CrossProjectSearchErrorHandler.crossProjectFanoutErrorHandling( - indicesOptions, - primaryResolvedIndexExpressions, - remoteResponses.isEmpty() ? Map.of() : getResolvedExpressionsByRemote(remoteResponses) - ); - return null; - } catch (Exception ex) { - return ex; - } - } - private Map getResolvedExpressionsByRemote(Map remoteResponses) { - return remoteResponses.entrySet() - .stream() - .collect( - Collectors.toMap( - Map.Entry::getKey, - e -> e.getValue().getResolvedIndexExpressions() == null - ? new ResolvedIndexExpressions(List.of()) - : e.getValue().getResolvedIndexExpressions() - ) - ); + return remoteResponses.entrySet().stream().collect(Collectors.toMap(Map.Entry::getKey, e -> { + final ResolvedIndexExpressions resolvedIndexExpressions = e.getValue().getResolvedIndexExpressions(); + assert resolvedIndexExpressions != null + : "remote response from cluster [" + e.getKey() + "] is missing resolved index expressions"; + return resolvedIndexExpressions == null ? new ResolvedIndexExpressions(List.of()) : resolvedIndexExpressions; + })); } /** diff --git a/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java b/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java index 3ec6d27d32bf7..c5b4e6c9ad7a9 100644 --- a/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java +++ b/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java @@ -54,7 +54,7 @@ public ResolvedIndexExpressions resolveIndexAbstractions( wildcardSeen = resolveIndexAbstraction( resolvedExpressionsBuilder, originalIndexExpression, - originalIndexExpression, // in the case of local resolution, the local expression is alwasy the same as the original + originalIndexExpression, // in the case of local resolution, the local expression is always the same as the original indicesOptions, projectMetadata, allAuthorizedAndAvailableBySelector, @@ -77,7 +77,7 @@ public ResolvedIndexExpressions resolveIndexAbstractions( boolean includeDataStreams ) { assert targetProjects != TargetProjects.NOT_CROSS_PROJECT - : "cannot resolve indices cross project if target set is marked NOT_CROSS_PROJECT"; + : "cannot resolve indices cross project if target set is NOT_CROSS_PROJECT"; if (targetProjects.isEmpty()) { assert false : "cannot resolve indices cross project if target set is empty"; throw new IllegalArgumentException("cannot resolve indices cross project if target set is empty"); diff --git a/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectSearchErrorHandler.java b/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectResponseValidator.java similarity index 94% rename from server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectSearchErrorHandler.java rename to server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectResponseValidator.java index f97a73f385012..68faf615b86af 100644 --- a/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectSearchErrorHandler.java +++ b/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectResponseValidator.java @@ -16,6 +16,7 @@ import org.elasticsearch.action.ResolvedIndexExpression; import org.elasticsearch.action.ResolvedIndexExpressions; import org.elasticsearch.action.support.IndicesOptions; +import org.elasticsearch.core.Booleans; import org.elasticsearch.index.IndexNotFoundException; import org.elasticsearch.rest.RestStatus; import org.elasticsearch.transport.RemoteClusterAware; @@ -48,8 +49,8 @@ * error response, throwing {@link IndexNotFoundException} for missing indices or * {@link ElasticsearchSecurityException} for authorization failures. */ -public class CrossProjectSearchErrorHandler { - private static final Logger logger = LogManager.getLogger(CrossProjectSearchErrorHandler.class); +public class CrossProjectResponseValidator { + private static final Logger logger = LogManager.getLogger(CrossProjectResponseValidator.class); private static final String WILDCARD = "*"; public static void crossProjectFanoutErrorHandling( @@ -63,6 +64,23 @@ public static void crossProjectFanoutErrorHandling( tempCrossProjectFanoutErrorHandling(indicesOptions, primaryResolvedIndexExpressions, remoteLinkedProjectExpressions); } + public static Exception validate( + IndicesOptions indicesOptions, + ResolvedIndexExpressions primaryResolvedIndexExpressions, + Map resolvedIndexExpressionsByRemote + ) { + try { + CrossProjectResponseValidator.crossProjectFanoutErrorHandling( + indicesOptions, + primaryResolvedIndexExpressions, + resolvedIndexExpressionsByRemote + ); + return null; + } catch (Exception ex) { + return ex; + } + } + /** * Validates the results of cross-project index resolution and throws appropriate exceptions based on the provided * {@link IndicesOptions}. @@ -135,7 +153,7 @@ private static void tempCrossProjectFanoutErrorHandling( public static boolean resolveCrossProject(IndicesOptions indicesOptions) { // TODO this needs to be based on the IndicesOptions flag instead, once available - return Boolean.parseBoolean(System.getProperty("cps.resolve_cross_project", "false")); + return Booleans.parseBoolean(System.getProperty("cps.resolve_cross_project", "false")); } public static IndicesOptions lenientIndicesOptionsForCrossProject(IndicesOptions indicesOptions) { diff --git a/server/src/main/resources/transport/definitions/referable/resolve_index_include_resolved_flag.csv b/server/src/main/resources/transport/definitions/referable/resolve_index_include_resolved_flag.csv deleted file mode 100644 index 02acf4c3b415f..0000000000000 --- a/server/src/main/resources/transport/definitions/referable/resolve_index_include_resolved_flag.csv +++ /dev/null @@ -1 +0,0 @@ -9184000 diff --git a/server/src/main/resources/transport/upper_bounds/9.2.csv b/server/src/main/resources/transport/upper_bounds/9.2.csv index 29697baeee92b..f575dcaf4efa8 100644 --- a/server/src/main/resources/transport/upper_bounds/9.2.csv +++ b/server/src/main/resources/transport/upper_bounds/9.2.csv @@ -1 +1 @@ -resolve_index_include_resolved_flag,9184000 +esql_dense_vector_created_version,9183000 diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java index be2a565d4dc8b..891063d3b5fa2 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java @@ -54,8 +54,8 @@ import java.util.concurrent.CopyOnWriteArraySet; import java.util.function.BiPredicate; -import static org.elasticsearch.search.crossproject.CrossProjectSearchErrorHandler.lenientIndicesOptionsForCrossProject; -import static org.elasticsearch.search.crossproject.CrossProjectSearchErrorHandler.resolveCrossProject; +import static org.elasticsearch.search.crossproject.CrossProjectResponseValidator.lenientIndicesOptionsForCrossProject; +import static org.elasticsearch.search.crossproject.CrossProjectResponseValidator.resolveCrossProject; import static org.elasticsearch.xpack.core.security.authz.IndicesAndAliasesResolverField.NO_INDEX_PLACEHOLDER; class IndicesAndAliasesResolver { From 4454e32d7ae9a4ef8ec9bef0ee3c3df63cb9d6c2 Mon Sep 17 00:00:00 2001 From: Nikolaj Volgushev Date: Wed, 1 Oct 2025 16:58:45 +0200 Subject: [PATCH 66/89] Transport version check --- .../indices/resolve/ResolveIndexAction.java | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java b/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java index ae9f9506a8c22..fd7393cb10067 100644 --- a/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java +++ b/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java @@ -169,14 +169,12 @@ public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; Request request = (Request) o; - return Arrays.equals(names, request.names) - && indexModes.equals(request.indexModes) - && includeResolvedExpressions == request.includeResolvedExpressions; + return Arrays.equals(names, request.names) && indexModes.equals(request.indexModes); } @Override public int hashCode() { - return Objects.hash(Arrays.hashCode(names), indexModes, includeResolvedExpressions); + return Objects.hash(Arrays.hashCode(names), indexModes); } @Override @@ -531,7 +529,11 @@ public Response(StreamInput in) throws IOException { this.indices = in.readCollectionAsList(ResolvedIndex::new); this.aliases = in.readCollectionAsList(ResolvedAlias::new); this.dataStreams = in.readCollectionAsList(ResolvedDataStream::new); - this.resolvedIndexExpressions = in.readOptionalWriteable(ResolvedIndexExpressions::new); + if (in.getTransportVersion().supports(ResolvedIndexExpressions.RESOLVED_INDEX_EXPRESSIONS)) { + this.resolvedIndexExpressions = in.readOptionalWriteable(ResolvedIndexExpressions::new); + } else { + this.resolvedIndexExpressions = null; + } } public List getIndices() { @@ -551,7 +553,9 @@ public void writeTo(StreamOutput out) throws IOException { out.writeCollection(indices); out.writeCollection(aliases); out.writeCollection(dataStreams); - out.writeOptionalWriteable(resolvedIndexExpressions); + if (out.getTransportVersion().supports(ResolvedIndexExpressions.RESOLVED_INDEX_EXPRESSIONS)) { + out.writeOptionalWriteable(resolvedIndexExpressions); + } } @Override From ba12b8f33cffaa660cce638e108b137b807e0de6 Mon Sep 17 00:00:00 2001 From: Nikolaj Volgushev Date: Wed, 1 Oct 2025 17:14:55 +0200 Subject: [PATCH 67/89] Transport version check --- .../org/elasticsearch/xpack/security/Security.java | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java index 31a3e8b5c0d1b..4dd28ef0c5c85 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java @@ -1143,7 +1143,6 @@ Collection createComponents( if (authorizationDenialMessages.get() == null) { authorizationDenialMessages.set(new AuthorizationDenialMessages.Default()); } - final AuthorizationService authzService = new AuthorizationService( settings, allRolesStore, @@ -1162,7 +1161,7 @@ Collection createComponents( authorizationDenialMessages.get(), linkedProjectConfigService, projectResolver, - getAuthorizedProjectsSupplier(extensionComponents) + getCustomAuthorizedProjectsSupplierOrDefault(extensionComponents) ); components.add(nativeRolesStore); // used by roles actions @@ -1347,12 +1346,14 @@ private List getCustomAuthenticatorFromExtensions(SecurityE } } - private AuthorizedProjectsSupplier getAuthorizedProjectsSupplier(SecurityExtension.SecurityComponents extensionComponents) { - AuthorizedProjectsSupplier serverlesAuthorizedProjectsSupplier = findValueFromExtensions( - "serverles authorized projects supplier", + private AuthorizedProjectsSupplier getCustomAuthorizedProjectsSupplierOrDefault( + SecurityExtension.SecurityComponents extensionComponents + ) { + final AuthorizedProjectsSupplier customAuthorizedProjectsSupplier = findValueFromExtensions( + "authorized projects supplier", extension -> extension.getAuthorizedProjectsSupplier(extensionComponents) ); - return serverlesAuthorizedProjectsSupplier == null ? new AuthorizedProjectsSupplier.Default() : serverlesAuthorizedProjectsSupplier; + return customAuthorizedProjectsSupplier == null ? new AuthorizedProjectsSupplier.Default() : customAuthorizedProjectsSupplier; } private ServiceAccountService createServiceAccountService( From 1c4ffe6db60d8b33206dcbcf3d9d2b8ba1180e5e Mon Sep 17 00:00:00 2001 From: Nikolaj Volgushev Date: Wed, 1 Oct 2025 20:40:16 +0200 Subject: [PATCH 68/89] Nits --- .../action/admin/indices/resolve/ResolveIndexAction.java | 4 ++-- .../search/crossproject/CrossProjectResponseValidator.java | 2 +- .../xpack/security/authz/IndicesAndAliasesResolver.java | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java b/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java index fd7393cb10067..107481c224f0f 100644 --- a/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java +++ b/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java @@ -73,7 +73,7 @@ import java.util.stream.Stream; import static org.elasticsearch.action.search.TransportSearchHelper.checkCCSVersionCompatibility; -import static org.elasticsearch.search.crossproject.CrossProjectResponseValidator.lenientIndicesOptionsForCrossProject; +import static org.elasticsearch.search.crossproject.CrossProjectResponseValidator.lenientIndicesOptions; import static org.elasticsearch.search.crossproject.CrossProjectResponseValidator.resolveCrossProject; public class ResolveIndexAction extends ActionType { @@ -621,7 +621,7 @@ protected void doExecute(Task task, Request request, final ActionListener remoteClusterIndices = remoteClusterService.groupIndices( - resolveCrossProject ? lenientIndicesOptionsForCrossProject(originalIndicesOptions) : originalIndicesOptions, + resolveCrossProject ? lenientIndicesOptions(originalIndicesOptions) : originalIndicesOptions, request.indices() ); final OriginalIndices localIndices = remoteClusterIndices.remove(RemoteClusterAware.LOCAL_CLUSTER_GROUP_KEY); diff --git a/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectResponseValidator.java b/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectResponseValidator.java index 68faf615b86af..9a23f140364f1 100644 --- a/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectResponseValidator.java +++ b/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectResponseValidator.java @@ -156,7 +156,7 @@ public static boolean resolveCrossProject(IndicesOptions indicesOptions) { return Booleans.parseBoolean(System.getProperty("cps.resolve_cross_project", "false")); } - public static IndicesOptions lenientIndicesOptionsForCrossProject(IndicesOptions indicesOptions) { + public static IndicesOptions lenientIndicesOptions(IndicesOptions indicesOptions) { return IndicesOptions.builder(indicesOptions) .concreteTargetOptions(new IndicesOptions.ConcreteTargetOptions(true)) .wildcardOptions(IndicesOptions.WildcardOptions.builder(indicesOptions.wildcardOptions()).allowEmptyExpressions(true).build()) diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java index 891063d3b5fa2..0f8b268cf145e 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java @@ -54,7 +54,7 @@ import java.util.concurrent.CopyOnWriteArraySet; import java.util.function.BiPredicate; -import static org.elasticsearch.search.crossproject.CrossProjectResponseValidator.lenientIndicesOptionsForCrossProject; +import static org.elasticsearch.search.crossproject.CrossProjectResponseValidator.lenientIndicesOptions; import static org.elasticsearch.search.crossproject.CrossProjectResponseValidator.resolveCrossProject; import static org.elasticsearch.xpack.core.security.authz.IndicesAndAliasesResolverField.NO_INDEX_PLACEHOLDER; @@ -371,7 +371,7 @@ ResolvedIndices resolveIndicesAndAliases( : "none expressions should be handled by local resolution logic"; final ResolvedIndexExpressions resolved = indexAbstractionResolver.resolveIndexAbstractions( Arrays.asList(replaceable.indices()), - lenientIndicesOptionsForCrossProject(indicesOptions), + lenientIndicesOptions(indicesOptions), projectMetadata, authorizedIndices::all, authorizedIndices::check, From 64aeef432b2ee7f97ee0bfbcc311f7270f5d32d4 Mon Sep 17 00:00:00 2001 From: Nikolaj Volgushev Date: Thu, 2 Oct 2025 11:47:17 +0200 Subject: [PATCH 69/89] Clean up --- .../org/elasticsearch/action/IndicesRequest.java | 5 ++++- .../admin/indices/resolve/ResolveIndexAction.java | 4 ++-- .../CrossProjectResponseValidator.java | 6 ++++-- .../security/authz/AuthorizationService.java | 2 +- .../security/authz/IndicesAndAliasesResolver.java | 15 ++++----------- 5 files changed, 15 insertions(+), 17 deletions(-) diff --git a/server/src/main/java/org/elasticsearch/action/IndicesRequest.java b/server/src/main/java/org/elasticsearch/action/IndicesRequest.java index 3d08fcc088d05..9493deb4de5f0 100644 --- a/server/src/main/java/org/elasticsearch/action/IndicesRequest.java +++ b/server/src/main/java/org/elasticsearch/action/IndicesRequest.java @@ -81,7 +81,10 @@ default boolean allowsRemoteIndices() { return false; } - default boolean crossProjectResolvable() { + /** + * Determines whether the request type allows cross-project processing. + */ + default boolean allowsCrossProject() { return false; } } diff --git a/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java b/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java index 107481c224f0f..0914ff1b38feb 100644 --- a/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java +++ b/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java @@ -199,7 +199,7 @@ public boolean allowsRemoteIndices() { } @Override - public boolean crossProjectResolvable() { + public boolean allowsCrossProject() { return true; } @@ -619,7 +619,7 @@ protected void doExecute(Task task, Request request, final ActionListener remoteClusterIndices = remoteClusterService.groupIndices( resolveCrossProject ? lenientIndicesOptions(originalIndicesOptions) : originalIndicesOptions, request.indices() diff --git a/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectResponseValidator.java b/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectResponseValidator.java index 9a23f140364f1..173fe48e6a917 100644 --- a/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectResponseValidator.java +++ b/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectResponseValidator.java @@ -13,6 +13,7 @@ import org.apache.logging.log4j.Logger; import org.elasticsearch.ElasticsearchException; import org.elasticsearch.ElasticsearchSecurityException; +import org.elasticsearch.action.IndicesRequest; import org.elasticsearch.action.ResolvedIndexExpression; import org.elasticsearch.action.ResolvedIndexExpressions; import org.elasticsearch.action.support.IndicesOptions; @@ -151,9 +152,10 @@ private static void tempCrossProjectFanoutErrorHandling( } } - public static boolean resolveCrossProject(IndicesOptions indicesOptions) { + public static boolean resolveCrossProject(IndicesRequest.Replaceable request) { // TODO this needs to be based on the IndicesOptions flag instead, once available - return Booleans.parseBoolean(System.getProperty("cps.resolve_cross_project", "false")); + final boolean indicesOptionsResolveCrossProject = Booleans.parseBoolean(System.getProperty("cps.resolve_cross_project", "false")); + return request.allowsCrossProject() && indicesOptionsResolveCrossProject; } public static IndicesOptions lenientIndicesOptions(IndicesOptions indicesOptions) { 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 8e6209e09a3d2..f626b28fa8553 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 @@ -515,7 +515,7 @@ private void authorizeAction( authzInfo, projectMetadata.getIndicesLookup(), ActionListener.wrap(authorizedIndices -> { - if (request instanceof IndicesRequest.Replaceable replaceable && replaceable.crossProjectResolvable()) { + if (request instanceof IndicesRequest.Replaceable replaceable && replaceable.allowsCrossProject()) { authorizedProjectsSupplier.getAuthorizedProjects( ActionListener.wrap( authorizedProjects -> resolvedIndicesListener.onResponse( diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java index 0f8b268cf145e..5c1398f20bf74 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java @@ -364,11 +364,12 @@ ResolvedIndices resolveIndicesAndAliases( // if we cannot replace wildcards the indices list stays empty. Same if there are no authorized indices. // we honour allow_no_indices like es core does. } else { - if (shouldResolveCrossProject(replaceable, authorizedProjects)) { + assert indicesRequest.indices() != null : "indices() cannot be null when resolving non-all-index expressions"; + if (resolveCrossProject(replaceable) + // a none expression should not go through cross-project resolution -- fall back to local resolution logic + && false == IndexNameExpressionResolver.isNoneExpression(replaceable.indices())) { assert replaceable.allowsRemoteIndices() : "cross-project requests must allow remote indices"; assert recordResolvedIndexExpressions : "cross-project requests must record resolved index expressions"; - assert false == IndexNameExpressionResolver.isNoneExpression(replaceable.indices()) - : "none expressions should be handled by local resolution logic"; final ResolvedIndexExpressions resolved = indexAbstractionResolver.resolveIndexAbstractions( Arrays.asList(replaceable.indices()), lenientIndicesOptions(indicesOptions), @@ -472,14 +473,6 @@ ResolvedIndices resolveIndicesAndAliases( return resolvedIndicesBuilder.build(); } - private static boolean shouldResolveCrossProject(IndicesRequest.Replaceable replaceable, TargetProjects authorizedProjects) { - return replaceable.crossProjectResolvable() - && authorizedProjects != TargetProjects.NOT_CROSS_PROJECT - && resolveCrossProject(replaceable.indicesOptions()) - // a none expression should not go through the cross-project resolution -- fall back to local resolution logic - && false == IndexNameExpressionResolver.isNoneExpression(replaceable.indices()); - } - private static void setResolvedIfNull(String action, IndicesRequest.Replaceable replaceable, ResolvedIndexExpressions resolved) { if (replaceable.getResolvedIndexExpressions() == null) { replaceable.setResolvedIndexExpressions(resolved); From ee80cb7ed335638cea5375a461d51077adc87aaf Mon Sep 17 00:00:00 2001 From: Nikolaj Volgushev Date: Thu, 2 Oct 2025 13:47:35 +0200 Subject: [PATCH 70/89] Feedback and validator --- .../action/ResolvedIndexExpression.java | 4 +- .../action/ResolvedIndexExpressions.java | 2 +- .../indices/resolve/ResolveIndexAction.java | 23 +- .../metadata/IndexAbstractionResolver.java | 26 +- .../CrossProjectResponseValidator.java | 278 -------- .../LinkedProjectExpressions.java | 47 -- .../crossproject/ResponseValidator.java | 304 +++++++++ .../crossproject/ResponseValidatorTests.java | 631 ++++++++++++++++++ .../security/authz/AuthorizationService.java | 108 ++- .../authz/IndicesAndAliasesResolver.java | 6 +- 10 files changed, 1013 insertions(+), 416 deletions(-) delete mode 100644 server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectResponseValidator.java delete mode 100644 server/src/main/java/org/elasticsearch/search/crossproject/LinkedProjectExpressions.java create mode 100644 server/src/main/java/org/elasticsearch/search/crossproject/ResponseValidator.java create mode 100644 server/src/test/java/org/elasticsearch/search/crossproject/ResponseValidatorTests.java diff --git a/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpression.java b/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpression.java index f285c7606ed22..ee18470237bc3 100644 --- a/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpression.java +++ b/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpression.java @@ -49,7 +49,7 @@ public record ResolvedIndexExpression(String original, LocalExpressions localExp Writeable { public ResolvedIndexExpression(StreamInput in) throws IOException { - this(in.readString(), new LocalExpressions(in), in.readCollectionAsSet(StreamInput::readString)); + this(in.readString(), new LocalExpressions(in), in.readCollectionAsImmutableSet(StreamInput::readString)); } @Override @@ -91,7 +91,7 @@ public record LocalExpressions( public LocalExpressions(StreamInput in) throws IOException { this( - in.readCollectionAsSet(StreamInput::readString), + in.readCollectionAsImmutableSet(StreamInput::readString), in.readEnum(LocalIndexResolutionResult.class), ElasticsearchException.readException(in) ); diff --git a/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpressions.java b/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpressions.java index 45ceebf04d5d2..774d62b672bdf 100644 --- a/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpressions.java +++ b/server/src/main/java/org/elasticsearch/action/ResolvedIndexExpressions.java @@ -29,7 +29,7 @@ public record ResolvedIndexExpressions(List expressions public static final TransportVersion RESOLVED_INDEX_EXPRESSIONS = TransportVersion.fromName("resolved_index_expressions"); public ResolvedIndexExpressions(StreamInput in) throws IOException { - this(in.readCollectionAsList(ResolvedIndexExpression::new)); + this(in.readCollectionAsImmutableList(ResolvedIndexExpression::new)); } public List getLocalIndicesList() { diff --git a/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java b/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java index 0914ff1b38feb..0a15c26d24f59 100644 --- a/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java +++ b/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java @@ -46,7 +46,7 @@ import org.elasticsearch.index.IndexMode; import org.elasticsearch.injection.guice.Inject; import org.elasticsearch.search.SearchService; -import org.elasticsearch.search.crossproject.CrossProjectResponseValidator; +import org.elasticsearch.search.crossproject.ResponseValidator; import org.elasticsearch.tasks.Task; import org.elasticsearch.transport.RemoteClusterAware; import org.elasticsearch.transport.RemoteClusterService; @@ -73,8 +73,8 @@ import java.util.stream.Stream; import static org.elasticsearch.action.search.TransportSearchHelper.checkCCSVersionCompatibility; -import static org.elasticsearch.search.crossproject.CrossProjectResponseValidator.lenientIndicesOptions; -import static org.elasticsearch.search.crossproject.CrossProjectResponseValidator.resolveCrossProject; +import static org.elasticsearch.search.crossproject.ResponseValidator.lenientIndicesOptions; +import static org.elasticsearch.search.crossproject.ResponseValidator.shouldResolveCrossProject; public class ResolveIndexAction extends ActionType { @@ -619,7 +619,7 @@ protected void doExecute(Task task, Request request, final ActionListener remoteClusterIndices = remoteClusterService.groupIndices( resolveCrossProject ? lenientIndicesOptions(originalIndicesOptions) : originalIndicesOptions, request.indices() @@ -630,7 +630,7 @@ protected void doExecute(Task task, Request request, final ActionListener dataStreams = new ArrayList<>(); resolveIndices(localIndices, projectState, indexNameExpressionResolver, indices, aliases, dataStreams, request.indexModes); - final ResolvedIndexExpressions primaryResolvedIndexExpressions = request.getResolvedIndexExpressions(); + final ResolvedIndexExpressions localResolvedIndexExpressions = request.getResolvedIndexExpressions(); if (remoteClusterIndices.size() > 0) { final int remoteRequests = remoteClusterIndices.size(); final CountDown completionCounter = new CountDown(remoteRequests); @@ -648,9 +648,9 @@ protected void doExecute(Task task, Request request, final ActionListener { @@ -690,18 +691,14 @@ protected void doExecute(Task task, Request request, final ActionListener:index` get deferred validation, also - final Exception ex = CrossProjectResponseValidator.validate( - originalIndicesOptions, - primaryResolvedIndexExpressions, - Map.of() - ); + final Exception ex = ResponseValidator.validate(originalIndicesOptions, localResolvedIndexExpressions, Map.of()); if (ex != null) { listener.onFailure(ex); return; } } listener.onResponse( - new Response(indices, aliases, dataStreams, request.includeResolvedExpressions ? primaryResolvedIndexExpressions : null) + new Response(indices, aliases, dataStreams, request.includeResolvedExpressions ? localResolvedIndexExpressions : null) ); } } diff --git a/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java b/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java index c5b4e6c9ad7a9..2afd6eecd3d22 100644 --- a/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java +++ b/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java @@ -41,12 +41,12 @@ public IndexAbstractionResolver(IndexNameExpressionResolver indexNameExpressionR } public ResolvedIndexExpressions resolveIndexAbstractions( - List indices, - IndicesOptions indicesOptions, - ProjectMetadata projectMetadata, - Function> allAuthorizedAndAvailableBySelector, - BiPredicate isAuthorized, - boolean includeDataStreams + final List indices, + final IndicesOptions indicesOptions, + final ProjectMetadata projectMetadata, + final Function> allAuthorizedAndAvailableBySelector, + final BiPredicate isAuthorized, + final boolean includeDataStreams ) { final ResolvedIndexExpressions.Builder resolvedExpressionsBuilder = ResolvedIndexExpressions.builder(); boolean wildcardSeen = false; @@ -68,13 +68,13 @@ public ResolvedIndexExpressions resolveIndexAbstractions( } public ResolvedIndexExpressions resolveIndexAbstractions( - List indices, - IndicesOptions indicesOptions, - ProjectMetadata projectMetadata, - Function> allAuthorizedAndAvailableBySelector, - BiPredicate isAuthorized, - TargetProjects targetProjects, - boolean includeDataStreams + final List indices, + final IndicesOptions indicesOptions, + final ProjectMetadata projectMetadata, + final Function> allAuthorizedAndAvailableBySelector, + final BiPredicate isAuthorized, + final TargetProjects targetProjects, + final boolean includeDataStreams ) { assert targetProjects != TargetProjects.NOT_CROSS_PROJECT : "cannot resolve indices cross project if target set is NOT_CROSS_PROJECT"; diff --git a/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectResponseValidator.java b/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectResponseValidator.java deleted file mode 100644 index 173fe48e6a917..0000000000000 --- a/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectResponseValidator.java +++ /dev/null @@ -1,278 +0,0 @@ -/* - * 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.search.crossproject; - -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; -import org.elasticsearch.ElasticsearchException; -import org.elasticsearch.ElasticsearchSecurityException; -import org.elasticsearch.action.IndicesRequest; -import org.elasticsearch.action.ResolvedIndexExpression; -import org.elasticsearch.action.ResolvedIndexExpressions; -import org.elasticsearch.action.support.IndicesOptions; -import org.elasticsearch.core.Booleans; -import org.elasticsearch.index.IndexNotFoundException; -import org.elasticsearch.rest.RestStatus; -import org.elasticsearch.transport.RemoteClusterAware; - -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; - -import static org.elasticsearch.action.ResolvedIndexExpression.LocalIndexResolutionResult.CONCRETE_RESOURCE_NOT_VISIBLE; -import static org.elasticsearch.action.ResolvedIndexExpression.LocalIndexResolutionResult.CONCRETE_RESOURCE_UNAUTHORIZED; -import static org.elasticsearch.action.ResolvedIndexExpression.LocalIndexResolutionResult.NONE; -import static org.elasticsearch.action.ResolvedIndexExpression.LocalIndexResolutionResult.SUCCESS; - -/** - * Utility class for handling errors in cross-project index operations. - *

- * This class provides consistent error handling for scenarios where index resolution - * spans multiple projects, taking into account the provided {@link IndicesOptions}. - * It handles: - *

    - *
  • Validation of index existence in the merged project view based on IndicesOptions (ignoreUnavailable, - * allowNoIndices)
  • - *
  • Authorization issues during cross-project index resolution
  • - *
  • Both flat (unqualified) and qualified index expressions
  • - *
  • Wildcard index patterns that may resolve differently across projects
  • - *
- *

- * The utility examines both local and remote resolution results to determine the appropriate - * error response, throwing {@link IndexNotFoundException} for missing indices or - * {@link ElasticsearchSecurityException} for authorization failures. - */ -public class CrossProjectResponseValidator { - private static final Logger logger = LogManager.getLogger(CrossProjectResponseValidator.class); - private static final String WILDCARD = "*"; - - public static void crossProjectFanoutErrorHandling( - IndicesOptions indicesOptions, - ResolvedIndexExpressions primaryResolvedIndexExpressions, - Map resolvedIndexExpressionsByRemote - ) { - Map remoteLinkedProjectExpressions = resolvedIndexExpressionsByRemote.entrySet() - .stream() - .collect(Collectors.toMap(Map.Entry::getKey, e -> LinkedProjectExpressions.fromResolvedExpressions(e.getValue()))); - tempCrossProjectFanoutErrorHandling(indicesOptions, primaryResolvedIndexExpressions, remoteLinkedProjectExpressions); - } - - public static Exception validate( - IndicesOptions indicesOptions, - ResolvedIndexExpressions primaryResolvedIndexExpressions, - Map resolvedIndexExpressionsByRemote - ) { - try { - CrossProjectResponseValidator.crossProjectFanoutErrorHandling( - indicesOptions, - primaryResolvedIndexExpressions, - resolvedIndexExpressionsByRemote - ); - return null; - } catch (Exception ex) { - return ex; - } - } - - /** - * Validates the results of cross-project index resolution and throws appropriate exceptions based on the provided - * {@link IndicesOptions}. - *

- * This method handles error scenarios when resolving indices across multiple projects: - *

    - *
  • If both {@code ignoreUnavailable} and {@code allowNoIndices} are true, the method returns without validation - * (lenient mode)
  • - *
  • For wildcard patterns that resolve to no indices, validates against {@code allowNoIndices}
  • - *
  • For concrete indices that don't exist, validates against {@code ignoreUnavailable}
  • - *
  • For indices with authorization issues, throws security exceptions
  • - *
- *

- * The method considers both flat (unqualified) and qualified index expressions, as well as - * local and linked project resolution results when determining whether to throw exceptions. - * - * @param indicesOptions Controls error behavior for missing indices - * @param localResolvedExpressions Resolution results from the origin project - * @param remoteResolvedExpressions Resolution results from linked projects - * @throws IndexNotFoundException If indices are missing and the {@code IndicesOptions} do not allow it - * @throws ElasticsearchSecurityException If authorization errors occurred during index resolution - */ - private static void tempCrossProjectFanoutErrorHandling( - IndicesOptions indicesOptions, - ResolvedIndexExpressions localResolvedExpressions, - Map remoteResolvedExpressions - ) { - logger.info( - "Checking cross-project index resolution results for [{}] and [{}]", - localResolvedExpressions, - remoteResolvedExpressions - ); - - if (indicesOptions.allowNoIndices() && indicesOptions.ignoreUnavailable()) { - logger.debug("Skipping index existence check in lenient mode"); - return; - } - - for (ResolvedIndexExpression localResolvedIndices : localResolvedExpressions.expressions()) { - String originalExpression = localResolvedIndices.original(); - - logger.debug("Checking replaced expression for original expression [{}]", originalExpression); - - String resource = originalExpression; - boolean isQualifiedResource = RemoteClusterAware.isRemoteIndexName(resource); - if (isQualifiedResource) { - // handle qualified resource eg. P1:logs* - String[] splitResource = RemoteClusterAware.splitIndexName(resource); - assert splitResource.length == 2 - : "Expected two strings (project and indexExpression) for a qualified resource [" - + resource - + "], but found [" - + splitResource.length - + "]"; - resource = splitResource[1]; - } - if (false == indicesOptions.allowNoIndices()) { - checkAllowNoIndices( - resource, - originalExpression, - localResolvedIndices, - remoteResolvedExpressions, - isQualifiedResource == false - ); - } else if (false == indicesOptions.ignoreUnavailable()) { - checkIndicesOptions(originalExpression, localResolvedIndices, remoteResolvedExpressions, isQualifiedResource == false); - } - } - } - - public static boolean resolveCrossProject(IndicesRequest.Replaceable request) { - // TODO this needs to be based on the IndicesOptions flag instead, once available - final boolean indicesOptionsResolveCrossProject = Booleans.parseBoolean(System.getProperty("cps.resolve_cross_project", "false")); - return request.allowsCrossProject() && indicesOptionsResolveCrossProject; - } - - public static IndicesOptions lenientIndicesOptions(IndicesOptions indicesOptions) { - return IndicesOptions.builder(indicesOptions) - .concreteTargetOptions(new IndicesOptions.ConcreteTargetOptions(true)) - .wildcardOptions(IndicesOptions.WildcardOptions.builder(indicesOptions.wildcardOptions()).allowEmptyExpressions(true).build()) - .build(); - } - - private static void checkAllowNoIndices( - String indexAlias, - String originalExpression, - ResolvedIndexExpression localResolvedIndices, - Map remoteResolvedExpressions, - boolean isFlatWorldResource - ) { - // strict behaviour of allowNoIndices checks if a wildcard expression resolves to no concrete indices. - if (false == indexAlias.contains(WILDCARD)) { - return; - } - checkIndicesOptions(originalExpression, localResolvedIndices, remoteResolvedExpressions, isFlatWorldResource); - } - - private static void checkIndicesOptions( - String originalExpression, - ResolvedIndexExpression localResolvedIndices, - Map remoteResolvedExpressions, - boolean isFlatWorldResource - ) { - ResolvedIndexExpression.LocalExpressions localExpressions = localResolvedIndices.localExpressions(); - boolean resourceFound = false == localExpressions.expressions().isEmpty() - && (localExpressions.localIndexResolutionResult() == SUCCESS || localExpressions.localIndexResolutionResult() == NONE); - - if (resourceFound && (isFlatWorldResource || localExpressions.expressions().size() == 1)) { - logger.info( - "Local cluster has canonical expression for original expression [{}], skipping remote existence check", - originalExpression - ); - return; - } - List exceptions = new ArrayList<>(); - ElasticsearchException localException = localExpressions.exception(); - if (localException != null) { - exceptions.add(localException); - } - - if (localResolvedIndices.remoteExpressions().isEmpty()) { - if (localExpressions.localIndexResolutionResult() == CONCRETE_RESOURCE_NOT_VISIBLE) { - throw new IndexNotFoundException(originalExpression); - } - if (localExpressions.localIndexResolutionResult() == CONCRETE_RESOURCE_UNAUTHORIZED) { - // 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 [" + originalExpression + "]", - RestStatus.FORBIDDEN - ); - exceptions.forEach(e::addSuppressed); - throw e; - } - } - - for (String remoteExpression : localResolvedIndices.remoteExpressions()) { - boolean isQualifiedResource = RemoteClusterAware.isRemoteIndexName(remoteExpression); - if (isQualifiedResource) { - // handle qualified resource eg. P1:logs* - String[] splitResource = RemoteClusterAware.splitIndexName(remoteExpression); - assert splitResource.length == 2 - : "Expected two strings (project and indexExpression) for a qualified resource [" - + remoteExpression - + "], but found [" - + splitResource.length - + "]"; - String projectAlias = splitResource[0]; - String resource = splitResource[1]; - LinkedProjectExpressions linkedProjectExpressions = remoteResolvedExpressions.get(projectAlias); - assert linkedProjectExpressions != null : "we should always have linked expressions from remote"; - - ResolvedIndexExpression.LocalExpressions resolvedRemoteExpression = linkedProjectExpressions.resolvedExpressions() - .get(resource); - assert resolvedRemoteExpression != null : "we should always have resolved expressions from remote"; - - // TODO in wildcard case? - if (resolvedRemoteExpression.expressions().isEmpty()) { - throw new IndexNotFoundException(originalExpression); - } - if (resolvedRemoteExpression.localIndexResolutionResult() == CONCRETE_RESOURCE_NOT_VISIBLE) { - throw new IndexNotFoundException(originalExpression); - } - if (resolvedRemoteExpression.localIndexResolutionResult() == CONCRETE_RESOURCE_UNAUTHORIZED) { - // 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 [" + remoteExpression + "]", - RestStatus.FORBIDDEN - ); - exceptions.forEach(e::addSuppressed); - throw e; - } - } else { - boolean foundFlat = false; - for (var linkedProjectExpressions : remoteResolvedExpressions.values()) { - ResolvedIndexExpression.LocalExpressions resolvedRemoteExpression = linkedProjectExpressions.resolvedExpressions() - .get(remoteExpression); - if (resolvedRemoteExpression != null - && resolvedRemoteExpression.expressions().isEmpty() == false - && resolvedRemoteExpression.localIndexResolutionResult() == SUCCESS) { - foundFlat = true; - break; - } - } - if (false == foundFlat) { - throw new IndexNotFoundException(originalExpression); - } - } - } - } -} diff --git a/server/src/main/java/org/elasticsearch/search/crossproject/LinkedProjectExpressions.java b/server/src/main/java/org/elasticsearch/search/crossproject/LinkedProjectExpressions.java deleted file mode 100644 index ac01f33db6c69..0000000000000 --- a/server/src/main/java/org/elasticsearch/search/crossproject/LinkedProjectExpressions.java +++ /dev/null @@ -1,47 +0,0 @@ -/* - * 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.search.crossproject; - -import org.elasticsearch.action.ResolvedIndexExpression; -import org.elasticsearch.action.ResolvedIndexExpressions; - -import java.util.HashMap; -import java.util.Map; - -/** - * This class allows capturing context about index expression replacements performed on a linked project. - *

- * The replacements are keyed by the original index expression and have as value {@link ResolvedIndexExpression.LocalExpressions} that - * contains the set of expression (if any) was found on the remote, the result of the resolution and possibly the exception thrown. - * - *

An example structure is:

- * - *
{@code
- * {
- *   "P*:my-index-*": {
- *     "expressions": ["my-index-000001", "my-index-000002"],
- *     "localIndexResolutionResult": "SUCCESS"
- *   }
- * }
- * }
- * - * @param resolvedExpressions a map keyed by the original expression and having as value the remote resolution for that expression. - */ -public record LinkedProjectExpressions(Map resolvedExpressions) { - public static LinkedProjectExpressions fromResolvedExpressions(ResolvedIndexExpressions resolvedExpressions) { - Map map = new HashMap<>(); - for (ResolvedIndexExpression e : resolvedExpressions.expressions()) { - if (map.put(e.original(), e.localExpressions()) != null) { - throw new IllegalStateException("duplicate key"); - } - } - return new LinkedProjectExpressions(map); - } -} diff --git a/server/src/main/java/org/elasticsearch/search/crossproject/ResponseValidator.java b/server/src/main/java/org/elasticsearch/search/crossproject/ResponseValidator.java new file mode 100644 index 0000000000000..345f91f7af029 --- /dev/null +++ b/server/src/main/java/org/elasticsearch/search/crossproject/ResponseValidator.java @@ -0,0 +1,304 @@ +/* + * 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.search.crossproject; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.elasticsearch.ElasticsearchException; +import org.elasticsearch.ElasticsearchSecurityException; +import org.elasticsearch.action.IndicesRequest; +import org.elasticsearch.action.ResolvedIndexExpression; +import org.elasticsearch.action.ResolvedIndexExpressions; +import org.elasticsearch.action.support.IndicesOptions; +import org.elasticsearch.core.Booleans; +import org.elasticsearch.index.IndexNotFoundException; +import org.elasticsearch.rest.RestStatus; +import org.elasticsearch.transport.RemoteClusterAware; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static org.elasticsearch.action.ResolvedIndexExpression.LocalIndexResolutionResult.CONCRETE_RESOURCE_NOT_VISIBLE; +import static org.elasticsearch.action.ResolvedIndexExpression.LocalIndexResolutionResult.CONCRETE_RESOURCE_UNAUTHORIZED; +import static org.elasticsearch.action.ResolvedIndexExpression.LocalIndexResolutionResult.SUCCESS; + +/** + * Utility class for validating index resolution results in cross-project operations. + *

+ * This class provides consistent error handling for scenarios where index resolution + * spans multiple projects, taking into account the provided {@link IndicesOptions}. + * It handles: + *

    + *
  • Validation of index existence in both origin and linked projects based on IndicesOptions + * (ignoreUnavailable, allowNoIndices)
  • + *
  • Authorization issues during cross-project index resolution, returning appropriate + * {@link ElasticsearchSecurityException} responses
  • + *
  • Both flat (unqualified) and qualified index expressions (including "_origin:" prefixed indices)
  • + *
  • Wildcard index patterns that may resolve differently across projects
  • + *
+ *

+ * The validator examines both local and remote resolution results to determine the appropriate + * error response, returning {@link IndexNotFoundException} for missing indices or + * {@link ElasticsearchSecurityException} for authorization failures. + */ +public class ResponseValidator { + private static final Logger logger = LogManager.getLogger(ResponseValidator.class); + private static final String WILDCARD = "*"; + private static final String ORIGIN = "_origin"; // TODO use available constants + + /** + * Validates the results of cross-project index resolution and returns appropriate exceptions based on the provided + * {@link IndicesOptions}. + *

+ * This method handles error scenarios when resolving indices across multiple projects: + *

    + *
  • If both {@code ignoreUnavailable} and {@code allowNoIndices} are true, the method returns null without validation + * (lenient mode)
  • + *
  • For wildcard patterns that resolve to no indices, validates against {@code allowNoIndices}
  • + *
  • For concrete indices that don't exist, validates against {@code ignoreUnavailable}
  • + *
  • For indices with authorization issues, returns security exceptions
  • + *
+ *

+ * The method considers both flat (unqualified) and qualified index expressions, as well as + * local and linked project resolution results when determining the appropriate error response. + * + * @param indicesOptions Controls error behavior for missing indices + * @param localResolvedExpressions Resolution results from the origin project + * @param remoteResolvedExpressions Resolution results from linked projects + * @return a {@link ElasticsearchException} if validation fails, null if validation passes + */ + public static ElasticsearchException validate( + IndicesOptions indicesOptions, + ResolvedIndexExpressions localResolvedExpressions, + Map remoteResolvedExpressions + ) { + if (indicesOptions.allowNoIndices() && indicesOptions.ignoreUnavailable()) { + logger.debug("Skipping index existence check in lenient mode"); + return null; + } + + logger.debug("Running index existence check for [{}] and {}", localResolvedExpressions, remoteResolvedExpressions); + for (ResolvedIndexExpression localResolvedIndices : localResolvedExpressions.expressions()) { + String originalExpression = localResolvedIndices.original(); + logger.debug("Checking replaced expression for original expression [{}]", originalExpression); + + // Check if this is a qualified resource (project:index pattern) + boolean isQualifiedResource = RemoteClusterAware.isRemoteIndexName(originalExpression); + + // Handle qualified resources specially + if (isQualifiedResource) { + String[] splitResource = splitQualifiedResource(originalExpression); + String projectAlias = splitResource[0]; + + // Special handling for _origin prefixed resources + if (projectAlias.equals(ORIGIN)) { + ElasticsearchException error = validateOriginResource( + localResolvedIndices, + localResolvedExpressions, + originalExpression + ); + if (error != null) { + return error; + } + continue; + } + } + + // Perform validation based on indices options + ElasticsearchException error = validateResourceWithOptions( + originalExpression, + localResolvedIndices, + remoteResolvedExpressions, + false == isQualifiedResource + ); + + if (error != null) { + return error; + } + } + + return null; + } + + // TODO probably does not belong here + public static boolean shouldResolveCrossProject(IndicesRequest.Replaceable request) { + // TODO this needs to be based on the IndicesOptions flag instead, once available + final boolean indicesOptionsResolveCrossProject = Booleans.parseBoolean(System.getProperty("cps.resolve_cross_project", "false")); + return request.allowsCrossProject() && indicesOptionsResolveCrossProject; + } + + public static IndicesOptions lenientIndicesOptions(IndicesOptions indicesOptions) { + return IndicesOptions.builder(indicesOptions) + .concreteTargetOptions(new IndicesOptions.ConcreteTargetOptions(true)) + .wildcardOptions(IndicesOptions.WildcardOptions.builder(indicesOptions.wildcardOptions()).allowEmptyExpressions(true).build()) + .build(); + } + + private static ElasticsearchException validateOriginResource( + ResolvedIndexExpression localResolvedIndices, + ResolvedIndexExpressions localResolvedExpressions, + String originalExpression + ) { + ResolvedIndexExpression.LocalExpressions localExpressions = localResolvedIndices.localExpressions(); + boolean resourceFoundLocally = isResourceFoundLocally(localExpressions); + + if (resourceFoundLocally) { + logger.debug("Local cluster has canonical expression for original expression [{}], skipping extra checks", originalExpression); + return null; + } + + logger.debug("Handling origin qualified expression [{}] with [{}]", originalExpression, localResolvedIndices); + // Treat local resolution as a remote with alias "_origin" to avoid duplicating code + var localAsRemote = Map.of(ORIGIN, localResolvedExpressions); + return checkSingleRemoteExpression( + localAsRemote, + false, + ORIGIN, + localResolvedIndices.original().substring(ORIGIN.length() + 1), // Remove "_origin:" prefix + originalExpression, + new ArrayList<>() + ); + } + + private static ElasticsearchException validateResourceWithOptions( + String originalExpression, + ResolvedIndexExpression localResolvedIndices, + Map remoteResolvedExpressions, + boolean isFlatWorldResource + ) { + ResolvedIndexExpression.LocalExpressions localExpressions = localResolvedIndices.localExpressions(); + + // Early return if we have local resource that satisfies conditions + if (isResourceFoundLocally(localExpressions) && (isFlatWorldResource || localExpressions.expressions().size() == 1)) { + logger.debug("Local cluster has canonical expression for [{}], skipping remote check", originalExpression); + return null; + } + + // Track exceptions + List exceptions = new ArrayList<>(); + if (localExpressions.exception() != null) { + exceptions.add(localExpressions.exception()); + } + + // Process remote expressions + for (String remoteExpression : localResolvedIndices.remoteExpressions()) { + // remoteExpressions are always qualified + assert RemoteClusterAware.isRemoteIndexName(remoteExpression); + + String[] splitResource = splitQualifiedResource(remoteExpression); + ElasticsearchException error = checkSingleRemoteExpression( + remoteResolvedExpressions, + isFlatWorldResource, + splitResource[0], // projectAlias + splitResource[1], // resource + remoteExpression, + exceptions + ); + + // Return immediately on error for qualified resources + if (error != null) { + return error; + } + } + + // Return first exception if any were collected (for flat world resources) + return exceptions.isEmpty() ? null : exceptions.get(0); + } + + private static ElasticsearchException checkSingleRemoteExpression( + Map remoteResolvedExpressions, + boolean isFlatWorldResource, + String projectAlias, + String resource, + String remoteExpression, + List exceptions + ) { + // Get resolution results from the linked project + ResolvedIndexExpressions resolvedExpressionsInProject = remoteResolvedExpressions.get(projectAlias); + assert resolvedExpressionsInProject != null : "We should always have resolved expressions from linked project"; + + // Find the matching expression in the linked project + ResolvedIndexExpression.LocalExpressions matchingExpression = findMatchingExpression(resolvedExpressionsInProject, resource); + if (matchingExpression == null) { + return createNotFoundException(remoteExpression, exceptions); + } + + // Check resolution result + if (matchingExpression.localIndexResolutionResult() == SUCCESS) { + // Successfully found if there are concrete expressions + return matchingExpression.expressions().isEmpty() ? createNotFoundException(remoteExpression, exceptions) : null; + } + + // Handle authorization and visibility failures + return handleResolutionFailure(matchingExpression.localIndexResolutionResult(), remoteExpression, isFlatWorldResource, exceptions); + } + + private static String[] splitQualifiedResource(String resource) { + String[] splitResource = RemoteClusterAware.splitIndexName(resource); + assert splitResource.length == 2 + : "Expected two strings (project and indexExpression) for a qualified resource [" + + resource + + "], but found [" + + splitResource.length + + "]"; + return splitResource; + } + + private static boolean isResourceFoundLocally(ResolvedIndexExpression.LocalExpressions localExpressions) { + return false == localExpressions.expressions().isEmpty() && localExpressions.localIndexResolutionResult() == SUCCESS; + } + + private static ResolvedIndexExpression.LocalExpressions findMatchingExpression( + ResolvedIndexExpressions projectExpressions, + String resource + ) { + return projectExpressions.expressions() + .stream() + .filter(expr -> expr.original().equals(resource)) + .map(ResolvedIndexExpression::localExpressions) + .findFirst() + .orElse(null); + } + + private static ElasticsearchException createNotFoundException(String expression, List exceptions) { + ElasticsearchException exception = new IndexNotFoundException(expression); + exceptions.forEach(exception::addSuppressed); + return exception; + } + + private static ElasticsearchException handleResolutionFailure( + ResolvedIndexExpression.LocalIndexResolutionResult result, + String expression, + boolean isFlatWorldResource, + List exceptions + ) { + ElasticsearchException exception; + + if (result == CONCRETE_RESOURCE_NOT_VISIBLE) { + exception = new IndexNotFoundException(expression); + } else if (result == CONCRETE_RESOURCE_UNAUTHORIZED) { + exception = new ElasticsearchSecurityException( + "authorization errors while resolving [" + expression + "]", + RestStatus.FORBIDDEN + ); + } else { + return null; + } + + if (isFlatWorldResource) { + exceptions.add(exception); + return null; + } else { + exceptions.forEach(exception::addSuppressed); + return exception; + } + } +} diff --git a/server/src/test/java/org/elasticsearch/search/crossproject/ResponseValidatorTests.java b/server/src/test/java/org/elasticsearch/search/crossproject/ResponseValidatorTests.java new file mode 100644 index 0000000000000..21a3eebc098b5 --- /dev/null +++ b/server/src/test/java/org/elasticsearch/search/crossproject/ResponseValidatorTests.java @@ -0,0 +1,631 @@ +/* + * 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.search.crossproject; + +import org.elasticsearch.ElasticsearchException; +import org.elasticsearch.ElasticsearchSecurityException; +import org.elasticsearch.action.ResolvedIndexExpression; +import org.elasticsearch.action.ResolvedIndexExpressions; +import org.elasticsearch.action.support.IndicesOptions; +import org.elasticsearch.index.IndexNotFoundException; +import org.elasticsearch.test.ESTestCase; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.instanceOf; + +public class ResponseValidatorTests extends ESTestCase { + + public void testLenientIndicesOptions() { + // with lenient IndicesOptions we early terminate without error + assertNull(ResponseValidator.validate(getLenientIndicesOptions(), null, null)); + } + + public void testFlatExpressionWithStrictIgnoreUnavailableMatchingInOriginProject() { + ResolvedIndexExpressions local = new ResolvedIndexExpressions( + List.of( + new ResolvedIndexExpression( + "logs", + new ResolvedIndexExpression.LocalExpressions( + Set.of("logs"), + ResolvedIndexExpression.LocalIndexResolutionResult.SUCCESS, + null + ), + Set.of("P1:logs") + ) + ) + ); + + // we matched resource locally thus no error + assertNull(ResponseValidator.validate(getStrictIgnoreUnavailable(), local, null)); + } + + public void testFlatExpressionWithStrictIgnoreUnavailableMatchingInLinkedProject() { + ResolvedIndexExpressions local = new ResolvedIndexExpressions( + List.of( + new ResolvedIndexExpression( + "logs", + new ResolvedIndexExpression.LocalExpressions( + Set.of(), + ResolvedIndexExpression.LocalIndexResolutionResult.CONCRETE_RESOURCE_NOT_VISIBLE, + null + ), + Set.of("P1:logs") + ) + ) + ); + + var remote = Map.of( + "P1", + new ResolvedIndexExpressions( + List.of( + new ResolvedIndexExpression( + "logs", + new ResolvedIndexExpression.LocalExpressions( + Set.of("logs"), + ResolvedIndexExpression.LocalIndexResolutionResult.SUCCESS, + null + ), + Set.of() + ) + ) + ) + ); + + // we matched the flat resource in a linked project thus no error + assertNull(ResponseValidator.validate(getStrictIgnoreUnavailable(), local, remote)); + } + + public void testMissingFlatExpressionWithStrictIgnoreUnavailable() { + ResolvedIndexExpressions local = new ResolvedIndexExpressions( + List.of( + new ResolvedIndexExpression( + "logs", + new ResolvedIndexExpression.LocalExpressions( + Set.of(), + ResolvedIndexExpression.LocalIndexResolutionResult.CONCRETE_RESOURCE_NOT_VISIBLE, + null + ), + Set.of("P1:logs") + ) + ) + ); + + var remote = Map.of( + "P1", + new ResolvedIndexExpressions( + List.of( + new ResolvedIndexExpression( + "logs", + new ResolvedIndexExpression.LocalExpressions( + Set.of(), + ResolvedIndexExpression.LocalIndexResolutionResult.CONCRETE_RESOURCE_NOT_VISIBLE, + null + ), + Set.of() + ) + ) + ) + ); + var e = ResponseValidator.validate(getStrictIgnoreUnavailable(), local, remote); + assertNotNull(e); + assertThat(e, instanceOf(IndexNotFoundException.class)); + assertThat(e.getMessage(), containsString("no such index [P1:logs]")); + } + + public void testUnauthorizedFlatExpressionWithStrictIgnoreUnavailable() { + ResolvedIndexExpressions local = new ResolvedIndexExpressions( + List.of( + new ResolvedIndexExpression( + "logs", + new ResolvedIndexExpression.LocalExpressions( + Set.of(), + ResolvedIndexExpression.LocalIndexResolutionResult.CONCRETE_RESOURCE_UNAUTHORIZED, + new ElasticsearchSecurityException("authorization errors while resolving [logs]") + ), + Set.of("P1:logs") + ) + ) + ); + + var remote = Map.of( + "P1", + new ResolvedIndexExpressions( + List.of( + new ResolvedIndexExpression( + "logs", + new ResolvedIndexExpression.LocalExpressions( + Set.of(), + ResolvedIndexExpression.LocalIndexResolutionResult.CONCRETE_RESOURCE_UNAUTHORIZED, + new ElasticsearchSecurityException("authorization errors while resolving [logs]") + ), + Set.of() + ) + ) + ) + ); + + var e = ResponseValidator.validate(getStrictIgnoreUnavailable(), local, remote); + assertNotNull(e); + assertThat(e, instanceOf(ElasticsearchSecurityException.class)); + assertThat(e.getMessage(), containsString("authorization errors while resolving [logs]")); + } + + public void testQualifiedExpressionWithStrictIgnoreUnavailableMatchingInOriginProject() { + ResolvedIndexExpressions local = new ResolvedIndexExpressions( + List.of( + new ResolvedIndexExpression( + "_origin:logs", + new ResolvedIndexExpression.LocalExpressions( + Set.of("logs"), + ResolvedIndexExpression.LocalIndexResolutionResult.SUCCESS, + null + ), + Set.of() + ) + ) + ); + + // we matched locally thus no error + assertNull(ResponseValidator.validate(getStrictIgnoreUnavailable(), local, null)); + } + + public void testQualifiedOriginExpressionWithStrictIgnoreUnavailableNotMatching() { + ResolvedIndexExpressions local = new ResolvedIndexExpressions( + List.of( + new ResolvedIndexExpression( + "_origin:logs", + new ResolvedIndexExpression.LocalExpressions( + Set.of(), + ResolvedIndexExpression.LocalIndexResolutionResult.CONCRETE_RESOURCE_NOT_VISIBLE, + null + ), + Set.of() + ) + ) + ); + + var e = ResponseValidator.validate(getStrictIgnoreUnavailable(), local, null); + assertNotNull(e); + assertThat(e, instanceOf(IndexNotFoundException.class)); + assertThat(e.getMessage(), containsString("no such index [_origin:logs]")); + } + + public void testQualifiedExpressionWithStrictIgnoreUnavailableMatchingInLinkedProject() { + ResolvedIndexExpressions local = new ResolvedIndexExpressions( + List.of( + new ResolvedIndexExpression( + "P1:logs", + new ResolvedIndexExpression.LocalExpressions( + Set.of(), + ResolvedIndexExpression.LocalIndexResolutionResult.CONCRETE_RESOURCE_NOT_VISIBLE, + null + ), + Set.of("P1:logs") + ) + ) + ); + + var remote = Map.of( + "P1", + new ResolvedIndexExpressions( + List.of( + new ResolvedIndexExpression( + "logs", + new ResolvedIndexExpression.LocalExpressions( + Set.of("logs"), + ResolvedIndexExpression.LocalIndexResolutionResult.SUCCESS, + null + ), + Set.of() + ) + ) + ) + ); + + // we matched the flat resource in a linked project thus no error + assertNull(ResponseValidator.validate(getStrictIgnoreUnavailable(), local, remote)); + } + + public void testMissingQualifiedExpressionWithStrictIgnoreUnavailable() { + ResolvedIndexExpressions local = new ResolvedIndexExpressions( + List.of( + new ResolvedIndexExpression( + "P1:logs", + new ResolvedIndexExpression.LocalExpressions( + Set.of(), + ResolvedIndexExpression.LocalIndexResolutionResult.CONCRETE_RESOURCE_NOT_VISIBLE, + null + ), + Set.of("P1:logs") + ) + ) + ); + + var remote = Map.of( + "P1", + new ResolvedIndexExpressions( + List.of( + new ResolvedIndexExpression( + "logs", + new ResolvedIndexExpression.LocalExpressions( + Set.of(), + ResolvedIndexExpression.LocalIndexResolutionResult.CONCRETE_RESOURCE_NOT_VISIBLE, + null + ), + Set.of() + ) + ) + ) + ); + + var e = ResponseValidator.validate(getStrictIgnoreUnavailable(), local, remote); + assertNotNull(e); + assertThat(e, instanceOf(IndexNotFoundException.class)); + assertThat(e.getMessage(), containsString("no such index [P1:logs]")); + } + + public void testUnauthorizedQualifiedExpressionWithStrictIgnoreUnavailable() { + ResolvedIndexExpressions local = new ResolvedIndexExpressions( + List.of( + new ResolvedIndexExpression( + "P1:logs", + new ResolvedIndexExpression.LocalExpressions( + Set.of(), + ResolvedIndexExpression.LocalIndexResolutionResult.CONCRETE_RESOURCE_NOT_VISIBLE, + null + ), + Set.of("P1:logs") + ) + ) + ); + + var remote = Map.of( + "P1", + new ResolvedIndexExpressions( + List.of( + new ResolvedIndexExpression( + "logs", + new ResolvedIndexExpression.LocalExpressions( + Set.of(), + ResolvedIndexExpression.LocalIndexResolutionResult.CONCRETE_RESOURCE_UNAUTHORIZED, + new ElasticsearchException("logs") + ), + Set.of() + ) + ) + ) + ); + + var e = ResponseValidator.validate(getStrictIgnoreUnavailable(), local, remote); + assertNotNull(e); + assertThat(e, instanceOf(ElasticsearchSecurityException.class)); + assertThat(e.getMessage(), containsString("authorization errors while resolving [P1:logs]")); + } + + public void testFlatExpressionWithStrictAllowNoIndicesMatchingInOriginProject() { + ResolvedIndexExpressions local = new ResolvedIndexExpressions( + List.of( + new ResolvedIndexExpression( + "logs*", + new ResolvedIndexExpression.LocalExpressions( + Set.of("logs-es"), + ResolvedIndexExpression.LocalIndexResolutionResult.SUCCESS, + null + ), + Set.of("P1:logs") + ) + ) + ); + + // we matched resource locally thus no error + assertNull(ResponseValidator.validate(getStrictAllowNoIndices(), local, null)); + } + + public void testFlatExpressionWithStrictAllowNoIndicesMatchingInLinkedProject() { + ResolvedIndexExpressions local = new ResolvedIndexExpressions( + List.of( + new ResolvedIndexExpression( + "logs*", + new ResolvedIndexExpression.LocalExpressions( + Set.of(), + ResolvedIndexExpression.LocalIndexResolutionResult.SUCCESS, + null + ), + Set.of("P1:logs*") + ) + ) + ); + + var remote = Map.of( + "P1", + new ResolvedIndexExpressions( + List.of( + new ResolvedIndexExpression( + "logs*", + new ResolvedIndexExpression.LocalExpressions( + Set.of("logs-es"), + ResolvedIndexExpression.LocalIndexResolutionResult.SUCCESS, + null + ), + Set.of() + ) + ) + ) + ); + + // we matched the flat resource in a linked project thus no error + assertNull(ResponseValidator.validate(getStrictAllowNoIndices(), local, remote)); + } + + public void testMissingFlatExpressionWithStrictAllowNoIndices() { + ResolvedIndexExpressions local = new ResolvedIndexExpressions( + List.of( + new ResolvedIndexExpression( + "logs*", + new ResolvedIndexExpression.LocalExpressions( + Set.of(), + ResolvedIndexExpression.LocalIndexResolutionResult.SUCCESS, + null + ), + Set.of("P1:logs*") + ) + ) + ); + + var remote = Map.of( + "P1", + new ResolvedIndexExpressions( + List.of( + new ResolvedIndexExpression( + "logs*", + new ResolvedIndexExpression.LocalExpressions( + Set.of(), + ResolvedIndexExpression.LocalIndexResolutionResult.SUCCESS, + null + ), + Set.of() + ) + ) + ) + ); + + var e = ResponseValidator.validate(getStrictAllowNoIndices(), local, remote); + assertNotNull(e); + assertThat(e, instanceOf(IndexNotFoundException.class)); + assertThat(e.getMessage(), containsString("no such index [P1:logs*]")); + } + + public void testUnauthorizedFlatExpressionWithStrictAllowNoIndices() { + ResolvedIndexExpressions local = new ResolvedIndexExpressions( + List.of( + new ResolvedIndexExpression( + "logs*", + new ResolvedIndexExpression.LocalExpressions( + Set.of(), + ResolvedIndexExpression.LocalIndexResolutionResult.SUCCESS, + null + ), + Set.of("P1:logs*") + ) + ) + ); + + var remote = Map.of( + "P1", + new ResolvedIndexExpressions( + List.of( + new ResolvedIndexExpression( + "logs*", + new ResolvedIndexExpression.LocalExpressions( + Set.of(), + ResolvedIndexExpression.LocalIndexResolutionResult.SUCCESS, + null + ), + Set.of() + ) + ) + ) + ); + + var e = ResponseValidator.validate(getStrictAllowNoIndices(), local, remote); + assertNotNull(e); + assertThat(e, instanceOf(IndexNotFoundException.class)); + assertThat(e.getMessage(), containsString("no such index [P1:logs*]")); + } + + public void testQualifiedExpressionWithStrictAllowNoIndicesMatchingInOriginProject() { + ResolvedIndexExpressions local = new ResolvedIndexExpressions( + List.of( + new ResolvedIndexExpression( + "_origin:logs*", + new ResolvedIndexExpression.LocalExpressions( + Set.of("logs-es"), + ResolvedIndexExpression.LocalIndexResolutionResult.SUCCESS, + null + ), + Set.of() + ) + ) + ); + + // we matched locally thus no error + assertNull(ResponseValidator.validate(getStrictAllowNoIndices(), local, null)); + } + + public void testQualifiedOriginExpressionWithStrictAllowNoIndicesNotMatching() { + ResolvedIndexExpressions local = new ResolvedIndexExpressions( + List.of( + new ResolvedIndexExpression( + "_origin:logs*", + new ResolvedIndexExpression.LocalExpressions( + Set.of(), + ResolvedIndexExpression.LocalIndexResolutionResult.SUCCESS, + null + ), + Set.of() + ) + ) + ); + var e = ResponseValidator.validate(getStrictAllowNoIndices(), local, null); + assertNotNull(e); + assertThat(e, instanceOf(IndexNotFoundException.class)); + assertThat(e.getMessage(), containsString("no such index [_origin:logs*]")); + } + + public void testQualifiedOriginExpressionWithWildcardAndStrictAllowNoIndicesMatching() { + for (var indexExpression : List.of("_all", "*", "local-*")) { + ResolvedIndexExpressions local = new ResolvedIndexExpressions( + List.of( + new ResolvedIndexExpression( + "_origin:" + indexExpression, + new ResolvedIndexExpression.LocalExpressions( + Set.of("local-index-1", "local-index-2"), + ResolvedIndexExpression.LocalIndexResolutionResult.SUCCESS, + null + ), + Set.of() + ) + ) + ); + assertNull(ResponseValidator.validate(getIndicesOptions(randomBoolean(), randomBoolean()), local, Map.of())); + } + } + + public void testQualifiedExpressionWithStrictAllowNoIndicesMatchingInLinkedProject() { + ResolvedIndexExpressions local = new ResolvedIndexExpressions( + List.of( + new ResolvedIndexExpression( + "P1:logs*", + new ResolvedIndexExpression.LocalExpressions( + Set.of(), + ResolvedIndexExpression.LocalIndexResolutionResult.SUCCESS, + null + ), + Set.of("P1:logs*") + ) + ) + ); + + var remote = Map.of( + "P1", + new ResolvedIndexExpressions( + List.of( + new ResolvedIndexExpression( + "logs*", + new ResolvedIndexExpression.LocalExpressions( + Set.of("logs-es"), + ResolvedIndexExpression.LocalIndexResolutionResult.SUCCESS, + null + ), + Set.of() + ) + ) + ) + ); + + // we matched the flat resource in a linked project thus no error + assertNull(ResponseValidator.validate(getStrictAllowNoIndices(), local, remote)); + } + + public void testMissingQualifiedExpressionWithStrictAllowNoIndices() { + ResolvedIndexExpressions local = new ResolvedIndexExpressions( + List.of( + new ResolvedIndexExpression( + "P1:logs*", + new ResolvedIndexExpression.LocalExpressions( + Set.of(), + ResolvedIndexExpression.LocalIndexResolutionResult.SUCCESS, + null + ), + Set.of("P1:logs*") + ) + ) + ); + + var remote = Map.of( + "P1", + new ResolvedIndexExpressions( + List.of( + new ResolvedIndexExpression( + "logs*", + new ResolvedIndexExpression.LocalExpressions( + Set.of(), + ResolvedIndexExpression.LocalIndexResolutionResult.SUCCESS, + null + ), + Set.of() + ) + ) + ) + ); + + var e = ResponseValidator.validate(getStrictAllowNoIndices(), local, remote); + assertNotNull(e); + assertThat(e, instanceOf(IndexNotFoundException.class)); + assertThat(e.getMessage(), containsString("no such index [P1:logs*]")); + } + + public void testUnauthorizedQualifiedExpressionWithStrictAllowNoIndices() { + ResolvedIndexExpressions local = new ResolvedIndexExpressions( + List.of( + new ResolvedIndexExpression( + "P1:logs*", + new ResolvedIndexExpression.LocalExpressions( + Set.of(), + ResolvedIndexExpression.LocalIndexResolutionResult.SUCCESS, + null + ), + Set.of("P1:logs*") + ) + ) + ); + + var remote = Map.of( + "P1", + new ResolvedIndexExpressions( + List.of( + new ResolvedIndexExpression( + "logs*", + new ResolvedIndexExpression.LocalExpressions( + Set.of(), + ResolvedIndexExpression.LocalIndexResolutionResult.SUCCESS, + null + ), + Set.of() + ) + ) + ) + ); + var e = ResponseValidator.validate(getStrictAllowNoIndices(), local, remote); + assertNotNull(e); + assertThat(e, instanceOf(IndexNotFoundException.class)); + assertThat(e.getMessage(), containsString("no such index [P1:logs*]")); + } + + private IndicesOptions getStrictAllowNoIndices() { + return getIndicesOptions(true, false); + } + + private IndicesOptions getStrictIgnoreUnavailable() { + return getIndicesOptions(false, true); + } + + private IndicesOptions getLenientIndicesOptions() { + return getIndicesOptions(true, true); + } + + private IndicesOptions getIndicesOptions(boolean ignoreUnavailable, boolean allowNoIndices) { + return IndicesOptions.fromOptions(ignoreUnavailable, allowNoIndices, randomBoolean(), randomBoolean()); + } +} 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 f626b28fa8553..b88508b0e1a33 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 @@ -50,6 +50,7 @@ import org.elasticsearch.indices.InvalidIndexNameException; import org.elasticsearch.license.XPackLicenseState; import org.elasticsearch.search.crossproject.NoMatchingProjectException; +import org.elasticsearch.search.crossproject.ResponseValidator; import org.elasticsearch.search.crossproject.TargetProjects; import org.elasticsearch.threadpool.ThreadPool; import org.elasticsearch.transport.LinkedProjectConfigService; @@ -510,39 +511,63 @@ private void authorizeAction( return SubscribableListener.newSucceeded(resolvedIndices); } else { final SubscribableListener resolvedIndicesListener = new SubscribableListener<>(); - authzEngine.loadAuthorizedIndices( - requestInfo, - authzInfo, - projectMetadata.getIndicesLookup(), - ActionListener.wrap(authorizedIndices -> { - if (request instanceof IndicesRequest.Replaceable replaceable && replaceable.allowsCrossProject()) { + final var authorizedIndicesListener = new SubscribableListener(); + authorizedIndicesListener.>andThen( + (l, authorizedIndices) -> { + if (request instanceof IndicesRequest.Replaceable replaceable + && ResponseValidator.shouldResolveCrossProject(replaceable)) { authorizedProjectsSupplier.getAuthorizedProjects( - ActionListener.wrap( - authorizedProjects -> resolvedIndicesListener.onResponse( - indicesAndAliasesResolver.resolve( - action, - request, - projectMetadata, - authorizedIndices, - authorizedProjects - ) - ), - e -> authzResourceLoadFailure(requestId, requestInfo, authzInfo, auditTrail, e, listener) - ) + l.map(targetProjects -> new Tuple<>(authorizedIndices, targetProjects)) ); } else { - resolvedIndicesListener.onResponse( + l.onResponse(new Tuple<>(authorizedIndices, TargetProjects.NOT_CROSS_PROJECT)); + } + } + ) + .addListener( + ActionListener.wrap( + authorizedIndicesAndProjects -> resolvedIndicesListener.onResponse( indicesAndAliasesResolver.resolve( action, request, projectMetadata, - authorizedIndices, - TargetProjects.NOT_CROSS_PROJECT + authorizedIndicesAndProjects.v1(), + authorizedIndicesAndProjects.v2() ) - ); - } - }, e -> authzResourceLoadFailure(requestId, requestInfo, authzInfo, auditTrail, e, listener)) + ), + e -> { + if (e instanceof InvalidIndexNameException + || e instanceof InvalidSelectorException + || e instanceof UnsupportedSelectorException) { + logger.info( + () -> Strings.format( + "failed [%s] action authorization for [%s] due to [%s] exception", + action, + authentication, + e.getClass().getSimpleName() + ), + e + ); + listener.onFailure(e); + return; + } + auditTrail.accessDenied(requestId, authentication, action, request, authzInfo); + if (e instanceof IndexNotFoundException || e instanceof NoMatchingProjectException) { + listener.onFailure(e); + } else { + listener.onFailure(actionDenied(authentication, authzInfo, action, request, e)); + } + } + ) + ); + + authzEngine.loadAuthorizedIndices( + requestInfo, + authzInfo, + projectMetadata.getIndicesLookup(), + authorizedIndicesListener ); + return resolvedIndicesListener; } }); @@ -575,41 +600,6 @@ private void authorizeAction( } } - private void authzResourceLoadFailure( - final String requestId, - final RequestInfo requestInfo, - final AuthorizationInfo authzInfo, - final AuditTrail auditTrail, - final Exception ex, - final ActionListener listener - ) { - final String action = requestInfo.getAction(); - final TransportRequest request = requestInfo.getRequest(); - final Authentication authentication = requestInfo.getAuthentication(); - - if (ex instanceof InvalidIndexNameException - || ex instanceof InvalidSelectorException - || ex instanceof UnsupportedSelectorException) { - logger.info( - () -> Strings.format( - "failed [%s] action authorization for [%s] due to [%s] exception", - action, - authentication, - ex.getClass().getSimpleName() - ), - ex - ); - listener.onFailure(ex); - return; - } - auditTrail.accessDenied(requestId, authentication, action, request, authzInfo); - if (ex instanceof IndexNotFoundException || ex instanceof NoMatchingProjectException) { - listener.onFailure(ex); - } else { - listener.onFailure(actionDenied(authentication, authzInfo, action, request, ex)); - } - } - private void handleIndexActionAuthorizationResult( final IndexAuthorizationResult result, final RequestInfo requestInfo, diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java index 5c1398f20bf74..6241a0c281c21 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java @@ -54,8 +54,8 @@ import java.util.concurrent.CopyOnWriteArraySet; import java.util.function.BiPredicate; -import static org.elasticsearch.search.crossproject.CrossProjectResponseValidator.lenientIndicesOptions; -import static org.elasticsearch.search.crossproject.CrossProjectResponseValidator.resolveCrossProject; +import static org.elasticsearch.search.crossproject.ResponseValidator.lenientIndicesOptions; +import static org.elasticsearch.search.crossproject.ResponseValidator.shouldResolveCrossProject; import static org.elasticsearch.xpack.core.security.authz.IndicesAndAliasesResolverField.NO_INDEX_PLACEHOLDER; class IndicesAndAliasesResolver { @@ -365,7 +365,7 @@ ResolvedIndices resolveIndicesAndAliases( // we honour allow_no_indices like es core does. } else { assert indicesRequest.indices() != null : "indices() cannot be null when resolving non-all-index expressions"; - if (resolveCrossProject(replaceable) + if (shouldResolveCrossProject(replaceable) // a none expression should not go through cross-project resolution -- fall back to local resolution logic && false == IndexNameExpressionResolver.isNoneExpression(replaceable.indices())) { assert replaceable.allowsRemoteIndices() : "cross-project requests must allow remote indices"; From 8be6bfd9b983576129a0a4c1a4e4fd1b0a167495 Mon Sep 17 00:00:00 2001 From: Nikolaj Volgushev Date: Thu, 2 Oct 2025 21:32:24 +0200 Subject: [PATCH 71/89] Validator tweaks --- .../crossproject/ResponseValidator.java | 252 ++++++++---------- .../crossproject/ResponseValidatorTests.java | 83 +++--- 2 files changed, 149 insertions(+), 186 deletions(-) diff --git a/server/src/main/java/org/elasticsearch/search/crossproject/ResponseValidator.java b/server/src/main/java/org/elasticsearch/search/crossproject/ResponseValidator.java index 345f91f7af029..1b33afae749fd 100644 --- a/server/src/main/java/org/elasticsearch/search/crossproject/ResponseValidator.java +++ b/server/src/main/java/org/elasticsearch/search/crossproject/ResponseValidator.java @@ -19,12 +19,10 @@ import org.elasticsearch.action.support.IndicesOptions; import org.elasticsearch.core.Booleans; import org.elasticsearch.index.IndexNotFoundException; -import org.elasticsearch.rest.RestStatus; import org.elasticsearch.transport.RemoteClusterAware; -import java.util.ArrayList; -import java.util.List; import java.util.Map; +import java.util.Set; import static org.elasticsearch.action.ResolvedIndexExpression.LocalIndexResolutionResult.CONCRETE_RESOURCE_NOT_VISIBLE; import static org.elasticsearch.action.ResolvedIndexExpression.LocalIndexResolutionResult.CONCRETE_RESOURCE_UNAUTHORIZED; @@ -51,8 +49,6 @@ */ public class ResponseValidator { private static final Logger logger = LogManager.getLogger(ResponseValidator.class); - private static final String WILDCARD = "*"; - private static final String ORIGIN = "_origin"; // TODO use available constants /** * Validates the results of cross-project index resolution and returns appropriate exceptions based on the provided @@ -85,49 +81,97 @@ public static ElasticsearchException validate( return null; } - logger.debug("Running index existence check for [{}] and {}", localResolvedExpressions, remoteResolvedExpressions); + logger.debug( + "Checking index existence for [{}] and [{}] with indices options [{}]", + localResolvedExpressions, + remoteResolvedExpressions, + indicesOptions + ); + for (ResolvedIndexExpression localResolvedIndices : localResolvedExpressions.expressions()) { String originalExpression = localResolvedIndices.original(); logger.debug("Checking replaced expression for original expression [{}]", originalExpression); // Check if this is a qualified resource (project:index pattern) - boolean isQualifiedResource = RemoteClusterAware.isRemoteIndexName(originalExpression); - - // Handle qualified resources specially - if (isQualifiedResource) { - String[] splitResource = splitQualifiedResource(originalExpression); - String projectAlias = splitResource[0]; - - // Special handling for _origin prefixed resources - if (projectAlias.equals(ORIGIN)) { - ElasticsearchException error = validateOriginResource( - localResolvedIndices, - localResolvedExpressions, - originalExpression + boolean isQualifiedExpression = RemoteClusterAware.isRemoteIndexName(originalExpression); + + Set remoteExpressions = localResolvedIndices.remoteExpressions(); + ResolvedIndexExpression.LocalExpressions localExpressions = localResolvedIndices.localExpressions(); + ResolvedIndexExpression.LocalIndexResolutionResult result = localExpressions.localIndexResolutionResult(); + if (isQualifiedExpression) { + ElasticsearchException e = checkResolutionFailure( + localExpressions.expressions(), + result, + originalExpression, + indicesOptions + ); + if (e != null) { + return e; + } + // qualified linked project expression + for (String remoteExpression : remoteExpressions) { + String[] splitResource = splitQualifiedResource(remoteExpression); + ElasticsearchException exception = checkSingleRemoteExpression( + remoteResolvedExpressions, + splitResource[0], // projectAlias + splitResource[1], // resource + remoteExpression, + indicesOptions ); - if (error != null) { - return error; + if (exception != null) { + return exception; } + } + } else { + ElasticsearchException localException = checkResolutionFailure( + localExpressions.expressions(), + result, + originalExpression, + indicesOptions + ); + if (localException == null) { + // found locally, continue to next expression continue; } - } - - // Perform validation based on indices options - ElasticsearchException error = validateResourceWithOptions( - originalExpression, - localResolvedIndices, - remoteResolvedExpressions, - false == isQualifiedResource - ); - - if (error != null) { - return error; + boolean isUnauthorized = localException instanceof ElasticsearchSecurityException; + boolean foundFlat = false; + // checking if flat expression matched remotely + for (String remoteExpression : remoteExpressions) { + String[] splitResource = splitQualifiedResource(remoteExpression); + ElasticsearchException exception = checkSingleRemoteExpression( + remoteResolvedExpressions, + splitResource[0], // projectAlias + splitResource[1], // resource + remoteExpression, + indicesOptions + ); + if (exception == null) { + // found flat expression somewhere + foundFlat = true; + break; + } + if (false == isUnauthorized && exception instanceof ElasticsearchSecurityException) { + isUnauthorized = true; + } + } + if (foundFlat) { + continue; + } + if (isUnauthorized) { + return securityException(originalExpression); + } + return new IndexNotFoundException(originalExpression); } } - + // if we didn't throw before it means that we can proceed with the request return null; } + private static ElasticsearchSecurityException securityException(String originalExpression) { + // TODO plug in proper recorded authorization exceptions instead, once available + return new ElasticsearchSecurityException("user cannot access [" + originalExpression + "]"); + } + // TODO probably does not belong here public static boolean shouldResolveCrossProject(IndicesRequest.Replaceable request) { // TODO this needs to be based on the IndicesOptions flag instead, once available @@ -142,103 +186,28 @@ public static IndicesOptions lenientIndicesOptions(IndicesOptions indicesOptions .build(); } - private static ElasticsearchException validateOriginResource( - ResolvedIndexExpression localResolvedIndices, - ResolvedIndexExpressions localResolvedExpressions, - String originalExpression - ) { - ResolvedIndexExpression.LocalExpressions localExpressions = localResolvedIndices.localExpressions(); - boolean resourceFoundLocally = isResourceFoundLocally(localExpressions); - - if (resourceFoundLocally) { - logger.debug("Local cluster has canonical expression for original expression [{}], skipping extra checks", originalExpression); - return null; - } - - logger.debug("Handling origin qualified expression [{}] with [{}]", originalExpression, localResolvedIndices); - // Treat local resolution as a remote with alias "_origin" to avoid duplicating code - var localAsRemote = Map.of(ORIGIN, localResolvedExpressions); - return checkSingleRemoteExpression( - localAsRemote, - false, - ORIGIN, - localResolvedIndices.original().substring(ORIGIN.length() + 1), // Remove "_origin:" prefix - originalExpression, - new ArrayList<>() - ); - } - - private static ElasticsearchException validateResourceWithOptions( - String originalExpression, - ResolvedIndexExpression localResolvedIndices, - Map remoteResolvedExpressions, - boolean isFlatWorldResource - ) { - ResolvedIndexExpression.LocalExpressions localExpressions = localResolvedIndices.localExpressions(); - - // Early return if we have local resource that satisfies conditions - if (isResourceFoundLocally(localExpressions) && (isFlatWorldResource || localExpressions.expressions().size() == 1)) { - logger.debug("Local cluster has canonical expression for [{}], skipping remote check", originalExpression); - return null; - } - - // Track exceptions - List exceptions = new ArrayList<>(); - if (localExpressions.exception() != null) { - exceptions.add(localExpressions.exception()); - } - - // Process remote expressions - for (String remoteExpression : localResolvedIndices.remoteExpressions()) { - // remoteExpressions are always qualified - assert RemoteClusterAware.isRemoteIndexName(remoteExpression); - - String[] splitResource = splitQualifiedResource(remoteExpression); - ElasticsearchException error = checkSingleRemoteExpression( - remoteResolvedExpressions, - isFlatWorldResource, - splitResource[0], // projectAlias - splitResource[1], // resource - remoteExpression, - exceptions - ); - - // Return immediately on error for qualified resources - if (error != null) { - return error; - } - } - - // Return first exception if any were collected (for flat world resources) - return exceptions.isEmpty() ? null : exceptions.get(0); - } - private static ElasticsearchException checkSingleRemoteExpression( Map remoteResolvedExpressions, - boolean isFlatWorldResource, String projectAlias, String resource, String remoteExpression, - List exceptions + IndicesOptions indicesOptions ) { - // Get resolution results from the linked project ResolvedIndexExpressions resolvedExpressionsInProject = remoteResolvedExpressions.get(projectAlias); assert resolvedExpressionsInProject != null : "We should always have resolved expressions from linked project"; - // Find the matching expression in the linked project ResolvedIndexExpression.LocalExpressions matchingExpression = findMatchingExpression(resolvedExpressionsInProject, resource); if (matchingExpression == null) { - return createNotFoundException(remoteExpression, exceptions); - } - - // Check resolution result - if (matchingExpression.localIndexResolutionResult() == SUCCESS) { - // Successfully found if there are concrete expressions - return matchingExpression.expressions().isEmpty() ? createNotFoundException(remoteExpression, exceptions) : null; + assert false : "Expected to find matching expression [" + resource + "] in project [" + projectAlias + "]"; + return new IndexNotFoundException(remoteExpression); } - // Handle authorization and visibility failures - return handleResolutionFailure(matchingExpression.localIndexResolutionResult(), remoteExpression, isFlatWorldResource, exceptions); + return checkResolutionFailure( + matchingExpression.expressions(), + matchingExpression.localIndexResolutionResult(), + remoteExpression, + indicesOptions + ); } private static String[] splitQualifiedResource(String resource) { @@ -252,10 +221,7 @@ private static String[] splitQualifiedResource(String resource) { return splitResource; } - private static boolean isResourceFoundLocally(ResolvedIndexExpression.LocalExpressions localExpressions) { - return false == localExpressions.expressions().isEmpty() && localExpressions.localIndexResolutionResult() == SUCCESS; - } - + // TODO optimize with a precomputed Map instead private static ResolvedIndexExpression.LocalExpressions findMatchingExpression( ResolvedIndexExpressions projectExpressions, String resource @@ -268,37 +234,27 @@ private static ResolvedIndexExpression.LocalExpressions findMatchingExpression( .orElse(null); } - private static ElasticsearchException createNotFoundException(String expression, List exceptions) { - ElasticsearchException exception = new IndexNotFoundException(expression); - exceptions.forEach(exception::addSuppressed); - return exception; - } - - private static ElasticsearchException handleResolutionFailure( + private static ElasticsearchException checkResolutionFailure( + Set localExpressions, ResolvedIndexExpression.LocalIndexResolutionResult result, String expression, - boolean isFlatWorldResource, - List exceptions + IndicesOptions indicesOptions ) { - ElasticsearchException exception; - - if (result == CONCRETE_RESOURCE_NOT_VISIBLE) { - exception = new IndexNotFoundException(expression); - } else if (result == CONCRETE_RESOURCE_UNAUTHORIZED) { - exception = new ElasticsearchSecurityException( - "authorization errors while resolving [" + expression + "]", - RestStatus.FORBIDDEN - ); - } else { - return null; + assert false == (indicesOptions.allowNoIndices() && indicesOptions.ignoreUnavailable()) + : "Should not be checking index existence in lenient mode"; + + if (indicesOptions.ignoreUnavailable() == false) { + if (result == CONCRETE_RESOURCE_NOT_VISIBLE) { + return new IndexNotFoundException(expression); + } else if (result == CONCRETE_RESOURCE_UNAUTHORIZED) { + return securityException(expression); + } } - if (isFlatWorldResource) { - exceptions.add(exception); - return null; - } else { - exceptions.forEach(exception::addSuppressed); - return exception; + if (indicesOptions.allowNoIndices() == false && result == SUCCESS && localExpressions.isEmpty()) { + return new IndexNotFoundException(expression); } + + return null; } } diff --git a/server/src/test/java/org/elasticsearch/search/crossproject/ResponseValidatorTests.java b/server/src/test/java/org/elasticsearch/search/crossproject/ResponseValidatorTests.java index 21a3eebc098b5..508966b383354 100644 --- a/server/src/test/java/org/elasticsearch/search/crossproject/ResponseValidatorTests.java +++ b/server/src/test/java/org/elasticsearch/search/crossproject/ResponseValidatorTests.java @@ -120,7 +120,7 @@ public void testMissingFlatExpressionWithStrictIgnoreUnavailable() { var e = ResponseValidator.validate(getStrictIgnoreUnavailable(), local, remote); assertNotNull(e); assertThat(e, instanceOf(IndexNotFoundException.class)); - assertThat(e.getMessage(), containsString("no such index [P1:logs]")); + assertThat(e.getMessage(), containsString("no such index [logs]")); } public void testUnauthorizedFlatExpressionWithStrictIgnoreUnavailable() { @@ -158,7 +158,7 @@ public void testUnauthorizedFlatExpressionWithStrictIgnoreUnavailable() { var e = ResponseValidator.validate(getStrictIgnoreUnavailable(), local, remote); assertNotNull(e); assertThat(e, instanceOf(ElasticsearchSecurityException.class)); - assertThat(e.getMessage(), containsString("authorization errors while resolving [logs]")); + assertThat(e.getMessage(), containsString("user cannot access [logs]")); } public void testQualifiedExpressionWithStrictIgnoreUnavailableMatchingInOriginProject() { @@ -203,17 +203,7 @@ public void testQualifiedOriginExpressionWithStrictIgnoreUnavailableNotMatching( public void testQualifiedExpressionWithStrictIgnoreUnavailableMatchingInLinkedProject() { ResolvedIndexExpressions local = new ResolvedIndexExpressions( - List.of( - new ResolvedIndexExpression( - "P1:logs", - new ResolvedIndexExpression.LocalExpressions( - Set.of(), - ResolvedIndexExpression.LocalIndexResolutionResult.CONCRETE_RESOURCE_NOT_VISIBLE, - null - ), - Set.of("P1:logs") - ) - ) + List.of(new ResolvedIndexExpression("P1:logs", ResolvedIndexExpression.LocalExpressions.NONE, Set.of("P1:logs"))) ); var remote = Map.of( @@ -277,17 +267,7 @@ public void testMissingQualifiedExpressionWithStrictIgnoreUnavailable() { public void testUnauthorizedQualifiedExpressionWithStrictIgnoreUnavailable() { ResolvedIndexExpressions local = new ResolvedIndexExpressions( - List.of( - new ResolvedIndexExpression( - "P1:logs", - new ResolvedIndexExpression.LocalExpressions( - Set.of(), - ResolvedIndexExpression.LocalIndexResolutionResult.CONCRETE_RESOURCE_NOT_VISIBLE, - null - ), - Set.of("P1:logs") - ) - ) + List.of(new ResolvedIndexExpression("P1:logs", ResolvedIndexExpression.LocalExpressions.NONE, Set.of("P1:logs"))) ); var remote = Map.of( @@ -310,7 +290,7 @@ public void testUnauthorizedQualifiedExpressionWithStrictIgnoreUnavailable() { var e = ResponseValidator.validate(getStrictIgnoreUnavailable(), local, remote); assertNotNull(e); assertThat(e, instanceOf(ElasticsearchSecurityException.class)); - assertThat(e.getMessage(), containsString("authorization errors while resolving [P1:logs]")); + assertThat(e.getMessage(), containsString("user cannot access [P1:logs]")); } public void testFlatExpressionWithStrictAllowNoIndicesMatchingInOriginProject() { @@ -332,6 +312,43 @@ public void testFlatExpressionWithStrictAllowNoIndicesMatchingInOriginProject() assertNull(ResponseValidator.validate(getStrictAllowNoIndices(), local, null)); } + public void testAllowNoIndicesFoundEmptyResultsOnOriginAndLinked() { + ResolvedIndexExpressions local = new ResolvedIndexExpressions( + List.of( + new ResolvedIndexExpression( + "shared-index-missing*", + new ResolvedIndexExpression.LocalExpressions( + Set.of(), + ResolvedIndexExpression.LocalIndexResolutionResult.SUCCESS, + null + ), + Set.of("P1:shared-index-missing*") + ) + ) + ); + + var remote = Map.of( + "P1", + new ResolvedIndexExpressions( + List.of( + new ResolvedIndexExpression( + "shared-index-missing*", + new ResolvedIndexExpression.LocalExpressions( + Set.of(), + ResolvedIndexExpression.LocalIndexResolutionResult.SUCCESS, + null + ), + Set.of() + ) + ) + ) + ); + + ElasticsearchException ex = ResponseValidator.validate(getIndicesOptions(false, false), local, remote); + assertNotNull(ex); + assertThat(ex, instanceOf(IndexNotFoundException.class)); + } + public void testFlatExpressionWithStrictAllowNoIndicesMatchingInLinkedProject() { ResolvedIndexExpressions local = new ResolvedIndexExpressions( List.of( @@ -403,7 +420,7 @@ public void testMissingFlatExpressionWithStrictAllowNoIndices() { var e = ResponseValidator.validate(getStrictAllowNoIndices(), local, remote); assertNotNull(e); assertThat(e, instanceOf(IndexNotFoundException.class)); - assertThat(e.getMessage(), containsString("no such index [P1:logs*]")); + assertThat(e.getMessage(), containsString("no such index [logs*]")); } public void testUnauthorizedFlatExpressionWithStrictAllowNoIndices() { @@ -441,7 +458,7 @@ public void testUnauthorizedFlatExpressionWithStrictAllowNoIndices() { var e = ResponseValidator.validate(getStrictAllowNoIndices(), local, remote); assertNotNull(e); assertThat(e, instanceOf(IndexNotFoundException.class)); - assertThat(e.getMessage(), containsString("no such index [P1:logs*]")); + assertThat(e.getMessage(), containsString("no such index [logs*]")); } public void testQualifiedExpressionWithStrictAllowNoIndicesMatchingInOriginProject() { @@ -504,17 +521,7 @@ public void testQualifiedOriginExpressionWithWildcardAndStrictAllowNoIndicesMatc public void testQualifiedExpressionWithStrictAllowNoIndicesMatchingInLinkedProject() { ResolvedIndexExpressions local = new ResolvedIndexExpressions( - List.of( - new ResolvedIndexExpression( - "P1:logs*", - new ResolvedIndexExpression.LocalExpressions( - Set.of(), - ResolvedIndexExpression.LocalIndexResolutionResult.SUCCESS, - null - ), - Set.of("P1:logs*") - ) - ) + List.of(new ResolvedIndexExpression("P1:logs*", ResolvedIndexExpression.LocalExpressions.NONE, Set.of("P1:logs*"))) ); var remote = Map.of( From 4bccf953e41598731b31e42ae9ecd430e2615c4c Mon Sep 17 00:00:00 2001 From: Nikolaj Volgushev Date: Thu, 2 Oct 2025 21:41:25 +0200 Subject: [PATCH 72/89] Transport --- .../definitions/referable/resolved_index_expressions.csv | 1 - server/src/main/resources/transport/upper_bounds/9.2.csv | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) delete mode 100644 server/src/main/resources/transport/definitions/referable/resolved_index_expressions.csv diff --git a/server/src/main/resources/transport/definitions/referable/resolved_index_expressions.csv b/server/src/main/resources/transport/definitions/referable/resolved_index_expressions.csv deleted file mode 100644 index 94a4d08acc2c7..0000000000000 --- a/server/src/main/resources/transport/definitions/referable/resolved_index_expressions.csv +++ /dev/null @@ -1 +0,0 @@ -9185000 diff --git a/server/src/main/resources/transport/upper_bounds/9.2.csv b/server/src/main/resources/transport/upper_bounds/9.2.csv index 035be6363a8a7..2147eab66c207 100644 --- a/server/src/main/resources/transport/upper_bounds/9.2.csv +++ b/server/src/main/resources/transport/upper_bounds/9.2.csv @@ -1 +1 @@ -resolved_index_expressions,9185000 +initial_9.2.0,9185000 From 31553550fc74b21cf8ebf83448665acd8a0e6c31 Mon Sep 17 00:00:00 2001 From: elasticsearchmachine Date: Thu, 2 Oct 2025 20:00:56 +0000 Subject: [PATCH 73/89] [CI] Auto commit changes from spotless --- .../xpack/security/authz/IndicesAndAliasesResolver.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java index a6bd5d3a4f5a2..d187c7d753451 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java @@ -54,9 +54,9 @@ import java.util.concurrent.CopyOnWriteArraySet; import java.util.function.BiPredicate; +import static org.elasticsearch.cluster.metadata.IndexNameExpressionResolver.isNoneExpression; import static org.elasticsearch.search.crossproject.ResponseValidator.lenientIndicesOptions; import static org.elasticsearch.search.crossproject.ResponseValidator.shouldResolveCrossProject; -import static org.elasticsearch.cluster.metadata.IndexNameExpressionResolver.isNoneExpression; import static org.elasticsearch.xpack.core.security.authz.IndicesAndAliasesResolverField.NO_INDEX_PLACEHOLDER; class IndicesAndAliasesResolver { From c7a5477da04cefbe973fe18ea426b38e325e4f7b Mon Sep 17 00:00:00 2001 From: Nikolaj Volgushev Date: Fri, 3 Oct 2025 10:09:21 +0200 Subject: [PATCH 74/89] Feedback --- .../indices/resolve/ResolveIndexAction.java | 37 ++----------------- .../core/security/SecurityExtension.java | 4 +- ...r.java => AuthorizedProjectsResolver.java} | 17 +++------ .../xpack/security/Security.java | 8 ++-- .../security/authz/AuthorizationService.java | 12 +++--- .../authz/IndicesAndAliasesResolver.java | 2 +- .../authz/AuthorizationServiceTests.java | 12 +++--- 7 files changed, 27 insertions(+), 65 deletions(-) rename x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/{AuthorizedProjectsSupplier.java => AuthorizedProjectsResolver.java} (59%) diff --git a/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java b/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java index 0a15c26d24f59..5c20be90cbf18 100644 --- a/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java +++ b/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java @@ -97,7 +97,6 @@ public static class Request extends LegacyActionRequest implements IndicesReques private IndicesOptions indicesOptions = DEFAULT_INDICES_OPTIONS; private EnumSet indexModes = EnumSet.noneOf(IndexMode.class); private ResolvedIndexExpressions resolvedIndexExpressions = null; - private boolean includeResolvedExpressions = false; public Request(String[] names) { this.names = names; @@ -116,20 +115,6 @@ public Request(String[] names, IndicesOptions indicesOptions, @Nullable EnumSet< } } - public Request( - String[] names, - IndicesOptions indicesOptions, - @Nullable EnumSet indexModes, - boolean includeResolvedExpressions - ) { - this.names = names; - this.indicesOptions = indicesOptions; - if (indexModes != null) { - this.indexModes = indexModes; - } - this.includeResolvedExpressions = includeResolvedExpressions; - } - @Override public ActionRequestValidationException validate() { return null; @@ -144,11 +129,6 @@ public Request(StreamInput in) throws IOException { } else { this.indexModes = EnumSet.noneOf(IndexMode.class); } - if (in.getTransportVersion().supports(ResolvedIndexExpressions.RESOLVED_INDEX_EXPRESSIONS)) { - this.includeResolvedExpressions = in.readBoolean(); - } else { - this.includeResolvedExpressions = false; - } } @Override @@ -159,9 +139,6 @@ public void writeTo(StreamOutput out) throws IOException { if (out.getTransportVersion().supports(RESOLVE_INDEX_MODE_FILTER)) { out.writeEnumSet(indexModes); } - if (out.getTransportVersion().supports(ResolvedIndexExpressions.RESOLVED_INDEX_EXPRESSIONS)) { - out.writeBoolean(includeResolvedExpressions); - } } @Override @@ -672,13 +649,7 @@ protected void doExecute(Task task, Request request, final ActionListener { remoteResponses.put(clusterAlias, response); terminalHandler.run(); @@ -697,9 +668,7 @@ protected void doExecute(Task task, Request request, final ActionListener getResolvedExpressionsByRemote(Map final ResolvedIndexExpressions resolvedIndexExpressions = e.getValue().getResolvedIndexExpressions(); assert resolvedIndexExpressions != null : "remote response from cluster [" + e.getKey() + "] is missing resolved index expressions"; - return resolvedIndexExpressions == null ? new ResolvedIndexExpressions(List.of()) : resolvedIndexExpressions; + return resolvedIndexExpressions; })); } diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/SecurityExtension.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/SecurityExtension.java index 57bd4cad49e63..37e7447fa117e 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/SecurityExtension.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/SecurityExtension.java @@ -21,7 +21,7 @@ import org.elasticsearch.xpack.core.security.authc.service.ServiceAccountTokenStore; import org.elasticsearch.xpack.core.security.authc.support.UserRoleMapper; import org.elasticsearch.xpack.core.security.authz.AuthorizationEngine; -import org.elasticsearch.xpack.core.security.authz.AuthorizedProjectsSupplier; +import org.elasticsearch.xpack.core.security.authz.AuthorizedProjectsResolver; import org.elasticsearch.xpack.core.security.authz.RoleDescriptor; import org.elasticsearch.xpack.core.security.authz.store.RoleRetrievalResult; @@ -150,7 +150,7 @@ default String extensionName() { return getClass().getName(); } - default AuthorizedProjectsSupplier getAuthorizedProjectsSupplier(SecurityComponents components) { + default AuthorizedProjectsResolver getAuthorizedProjectsSupplier(SecurityComponents components) { return null; } } diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/AuthorizedProjectsSupplier.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/AuthorizedProjectsResolver.java similarity index 59% rename from x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/AuthorizedProjectsSupplier.java rename to x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/AuthorizedProjectsResolver.java index 9104e4cc7589b..d73488f710bc1 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/AuthorizedProjectsSupplier.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/AuthorizedProjectsResolver.java @@ -11,23 +11,16 @@ import org.elasticsearch.search.crossproject.TargetProjects; /** - * A supplier of authorized projects for the current user. This includes the origin project and all linked projects the user has access to. + * A resolver of authorized projects for the current user. This includes the origin project and all linked projects the user has access to. * If we are not in a cross-project search context, the supplier returns {@link TargetProjects#NOT_CROSS_PROJECT}. */ -public interface AuthorizedProjectsSupplier { - void getAuthorizedProjects(ActionListener listener); +public interface AuthorizedProjectsResolver { + void resolveAuthorizedProjects(ActionListener listener); - boolean recordResolvedIndexExpressions(); - - class Default implements AuthorizedProjectsSupplier { + class Default implements AuthorizedProjectsResolver { @Override - public void getAuthorizedProjects(ActionListener listener) { + public void resolveAuthorizedProjects(ActionListener listener) { listener.onResponse(TargetProjects.NOT_CROSS_PROJECT); } - - @Override - public boolean recordResolvedIndexExpressions() { - return false; - } } } diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java index 4dd28ef0c5c85..ad2fc02b7f1c7 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java @@ -210,7 +210,7 @@ import org.elasticsearch.xpack.core.security.authc.support.UserRoleMapper; import org.elasticsearch.xpack.core.security.authc.support.UsernamePasswordToken; import org.elasticsearch.xpack.core.security.authz.AuthorizationEngine; -import org.elasticsearch.xpack.core.security.authz.AuthorizedProjectsSupplier; +import org.elasticsearch.xpack.core.security.authz.AuthorizedProjectsResolver; import org.elasticsearch.xpack.core.security.authz.RestrictedIndices; import org.elasticsearch.xpack.core.security.authz.RoleDescriptor; import org.elasticsearch.xpack.core.security.authz.accesscontrol.DocumentSubsetBitsetCache; @@ -1346,14 +1346,14 @@ private List getCustomAuthenticatorFromExtensions(SecurityE } } - private AuthorizedProjectsSupplier getCustomAuthorizedProjectsSupplierOrDefault( + private AuthorizedProjectsResolver getCustomAuthorizedProjectsSupplierOrDefault( SecurityExtension.SecurityComponents extensionComponents ) { - final AuthorizedProjectsSupplier customAuthorizedProjectsSupplier = findValueFromExtensions( + final AuthorizedProjectsResolver customAuthorizedProjectsResolver = findValueFromExtensions( "authorized projects supplier", extension -> extension.getAuthorizedProjectsSupplier(extensionComponents) ); - return customAuthorizedProjectsSupplier == null ? new AuthorizedProjectsSupplier.Default() : customAuthorizedProjectsSupplier; + return customAuthorizedProjectsResolver == null ? new AuthorizedProjectsResolver.Default() : customAuthorizedProjectsResolver; } private ServiceAccountService createServiceAccountService( 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 b88508b0e1a33..b7ee56352727a 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 @@ -74,7 +74,7 @@ import org.elasticsearch.xpack.core.security.authz.AuthorizationEngine.ParentActionAuthorization; import org.elasticsearch.xpack.core.security.authz.AuthorizationEngine.RequestInfo; import org.elasticsearch.xpack.core.security.authz.AuthorizationServiceField; -import org.elasticsearch.xpack.core.security.authz.AuthorizedProjectsSupplier; +import org.elasticsearch.xpack.core.security.authz.AuthorizedProjectsResolver; import org.elasticsearch.xpack.core.security.authz.ResolvedIndices; import org.elasticsearch.xpack.core.security.authz.RestrictedIndices; import org.elasticsearch.xpack.core.security.authz.RoleDescriptorsIntersection; @@ -153,7 +153,7 @@ public class AuthorizationService { private final boolean isAnonymousEnabled; private final boolean anonymousAuthzExceptionEnabled; private final DlsFlsFeatureTrackingIndicesAccessControlWrapper indicesAccessControlWrapper; - private final AuthorizedProjectsSupplier authorizedProjectsSupplier; + private final AuthorizedProjectsResolver authorizedProjectsResolver; public AuthorizationService( Settings settings, @@ -173,7 +173,7 @@ public AuthorizationService( AuthorizationDenialMessages authorizationDenialMessages, LinkedProjectConfigService linkedProjectConfigService, ProjectResolver projectResolver, - AuthorizedProjectsSupplier authorizedProjectsSupplier + AuthorizedProjectsResolver authorizedProjectsResolver ) { this.clusterService = clusterService; this.auditTrailService = auditTrailService; @@ -182,7 +182,7 @@ public AuthorizationService( settings, linkedProjectConfigService, resolver, - authorizedProjectsSupplier.recordResolvedIndexExpressions() + settings.getAsBoolean("serverless.cross_project.enabled", false) ); this.authcFailureHandler = authcFailureHandler; this.threadContext = threadPool.getThreadContext(); @@ -204,7 +204,7 @@ public AuthorizationService( this.indicesAccessControlWrapper = new DlsFlsFeatureTrackingIndicesAccessControlWrapper(settings, licenseState); this.authorizationDenialMessages = authorizationDenialMessages; this.projectResolver = projectResolver; - this.authorizedProjectsSupplier = authorizedProjectsSupplier; + this.authorizedProjectsResolver = authorizedProjectsResolver; } public void checkPrivileges( @@ -516,7 +516,7 @@ private void authorizeAction( (l, authorizedIndices) -> { if (request instanceof IndicesRequest.Replaceable replaceable && ResponseValidator.shouldResolveCrossProject(replaceable)) { - authorizedProjectsSupplier.getAuthorizedProjects( + authorizedProjectsResolver.resolveAuthorizedProjects( l.map(targetProjects -> new Tuple<>(authorizedIndices, targetProjects)) ); } else { diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java index a6bd5d3a4f5a2..d187c7d753451 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java @@ -54,9 +54,9 @@ import java.util.concurrent.CopyOnWriteArraySet; import java.util.function.BiPredicate; +import static org.elasticsearch.cluster.metadata.IndexNameExpressionResolver.isNoneExpression; import static org.elasticsearch.search.crossproject.ResponseValidator.lenientIndicesOptions; import static org.elasticsearch.search.crossproject.ResponseValidator.shouldResolveCrossProject; -import static org.elasticsearch.cluster.metadata.IndexNameExpressionResolver.isNoneExpression; import static org.elasticsearch.xpack.core.security.authz.IndicesAndAliasesResolverField.NO_INDEX_PLACEHOLDER; class IndicesAndAliasesResolver { 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 4063ccf26941e..cb11095a5aad8 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 @@ -157,7 +157,7 @@ import org.elasticsearch.xpack.core.security.authc.Subject; import org.elasticsearch.xpack.core.security.authz.AuthorizationEngine; import org.elasticsearch.xpack.core.security.authz.AuthorizationEngine.AuthorizationInfo; -import org.elasticsearch.xpack.core.security.authz.AuthorizedProjectsSupplier; +import org.elasticsearch.xpack.core.security.authz.AuthorizedProjectsResolver; import org.elasticsearch.xpack.core.security.authz.IndicesAndAliasesResolverField; import org.elasticsearch.xpack.core.security.authz.ResolvedIndices; import org.elasticsearch.xpack.core.security.authz.RoleDescriptor; @@ -343,7 +343,7 @@ public void setup() { new AuthorizationDenialMessages.Default(), linkedProjectConfigService, projectResolver, - new AuthorizedProjectsSupplier.Default() + new AuthorizedProjectsResolver.Default() ); } @@ -1778,7 +1778,7 @@ public void testDenialForAnonymousUser() { new AuthorizationDenialMessages.Default(), linkedProjectConfigService, projectResolver, - new AuthorizedProjectsSupplier.Default() + new AuthorizedProjectsResolver.Default() ); RoleDescriptor role = new RoleDescriptor( @@ -1830,7 +1830,7 @@ public void testDenialForAnonymousUserAuthorizationExceptionDisabled() { new AuthorizationDenialMessages.Default(), linkedProjectConfigService, projectResolver, - new AuthorizedProjectsSupplier.Default() + new AuthorizedProjectsResolver.Default() ); RoleDescriptor role = new RoleDescriptor( @@ -3370,7 +3370,7 @@ public void testAuthorizationEngineSelectionForCheckPrivileges() throws Exceptio new AuthorizationDenialMessages.Default(), linkedProjectConfigService, projectResolver, - new AuthorizedProjectsSupplier.Default() + new AuthorizedProjectsResolver.Default() ); Subject subject = new Subject(new User("test", "a role"), mock(RealmRef.class)); @@ -3528,7 +3528,7 @@ public void getUserPrivileges(AuthorizationInfo authorizationInfo, ActionListene new AuthorizationDenialMessages.Default(), linkedProjectConfigService, projectResolver, - new AuthorizedProjectsSupplier.Default() + new AuthorizedProjectsResolver.Default() ); Authentication authentication; try (StoredContext ignore = threadContext.stashContext()) { From 5b5a7a923ac8f58735bcefb5034ff20cd8846b5e Mon Sep 17 00:00:00 2001 From: Nikolaj Volgushev Date: Fri, 3 Oct 2025 10:18:35 +0200 Subject: [PATCH 75/89] Rename --- .../security/authz/AuthorizationService.java | 59 +++++++++++-------- 1 file changed, 36 insertions(+), 23 deletions(-) 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 b7ee56352727a..bc59c131c30dc 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 @@ -535,29 +535,7 @@ private void authorizeAction( authorizedIndicesAndProjects.v2() ) ), - e -> { - if (e instanceof InvalidIndexNameException - || e instanceof InvalidSelectorException - || e instanceof UnsupportedSelectorException) { - logger.info( - () -> Strings.format( - "failed [%s] action authorization for [%s] due to [%s] exception", - action, - authentication, - e.getClass().getSimpleName() - ), - e - ); - listener.onFailure(e); - return; - } - auditTrail.accessDenied(requestId, authentication, action, request, authzInfo); - if (e instanceof IndexNotFoundException || e instanceof NoMatchingProjectException) { - listener.onFailure(e); - } else { - listener.onFailure(actionDenied(authentication, authzInfo, action, request, e)); - } - } + e -> onAuthorizedResourceLoadFailure(requestId, requestInfo, authzInfo, auditTrail, listener, e) ) ); @@ -600,6 +578,41 @@ private void authorizeAction( } } + private void onAuthorizedResourceLoadFailure( + String requestId, + RequestInfo requestInfo, + AuthorizationInfo authzInfo, + AuditTrail auditTrail, + ActionListener listener, + Exception ex + ) { + final String action = requestInfo.getAction(); + final TransportRequest request = requestInfo.getRequest(); + final Authentication authentication = requestInfo.getAuthentication(); + + if (ex instanceof InvalidIndexNameException + || ex instanceof InvalidSelectorException + || ex instanceof UnsupportedSelectorException) { + logger.info( + () -> Strings.format( + "failed [%s] action authorization for [%s] due to [%s] exception", + action, + authentication, + ex.getClass().getSimpleName() + ), + ex + ); + listener.onFailure(ex); + return; + } + auditTrail.accessDenied(requestId, authentication, action, request, authzInfo); + if (ex instanceof IndexNotFoundException || ex instanceof NoMatchingProjectException) { + listener.onFailure(ex); + } else { + listener.onFailure(actionDenied(authentication, authzInfo, action, request, ex)); + } + } + private void handleIndexActionAuthorizationResult( final IndexAuthorizationResult result, final RequestInfo requestInfo, From 407cffed4be7876564918ea63a53b598a3a02ae1 Mon Sep 17 00:00:00 2001 From: Nikolaj Volgushev Date: Fri, 3 Oct 2025 10:38:13 +0200 Subject: [PATCH 76/89] Helper --- .../indices/resolve/ResolveIndexAction.java | 6 ++-- .../CrossProjectIndicesRequestHelper.java | 35 +++++++++++++++++++ .../crossproject/ResponseValidator.java | 16 --------- .../security/authz/AuthorizationService.java | 6 ++-- .../authz/IndicesAndAliasesResolver.java | 6 ++-- 5 files changed, 43 insertions(+), 26 deletions(-) create mode 100644 server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectIndicesRequestHelper.java diff --git a/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java b/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java index 5c20be90cbf18..6fad12f1b4622 100644 --- a/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java +++ b/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java @@ -73,8 +73,8 @@ import java.util.stream.Stream; import static org.elasticsearch.action.search.TransportSearchHelper.checkCCSVersionCompatibility; -import static org.elasticsearch.search.crossproject.ResponseValidator.lenientIndicesOptions; -import static org.elasticsearch.search.crossproject.ResponseValidator.shouldResolveCrossProject; +import static org.elasticsearch.search.crossproject.CrossProjectIndicesRequestHelper.crossProjectFanoutIndicesOptions; +import static org.elasticsearch.search.crossproject.CrossProjectIndicesRequestHelper.shouldResolveCrossProject; public class ResolveIndexAction extends ActionType { @@ -598,7 +598,7 @@ protected void doExecute(Task task, Request request, final ActionListener remoteClusterIndices = remoteClusterService.groupIndices( - resolveCrossProject ? lenientIndicesOptions(originalIndicesOptions) : originalIndicesOptions, + resolveCrossProject ? crossProjectFanoutIndicesOptions(originalIndicesOptions) : originalIndicesOptions, request.indices() ); final OriginalIndices localIndices = remoteClusterIndices.remove(RemoteClusterAware.LOCAL_CLUSTER_GROUP_KEY); diff --git a/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectIndicesRequestHelper.java b/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectIndicesRequestHelper.java new file mode 100644 index 0000000000000..7f226b38a4e9f --- /dev/null +++ b/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectIndicesRequestHelper.java @@ -0,0 +1,35 @@ +/* + * 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.search.crossproject; + +import org.elasticsearch.action.IndicesRequest; +import org.elasticsearch.action.support.IndicesOptions; +import org.elasticsearch.core.Booleans; +import org.elasticsearch.transport.TransportRequest; + +public record CrossProjectIndicesRequestHelper() { + public static boolean shouldResolveCrossProject(IndicesRequest.Replaceable request) { + // TODO this needs to be based on the IndicesOptions flag instead, once available + final boolean indicesOptionsResolveCrossProject = Booleans.parseBoolean(System.getProperty("cps.resolve_cross_project", "false")); + return request.allowsCrossProject() && indicesOptionsResolveCrossProject; + } + + public static boolean shouldResolveTransportRequestCrossProject(TransportRequest request) { + return request instanceof IndicesRequest.Replaceable replaceable && shouldResolveCrossProject(replaceable); + } + + public static IndicesOptions crossProjectFanoutIndicesOptions(IndicesOptions indicesOptions) { + // TODO set resolveCrossProject=false here once we have an IndicesOptions flag for that + return IndicesOptions.builder(indicesOptions) + .concreteTargetOptions(new IndicesOptions.ConcreteTargetOptions(true)) + .wildcardOptions(IndicesOptions.WildcardOptions.builder(indicesOptions.wildcardOptions()).allowEmptyExpressions(true).build()) + .build(); + } +} diff --git a/server/src/main/java/org/elasticsearch/search/crossproject/ResponseValidator.java b/server/src/main/java/org/elasticsearch/search/crossproject/ResponseValidator.java index 1b33afae749fd..89d38cf432747 100644 --- a/server/src/main/java/org/elasticsearch/search/crossproject/ResponseValidator.java +++ b/server/src/main/java/org/elasticsearch/search/crossproject/ResponseValidator.java @@ -13,11 +13,9 @@ import org.apache.logging.log4j.Logger; import org.elasticsearch.ElasticsearchException; import org.elasticsearch.ElasticsearchSecurityException; -import org.elasticsearch.action.IndicesRequest; import org.elasticsearch.action.ResolvedIndexExpression; import org.elasticsearch.action.ResolvedIndexExpressions; import org.elasticsearch.action.support.IndicesOptions; -import org.elasticsearch.core.Booleans; import org.elasticsearch.index.IndexNotFoundException; import org.elasticsearch.transport.RemoteClusterAware; @@ -172,20 +170,6 @@ private static ElasticsearchSecurityException securityException(String originalE return new ElasticsearchSecurityException("user cannot access [" + originalExpression + "]"); } - // TODO probably does not belong here - public static boolean shouldResolveCrossProject(IndicesRequest.Replaceable request) { - // TODO this needs to be based on the IndicesOptions flag instead, once available - final boolean indicesOptionsResolveCrossProject = Booleans.parseBoolean(System.getProperty("cps.resolve_cross_project", "false")); - return request.allowsCrossProject() && indicesOptionsResolveCrossProject; - } - - public static IndicesOptions lenientIndicesOptions(IndicesOptions indicesOptions) { - return IndicesOptions.builder(indicesOptions) - .concreteTargetOptions(new IndicesOptions.ConcreteTargetOptions(true)) - .wildcardOptions(IndicesOptions.WildcardOptions.builder(indicesOptions.wildcardOptions()).allowEmptyExpressions(true).build()) - .build(); - } - private static ElasticsearchException checkSingleRemoteExpression( Map remoteResolvedExpressions, String projectAlias, 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 bc59c131c30dc..1f8c7dfdbb366 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,7 +16,6 @@ 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; @@ -49,8 +48,8 @@ import org.elasticsearch.index.IndexNotFoundException; import org.elasticsearch.indices.InvalidIndexNameException; import org.elasticsearch.license.XPackLicenseState; +import org.elasticsearch.search.crossproject.CrossProjectIndicesRequestHelper; import org.elasticsearch.search.crossproject.NoMatchingProjectException; -import org.elasticsearch.search.crossproject.ResponseValidator; import org.elasticsearch.search.crossproject.TargetProjects; import org.elasticsearch.threadpool.ThreadPool; import org.elasticsearch.transport.LinkedProjectConfigService; @@ -514,8 +513,7 @@ private void authorizeAction( final var authorizedIndicesListener = new SubscribableListener(); authorizedIndicesListener.>andThen( (l, authorizedIndices) -> { - if (request instanceof IndicesRequest.Replaceable replaceable - && ResponseValidator.shouldResolveCrossProject(replaceable)) { + if (CrossProjectIndicesRequestHelper.shouldResolveTransportRequestCrossProject(request)) { authorizedProjectsResolver.resolveAuthorizedProjects( l.map(targetProjects -> new Tuple<>(authorizedIndices, targetProjects)) ); diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java index d187c7d753451..875e56ff22f50 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java @@ -55,8 +55,8 @@ import java.util.function.BiPredicate; import static org.elasticsearch.cluster.metadata.IndexNameExpressionResolver.isNoneExpression; -import static org.elasticsearch.search.crossproject.ResponseValidator.lenientIndicesOptions; -import static org.elasticsearch.search.crossproject.ResponseValidator.shouldResolveCrossProject; +import static org.elasticsearch.search.crossproject.CrossProjectIndicesRequestHelper.crossProjectFanoutIndicesOptions; +import static org.elasticsearch.search.crossproject.CrossProjectIndicesRequestHelper.shouldResolveCrossProject; import static org.elasticsearch.xpack.core.security.authz.IndicesAndAliasesResolverField.NO_INDEX_PLACEHOLDER; class IndicesAndAliasesResolver { @@ -373,7 +373,7 @@ ResolvedIndices resolveIndicesAndAliases( assert recordResolvedIndexExpressions : "cross-project requests must record resolved index expressions"; final ResolvedIndexExpressions resolved = indexAbstractionResolver.resolveIndexAbstractions( Arrays.asList(replaceable.indices()), - lenientIndicesOptions(indicesOptions), + crossProjectFanoutIndicesOptions(indicesOptions), projectMetadata, authorizedIndices::all, authorizedIndices::check, From 73ba8a9825ee7f1fdc24c0da3fb19e5b0be4eefc Mon Sep 17 00:00:00 2001 From: Nikolaj Volgushev Date: Fri, 3 Oct 2025 10:48:35 +0200 Subject: [PATCH 77/89] More --- .../search/crossproject/CrossProjectIndicesRequestHelper.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectIndicesRequestHelper.java b/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectIndicesRequestHelper.java index 7f226b38a4e9f..eda199b9cce3b 100644 --- a/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectIndicesRequestHelper.java +++ b/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectIndicesRequestHelper.java @@ -14,7 +14,9 @@ import org.elasticsearch.core.Booleans; import org.elasticsearch.transport.TransportRequest; -public record CrossProjectIndicesRequestHelper() { +public final class CrossProjectIndicesRequestHelper { + private CrossProjectIndicesRequestHelper() {} + public static boolean shouldResolveCrossProject(IndicesRequest.Replaceable request) { // TODO this needs to be based on the IndicesOptions flag instead, once available final boolean indicesOptionsResolveCrossProject = Booleans.parseBoolean(System.getProperty("cps.resolve_cross_project", "false")); From 7240d8939ac83f07283d042adc77cea7a6d8360d Mon Sep 17 00:00:00 2001 From: elasticsearchmachine Date: Fri, 3 Oct 2025 09:02:53 +0000 Subject: [PATCH 78/89] [CI] Auto commit changes from spotless --- .../action/admin/indices/resolve/ResolveIndexAction.java | 6 +++++- .../cluster/metadata/IndexAbstractionResolver.java | 7 ++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java b/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java index 35606b2c7a3ea..a49da50b8c03d 100644 --- a/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java +++ b/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java @@ -662,7 +662,11 @@ protected void doExecute(Task task, Request request, final ActionListener:index` get deferred validation, also - final Exception ex = CrossProjectIndexResolutionValidator.validate(originalIndicesOptions, localResolvedIndexExpressions, Map.of()); + final Exception ex = CrossProjectIndexResolutionValidator.validate( + originalIndicesOptions, + localResolvedIndexExpressions, + Map.of() + ); if (ex != null) { listener.onFailure(ex); return; diff --git a/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java b/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java index 004e118b0ef21..06a6905416ced 100644 --- a/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java +++ b/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java @@ -88,11 +88,8 @@ public ResolvedIndexExpressions resolveIndexAbstractions( final ResolvedIndexExpressions.Builder resolvedExpressionsBuilder = ResolvedIndexExpressions.builder(); boolean wildcardSeen = false; for (String originalIndexExpression : indices) { - final CrossProjectIndexExpressionsRewriter.IndexRewriteResult indexRewriteResult = CrossProjectIndexExpressionsRewriter.rewriteIndexExpression( - originalIndexExpression, - originProjectAlias, - linkedProjectAliases - ); + final CrossProjectIndexExpressionsRewriter.IndexRewriteResult indexRewriteResult = CrossProjectIndexExpressionsRewriter + .rewriteIndexExpression(originalIndexExpression, originProjectAlias, linkedProjectAliases); final String localIndexExpression = indexRewriteResult.localExpression(); if (localIndexExpression == null) { From 395f3a0843708f9a93e7bb7d366ec60cd19c7602 Mon Sep 17 00:00:00 2001 From: Nikolaj Volgushev Date: Fri, 3 Oct 2025 11:09:24 +0200 Subject: [PATCH 79/89] Fix response code --- .../crossproject/CrossProjectIndexResolutionValidator.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectIndexResolutionValidator.java b/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectIndexResolutionValidator.java index dc3034d2f41fe..2d958ddd88341 100644 --- a/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectIndexResolutionValidator.java +++ b/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectIndexResolutionValidator.java @@ -17,6 +17,7 @@ import org.elasticsearch.action.ResolvedIndexExpressions; import org.elasticsearch.action.support.IndicesOptions; import org.elasticsearch.index.IndexNotFoundException; +import org.elasticsearch.rest.RestStatus; import org.elasticsearch.transport.RemoteClusterAware; import java.util.Map; @@ -167,7 +168,7 @@ public static ElasticsearchException validate( private static ElasticsearchSecurityException securityException(String originalExpression) { // TODO plug in proper recorded authorization exceptions instead, once available - return new ElasticsearchSecurityException("user cannot access [" + originalExpression + "]"); + return new ElasticsearchSecurityException("user cannot access [" + originalExpression + "]", RestStatus.FORBIDDEN); } private static ElasticsearchException checkSingleRemoteExpression( From 39a50a48a4e87740914d2ab4b7cd310efd8e7e25 Mon Sep 17 00:00:00 2001 From: Nikolaj Volgushev Date: Fri, 3 Oct 2025 11:09:38 +0200 Subject: [PATCH 80/89] Method --- .../main/java/org/elasticsearch/xpack/security/Security.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java index ad2fc02b7f1c7..e4661c7e30008 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java @@ -1161,7 +1161,7 @@ Collection createComponents( authorizationDenialMessages.get(), linkedProjectConfigService, projectResolver, - getCustomAuthorizedProjectsSupplierOrDefault(extensionComponents) + getCustomAuthorizedProjectsResolverOrDefault(extensionComponents) ); components.add(nativeRolesStore); // used by roles actions @@ -1346,7 +1346,7 @@ private List getCustomAuthenticatorFromExtensions(SecurityE } } - private AuthorizedProjectsResolver getCustomAuthorizedProjectsSupplierOrDefault( + private AuthorizedProjectsResolver getCustomAuthorizedProjectsResolverOrDefault( SecurityExtension.SecurityComponents extensionComponents ) { final AuthorizedProjectsResolver customAuthorizedProjectsResolver = findValueFromExtensions( From 02e4693a70e9aa60647cd61f27452b2899846eb4 Mon Sep 17 00:00:00 2001 From: Nikolaj Volgushev Date: Fri, 3 Oct 2025 12:33:15 +0200 Subject: [PATCH 81/89] Naming nit --- .../main/java/org/elasticsearch/xpack/security/Security.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java index e4661c7e30008..ba496b772d4a4 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java @@ -1350,7 +1350,7 @@ private AuthorizedProjectsResolver getCustomAuthorizedProjectsResolverOrDefault( SecurityExtension.SecurityComponents extensionComponents ) { final AuthorizedProjectsResolver customAuthorizedProjectsResolver = findValueFromExtensions( - "authorized projects supplier", + "authorized projects resolver", extension -> extension.getAuthorizedProjectsSupplier(extensionComponents) ); return customAuthorizedProjectsResolver == null ? new AuthorizedProjectsResolver.Default() : customAuthorizedProjectsResolver; From 1303b066fa778decbd46481237656bbe753e61a7 Mon Sep 17 00:00:00 2001 From: Nikolaj Volgushev Date: Fri, 3 Oct 2025 14:34:36 +0200 Subject: [PATCH 82/89] Early return --- .../authz/IndicesAndAliasesResolver.java | 27 +++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java index 875e56ff22f50..deffe6b1bce4d 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java @@ -371,6 +371,7 @@ ResolvedIndices resolveIndicesAndAliases( && false == IndexNameExpressionResolver.isNoneExpression(replaceable.indices())) { assert replaceable.allowsRemoteIndices() : "cross-project requests must allow remote indices"; assert recordResolvedIndexExpressions : "cross-project requests must record resolved index expressions"; + final ResolvedIndexExpressions resolved = indexAbstractionResolver.resolveIndexAbstractions( Arrays.asList(replaceable.indices()), crossProjectFanoutIndicesOptions(indicesOptions), @@ -380,9 +381,20 @@ ResolvedIndices resolveIndicesAndAliases( authorizedProjects, indicesRequest.includeDataStreams() ); + setResolvedIndexExpressionsIfUnset(replaceable, resolved); + resolvedIndicesBuilder.addLocal(resolved.getLocalIndicesList()); resolvedIndicesBuilder.addRemote(resolved.getRemoteIndicesList()); + + // TODO explain why we're doing this + if (resolvedIndicesBuilder.isEmpty()) { + markWithNoneExpression(replaceable, resolvedIndicesBuilder); + } else { + replaceable.indices(resolvedIndicesBuilder.build().toArray()); + } + + return resolvedIndicesBuilder.build(); } else { final ResolvedIndices split; if (replaceable.allowsRemoteIndices()) { @@ -410,12 +422,8 @@ ResolvedIndices resolveIndicesAndAliases( } if (resolvedIndicesBuilder.isEmpty()) { if (indicesOptions.allowNoIndices()) { - // this is how we tell es core to return an empty response, we can let the request through being sure - // that the '-*' wildcard expression will be resolved to no indices. We can't let empty indices through - // as that would be resolved to _all by es core. - replaceable.indices(IndicesAndAliasesResolverField.NO_INDICES_OR_ALIASES_ARRAY); indicesReplacedWithNoIndices = true; - resolvedIndicesBuilder.addLocal(NO_INDEX_PLACEHOLDER); + markWithNoneExpression(replaceable, resolvedIndicesBuilder); } else { throw new IndexNotFoundException(Arrays.toString(indicesRequest.indices())); } @@ -473,6 +481,15 @@ ResolvedIndices resolveIndicesAndAliases( return resolvedIndicesBuilder.build(); } + private static void markWithNoneExpression(IndicesRequest.Replaceable replaceable, ResolvedIndices.Builder resolvedIndicesBuilder) { + assert resolvedIndicesBuilder.isEmpty() : "we only mark with none expression on empty resolved indices"; + // this is how we tell es core to return an empty response, we can let the request through being sure + // that the '-*' wildcard expression will be resolved to no indices. We can't let empty indices through + // as that would be resolved to _all by es core. + replaceable.indices(IndicesAndAliasesResolverField.NO_INDICES_OR_ALIASES_ARRAY); + resolvedIndicesBuilder.addLocal(NO_INDEX_PLACEHOLDER); + } + private static void setResolvedIndexExpressionsIfUnset(IndicesRequest.Replaceable replaceable, ResolvedIndexExpressions resolved) { if (replaceable.getResolvedIndexExpressions() == null) { replaceable.setResolvedIndexExpressions(resolved); From 732ace0a49ccce009afdc2ff9556aa85ef7ac7b9 Mon Sep 17 00:00:00 2001 From: Nikolaj Volgushev Date: Fri, 3 Oct 2025 15:04:22 +0200 Subject: [PATCH 83/89] Feedback --- .../xpack/core/security/SecurityExtension.java | 2 +- .../org/elasticsearch/xpack/security/Security.java | 13 ++++++++++++- .../security/authz/IndicesAndAliasesResolver.java | 6 +++--- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/SecurityExtension.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/SecurityExtension.java index 37e7447fa117e..4c4db9a035e93 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/SecurityExtension.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/SecurityExtension.java @@ -150,7 +150,7 @@ default String extensionName() { return getClass().getName(); } - default AuthorizedProjectsResolver getAuthorizedProjectsSupplier(SecurityComponents components) { + default AuthorizedProjectsResolver getAuthorizedProjectsResolver(SecurityComponents components) { return null; } } diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java index ba496b772d4a4..13497991c6fb3 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java @@ -1351,7 +1351,18 @@ private AuthorizedProjectsResolver getCustomAuthorizedProjectsResolverOrDefault( ) { final AuthorizedProjectsResolver customAuthorizedProjectsResolver = findValueFromExtensions( "authorized projects resolver", - extension -> extension.getAuthorizedProjectsSupplier(extensionComponents) + extension -> { + final AuthorizedProjectsResolver authorizedProjectsResolver = extension.getAuthorizedProjectsResolver(extensionComponents); + if (authorizedProjectsResolver != null && isInternalExtension(extension) == false) { + throw new IllegalStateException( + "The [" + + extension.getClass().getName() + + "] extension tried to install a custom AuthorizedProjectsResolver. This functionality is not available to " + + "external extensions." + ); + } + return authorizedProjectsResolver; + } ); return customAuthorizedProjectsResolver == null ? new AuthorizedProjectsResolver.Default() : customAuthorizedProjectsResolver; } diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java index deffe6b1bce4d..1bf939648c8ae 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java @@ -389,7 +389,7 @@ ResolvedIndices resolveIndicesAndAliases( // TODO explain why we're doing this if (resolvedIndicesBuilder.isEmpty()) { - markWithNoneExpression(replaceable, resolvedIndicesBuilder); + setNoneExpression(replaceable, resolvedIndicesBuilder); } else { replaceable.indices(resolvedIndicesBuilder.build().toArray()); } @@ -423,7 +423,7 @@ ResolvedIndices resolveIndicesAndAliases( if (resolvedIndicesBuilder.isEmpty()) { if (indicesOptions.allowNoIndices()) { indicesReplacedWithNoIndices = true; - markWithNoneExpression(replaceable, resolvedIndicesBuilder); + setNoneExpression(replaceable, resolvedIndicesBuilder); } else { throw new IndexNotFoundException(Arrays.toString(indicesRequest.indices())); } @@ -481,7 +481,7 @@ ResolvedIndices resolveIndicesAndAliases( return resolvedIndicesBuilder.build(); } - private static void markWithNoneExpression(IndicesRequest.Replaceable replaceable, ResolvedIndices.Builder resolvedIndicesBuilder) { + private static void setNoneExpression(IndicesRequest.Replaceable replaceable, ResolvedIndices.Builder resolvedIndicesBuilder) { assert resolvedIndicesBuilder.isEmpty() : "we only mark with none expression on empty resolved indices"; // this is how we tell es core to return an empty response, we can let the request through being sure // that the '-*' wildcard expression will be resolved to no indices. We can't let empty indices through From 097a111763c319cd0682afaa3b65d685237ed256 Mon Sep 17 00:00:00 2001 From: Nikolaj Volgushev Date: Sat, 4 Oct 2025 17:31:01 +0200 Subject: [PATCH 84/89] Clean up --- .../indices/resolve/ResolveIndexAction.java | 8 ++++---- ...elper.java => CrossProjectModeDecider.java} | 18 ++++++++++++------ .../security/authz/AuthorizationService.java | 6 +++--- .../authz/IndicesAndAliasesResolver.java | 8 ++++---- 4 files changed, 23 insertions(+), 17 deletions(-) rename server/src/main/java/org/elasticsearch/search/crossproject/{CrossProjectIndicesRequestHelper.java => CrossProjectModeDecider.java} (70%) diff --git a/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java b/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java index a49da50b8c03d..869e5df1bc95d 100644 --- a/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java +++ b/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java @@ -73,8 +73,8 @@ import java.util.stream.Stream; import static org.elasticsearch.action.search.TransportSearchHelper.checkCCSVersionCompatibility; -import static org.elasticsearch.search.crossproject.CrossProjectIndicesRequestHelper.crossProjectFanoutIndicesOptions; -import static org.elasticsearch.search.crossproject.CrossProjectIndicesRequestHelper.shouldResolveCrossProject; +import static org.elasticsearch.search.crossproject.CrossProjectModeDecider.fanoutRequestIndicesOptions; +import static org.elasticsearch.search.crossproject.CrossProjectModeDecider.resolvesCrossProject; public class ResolveIndexAction extends ActionType { @@ -596,9 +596,9 @@ protected void doExecute(Task task, Request request, final ActionListener remoteClusterIndices = remoteClusterService.groupIndices( - resolveCrossProject ? crossProjectFanoutIndicesOptions(originalIndicesOptions) : originalIndicesOptions, + resolveCrossProject ? fanoutRequestIndicesOptions(originalIndicesOptions) : originalIndicesOptions, request.indices() ); final OriginalIndices localIndices = remoteClusterIndices.remove(RemoteClusterAware.LOCAL_CLUSTER_GROUP_KEY); diff --git a/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectIndicesRequestHelper.java b/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectModeDecider.java similarity index 70% rename from server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectIndicesRequestHelper.java rename to server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectModeDecider.java index eda199b9cce3b..c840049fd5f6b 100644 --- a/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectIndicesRequestHelper.java +++ b/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectModeDecider.java @@ -11,23 +11,29 @@ import org.elasticsearch.action.IndicesRequest; import org.elasticsearch.action.support.IndicesOptions; +import org.elasticsearch.common.settings.Settings; import org.elasticsearch.core.Booleans; import org.elasticsearch.transport.TransportRequest; -public final class CrossProjectIndicesRequestHelper { - private CrossProjectIndicesRequestHelper() {} +public final class CrossProjectModeDecider { + private CrossProjectModeDecider() {} - public static boolean shouldResolveCrossProject(IndicesRequest.Replaceable request) { + public static boolean isCrossProject(Settings settings) { + return settings.getAsBoolean("serverless.cross_project.enabled", false); + } + + public static boolean resolvesCrossProject(IndicesRequest.Replaceable request) { // TODO this needs to be based on the IndicesOptions flag instead, once available final boolean indicesOptionsResolveCrossProject = Booleans.parseBoolean(System.getProperty("cps.resolve_cross_project", "false")); return request.allowsCrossProject() && indicesOptionsResolveCrossProject; } - public static boolean shouldResolveTransportRequestCrossProject(TransportRequest request) { - return request instanceof IndicesRequest.Replaceable replaceable && shouldResolveCrossProject(replaceable); + public static boolean transportRequestResolvesCrossProject(TransportRequest request) { + return request instanceof IndicesRequest.Replaceable replaceable && resolvesCrossProject(replaceable); } - public static IndicesOptions crossProjectFanoutIndicesOptions(IndicesOptions indicesOptions) { + // TODO doesn't belong here + public static IndicesOptions fanoutRequestIndicesOptions(IndicesOptions indicesOptions) { // TODO set resolveCrossProject=false here once we have an IndicesOptions flag for that return IndicesOptions.builder(indicesOptions) .concreteTargetOptions(new IndicesOptions.ConcreteTargetOptions(true)) 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 1f8c7dfdbb366..ee86ea498129f 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 @@ -48,7 +48,7 @@ import org.elasticsearch.index.IndexNotFoundException; import org.elasticsearch.indices.InvalidIndexNameException; import org.elasticsearch.license.XPackLicenseState; -import org.elasticsearch.search.crossproject.CrossProjectIndicesRequestHelper; +import org.elasticsearch.search.crossproject.CrossProjectModeDecider; import org.elasticsearch.search.crossproject.NoMatchingProjectException; import org.elasticsearch.search.crossproject.TargetProjects; import org.elasticsearch.threadpool.ThreadPool; @@ -181,7 +181,7 @@ public AuthorizationService( settings, linkedProjectConfigService, resolver, - settings.getAsBoolean("serverless.cross_project.enabled", false) + CrossProjectModeDecider.isCrossProject(settings) ); this.authcFailureHandler = authcFailureHandler; this.threadContext = threadPool.getThreadContext(); @@ -513,7 +513,7 @@ private void authorizeAction( final var authorizedIndicesListener = new SubscribableListener(); authorizedIndicesListener.>andThen( (l, authorizedIndices) -> { - if (CrossProjectIndicesRequestHelper.shouldResolveTransportRequestCrossProject(request)) { + if (CrossProjectModeDecider.transportRequestResolvesCrossProject(request)) { authorizedProjectsResolver.resolveAuthorizedProjects( l.map(targetProjects -> new Tuple<>(authorizedIndices, targetProjects)) ); diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java index 1bf939648c8ae..d45403433aaa9 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java @@ -55,8 +55,8 @@ import java.util.function.BiPredicate; import static org.elasticsearch.cluster.metadata.IndexNameExpressionResolver.isNoneExpression; -import static org.elasticsearch.search.crossproject.CrossProjectIndicesRequestHelper.crossProjectFanoutIndicesOptions; -import static org.elasticsearch.search.crossproject.CrossProjectIndicesRequestHelper.shouldResolveCrossProject; +import static org.elasticsearch.search.crossproject.CrossProjectModeDecider.fanoutRequestIndicesOptions; +import static org.elasticsearch.search.crossproject.CrossProjectModeDecider.resolvesCrossProject; import static org.elasticsearch.xpack.core.security.authz.IndicesAndAliasesResolverField.NO_INDEX_PLACEHOLDER; class IndicesAndAliasesResolver { @@ -366,7 +366,7 @@ ResolvedIndices resolveIndicesAndAliases( // we honour allow_no_indices like es core does. } else { assert indicesRequest.indices() != null : "indices() cannot be null when resolving non-all-index expressions"; - if (shouldResolveCrossProject(replaceable) + if (resolvesCrossProject(replaceable) // a none expression should not go through cross-project resolution -- fall back to local resolution logic && false == IndexNameExpressionResolver.isNoneExpression(replaceable.indices())) { assert replaceable.allowsRemoteIndices() : "cross-project requests must allow remote indices"; @@ -374,7 +374,7 @@ ResolvedIndices resolveIndicesAndAliases( final ResolvedIndexExpressions resolved = indexAbstractionResolver.resolveIndexAbstractions( Arrays.asList(replaceable.indices()), - crossProjectFanoutIndicesOptions(indicesOptions), + fanoutRequestIndicesOptions(indicesOptions), projectMetadata, authorizedIndices::all, authorizedIndices::check, From e9f2f4ead2ae1efb516ff0c2efcab79564271552 Mon Sep 17 00:00:00 2001 From: Nikolaj Volgushev Date: Sun, 5 Oct 2025 14:29:01 +0200 Subject: [PATCH 85/89] Javadoc --- .../elasticsearch/action/IndicesRequest.java | 4 +++- .../crossproject/CrossProjectModeDecider.java | 20 +++++++++++++++++++ .../authz/IndicesAndAliasesResolver.java | 7 ++----- 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/server/src/main/java/org/elasticsearch/action/IndicesRequest.java b/server/src/main/java/org/elasticsearch/action/IndicesRequest.java index 9493deb4de5f0..e1a0f649621ec 100644 --- a/server/src/main/java/org/elasticsearch/action/IndicesRequest.java +++ b/server/src/main/java/org/elasticsearch/action/IndicesRequest.java @@ -82,7 +82,9 @@ default boolean allowsRemoteIndices() { } /** - * Determines whether the request type allows cross-project processing. + * Determines whether the request type allows cross-project processing. Cross-project processing entails cross-project search + * index resolution and error handling. Note: this method only determines in the request _supports_ cross-project. + * Whether cross-project processing is actually performed is determined by {@link IndicesOptions}. */ default boolean allowsCrossProject() { return false; diff --git a/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectModeDecider.java b/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectModeDecider.java index c840049fd5f6b..07b00114c6057 100644 --- a/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectModeDecider.java +++ b/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectModeDecider.java @@ -15,6 +15,26 @@ import org.elasticsearch.core.Booleans; import org.elasticsearch.transport.TransportRequest; +/** + * Utility class to determine whether Cross-Project Search (CPS) applies to an inbound request. + *

+ * CPS applicability is controlled at three levels: + *

    + *
  • Cluster level: The {@code serverless.cross_project.enabled} setting determines + * whether CPS processing is available at all. In the future, all Serverless projects + * will support CPS, so this distinction will depend on whether the cluster is a + * Serverless cluster or not.
  • + *
  • API level: The {@link org.elasticsearch.action.IndicesRequest.Replaceable#allowsCrossProject()} + * method determines whether a particular request type supports CPS processing.
  • + *
  • Request level: An {@link org.elasticsearch.action.support.IndicesOptions} flag + * determines whether CPS should apply to the current + * request being processed. This fine-grained control is required because APIs that + * support CPS may also be used in contexts where CPS should not apply—for example, + * internal searches against the security system index to retrieve user roles, or CPS + * actions that execute in a flow where a parent action has already performed CPS + * processing.
  • + *
+ */ public final class CrossProjectModeDecider { private CrossProjectModeDecider() {} diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java index d45403433aaa9..5116931aca000 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java @@ -381,19 +381,16 @@ ResolvedIndices resolveIndicesAndAliases( authorizedProjects, indicesRequest.includeDataStreams() ); - setResolvedIndexExpressionsIfUnset(replaceable, resolved); - resolvedIndicesBuilder.addLocal(resolved.getLocalIndicesList()); resolvedIndicesBuilder.addRemote(resolved.getRemoteIndicesList()); - - // TODO explain why we're doing this + // we need an early return here, instead of relying on the outer none expression logic since the outer handling will + // prematurely throw an IndexNotFound exception if the resolved indices are empty and allow_no_indices is false. if (resolvedIndicesBuilder.isEmpty()) { setNoneExpression(replaceable, resolvedIndicesBuilder); } else { replaceable.indices(resolvedIndicesBuilder.build().toArray()); } - return resolvedIndicesBuilder.build(); } else { final ResolvedIndices split; From c7748c2e6a7078755e8360ee3014aa61d98c8530 Mon Sep 17 00:00:00 2001 From: Nikolaj Volgushev Date: Sun, 5 Oct 2025 21:18:28 +0200 Subject: [PATCH 86/89] Tweaks and renames --- .../indices/resolve/ResolveIndexAction.java | 12 ++++--- .../CrossProjectIndexResolutionValidator.java | 8 +++++ .../crossproject/CrossProjectModeDecider.java | 33 ++++++++----------- .../TransportResolveIndexActionTests.java | 1 + .../security/authz/AuthorizationService.java | 5 +-- .../authz/IndicesAndAliasesResolver.java | 18 ++++++---- .../authz/IndicesAndAliasesResolverTests.java | 6 +++- 7 files changed, 50 insertions(+), 33 deletions(-) diff --git a/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java b/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java index 869e5df1bc95d..cfc26f445a3c8 100644 --- a/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java +++ b/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java @@ -39,6 +39,7 @@ import org.elasticsearch.common.io.stream.StreamOutput; import org.elasticsearch.common.io.stream.Writeable; import org.elasticsearch.common.regex.Regex; +import org.elasticsearch.common.settings.Settings; import org.elasticsearch.common.util.concurrent.CountDown; import org.elasticsearch.common.util.concurrent.EsExecutors; import org.elasticsearch.core.Nullable; @@ -47,6 +48,7 @@ import org.elasticsearch.injection.guice.Inject; import org.elasticsearch.search.SearchService; import org.elasticsearch.search.crossproject.CrossProjectIndexResolutionValidator; +import org.elasticsearch.search.crossproject.CrossProjectModeDecider; import org.elasticsearch.tasks.Task; import org.elasticsearch.transport.RemoteClusterAware; import org.elasticsearch.transport.RemoteClusterService; @@ -73,8 +75,7 @@ import java.util.stream.Stream; import static org.elasticsearch.action.search.TransportSearchHelper.checkCCSVersionCompatibility; -import static org.elasticsearch.search.crossproject.CrossProjectModeDecider.fanoutRequestIndicesOptions; -import static org.elasticsearch.search.crossproject.CrossProjectModeDecider.resolvesCrossProject; +import static org.elasticsearch.search.crossproject.CrossProjectIndexResolutionValidator.indicesOptionsForCrossProjectFanout; public class ResolveIndexAction extends ActionType { @@ -572,6 +573,7 @@ public static class TransportAction extends HandledTransportAction remoteClusterIndices = remoteClusterService.groupIndices( - resolveCrossProject ? fanoutRequestIndicesOptions(originalIndicesOptions) : originalIndicesOptions, + resolveCrossProject ? indicesOptionsForCrossProjectFanout(originalIndicesOptions) : originalIndicesOptions, request.indices() ); final OriginalIndices localIndices = remoteClusterIndices.remove(RemoteClusterAware.LOCAL_CLUSTER_GROUP_KEY); diff --git a/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectIndexResolutionValidator.java b/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectIndexResolutionValidator.java index 2d958ddd88341..92322b771cd67 100644 --- a/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectIndexResolutionValidator.java +++ b/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectIndexResolutionValidator.java @@ -166,6 +166,14 @@ public static ElasticsearchException validate( return null; } + public static IndicesOptions indicesOptionsForCrossProjectFanout(IndicesOptions indicesOptions) { + // TODO set resolveCrossProject=false here once we have an IndicesOptions flag for that + return IndicesOptions.builder(indicesOptions) + .concreteTargetOptions(new IndicesOptions.ConcreteTargetOptions(true)) + .wildcardOptions(IndicesOptions.WildcardOptions.builder(indicesOptions.wildcardOptions()).allowEmptyExpressions(true).build()) + .build(); + } + private static ElasticsearchSecurityException securityException(String originalExpression) { // TODO plug in proper recorded authorization exceptions instead, once available return new ElasticsearchSecurityException("user cannot access [" + originalExpression + "]", RestStatus.FORBIDDEN); diff --git a/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectModeDecider.java b/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectModeDecider.java index 07b00114c6057..7afdfd181f661 100644 --- a/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectModeDecider.java +++ b/server/src/main/java/org/elasticsearch/search/crossproject/CrossProjectModeDecider.java @@ -10,10 +10,8 @@ package org.elasticsearch.search.crossproject; import org.elasticsearch.action.IndicesRequest; -import org.elasticsearch.action.support.IndicesOptions; import org.elasticsearch.common.settings.Settings; import org.elasticsearch.core.Booleans; -import org.elasticsearch.transport.TransportRequest; /** * Utility class to determine whether Cross-Project Search (CPS) applies to an inbound request. @@ -35,29 +33,24 @@ * processing. * */ -public final class CrossProjectModeDecider { - private CrossProjectModeDecider() {} +public class CrossProjectModeDecider { + private static final String CROSS_PROJECT_ENABLED_SETTING_KEY = "serverless.cross_project.enabled"; + private final boolean crossProjectEnabled; - public static boolean isCrossProject(Settings settings) { - return settings.getAsBoolean("serverless.cross_project.enabled", false); + public CrossProjectModeDecider(Settings settings) { + this.crossProjectEnabled = settings.getAsBoolean(CROSS_PROJECT_ENABLED_SETTING_KEY, false); } - public static boolean resolvesCrossProject(IndicesRequest.Replaceable request) { + public boolean crossProjectEnabled() { + return crossProjectEnabled; + } + + public boolean resolvesCrossProject(IndicesRequest.Replaceable request) { + if (crossProjectEnabled == false) { + return false; + } // TODO this needs to be based on the IndicesOptions flag instead, once available final boolean indicesOptionsResolveCrossProject = Booleans.parseBoolean(System.getProperty("cps.resolve_cross_project", "false")); return request.allowsCrossProject() && indicesOptionsResolveCrossProject; } - - public static boolean transportRequestResolvesCrossProject(TransportRequest request) { - return request instanceof IndicesRequest.Replaceable replaceable && resolvesCrossProject(replaceable); - } - - // TODO doesn't belong here - public static IndicesOptions fanoutRequestIndicesOptions(IndicesOptions indicesOptions) { - // TODO set resolveCrossProject=false here once we have an IndicesOptions flag for that - return IndicesOptions.builder(indicesOptions) - .concreteTargetOptions(new IndicesOptions.ConcreteTargetOptions(true)) - .wildcardOptions(IndicesOptions.WildcardOptions.builder(indicesOptions.wildcardOptions()).allowEmptyExpressions(true).build()) - .build(); - } } diff --git a/server/src/test/java/org/elasticsearch/action/admin/indices/resolve/TransportResolveIndexActionTests.java b/server/src/test/java/org/elasticsearch/action/admin/indices/resolve/TransportResolveIndexActionTests.java index 6e37335603572..4e247ad2f122e 100644 --- a/server/src/test/java/org/elasticsearch/action/admin/indices/resolve/TransportResolveIndexActionTests.java +++ b/server/src/test/java/org/elasticsearch/action/admin/indices/resolve/TransportResolveIndexActionTests.java @@ -81,6 +81,7 @@ public void writeTo(StreamOutput out) throws IOException { clusterService, actionFilters, TestProjectResolvers.DEFAULT_PROJECT_ONLY, + Settings.EMPTY, null ); 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 ee86ea498129f..fa343fa0c4a81 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; @@ -181,7 +182,7 @@ public AuthorizationService( settings, linkedProjectConfigService, resolver, - CrossProjectModeDecider.isCrossProject(settings) + new CrossProjectModeDecider(settings) ); this.authcFailureHandler = authcFailureHandler; this.threadContext = threadPool.getThreadContext(); @@ -513,7 +514,7 @@ private void authorizeAction( final var authorizedIndicesListener = new SubscribableListener(); authorizedIndicesListener.>andThen( (l, authorizedIndices) -> { - if (CrossProjectModeDecider.transportRequestResolvesCrossProject(request)) { + if (indicesAndAliasesResolver.resolvesCrossProject(request)) { authorizedProjectsResolver.resolveAuthorizedProjects( l.map(targetProjects -> new Tuple<>(authorizedIndices, targetProjects)) ); diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java index 5116931aca000..cf2dd0a769c42 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java @@ -32,6 +32,7 @@ import org.elasticsearch.core.Tuple; import org.elasticsearch.index.Index; import org.elasticsearch.index.IndexNotFoundException; +import org.elasticsearch.search.crossproject.CrossProjectModeDecider; import org.elasticsearch.search.crossproject.TargetProjects; import org.elasticsearch.transport.LinkedProjectConfig; import org.elasticsearch.transport.LinkedProjectConfigService; @@ -55,8 +56,7 @@ import java.util.function.BiPredicate; import static org.elasticsearch.cluster.metadata.IndexNameExpressionResolver.isNoneExpression; -import static org.elasticsearch.search.crossproject.CrossProjectModeDecider.fanoutRequestIndicesOptions; -import static org.elasticsearch.search.crossproject.CrossProjectModeDecider.resolvesCrossProject; +import static org.elasticsearch.search.crossproject.CrossProjectIndexResolutionValidator.indicesOptionsForCrossProjectFanout; import static org.elasticsearch.xpack.core.security.authz.IndicesAndAliasesResolverField.NO_INDEX_PLACEHOLDER; class IndicesAndAliasesResolver { @@ -67,17 +67,19 @@ class IndicesAndAliasesResolver { private final IndexAbstractionResolver indexAbstractionResolver; private final RemoteClusterResolver remoteClusterResolver; private final boolean recordResolvedIndexExpressions; + private final CrossProjectModeDecider crossProjectModeDecider; IndicesAndAliasesResolver( Settings settings, LinkedProjectConfigService linkedProjectConfigService, IndexNameExpressionResolver resolver, - boolean recordResolvedIndexExpressions + CrossProjectModeDecider crossProjectModeDecider ) { this.nameExpressionResolver = resolver; this.indexAbstractionResolver = new IndexAbstractionResolver(resolver); this.remoteClusterResolver = new RemoteClusterResolver(settings, linkedProjectConfigService); - this.recordResolvedIndexExpressions = recordResolvedIndexExpressions; + this.crossProjectModeDecider = crossProjectModeDecider; + this.recordResolvedIndexExpressions = crossProjectModeDecider.crossProjectEnabled(); } /** @@ -160,6 +162,10 @@ ResolvedIndices tryResolveWithoutWildcards(String action, TransportRequest trans return resolveIndicesAndAliasesWithoutWildcards(action, indicesRequest); } + boolean resolvesCrossProject(TransportRequest request) { + return request instanceof IndicesRequest.Replaceable replaceable && crossProjectModeDecider.resolvesCrossProject(replaceable); + } + private static boolean requiresWildcardExpansion(IndicesRequest indicesRequest) { // IndicesAliasesRequest requires special handling because it can have wildcards in request body if (indicesRequest instanceof IndicesAliasesRequest) { @@ -366,7 +372,7 @@ ResolvedIndices resolveIndicesAndAliases( // we honour allow_no_indices like es core does. } else { assert indicesRequest.indices() != null : "indices() cannot be null when resolving non-all-index expressions"; - if (resolvesCrossProject(replaceable) + if (crossProjectModeDecider.resolvesCrossProject(replaceable) // a none expression should not go through cross-project resolution -- fall back to local resolution logic && false == IndexNameExpressionResolver.isNoneExpression(replaceable.indices())) { assert replaceable.allowsRemoteIndices() : "cross-project requests must allow remote indices"; @@ -374,7 +380,7 @@ ResolvedIndices resolveIndicesAndAliases( final ResolvedIndexExpressions resolved = indexAbstractionResolver.resolveIndexAbstractions( Arrays.asList(replaceable.indices()), - fanoutRequestIndicesOptions(indicesOptions), + indicesOptionsForCrossProjectFanout(indicesOptions), projectMetadata, authorizedIndices::all, authorizedIndices::check, diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolverTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolverTests.java index beb6862d389e0..e28c256e5ed18 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolverTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolverTests.java @@ -61,6 +61,7 @@ import org.elasticsearch.indices.TestIndexNameExpressionResolver; import org.elasticsearch.license.MockLicenseState; import org.elasticsearch.protocol.xpack.graph.GraphExploreRequest; +import org.elasticsearch.search.crossproject.CrossProjectModeDecider; import org.elasticsearch.search.crossproject.TargetProjects; import org.elasticsearch.search.internal.ShardSearchRequest; import org.elasticsearch.test.ESTestCase; @@ -160,6 +161,9 @@ public void setup() { ).put("cluster.remote.other_remote.seeds", "127.0.0.1:" + randomIntBetween(9351, 9399)).build(); IndexNameExpressionResolver indexNameExpressionResolver = TestIndexNameExpressionResolver.newInstance(); + CrossProjectModeDecider crossProjectModeDecider = mock(CrossProjectModeDecider.class); + when(crossProjectModeDecider.crossProjectEnabled()).thenReturn(true); + when(crossProjectModeDecider.resolvesCrossProject(any(IndicesRequest.Replaceable.class))).thenReturn(false); DateFormatter dateFormatter = DateFormatter.forPattern("uuuu.MM.dd"); Instant now = Instant.now(Clock.systemUTC()); @@ -433,7 +437,7 @@ public void setup() { settings, new ClusterSettingsLinkedProjectConfigService(settings, clusterService.getClusterSettings(), projectResolver), indexNameExpressionResolver, - true + crossProjectModeDecider ); } From 69c4a803d83dbe1e01b7fbcb6019d0de5aedd359 Mon Sep 17 00:00:00 2001 From: elasticsearchmachine Date: Sun, 5 Oct 2025 19:25:39 +0000 Subject: [PATCH 87/89] [CI] Auto commit changes from spotless --- .../elasticsearch/xpack/security/authz/AuthorizationService.java | 1 - 1 file changed, 1 deletion(-) 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 fa343fa0c4a81..9297fce4326ca 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,7 +16,6 @@ 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; From 2bffce31de5fe1a23ca098960cab9f4c75f36bbc Mon Sep 17 00:00:00 2001 From: Nikolaj Volgushev Date: Mon, 6 Oct 2025 12:26:22 +0200 Subject: [PATCH 88/89] Feedback --- .../metadata/IndexAbstractionResolver.java | 9 +++-- .../search/crossproject/TargetProjects.java | 11 +++++-- .../authz/IndicesAndAliasesResolver.java | 33 ++++++------------- 3 files changed, 25 insertions(+), 28 deletions(-) diff --git a/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java b/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java index 06a6905416ced..246ed0b5f9ebf 100644 --- a/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java +++ b/server/src/main/java/org/elasticsearch/cluster/metadata/IndexAbstractionResolver.java @@ -78,9 +78,10 @@ public ResolvedIndexExpressions resolveIndexAbstractions( ) { assert targetProjects != TargetProjects.NOT_CROSS_PROJECT : "cannot resolve indices cross project if target set is NOT_CROSS_PROJECT"; - if (targetProjects.isEmpty()) { - assert false : "cannot resolve indices cross project if target set is empty"; - throw new IllegalArgumentException("cannot resolve indices cross project if target set is empty"); + if (false == targetProjects.crossProject()) { + final String message = "cannot resolve indices cross project if target set is empty"; + assert false : message; + throw new IllegalArgumentException(message); } final String originProjectAlias = targetProjects.originProjectAlias(); @@ -93,6 +94,8 @@ public ResolvedIndexExpressions resolveIndexAbstractions( final String localIndexExpression = indexRewriteResult.localExpression(); if (localIndexExpression == null) { + // TODO we may still need to update the `wildcardSeen` value to correctly handle exclusions + // (there can be an exclusion without any local index expressions) // nothing to resolve locally so skip resolve abstraction call resolvedExpressionsBuilder.addRemoteExpressions(originalIndexExpression, indexRewriteResult.remoteExpressions()); continue; diff --git a/server/src/main/java/org/elasticsearch/search/crossproject/TargetProjects.java b/server/src/main/java/org/elasticsearch/search/crossproject/TargetProjects.java index c700e5e265325..8960a7106cd8a 100644 --- a/server/src/main/java/org/elasticsearch/search/crossproject/TargetProjects.java +++ b/server/src/main/java/org/elasticsearch/search/crossproject/TargetProjects.java @@ -16,6 +16,13 @@ import java.util.Set; import java.util.stream.Collectors; +/** + * Holds information about the target projects for a cross-project search request. This record is used both by the + * project authorization filter and project routing logic. + * @param originProject the origin project, can be null if the request is not cross-project OR it was excluded by + * project routing + * @param linkedProjects all projects that are linked and authorized, can be empty if the request is not cross-project + */ public record TargetProjects(@Nullable ProjectRoutingInfo originProject, List linkedProjects) { public static final TargetProjects NOT_CROSS_PROJECT = new TargetProjects(null, List.of()); @@ -37,7 +44,7 @@ public Set allProjectAliases() { return Collections.unmodifiableSet(allProjectAliases); } - public boolean isEmpty() { - return originProject == null && linkedProjects.isEmpty(); + public boolean crossProject() { + return originProject != null || linkedProjects.isEmpty() == false; } } diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java index cf2dd0a769c42..c2daabd741926 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java @@ -66,7 +66,6 @@ class IndicesAndAliasesResolver { private final IndexNameExpressionResolver nameExpressionResolver; private final IndexAbstractionResolver indexAbstractionResolver; private final RemoteClusterResolver remoteClusterResolver; - private final boolean recordResolvedIndexExpressions; private final CrossProjectModeDecider crossProjectModeDecider; IndicesAndAliasesResolver( @@ -79,7 +78,6 @@ class IndicesAndAliasesResolver { this.indexAbstractionResolver = new IndexAbstractionResolver(resolver); this.remoteClusterResolver = new RemoteClusterResolver(settings, linkedProjectConfigService); this.crossProjectModeDecider = crossProjectModeDecider; - this.recordResolvedIndexExpressions = crossProjectModeDecider.crossProjectEnabled(); } /** @@ -376,7 +374,7 @@ ResolvedIndices resolveIndicesAndAliases( // a none expression should not go through cross-project resolution -- fall back to local resolution logic && false == IndexNameExpressionResolver.isNoneExpression(replaceable.indices())) { assert replaceable.allowsRemoteIndices() : "cross-project requests must allow remote indices"; - assert recordResolvedIndexExpressions : "cross-project requests must record resolved index expressions"; + assert authorizedProjects.crossProject() : "cross-project requests must have cross-project target set"; final ResolvedIndexExpressions resolved = indexAbstractionResolver.resolveIndexAbstractions( Arrays.asList(replaceable.indices()), @@ -390,14 +388,6 @@ ResolvedIndices resolveIndicesAndAliases( setResolvedIndexExpressionsIfUnset(replaceable, resolved); resolvedIndicesBuilder.addLocal(resolved.getLocalIndicesList()); resolvedIndicesBuilder.addRemote(resolved.getRemoteIndicesList()); - // we need an early return here, instead of relying on the outer none expression logic since the outer handling will - // prematurely throw an IndexNotFound exception if the resolved indices are empty and allow_no_indices is false. - if (resolvedIndicesBuilder.isEmpty()) { - setNoneExpression(replaceable, resolvedIndicesBuilder); - } else { - replaceable.indices(resolvedIndicesBuilder.build().toArray()); - } - return resolvedIndicesBuilder.build(); } else { final ResolvedIndices split; if (replaceable.allowsRemoteIndices()) { @@ -416,7 +406,7 @@ ResolvedIndices resolveIndicesAndAliases( // only store resolved expressions if configured, to avoid unnecessary memory usage // once we've migrated from `indices()` to using resolved expressions holistically, // we will always store them - if (recordResolvedIndexExpressions) { + if (crossProjectModeDecider.crossProjectEnabled()) { setResolvedIndexExpressionsIfUnset(replaceable, resolved); } resolvedIndicesBuilder.addLocal(resolved.getLocalIndicesList()); @@ -424,9 +414,15 @@ ResolvedIndices resolveIndicesAndAliases( } } if (resolvedIndicesBuilder.isEmpty()) { - if (indicesOptions.allowNoIndices()) { + // if we resolved the request according to CPS rules, error handling (like throwing IndexNotFoundException) happens later + // therefore, don't throw here + if (indicesOptions.allowNoIndices() || crossProjectModeDecider.resolvesCrossProject(replaceable)) { indicesReplacedWithNoIndices = true; - setNoneExpression(replaceable, resolvedIndicesBuilder); + // this is how we tell es core to return an empty response, we can let the request through being sure + // that the '-*' wildcard expression will be resolved to no indices. We can't let empty indices through + // as that would be resolved to _all by es core. + replaceable.indices(IndicesAndAliasesResolverField.NO_INDICES_OR_ALIASES_ARRAY); + resolvedIndicesBuilder.addLocal(NO_INDEX_PLACEHOLDER); } else { throw new IndexNotFoundException(Arrays.toString(indicesRequest.indices())); } @@ -484,15 +480,6 @@ ResolvedIndices resolveIndicesAndAliases( return resolvedIndicesBuilder.build(); } - private static void setNoneExpression(IndicesRequest.Replaceable replaceable, ResolvedIndices.Builder resolvedIndicesBuilder) { - assert resolvedIndicesBuilder.isEmpty() : "we only mark with none expression on empty resolved indices"; - // this is how we tell es core to return an empty response, we can let the request through being sure - // that the '-*' wildcard expression will be resolved to no indices. We can't let empty indices through - // as that would be resolved to _all by es core. - replaceable.indices(IndicesAndAliasesResolverField.NO_INDICES_OR_ALIASES_ARRAY); - resolvedIndicesBuilder.addLocal(NO_INDEX_PLACEHOLDER); - } - private static void setResolvedIndexExpressionsIfUnset(IndicesRequest.Replaceable replaceable, ResolvedIndexExpressions resolved) { if (replaceable.getResolvedIndexExpressions() == null) { replaceable.setResolvedIndexExpressions(resolved); From 1260c159b9a8ddcb9d8c6a8e14845255f19d76ec Mon Sep 17 00:00:00 2001 From: Nikolaj Volgushev Date: Mon, 6 Oct 2025 12:43:06 +0200 Subject: [PATCH 89/89] Fix comment --- .../action/admin/indices/resolve/ResolveIndexAction.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java b/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java index cfc26f445a3c8..7c5f99310f417 100644 --- a/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java +++ b/server/src/main/java/org/elasticsearch/action/admin/indices/resolve/ResolveIndexAction.java @@ -665,7 +665,7 @@ protected void doExecute(Task task, Request request, final ActionListener:index` get deferred validation, also + // `:index` also get deferred validation final Exception ex = CrossProjectIndexResolutionValidator.validate( originalIndicesOptions, localResolvedIndexExpressions,