Repository: elastic/elasticsearch
Pull request #126852: [Entitlements] Validation checks on paths
Author: login: ldematte
Labels: name: >enhancement
name: Team:Core/Infra
description: Meta label for core/infra team
name: auto-backport
description: Automatically create backport pull requests when merged
name: v8.18.1
name: v8.19.0
name: v9.0.1
name: v9.1.0
name: :Core/Infra/Entitlements
description: Entitlements infrastructure
Created: 2025-04-15T13:56:27Z | Merged: 2025-04-18T13:36:08Z

With this PR we restrict the paths we allow access to, forbidding plugins to specify/request entitlements for reading or writing to specific protected directories.

I added this validation to `EntitlementInitialization`, as I wanted to fail fast and this is the earliest occurrence where we have all we need: PathLookup to resolve relative paths, policies (for plugins, server, agents) and the Paths for the specific directories we want to protect.

Relates to ES-10918


Diff:
diff --git a/docs/changelog/126852.yaml b/docs/changelog/126852.yaml
new file mode 100644
index 0000000000000..e2fe44b24ed69
--- /dev/null
+++ b/docs/changelog/126852.yaml
@@ -0,0 +1,5 @@
+pr: 126852
+summary: "Validation checks on paths allowed for 'files' entitlements. Restrict the paths we allow access to, forbidding plugins to specify/request entitlements for reading or writing to specific protected directories."
+area: Infra/Core
+type: enhancement
+issues: []
diff --git a/libs/entitlement/src/main/java/org/elasticsearch/entitlement/initialization/EntitlementInitialization.java b/libs/entitlement/src/main/java/org/elasticsearch/entitlement/initialization/EntitlementInitialization.java
index 8c13fc398e5d4..911eff0615f73 100644
--- a/libs/entitlement/src/main/java/org/elasticsearch/entitlement/initialization/EntitlementInitialization.java
+++ b/libs/entitlement/src/main/java/org/elasticsearch/entitlement/initialization/EntitlementInitialization.java
@@ -11,6 +11,7 @@
 
 import org.elasticsearch.core.Booleans;
 import org.elasticsearch.core.PathUtils;
+import org.elasticsearch.core.Strings;
 import org.elasticsearch.core.internal.provider.ProviderLocator;
 import org.elasticsearch.entitlement.bootstrap.EntitlementBootstrap;
 import org.elasticsearch.entitlement.bridge.EntitlementChecker;
@@ -20,6 +21,7 @@
 import org.elasticsearch.entitlement.instrumentation.MethodKey;
 import org.elasticsearch.entitlement.instrumentation.Transformer;
 import org.elasticsearch.entitlement.runtime.api.ElasticsearchEntitlementChecker;
+import org.elasticsearch.entitlement.runtime.policy.FileAccessTree;
 import org.elasticsearch.entitlement.runtime.policy.PathLookup;
 import org.elasticsearch.entitlement.runtime.policy.Policy;
 import org.elasticsearch.entitlement.runtime.policy.PolicyManager;
@@ -56,6 +58,7 @@
 import java.nio.file.attribute.FileAttribute;
 import java.nio.file.spi.FileSystemProvider;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
@@ -315,6 +318,16 @@ private static PolicyManager createPolicyManager() {
                 )
             )
         );
+
+        validateFilesEntitlements(
+            pluginPolicies,
+            pathLookup,
+            bootstrapArgs.configDir(),
+            bootstrapArgs.pluginsDir(),
+            bootstrapArgs.modulesDir(),
+            bootstrapArgs.libDir()
+        );
+
         return new PolicyManager(
             serverPolicy,
             agentEntitlements,
@@ -328,6 +341,81 @@ private static PolicyManager createPolicyManager() {
         );
     }
 
+    private static Set<Path> pathSet(Path... paths) {
+        return Arrays.stream(paths).map(x -> x.toAbsolutePath().normalize()).collect(Collectors.toUnmodifiableSet());
+    }
+
+    // package visible for tests
+    static void validateFilesEntitlements(
+        Map<String, Policy> pluginPolicies,
+        PathLookup pathLookup,
+        Path configDir,
+        Path pluginsDir,
+        Path modulesDir,
+        Path libDir
+    ) {
+        var readAccessForbidden = pathSet(pluginsDir, modulesDir, libDir);
+        var writeAccessForbidden = pathSet(configDir);
+        for (var pluginPolicy : pluginPolicies.entrySet()) {
+            for (var scope : pluginPolicy.getValue().scopes()) {
+                var filesEntitlement = scope.entitlements()
+                    .stream()
+                    .filter(x -> x instanceof FilesEntitlement)
+                    .map(x -> ((FilesEntitlement) x))
+                    .findFirst();
+                if (filesEntitlement.isPresent()) {
+                    var fileAccessTree = FileAccessTree.withoutExclusivePaths(filesEntitlement.get(), pathLookup, null);
+                    validateReadFilesEntitlements(pluginPolicy.getKey(), scope.moduleName(), fileAccessTree, readAccessForbidden);
+                    validateWriteFilesEntitlements(pluginPolicy.getKey(), scope.moduleName(), fileAccessTree, writeAccessForbidden);
+                }
+            }
+        }
+    }
+
+    private static IllegalArgumentException buildValidationException(
+        String componentName,
+        String moduleName,
+        Path forbiddenPath,
+        FilesEntitlement.Mode mode
+    ) {
+        return new IllegalArgumentException(
+            Strings.format(
+                "policy for module [%s] in [%s] has an invalid file entitlement. Any path under [%s] is forbidden for mode [%s].",
+                moduleName,
+                componentName,
+                forbiddenPath,
+                mode
+            )
+        );
+    }
+
+    private static void validateReadFilesEntitlements(
+        String componentName,
+        String moduleName,
+        FileAccessTree fileAccessTree,
+        Set<Path> readForbiddenPaths
+    ) {
+
+        for (Path forbiddenPath : readForbiddenPaths) {
+            if (fileAccessTree.canRead(forbiddenPath)) {
+                throw buildValidationException(componentName, moduleName, forbiddenPath, READ);
+            }
+        }
+    }
+
+    private static void validateWriteFilesEntitlements(
+        String componentName,
+        String moduleName,
+        FileAccessTree fileAccessTree,
+        Set<Path> writeForbiddenPaths
+    ) {
+        for (Path forbiddenPath : writeForbiddenPaths) {
+            if (fileAccessTree.canWrite(forbiddenPath)) {
+                throw buildValidationException(componentName, moduleName, forbiddenPath, READ_WRITE);
+            }
+        }
+    }
+
     private static Path getUserHome() {
         String userHome = System.getProperty("user.home");
         if (userHome == null) {
diff --git a/libs/entitlement/src/main/java/org/elasticsearch/entitlement/runtime/policy/FileAccessTree.java b/libs/entitlement/src/main/java/org/elasticsearch/entitlement/runtime/policy/FileAccessTree.java
index 0e35ef0f0c72e..cae5ddda6eb03 100644
--- a/libs/entitlement/src/main/java/org/elasticsearch/entitlement/runtime/policy/FileAccessTree.java
+++ b/libs/entitlement/src/main/java/org/elasticsearch/entitlement/runtime/policy/FileAccessTree.java
@@ -111,12 +111,9 @@ static void validateExclusivePaths(List<ExclusivePath> exclusivePaths) {
     private final String[] readPaths;
     private final String[] writePaths;
 
-    private FileAccessTree(
+    private static String[] buildUpdatedAndSortedExclusivePaths(
         String componentName,
         String moduleName,
-        FilesEntitlement filesEntitlement,
-        PathLookup pathLookup,
-        Path componentPath,
         List<ExclusivePath> exclusivePaths
     ) {
         List<String> updatedExclusivePaths = new ArrayList<>();
@@ -125,7 +122,11 @@ private FileAccessTree(
                 updatedExclusivePaths.add(exclusivePath.path());
             }
         }
+        updatedExclusivePaths.sort(PATH_ORDER);
+        return updatedExclusivePaths.toArray(new String[0]);
+    }
 
+    private FileAccessTree(FilesEntitlement filesEntitlement, PathLookup pathLookup, Path componentPath, String[] sortedExclusivePaths) {
         List<String> readPaths = new ArrayList<>();
         List<String> writePaths = new ArrayList<>();
         BiConsumer<Path, Mode> addPath = (path, mode) -> {
@@ -177,11 +178,10 @@ private FileAccessTree(
         Path jdk = Paths.get(System.getProperty("java.home"));
         addPathAndMaybeLink.accept(jdk.resolve("conf"), Mode.READ);
 
-        updatedExclusivePaths.sort(PATH_ORDER);
         readPaths.sort(PATH_ORDER);
         writePaths.sort(PATH_ORDER);
 
-        this.exclusivePaths = updatedExclusivePaths.toArray(new String[0]);
+        this.exclusivePaths = sortedExclusivePaths;
         this.readPaths = pruneSortedPaths(readPaths).toArray(new String[0]);
         this.writePaths = pruneSortedPaths(writePaths).toArray(new String[0]);
     }
@@ -203,7 +203,7 @@ static List<String> pruneSortedPaths(List<String> paths) {
         return prunedReadPaths;
     }
 
-    public static FileAccessTree of(
+    static FileAccessTree of(
         String componentName,
         String moduleName,
         FilesEntitlement filesEntitlement,
@@ -211,14 +211,30 @@ public static FileAccessTree of(
         @Nullable Path componentPath,
         List<ExclusivePath> exclusivePaths
     ) {
-        return new FileAccessTree(componentName, moduleName, filesEntitlement, pathLookup, componentPath, exclusivePaths);
+        return new FileAccessTree(
+            filesEntitlement,
+            pathLookup,
+            componentPath,
+            buildUpdatedAndSortedExclusivePaths(componentName, moduleName, exclusivePaths)
+        );
+    }
+
+    /**
+     * A special factory method to create a FileAccessTree with no ExclusivePaths, e.g. for quick validation or for default file access
+     */
+    public static FileAccessTree withoutExclusivePaths(
+        FilesEntitlement filesEntitlement,
+        PathLookup pathLookup,
+        @Nullable Path componentPath
+    ) {
+        return new FileAccessTree(filesEntitlement, pathLookup, componentPath, new String[0]);
     }
 
-    boolean canRead(Path path) {
+    public boolean canRead(Path path) {
         return checkPath(normalizePath(path), readPaths);
     }
 
-    boolean canWrite(Path path) {
+    public boolean canWrite(Path path) {
         return checkPath(normalizePath(path), writePaths);
     }
 
diff --git a/libs/entitlement/src/main/java/org/elasticsearch/entitlement/runtime/policy/PolicyManager.java b/libs/entitlement/src/main/java/org/elasticsearch/entitlement/runtime/policy/PolicyManager.java
index 75e098a95902d..71c0f9909d3e3 100644
--- a/libs/entitlement/src/main/java/org/elasticsearch/entitlement/runtime/policy/PolicyManager.java
+++ b/libs/entitlement/src/main/java/org/elasticsearch/entitlement/runtime/policy/PolicyManager.java
@@ -101,18 +101,13 @@ public <E extends Entitlement> Stream<E> getEntitlements(Class<E> entitlementCla
         }
     }
 
-    private FileAccessTree getDefaultFileAccess(String componentName, Path componentPath) {
-        return FileAccessTree.of(componentName, UNKNOWN_COMPONENT_NAME, FilesEntitlement.EMPTY, pathLookup, componentPath, List.of());
+    private FileAccessTree getDefaultFileAccess(Path componentPath) {
+        return FileAccessTree.withoutExclusivePaths(FilesEntitlement.EMPTY, pathLookup, componentPath);
     }
 
     // pkg private for testing
     ModuleEntitlements defaultEntitlements(String componentName, Path componentPath, String moduleName) {
-        return new ModuleEntitlements(
-            componentName,
-            Map.of(),
-            getDefaultFileAccess(componentName, componentPath),
-            getLogger(componentName, moduleName)
-        );
+        return new ModuleEntitlements(componentName, Map.of(), getDefaultFileAccess(componentPath), getLogger(componentName, moduleName));
     }
 
     // pkg private for testing
diff --git a/libs/entitlement/src/test/java/org/elasticsearch/entitlement/initialization/EntitlementInitializationTests.java b/libs/entitlement/src/test/java/org/elasticsearch/entitlement/initialization/EntitlementInitializationTests.java
new file mode 100644
index 0000000000000..80c7d0d77d449
--- /dev/null
+++ b/libs/entitlement/src/test/java/org/elasticsearch/entitlement/initialization/EntitlementInitializationTests.java
@@ -0,0 +1,181 @@
+/*
+ * 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.entitlement.initialization;
+
+import org.elasticsearch.common.settings.Settings;
+import org.elasticsearch.entitlement.runtime.policy.PathLookup;
+import org.elasticsearch.entitlement.runtime.policy.Policy;
+import org.elasticsearch.entitlement.runtime.policy.Scope;
+import org.elasticsearch.entitlement.runtime.policy.entitlements.CreateClassLoaderEntitlement;
+import org.elasticsearch.entitlement.runtime.policy.entitlements.FilesEntitlement;
+import org.elasticsearch.test.ESTestCase;
+import org.junit.BeforeClass;
+
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Map;
+
+import static org.hamcrest.Matchers.both;
+import static org.hamcrest.Matchers.endsWith;
+import static org.hamcrest.Matchers.startsWith;
+
+public class EntitlementInitializationTests extends ESTestCase {
+
+    private static PathLookup TEST_PATH_LOOKUP;
+
+    private static Path TEST_CONFIG_DIR;
+
+    private static Path TEST_PLUGINS_DIR;
+    private static Path TEST_MODULES_DIR;
+    private static Path TEST_LIBS_DIR;
+
+    @BeforeClass
+    public static void beforeClass() {
+        try {
+            Path testBaseDir = createTempDir().toAbsolutePath();
+            TEST_CONFIG_DIR = testBaseDir.resolve("config");
+            TEST_PLUGINS_DIR = testBaseDir.resolve("plugins");
+            TEST_MODULES_DIR = testBaseDir.resolve("modules");
+            TEST_LIBS_DIR = testBaseDir.resolve("libs");
+
+            TEST_PATH_LOOKUP = new PathLookup(
+                testBaseDir.resolve("user/home"),
+                TEST_CONFIG_DIR,
+                new Path[] { testBaseDir.resolve("data1"), testBaseDir.resolve("data2") },
+                new Path[] { testBaseDir.resolve("shared1"), testBaseDir.resolve("shared2") },
+                testBaseDir.resolve("temp"),
+                Settings.EMPTY::getValues
+            );
+        } catch (Exception e) {
+            throw new IllegalStateException(e);
+        }
+    }
+
+    public void testValidationPass() {
+        var policy = new Policy(
+            "plugin",
+            List.of(
+                new Scope(
+                    "module1",
+                    List.of(
+                        new FilesEntitlement(List.of(FilesEntitlement.FileData.ofPath(TEST_CONFIG_DIR, FilesEntitlement.Mode.READ))),
+                        new CreateClassLoaderEntitlement()
+                    )
+                )
+            )
+        );
+        EntitlementInitialization.validateFilesEntitlements(
+            Map.of("plugin", policy),
+            TEST_PATH_LOOKUP,
+            TEST_CONFIG_DIR,
+            TEST_PLUGINS_DIR,
+            TEST_MODULES_DIR,
+            TEST_LIBS_DIR
+        );
+    }
+
+    public void testValidationFailForRead() {
+        var policy = new Policy(
+            "plugin",
+            List.of(
+                new Scope(
+                    "module2",
+                    List.of(
+                        new FilesEntitlement(List.of(FilesEntitlement.FileData.ofPath(TEST_PLUGINS_DIR, FilesEntitlement.Mode.READ))),
+                        new CreateClassLoaderEntitlement()
+                    )
+                )
+            )
+        );
+
+        var ex = expectThrows(
+            IllegalArgumentException.class,
+            () -> EntitlementInitialization.validateFilesEntitlements(
+                Map.of("plugin", policy),
+                TEST_PATH_LOOKUP,
+                TEST_CONFIG_DIR,
+                TEST_PLUGINS_DIR,
+                TEST_MODULES_DIR,
+                TEST_LIBS_DIR
+            )
+        );
+        assertThat(
+            ex.getMessage(),
+            both(startsWith("policy for module [module2] in [plugin] has an invalid file entitlement")).and(
+                endsWith("is forbidden for mode [READ].")
+            )
+        );
+
+        // check fails for mode READ_WRITE too
+        var policy2 = new Policy(
+            "plugin",
+            List.of(
+                new Scope(
+                    "module1",
+                    List.of(
+                        new FilesEntitlement(List.of(FilesEntitlement.FileData.ofPath(TEST_LIBS_DIR, FilesEntitlement.Mode.READ_WRITE))),
+                        new CreateClassLoaderEntitlement()
+                    )
+                )
+            )
+        );
+
+        ex = expectThrows(
+            IllegalArgumentException.class,
+            () -> EntitlementInitialization.validateFilesEntitlements(
+                Map.of("plugin2", policy2),
+                TEST_PATH_LOOKUP,
+                TEST_CONFIG_DIR,
+                TEST_PLUGINS_DIR,
+                TEST_MODULES_DIR,
+                TEST_LIBS_DIR
+            )
+        );
+        assertThat(
+            ex.getMessage(),
+            both(startsWith("policy for module [module1] in [plugin2] has an invalid file entitlement")).and(
+                endsWith("is forbidden for mode [READ].")
+            )
+        );
+    }
+
+    public void testValidationFailForWrite() {
+        var policy = new Policy(
+            "plugin",
+            List.of(
+                new Scope(
+                    "module1",
+                    List.of(
+                        new FilesEntitlement(List.of(FilesEntitlement.FileData.ofPath(TEST_CONFIG_DIR, FilesEntitlement.Mode.READ_WRITE))),
+                        new CreateClassLoaderEntitlement()
+                    )
+                )
+            )
+        );
+
+        var ex = expectThrows(
+            IllegalArgumentException.class,
+            () -> EntitlementInitialization.validateFilesEntitlements(
+                Map.of("plugin", policy),
+                TEST_PATH_LOOKUP,
+                TEST_CONFIG_DIR,
+                TEST_PLUGINS_DIR,
+                TEST_MODULES_DIR,
+                TEST_LIBS_DIR
+            )
+        );
+        assertThat(
+            ex.getMessage(),
+            both(startsWith("policy for module [module1] in [plugin] has an invalid file entitlement")).and(
+                endsWith("is forbidden for mode [READ_WRITE].")
+            )
+        );
+    }
+}
diff --git a/modules/ingest-geoip/src/main/plugin-metadata/entitlement-policy.yaml b/modules/ingest-geoip/src/main/plugin-metadata/entitlement-policy.yaml
index a96c07a2d3b64..6c9edf6a5cf63 100644
--- a/modules/ingest-geoip/src/main/plugin-metadata/entitlement-policy.yaml
+++ b/modules/ingest-geoip/src/main/plugin-metadata/entitlement-policy.yaml
@@ -6,6 +6,6 @@ org.elasticsearch.ingest.geoip:
         mode: read
 com.maxmind.db:
   - files:
-      - relative_path: "ingest-geoip/"
-        relative_to: "config"
-        mode: "read_write"
+      - relative_path: "ingest-geoip"
+        relative_to: config
+        mode: read
diff --git a/plugins/repository-hdfs/src/main/plugin-metadata/entitlement-policy.yaml b/plugins/repository-hdfs/src/main/plugin-metadata/entitlement-policy.yaml
index 21d5fed283531..a036cab30d31b 100644
--- a/plugins/repository-hdfs/src/main/plugin-metadata/entitlement-policy.yaml
+++ b/plugins/repository-hdfs/src/main/plugin-metadata/entitlement-policy.yaml
@@ -7,6 +7,6 @@ ALL-UNNAMED:
       properties:
         - hadoop.home.dir
   - files:
-      - relative_path: "repository-hdfs/"
+      - relative_path: "repository-hdfs"
         relative_to: config
-        mode: read_write
+        mode: read
diff --git a/qa/packaging/src/test/java/org/elasticsearch/packaging/test/ArchiveTests.java b/qa/packaging/src/test/java/org/elasticsearch/packaging/test/ArchiveTests.java
index 64fbc6176a31c..14178e84969ed 100644
--- a/qa/packaging/src/test/java/org/elasticsearch/packaging/test/ArchiveTests.java
+++ b/qa/packaging/src/test/java/org/elasticsearch/packaging/test/ArchiveTests.java
@@ -571,7 +571,7 @@ public void test92ElasticsearchNodeCliPackaging() throws Exception {
     }
 
     public void test93ElasticsearchNodeCustomDataPathAndNotEsHomeWorkDir() throws Exception {
-        Path relativeDataPath = installation.data.relativize(installation.home);
+        Path relativeDataPath = getRootTempDir().resolve("custom_data");
         append(installation.config("elasticsearch.yml"), "path.data: " + relativeDataPath);
         sh.setWorkingDirectory(getRootTempDir());
 


Reviews:
user: login: ldematte
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T13:57:20Z
user: login: prdoyle
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T15:09:04Z
user: login: prdoyle
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T15:15:32Z
user: login: prdoyle
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T16:27:02Z
user: login: ldematte
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T07:21:22Z
user: login: ldematte
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T07:23:38Z
user: login: ldematte
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T15:20:05Z
user: login: prdoyle
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T18:45:15Z
user: login: prdoyle
body: My suggestions above are minor and I think @ldematte can address them as he sees fit. Approving to avoid a wasted day due to time zones. 😄 
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T18:47:04Z
user: login: ldematte
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-18T09:13:25Z
user: login: ldematte
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-18T09:13:56Z
user: login: ldematte
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-18T09:25:12Z

Review comments:
diff_hunk: @@ -317,6 +319,16 @@ private static PolicyManager createPolicyManager() {
                 )
             )
         );
+
+        validateFilesEntitlements(
+            pluginPolicies,
path: libs/entitlement/src/main/java/org/elasticsearch/entitlement/initialization/EntitlementInitialization.java
user: login: ldematte
body: I considered to add validation for server and agent entitlements here too, but decided it's not worth it. Let me know if you thing those should be validated too.
created_at: 2025-04-15T13:57:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -328,6 +340,62 @@ private static PolicyManager createPolicyManager() {
         );
     }
 
+    private static Set<Path> pathSet(Path... paths) {
+        return Arrays.stream(paths).map(x -> x.toAbsolutePath().normalize()).collect(Collectors.toUnmodifiableSet());
+    }
+
+    // package visible for tests
+    static void validateFilesEntitlements(
+        Map<String, Policy> pluginPolicies,
+        PathLookup pathLookup,
+        Path configDir,
+        Path pluginsDir,
+        Path modulesDir,
+        Path libDir
path: libs/entitlement/src/main/java/org/elasticsearch/entitlement/initialization/EntitlementInitialization.java
user: login: prdoyle
body: Optional: I'd consider simply passing in the path sets here, rather than all individual dirs.

Soon we'll be changing `bootstrapArgs` so it doesn't have N individual dir fields, but rather has a single getter that accepts an enum parameter. Making `validateFilesEntitlements` accept sets would probably work more smoothly once this change happens.

As an example: consider that during unit testing, there is usually no `configDir`.

I do see the benefit of having this method have the responsibility for determining which dirs are read/write forbidden, though, so I understand the inclination to pass them all in the way you did.
created_at: 2025-04-16T15:05:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -328,6 +340,62 @@ private static PolicyManager createPolicyManager() {
         );
     }
 
+    private static Set<Path> pathSet(Path... paths) {
+        return Arrays.stream(paths).map(x -> x.toAbsolutePath().normalize()).collect(Collectors.toUnmodifiableSet());
+    }
+
+    // package visible for tests
+    static void validateFilesEntitlements(
+        Map<String, Policy> pluginPolicies,
+        PathLookup pathLookup,
+        Path configDir,
+        Path pluginsDir,
+        Path modulesDir,
+        Path libDir
+    ) {
+        var pluginReadAccessForbidden = pathSet(pluginsDir, modulesDir, libDir);
+        var pluginWriteAccessForbidden = pathSet(configDir);
+        for (var pluginPolicy : pluginPolicies.entrySet()) {
+            List<Path> readPaths = getFileDataStream(pluginPolicy.getValue()).flatMap(x -> x.resolvePaths(pathLookup)).toList();
+            List<Path> writePaths = getFileDataStream(pluginPolicy.getValue()).filter(x -> x.mode().equals(READ_WRITE))
+                .flatMap(x -> x.resolvePaths(pathLookup))
+                .toList();
+            validateLayerFilesEntitlements(pluginPolicy.getKey(), readPaths, pluginReadAccessForbidden, READ);
+            validateLayerFilesEntitlements(pluginPolicy.getKey(), writePaths, pluginWriteAccessForbidden, READ_WRITE);
+        }
+    }
+
+    private static Stream<FileData> getFileDataStream(Policy policy) {
+        return getFileDataStream(policy.scopes().stream().flatMap(x -> x.entitlements().stream()));
+    }
+
+    private static Stream<FileData> getFileDataStream(Stream<Entitlement> entitlements) {
+        return entitlements.filter(x -> x instanceof FilesEntitlement).flatMap(x -> ((FilesEntitlement) x).filesData().stream());
+    }
+
+    private static void validateLayerFilesEntitlements(
path: libs/entitlement/src/main/java/org/elasticsearch/entitlement/initialization/EntitlementInitialization.java
user: login: prdoyle
body: What does `Layer` mean in this context? Is this anticipating renaming "component" to "layer"? If so, we might want to consider holding off on that until the renaming actually happens.
created_at: 2025-04-16T15:06:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -328,6 +340,62 @@ private static PolicyManager createPolicyManager() {
         );
     }
 
+    private static Set<Path> pathSet(Path... paths) {
+        return Arrays.stream(paths).map(x -> x.toAbsolutePath().normalize()).collect(Collectors.toUnmodifiableSet());
+    }
+
+    // package visible for tests
+    static void validateFilesEntitlements(
+        Map<String, Policy> pluginPolicies,
+        PathLookup pathLookup,
+        Path configDir,
+        Path pluginsDir,
+        Path modulesDir,
+        Path libDir
+    ) {
+        var pluginReadAccessForbidden = pathSet(pluginsDir, modulesDir, libDir);
+        var pluginWriteAccessForbidden = pathSet(configDir);
+        for (var pluginPolicy : pluginPolicies.entrySet()) {
+            List<Path> readPaths = getFileDataStream(pluginPolicy.getValue()).flatMap(x -> x.resolvePaths(pathLookup)).toList();
+            List<Path> writePaths = getFileDataStream(pluginPolicy.getValue()).filter(x -> x.mode().equals(READ_WRITE))
+                .flatMap(x -> x.resolvePaths(pathLookup))
+                .toList();
+            validateLayerFilesEntitlements(pluginPolicy.getKey(), readPaths, pluginReadAccessForbidden, READ);
+            validateLayerFilesEntitlements(pluginPolicy.getKey(), writePaths, pluginWriteAccessForbidden, READ_WRITE);
+        }
+    }
+
+    private static Stream<FileData> getFileDataStream(Policy policy) {
+        return getFileDataStream(policy.scopes().stream().flatMap(x -> x.entitlements().stream()));
+    }
+
+    private static Stream<FileData> getFileDataStream(Stream<Entitlement> entitlements) {
+        return entitlements.filter(x -> x instanceof FilesEntitlement).flatMap(x -> ((FilesEntitlement) x).filesData().stream());
+    }
+
+    private static void validateLayerFilesEntitlements(
+        String layerName,
+        List<Path> paths,
+        Set<Path> forbiddenPaths,
+        FilesEntitlement.Mode mode
+    ) {
+        for (var path : paths) {
+            for (Path forbiddenPath : forbiddenPaths) {
path: libs/entitlement/src/main/java/org/elasticsearch/entitlement/initialization/EntitlementInitialization.java
user: login: prdoyle
body: This is quadratic, right? I wonder if we can do better by building a `FileAccessTree` out of the forbidden paths?
created_at: 2025-04-16T15:07:04Z
author_association: CONTRIBUTOR
diff_hunk: @@ -6,6 +6,6 @@ org.elasticsearch.ingest.geoip:
         mode: read
 com.maxmind.db:
   - files:
-      - relative_path: "ingest-geoip/"
-        relative_to: "config"
-        mode: "read_write"
+      - relative_path: "ingest-geoip"
+        relative_to: config
+        mode: read
path: modules/ingest-geoip/src/main/plugin-metadata/entitlement-policy.yaml
user: login: prdoyle
body: 🌶️ 
created_at: 2025-04-16T15:08:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -328,6 +340,62 @@ private static PolicyManager createPolicyManager() {
         );
     }
 
+    private static Set<Path> pathSet(Path... paths) {
+        return Arrays.stream(paths).map(x -> x.toAbsolutePath().normalize()).collect(Collectors.toUnmodifiableSet());
+    }
+
+    // package visible for tests
+    static void validateFilesEntitlements(
+        Map<String, Policy> pluginPolicies,
+        PathLookup pathLookup,
+        Path configDir,
+        Path pluginsDir,
+        Path modulesDir,
+        Path libDir
path: libs/entitlement/src/main/java/org/elasticsearch/entitlement/initialization/EntitlementInitialization.java
user: login: prdoyle
body: Actually, based on my additional comments below... what about passing a pair of `FileAccessTree`s?
created_at: 2025-04-16T15:15:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -328,6 +340,62 @@ private static PolicyManager createPolicyManager() {
         );
     }
 
+    private static Set<Path> pathSet(Path... paths) {
+        return Arrays.stream(paths).map(x -> x.toAbsolutePath().normalize()).collect(Collectors.toUnmodifiableSet());
+    }
+
+    // package visible for tests
+    static void validateFilesEntitlements(
+        Map<String, Policy> pluginPolicies,
+        PathLookup pathLookup,
+        Path configDir,
+        Path pluginsDir,
+        Path modulesDir,
+        Path libDir
+    ) {
+        var pluginReadAccessForbidden = pathSet(pluginsDir, modulesDir, libDir);
+        var pluginWriteAccessForbidden = pathSet(configDir);
+        for (var pluginPolicy : pluginPolicies.entrySet()) {
+            List<Path> readPaths = getFileDataStream(pluginPolicy.getValue()).flatMap(x -> x.resolvePaths(pathLookup)).toList();
+            List<Path> writePaths = getFileDataStream(pluginPolicy.getValue()).filter(x -> x.mode().equals(READ_WRITE))
+                .flatMap(x -> x.resolvePaths(pathLookup))
+                .toList();
+            validateLayerFilesEntitlements(pluginPolicy.getKey(), readPaths, pluginReadAccessForbidden, READ);
+            validateLayerFilesEntitlements(pluginPolicy.getKey(), writePaths, pluginWriteAccessForbidden, READ_WRITE);
+        }
+    }
+
+    private static Stream<FileData> getFileDataStream(Policy policy) {
+        return getFileDataStream(policy.scopes().stream().flatMap(x -> x.entitlements().stream()));
+    }
+
+    private static Stream<FileData> getFileDataStream(Stream<Entitlement> entitlements) {
+        return entitlements.filter(x -> x instanceof FilesEntitlement).flatMap(x -> ((FilesEntitlement) x).filesData().stream());
+    }
+
+    private static void validateLayerFilesEntitlements(
+        String layerName,
+        List<Path> paths,
+        Set<Path> forbiddenPaths,
+        FilesEntitlement.Mode mode
+    ) {
+        for (var path : paths) {
+            for (Path forbiddenPath : forbiddenPaths) {
path: libs/entitlement/src/main/java/org/elasticsearch/entitlement/initialization/EntitlementInitialization.java
user: login: prdoyle
body: This might make also it more robust against various corner cases we've already handled in `FileAccessTree`.
created_at: 2025-04-16T16:27:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -328,6 +340,62 @@ private static PolicyManager createPolicyManager() {
         );
     }
 
+    private static Set<Path> pathSet(Path... paths) {
+        return Arrays.stream(paths).map(x -> x.toAbsolutePath().normalize()).collect(Collectors.toUnmodifiableSet());
+    }
+
+    // package visible for tests
+    static void validateFilesEntitlements(
+        Map<String, Policy> pluginPolicies,
+        PathLookup pathLookup,
+        Path configDir,
+        Path pluginsDir,
+        Path modulesDir,
+        Path libDir
+    ) {
+        var pluginReadAccessForbidden = pathSet(pluginsDir, modulesDir, libDir);
+        var pluginWriteAccessForbidden = pathSet(configDir);
+        for (var pluginPolicy : pluginPolicies.entrySet()) {
+            List<Path> readPaths = getFileDataStream(pluginPolicy.getValue()).flatMap(x -> x.resolvePaths(pathLookup)).toList();
+            List<Path> writePaths = getFileDataStream(pluginPolicy.getValue()).filter(x -> x.mode().equals(READ_WRITE))
+                .flatMap(x -> x.resolvePaths(pathLookup))
+                .toList();
+            validateLayerFilesEntitlements(pluginPolicy.getKey(), readPaths, pluginReadAccessForbidden, READ);
+            validateLayerFilesEntitlements(pluginPolicy.getKey(), writePaths, pluginWriteAccessForbidden, READ_WRITE);
+        }
+    }
+
+    private static Stream<FileData> getFileDataStream(Policy policy) {
+        return getFileDataStream(policy.scopes().stream().flatMap(x -> x.entitlements().stream()));
+    }
+
+    private static Stream<FileData> getFileDataStream(Stream<Entitlement> entitlements) {
+        return entitlements.filter(x -> x instanceof FilesEntitlement).flatMap(x -> ((FilesEntitlement) x).filesData().stream());
+    }
+
+    private static void validateLayerFilesEntitlements(
path: libs/entitlement/src/main/java/org/elasticsearch/entitlement/initialization/EntitlementInitialization.java
user: login: ldematte
body: We already have a mix naming here. I'm fine with keeping component for now though.
created_at: 2025-04-17T07:21:22Z
author_association: CONTRIBUTOR
diff_hunk: @@ -328,6 +340,62 @@ private static PolicyManager createPolicyManager() {
         );
     }
 
+    private static Set<Path> pathSet(Path... paths) {
+        return Arrays.stream(paths).map(x -> x.toAbsolutePath().normalize()).collect(Collectors.toUnmodifiableSet());
+    }
+
+    // package visible for tests
+    static void validateFilesEntitlements(
+        Map<String, Policy> pluginPolicies,
+        PathLookup pathLookup,
+        Path configDir,
+        Path pluginsDir,
+        Path modulesDir,
+        Path libDir
+    ) {
+        var pluginReadAccessForbidden = pathSet(pluginsDir, modulesDir, libDir);
+        var pluginWriteAccessForbidden = pathSet(configDir);
+        for (var pluginPolicy : pluginPolicies.entrySet()) {
+            List<Path> readPaths = getFileDataStream(pluginPolicy.getValue()).flatMap(x -> x.resolvePaths(pathLookup)).toList();
+            List<Path> writePaths = getFileDataStream(pluginPolicy.getValue()).filter(x -> x.mode().equals(READ_WRITE))
+                .flatMap(x -> x.resolvePaths(pathLookup))
+                .toList();
+            validateLayerFilesEntitlements(pluginPolicy.getKey(), readPaths, pluginReadAccessForbidden, READ);
+            validateLayerFilesEntitlements(pluginPolicy.getKey(), writePaths, pluginWriteAccessForbidden, READ_WRITE);
+        }
+    }
+
+    private static Stream<FileData> getFileDataStream(Policy policy) {
+        return getFileDataStream(policy.scopes().stream().flatMap(x -> x.entitlements().stream()));
+    }
+
+    private static Stream<FileData> getFileDataStream(Stream<Entitlement> entitlements) {
+        return entitlements.filter(x -> x instanceof FilesEntitlement).flatMap(x -> ((FilesEntitlement) x).filesData().stream());
+    }
+
+    private static void validateLayerFilesEntitlements(
+        String layerName,
+        List<Path> paths,
+        Set<Path> forbiddenPaths,
+        FilesEntitlement.Mode mode
+    ) {
+        for (var path : paths) {
+            for (Path forbiddenPath : forbiddenPaths) {
path: libs/entitlement/src/main/java/org/elasticsearch/entitlement/initialization/EntitlementInitialization.java
user: login: ldematte
body: > This is quadratic, right? I wonder if we can do better by building a FileAccessTree out of the forbidden paths?

I'm not particularly worried about this, it's happening once (initialization) and the forbidden paths are 1-3.
I'm not sure FileAccessTree is suitable for this case (or exposes the correct logic for this), let me see.
created_at: 2025-04-17T07:23:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -328,6 +340,62 @@ private static PolicyManager createPolicyManager() {
         );
     }
 
+    private static Set<Path> pathSet(Path... paths) {
+        return Arrays.stream(paths).map(x -> x.toAbsolutePath().normalize()).collect(Collectors.toUnmodifiableSet());
+    }
+
+    // package visible for tests
+    static void validateFilesEntitlements(
+        Map<String, Policy> pluginPolicies,
+        PathLookup pathLookup,
+        Path configDir,
+        Path pluginsDir,
+        Path modulesDir,
+        Path libDir
+    ) {
+        var pluginReadAccessForbidden = pathSet(pluginsDir, modulesDir, libDir);
+        var pluginWriteAccessForbidden = pathSet(configDir);
+        for (var pluginPolicy : pluginPolicies.entrySet()) {
+            List<Path> readPaths = getFileDataStream(pluginPolicy.getValue()).flatMap(x -> x.resolvePaths(pathLookup)).toList();
+            List<Path> writePaths = getFileDataStream(pluginPolicy.getValue()).filter(x -> x.mode().equals(READ_WRITE))
+                .flatMap(x -> x.resolvePaths(pathLookup))
+                .toList();
+            validateLayerFilesEntitlements(pluginPolicy.getKey(), readPaths, pluginReadAccessForbidden, READ);
+            validateLayerFilesEntitlements(pluginPolicy.getKey(), writePaths, pluginWriteAccessForbidden, READ_WRITE);
+        }
+    }
+
+    private static Stream<FileData> getFileDataStream(Policy policy) {
+        return getFileDataStream(policy.scopes().stream().flatMap(x -> x.entitlements().stream()));
+    }
+
+    private static Stream<FileData> getFileDataStream(Stream<Entitlement> entitlements) {
+        return entitlements.filter(x -> x instanceof FilesEntitlement).flatMap(x -> ((FilesEntitlement) x).filesData().stream());
+    }
+
+    private static void validateLayerFilesEntitlements(
+        String layerName,
+        List<Path> paths,
+        Set<Path> forbiddenPaths,
+        FilesEntitlement.Mode mode
+    ) {
+        for (var path : paths) {
+            for (Path forbiddenPath : forbiddenPaths) {
path: libs/entitlement/src/main/java/org/elasticsearch/entitlement/initialization/EntitlementInitialization.java
user: login: ldematte
body: > This might make also it more robust against various corner cases we've already handled in FileAccessTree.

True, you are right. I probably need to create a separate ctor (e.g. IDK if we want/need to add special paths) or expose some other method; I'll try it and see where it goes.
created_at: 2025-04-17T15:20:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -328,6 +340,62 @@ private static PolicyManager createPolicyManager() {
         );
     }
 
+    private static Set<Path> pathSet(Path... paths) {
+        return Arrays.stream(paths).map(x -> x.toAbsolutePath().normalize()).collect(Collectors.toUnmodifiableSet());
+    }
+
+    // package visible for tests
+    static void validateFilesEntitlements(
+        Map<String, Policy> pluginPolicies,
+        PathLookup pathLookup,
+        Path configDir,
+        Path pluginsDir,
+        Path modulesDir,
+        Path libDir
+    ) {
+        var pluginReadAccessForbidden = pathSet(pluginsDir, modulesDir, libDir);
+        var pluginWriteAccessForbidden = pathSet(configDir);
+        for (var pluginPolicy : pluginPolicies.entrySet()) {
+            List<Path> readPaths = getFileDataStream(pluginPolicy.getValue()).flatMap(x -> x.resolvePaths(pathLookup)).toList();
+            List<Path> writePaths = getFileDataStream(pluginPolicy.getValue()).filter(x -> x.mode().equals(READ_WRITE))
+                .flatMap(x -> x.resolvePaths(pathLookup))
+                .toList();
+            validateLayerFilesEntitlements(pluginPolicy.getKey(), readPaths, pluginReadAccessForbidden, READ);
+            validateLayerFilesEntitlements(pluginPolicy.getKey(), writePaths, pluginWriteAccessForbidden, READ_WRITE);
+        }
+    }
+
+    private static Stream<FileData> getFileDataStream(Policy policy) {
+        return getFileDataStream(policy.scopes().stream().flatMap(x -> x.entitlements().stream()));
+    }
+
+    private static Stream<FileData> getFileDataStream(Stream<Entitlement> entitlements) {
+        return entitlements.filter(x -> x instanceof FilesEntitlement).flatMap(x -> ((FilesEntitlement) x).filesData().stream());
+    }
+
+    private static void validateLayerFilesEntitlements(
+        String layerName,
+        List<Path> paths,
+        Set<Path> forbiddenPaths,
+        FilesEntitlement.Mode mode
+    ) {
+        for (var path : paths) {
+            for (Path forbiddenPath : forbiddenPaths) {
path: libs/entitlement/src/main/java/org/elasticsearch/entitlement/initialization/EntitlementInitialization.java
user: login: prdoyle
body: Agreed about the quadratic part. It occurred to me later that evening that the number of forbidden paths is probably just a handful.
created_at: 2025-04-17T18:45:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -328,6 +340,62 @@ private static PolicyManager createPolicyManager() {
         );
     }
 
+    private static Set<Path> pathSet(Path... paths) {
+        return Arrays.stream(paths).map(x -> x.toAbsolutePath().normalize()).collect(Collectors.toUnmodifiableSet());
+    }
+
+    // package visible for tests
+    static void validateFilesEntitlements(
+        Map<String, Policy> pluginPolicies,
+        PathLookup pathLookup,
+        Path configDir,
+        Path pluginsDir,
+        Path modulesDir,
+        Path libDir
+    ) {
+        var pluginReadAccessForbidden = pathSet(pluginsDir, modulesDir, libDir);
+        var pluginWriteAccessForbidden = pathSet(configDir);
+        for (var pluginPolicy : pluginPolicies.entrySet()) {
+            List<Path> readPaths = getFileDataStream(pluginPolicy.getValue()).flatMap(x -> x.resolvePaths(pathLookup)).toList();
+            List<Path> writePaths = getFileDataStream(pluginPolicy.getValue()).filter(x -> x.mode().equals(READ_WRITE))
+                .flatMap(x -> x.resolvePaths(pathLookup))
+                .toList();
+            validateLayerFilesEntitlements(pluginPolicy.getKey(), readPaths, pluginReadAccessForbidden, READ);
+            validateLayerFilesEntitlements(pluginPolicy.getKey(), writePaths, pluginWriteAccessForbidden, READ_WRITE);
+        }
+    }
+
+    private static Stream<FileData> getFileDataStream(Policy policy) {
+        return getFileDataStream(policy.scopes().stream().flatMap(x -> x.entitlements().stream()));
+    }
+
+    private static Stream<FileData> getFileDataStream(Stream<Entitlement> entitlements) {
+        return entitlements.filter(x -> x instanceof FilesEntitlement).flatMap(x -> ((FilesEntitlement) x).filesData().stream());
+    }
+
+    private static void validateLayerFilesEntitlements(
path: libs/entitlement/src/main/java/org/elasticsearch/entitlement/initialization/EntitlementInitialization.java
user: login: ldematte
body: Changed back to component; we can rename it later
created_at: 2025-04-18T09:13:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -328,6 +340,62 @@ private static PolicyManager createPolicyManager() {
         );
     }
 
+    private static Set<Path> pathSet(Path... paths) {
+        return Arrays.stream(paths).map(x -> x.toAbsolutePath().normalize()).collect(Collectors.toUnmodifiableSet());
+    }
+
+    // package visible for tests
+    static void validateFilesEntitlements(
+        Map<String, Policy> pluginPolicies,
+        PathLookup pathLookup,
+        Path configDir,
+        Path pluginsDir,
+        Path modulesDir,
+        Path libDir
+    ) {
+        var pluginReadAccessForbidden = pathSet(pluginsDir, modulesDir, libDir);
+        var pluginWriteAccessForbidden = pathSet(configDir);
+        for (var pluginPolicy : pluginPolicies.entrySet()) {
+            List<Path> readPaths = getFileDataStream(pluginPolicy.getValue()).flatMap(x -> x.resolvePaths(pathLookup)).toList();
+            List<Path> writePaths = getFileDataStream(pluginPolicy.getValue()).filter(x -> x.mode().equals(READ_WRITE))
+                .flatMap(x -> x.resolvePaths(pathLookup))
+                .toList();
+            validateLayerFilesEntitlements(pluginPolicy.getKey(), readPaths, pluginReadAccessForbidden, READ);
+            validateLayerFilesEntitlements(pluginPolicy.getKey(), writePaths, pluginWriteAccessForbidden, READ_WRITE);
+        }
+    }
+
+    private static Stream<FileData> getFileDataStream(Policy policy) {
+        return getFileDataStream(policy.scopes().stream().flatMap(x -> x.entitlements().stream()));
+    }
+
+    private static Stream<FileData> getFileDataStream(Stream<Entitlement> entitlements) {
+        return entitlements.filter(x -> x instanceof FilesEntitlement).flatMap(x -> ((FilesEntitlement) x).filesData().stream());
+    }
+
+    private static void validateLayerFilesEntitlements(
+        String layerName,
+        List<Path> paths,
+        Set<Path> forbiddenPaths,
+        FilesEntitlement.Mode mode
+    ) {
+        for (var path : paths) {
+            for (Path forbiddenPath : forbiddenPaths) {
path: libs/entitlement/src/main/java/org/elasticsearch/entitlement/initialization/EntitlementInitialization.java
user: login: ldematte
body: Change to use FileAccessTree
created_at: 2025-04-18T09:13:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -328,6 +340,62 @@ private static PolicyManager createPolicyManager() {
         );
     }
 
+    private static Set<Path> pathSet(Path... paths) {
+        return Arrays.stream(paths).map(x -> x.toAbsolutePath().normalize()).collect(Collectors.toUnmodifiableSet());
+    }
+
+    // package visible for tests
+    static void validateFilesEntitlements(
+        Map<String, Policy> pluginPolicies,
+        PathLookup pathLookup,
+        Path configDir,
+        Path pluginsDir,
+        Path modulesDir,
+        Path libDir
path: libs/entitlement/src/main/java/org/elasticsearch/entitlement/initialization/EntitlementInitialization.java
user: login: ldematte
body: I used a FileAccessTree in the end, for the corner cases, but that does not help with the paths to check.
created_at: 2025-04-18T09:25:12Z
author_association: CONTRIBUTOR

PR comments:
user: login: elasticsearchmachine
created_at: 2025-04-15T13:56:51Z
body: Pinging @elastic/es-core-infra (Team:Core/Infra)
author_association: COLLABORATOR
user: login: elasticsearchmachine
created_at: 2025-04-15T13:56:52Z
body: Hi @ldematte, I've created a changelog YAML for you.
author_association: COLLABORATOR
user: login: elasticsearchmachine
created_at: 2025-04-18T13:39:36Z
body: ## 💚 Backport successful
| Status | Branch | Result |
|:------:|:------:|:------:|
| ✅ |  [8.18](https://github.com/elastic/elasticsearch/pull/127055)  | [<img src="https://img.shields.io/github/pulls/detail/state/elastic/elasticsearch/127055">](https://github.com/elastic/elasticsearch/pull/127055) |
| ✅ |  [8.x](https://github.com/elastic/elasticsearch/pull/127056)  | [<img src="https://img.shields.io/github/pulls/detail/state/elastic/elasticsearch/127056">](https://github.com/elastic/elasticsearch/pull/127056) |
| ✅ |  [9.0](https://github.com/elastic/elasticsearch/pull/127057)  | [<img src="https://img.shields.io/github/pulls/detail/state/elastic/elasticsearch/127057">](https://github.com/elastic/elasticsearch/pull/127057) |
author_association: COLLABORATOR