Browse Source

Backport entitlement work up to #116473 to 8.x (#116613)

* Add initial entitlement policy parsing (#114448)

This change adds entitlement policy parsing with the following design:
* YAML file for readability and re-use of our x-content parsers
* hierarchical structure to group entitlements under a single scope
* no general entitlements without a scope or for the entire project

* Avoid double instrumentation via class annotation (#115398)

* Move entitlement jars to libs (#115883)

The distribution tools are meant to be CLIs. This commit moves the
entitlements jar projects to the libs dir, under a single
libs/entitlement root directory to keep the related jars together.

* Entitlement tools: SecurityManager scanner (#116020)

* Dynamic entitlement agent (#116125)

* Refactor: treat "maybe" JVM options uniformly

* WIP

* Get entitlement running with bridge all the way through, with qualified
exports

* Cosmetic changes to SystemJvmOptions

* Disable entitlements by default

* Bridge module comments

* Fixup forbidden APIs

* spotless

* Rename EntitlementChecker

* Fixup InstrumenterTests

* exclude recursive dep

* Fix some compliance stuff

* Rename asm-provider

* Stop using bridge in InstrumenterTests

* Generalize readme for asm-provider

* InstrumenterTests doesn't need EntitlementCheckerHandle

* Better javadoc

* Call parseBoolean

* Add entitlement to internal module list

* Docs as requested by Lorenzo

* Changes from Jack

* Rename ElasticsearchEntitlementChecker

* Remove logging javadoc

* exportInitializationToAgent should reference EntitlementInitialization, not EntitlementBootstrap.

They're currently in the same module, but if that ever changes, this code would have become wrong.

* Some suggestions from Mark

---------

Co-authored-by: Ryan Ernst <ryan@iernst.net>

* Remove unused EntitlementInternals (#116473)

* Revert "Entitlement tools: SecurityManager scanner (#116020)"

This reverts commit 023fb663dea952a669cf67421e336e2f0f24ec12.

---------

Co-authored-by: Jack Conradson <osjdconrad@gmail.com>
Co-authored-by: Lorenzo Dematté <lorenzo.dematte@elastic.co>
Co-authored-by: Ryan Ernst <ryan@iernst.net>
Patrick Doyle 11 months ago
parent
commit
37edf70bda
58 changed files with 1375 additions and 505 deletions
  1. 2 1
      build-tools-internal/src/main/java/org/elasticsearch/gradle/internal/InternalDistributionModuleCheckTaskProvider.java
  2. 17 0
      build-tools/src/main/java/org/elasticsearch/gradle/testclusters/RunTask.java
  3. 8 0
      build-tools/src/main/java/org/elasticsearch/gradle/testclusters/TestClustersAware.java
  4. 9 1
      distribution/build.gradle
  5. 0 62
      distribution/tools/entitlement-agent/build.gradle
  6. 0 153
      distribution/tools/entitlement-agent/impl/src/test/java/org/elasticsearch/entitlement/instrumentation/impl/InstrumenterTests.java
  7. 0 52
      distribution/tools/entitlement-agent/src/test/java/org/elasticsearch/entitlement/agent/EntitlementAgentTests.java
  8. 0 34
      distribution/tools/entitlement-bridge/src/main/java/org/elasticsearch/entitlement/api/EntitlementProvider.java
  9. 0 7
      distribution/tools/entitlement-runtime/README.md
  10. 0 19
      distribution/tools/entitlement-runtime/src/main/java/module-info.java
  11. 0 24
      distribution/tools/entitlement-runtime/src/main/java/org/elasticsearch/entitlement/runtime/internals/EntitlementInternals.java
  12. 0 10
      distribution/tools/entitlement-runtime/src/main/resources/META-INF/services/org.elasticsearch.entitlement.api.EntitlementChecks
  13. 58 23
      distribution/tools/server-cli/src/main/java/org/elasticsearch/server/cli/SystemJvmOptions.java
  14. 1 1
      libs/core/src/main/java/module-info.java
  15. 11 0
      libs/entitlement/README.md
  16. 2 2
      libs/entitlement/agent/README.md
  17. 17 7
      libs/entitlement/agent/build.gradle
  18. 49 0
      libs/entitlement/agent/src/main/java/org/elasticsearch/entitlement/agent/EntitlementAgent.java
  19. 2 0
      libs/entitlement/asm-provider/README.md
  20. 2 2
      libs/entitlement/asm-provider/build.gradle
  21. 0 0
      libs/entitlement/asm-provider/licenses/asm-LICENSE.txt
  22. 0 0
      libs/entitlement/asm-provider/licenses/asm-NOTICE.txt
  23. 2 2
      libs/entitlement/asm-provider/src/main/java/module-info.java
  24. 0 0
      libs/entitlement/asm-provider/src/main/java/org/elasticsearch/entitlement/instrumentation/impl/InstrumentationServiceImpl.java
  25. 95 25
      libs/entitlement/asm-provider/src/main/java/org/elasticsearch/entitlement/instrumentation/impl/InstrumenterImpl.java
  26. 0 0
      libs/entitlement/asm-provider/src/main/resources/META-INF/services/org.elasticsearch.entitlement.instrumentation.InstrumentationService
  27. 0 0
      libs/entitlement/asm-provider/src/test/java/org/elasticsearch/entitlement/instrumentation/impl/ASMUtils.java
  28. 252 0
      libs/entitlement/asm-provider/src/test/java/org/elasticsearch/entitlement/instrumentation/impl/InstrumenterTests.java
  29. 1 1
      libs/entitlement/asm-provider/src/test/resources/META-INF/services/org.elasticsearch.entitlement.bridge.EntitlementChecker
  30. 3 3
      libs/entitlement/bridge/README.md
  31. 0 2
      libs/entitlement/bridge/build.gradle
  32. 3 3
      libs/entitlement/bridge/src/main/java/module-info.java
  33. 2 2
      libs/entitlement/bridge/src/main/java/org/elasticsearch/entitlement/bridge/EntitlementChecker.java
  34. 63 0
      libs/entitlement/bridge/src/main/java/org/elasticsearch/entitlement/bridge/EntitlementCheckerHandle.java
  35. 11 8
      libs/entitlement/build.gradle
  36. 25 0
      libs/entitlement/src/main/java/module-info.java
  37. 82 0
      libs/entitlement/src/main/java/org/elasticsearch/entitlement/bootstrap/EntitlementBootstrap.java
  38. 28 26
      libs/entitlement/src/main/java/org/elasticsearch/entitlement/initialization/EntitlementInitialization.java
  39. 0 0
      libs/entitlement/src/main/java/org/elasticsearch/entitlement/instrumentation/InstrumentationService.java
  40. 0 0
      libs/entitlement/src/main/java/org/elasticsearch/entitlement/instrumentation/Instrumenter.java
  41. 0 0
      libs/entitlement/src/main/java/org/elasticsearch/entitlement/instrumentation/MethodKey.java
  42. 1 3
      libs/entitlement/src/main/java/org/elasticsearch/entitlement/instrumentation/Transformer.java
  43. 16 22
      libs/entitlement/src/main/java/org/elasticsearch/entitlement/runtime/api/ElasticsearchEntitlementChecker.java
  44. 0 0
      libs/entitlement/src/main/java/org/elasticsearch/entitlement/runtime/api/NotEntitledException.java
  45. 19 0
      libs/entitlement/src/main/java/org/elasticsearch/entitlement/runtime/policy/Entitlement.java
  46. 36 0
      libs/entitlement/src/main/java/org/elasticsearch/entitlement/runtime/policy/ExternalEntitlement.java
  47. 67 0
      libs/entitlement/src/main/java/org/elasticsearch/entitlement/runtime/policy/FileEntitlement.java
  48. 46 0
      libs/entitlement/src/main/java/org/elasticsearch/entitlement/runtime/policy/Policy.java
  49. 176 0
      libs/entitlement/src/main/java/org/elasticsearch/entitlement/runtime/policy/PolicyParser.java
  50. 92 0
      libs/entitlement/src/main/java/org/elasticsearch/entitlement/runtime/policy/PolicyParserException.java
  51. 46 0
      libs/entitlement/src/main/java/org/elasticsearch/entitlement/runtime/policy/Scope.java
  52. 83 0
      libs/entitlement/src/test/java/org/elasticsearch/entitlement/runtime/policy/PolicyParserFailureTests.java
  53. 28 0
      libs/entitlement/src/test/java/org/elasticsearch/entitlement/runtime/policy/PolicyParserTests.java
  54. 7 0
      libs/entitlement/src/test/resources/org/elasticsearch/entitlement/runtime/policy/test-policy.yaml
  55. 1 0
      server/build.gradle
  56. 1 0
      server/src/main/java/module-info.java
  57. 11 6
      server/src/main/java/org/elasticsearch/bootstrap/Elasticsearch.java
  58. 0 4
      settings.gradle

+ 2 - 1
build-tools-internal/src/main/java/org/elasticsearch/gradle/internal/InternalDistributionModuleCheckTaskProvider.java

@@ -48,10 +48,11 @@ public class InternalDistributionModuleCheckTaskProvider {
     /** ES jars in the lib directory that are not modularized. For now, es-log4j is the only one. */
     private static final List<String> ES_JAR_EXCLUDES = List.of("elasticsearch-log4j");
 
-    /** List of the current Elasticsearch Java Modules, by name. */
+    /** List of the current Elasticsearch Java Modules, alphabetically by name. */
     private static final List<String> EXPECTED_ES_SERVER_MODULES = List.of(
         "org.elasticsearch.base",
         "org.elasticsearch.cli",
+        "org.elasticsearch.entitlement",
         "org.elasticsearch.geo",
         "org.elasticsearch.grok",
         "org.elasticsearch.logging",

+ 17 - 0
build-tools/src/main/java/org/elasticsearch/gradle/testclusters/RunTask.java

@@ -42,6 +42,7 @@ public abstract class RunTask extends DefaultTestClustersTask {
 
     private Boolean debug = false;
     private Boolean cliDebug = false;
+    private Boolean entitlementsEnabled = false;
     private Boolean apmServerEnabled = false;
 
     private Boolean preserveData = false;
@@ -69,6 +70,14 @@ public abstract class RunTask extends DefaultTestClustersTask {
         this.cliDebug = enabled;
     }
 
+    @Option(
+        option = "entitlements",
+        description = "Use the Entitlements agent system in place of SecurityManager to enforce sandbox policies."
+    )
+    public void setEntitlementsEnabled(boolean enabled) {
+        this.entitlementsEnabled = enabled;
+    }
+
     @Input
     public Boolean getDebug() {
         return debug;
@@ -79,6 +88,11 @@ public abstract class RunTask extends DefaultTestClustersTask {
         return cliDebug;
     }
 
+    @Input
+    public Boolean getEntitlementsEnabled() {
+        return entitlementsEnabled;
+    }
+
     @Input
     public Boolean getApmServerEnabled() {
         return apmServerEnabled;
@@ -226,6 +240,9 @@ public abstract class RunTask extends DefaultTestClustersTask {
         if (cliDebug) {
             enableCliDebug();
         }
+        if (entitlementsEnabled) {
+            enableEntitlements();
+        }
     }
 
     @TaskAction

+ 8 - 0
build-tools/src/main/java/org/elasticsearch/gradle/testclusters/TestClustersAware.java

@@ -74,4 +74,12 @@ public interface TestClustersAware extends Task {
             }
         }
     }
+
+    default void enableEntitlements() {
+        for (ElasticsearchCluster cluster : getClusters()) {
+            for (ElasticsearchNode node : cluster.getNodes()) {
+                node.cliJvmArgs("-Des.entitlements.enabled=true");
+            }
+        }
+    }
 }

+ 9 - 1
distribution/build.gradle

@@ -262,7 +262,7 @@ configure(subprojects.findAll { ['archives', 'packages'].contains(it.name) }) {
    *             Properties to expand when copying packaging files             *
    *****************************************************************************/
   configurations {
-    ['libs', 'libsVersionChecker', 'libsCliLauncher', 'libsServerCli', 'libsWindowsServiceCli', 'libsPluginCli', 'libsKeystoreCli', 'libsSecurityCli', 'libsGeoIpCli', 'libsAnsiConsole', 'libsNative'].each {
+    ['libs', 'libsVersionChecker', 'libsCliLauncher', 'libsServerCli', 'libsWindowsServiceCli', 'libsPluginCli', 'libsKeystoreCli', 'libsSecurityCli', 'libsGeoIpCli', 'libsAnsiConsole', 'libsNative', 'libsEntitlementAgent', 'libsEntitlementBridge'].each {
       create(it) {
         canBeConsumed = false
         canBeResolved = true
@@ -292,6 +292,8 @@ configure(subprojects.findAll { ['archives', 'packages'].contains(it.name) }) {
     libsSecurityCli project(':x-pack:plugin:security:cli')
     libsGeoIpCli project(':distribution:tools:geoip-cli')
     libsNative project(':libs:native:native-libraries')
+    libsEntitlementAgent project(':libs:entitlement:agent')
+    libsEntitlementBridge project(':libs:entitlement:bridge')
   }
 
   project.ext {
@@ -336,6 +338,12 @@ configure(subprojects.findAll { ['archives', 'packages'].contains(it.name) }) {
             include (os + '-' + architecture + '/*')
           }
         }
+        into('entitlement-agent') {
+          from(configurations.libsEntitlementAgent)
+        }
+        into('entitlement-bridge') {
+          from(configurations.libsEntitlementBridge)
+        }
       }
     }
 

+ 0 - 62
distribution/tools/entitlement-agent/build.gradle

@@ -1,62 +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".
- */
-
-import static java.util.stream.Collectors.joining
-
-apply plugin: 'elasticsearch.build'
-apply plugin: 'elasticsearch.embedded-providers'
-
-embeddedProviders {
-  impl 'entitlement-agent', project(':distribution:tools:entitlement-agent:impl')
-}
-
-configurations {
-  entitlementBridge
-}
-
-dependencies {
-  entitlementBridge project(":distribution:tools:entitlement-bridge")
-  compileOnly project(":libs:core")
-  compileOnly project(":distribution:tools:entitlement-runtime")
-  testImplementation project(":test:framework")
-  testImplementation project(":distribution:tools:entitlement-bridge")
-  testImplementation project(":distribution:tools:entitlement-agent:impl")
-}
-
-tasks.named('test').configure {
-  systemProperty "tests.security.manager", "false"
-  dependsOn('jar')
-
-  // Register an argument provider to avoid eager resolution of configurations
-  jvmArgumentProviders.add(new CommandLineArgumentProvider() {
-    @Override
-    Iterable<String> asArguments() {
-      return ["-javaagent:${tasks.jar.archiveFile.get()}", "-Des.entitlements.bridgeJar=${configurations.entitlementBridge.singleFile}"]
-    }
-  })
-
-
-  // The Elasticsearch build plugin automatically adds all compileOnly deps as testImplementation.
-  // We must not add the bridge this way because it is also on the boot classpath, and that would lead to jar hell.
-  classpath -= files(configurations.entitlementBridge)
-}
-
-tasks.named('jar').configure {
-  manifest {
-    attributes(
-      'Premain-Class': 'org.elasticsearch.entitlement.agent.EntitlementAgent'
-      , 'Can-Retransform-Classes': 'true'
-    )
-  }
-}
-
-tasks.named('forbiddenApisMain').configure {
-  replaceSignatureFiles 'jdk-signatures'
-}
-

+ 0 - 153
distribution/tools/entitlement-agent/impl/src/test/java/org/elasticsearch/entitlement/instrumentation/impl/InstrumenterTests.java

@@ -1,153 +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.entitlement.instrumentation.impl;
-
-import org.elasticsearch.entitlement.api.EntitlementChecks;
-import org.elasticsearch.entitlement.api.EntitlementProvider;
-import org.elasticsearch.entitlement.instrumentation.InstrumentationService;
-import org.elasticsearch.entitlement.instrumentation.MethodKey;
-import org.elasticsearch.logging.LogManager;
-import org.elasticsearch.logging.Logger;
-import org.elasticsearch.test.ESTestCase;
-import org.junit.Before;
-
-import java.lang.reflect.InvocationTargetException;
-import java.lang.reflect.Method;
-import java.util.Map;
-
-import static org.elasticsearch.entitlement.instrumentation.impl.ASMUtils.bytecode2text;
-
-/**
- * This tests {@link InstrumenterImpl} in isolation, without a java agent.
- * It causes the methods to be instrumented, and verifies that the instrumentation is called as expected.
- * Problems with bytecode generation are easier to debug this way than in the context of an agent.
- */
-@ESTestCase.WithoutSecurityManager
-public class InstrumenterTests extends ESTestCase {
-    final InstrumentationService instrumentationService = new InstrumentationServiceImpl();
-
-    private static TestEntitlementManager getTestChecks() {
-        return (TestEntitlementManager) EntitlementProvider.checks();
-    }
-
-    @Before
-    public void initialize() {
-        getTestChecks().isActive = false;
-    }
-
-    /**
-     * Contains all the virtual methods from {@link ClassToInstrument},
-     * allowing this test to call them on the dynamically loaded instrumented class.
-     */
-    public interface Testable {}
-
-    /**
-     * This is a placeholder for real class library methods.
-     * Without the java agent, we can't instrument the real methods, so we instrument this instead.
-     * <p>
-     * Methods of this class must have the same signature and the same static/virtual condition as the corresponding real method.
-     * They should assert that the arguments came through correctly.
-     * They must not throw {@link TestException}.
-     */
-    public static class ClassToInstrument implements Testable {
-        public static void systemExit(int status) {
-            assertEquals(123, status);
-        }
-    }
-
-    static final class TestException extends RuntimeException {}
-
-    /**
-     * We're not testing the permission checking logic here.
-     * This is a trivial implementation of {@link EntitlementChecks} that just always throws,
-     * just to demonstrate that the injected bytecodes succeed in calling these methods.
-     */
-    public static class TestEntitlementManager implements EntitlementChecks {
-        /**
-         * This allows us to test that the instrumentation is correct in both cases:
-         * if the check throws, and if it doesn't.
-         */
-        volatile boolean isActive;
-
-        @Override
-        public void checkSystemExit(Class<?> callerClass, int status) {
-            assertSame(InstrumenterTests.class, callerClass);
-            assertEquals(123, status);
-            throwIfActive();
-        }
-
-        private void throwIfActive() {
-            if (isActive) {
-                throw new TestException();
-            }
-        }
-    }
-
-    public void test() throws Exception {
-        // This test doesn't replace ClassToInstrument in-place but instead loads a separate
-        // class ClassToInstrument_NEW that contains the instrumentation. Because of this,
-        // we need to configure the Transformer to use a MethodKey and instrumentationMethod
-        // with slightly different signatures (using the common interface Testable) which
-        // is not what would happen when it's run by the agent.
-
-        MethodKey k1 = instrumentationService.methodKeyForTarget(ClassToInstrument.class.getMethod("systemExit", int.class));
-        Method v1 = EntitlementChecks.class.getMethod("checkSystemExit", Class.class, int.class);
-        var instrumenter = new InstrumenterImpl("_NEW", Map.of(k1, v1));
-
-        byte[] newBytecode = instrumenter.instrumentClassFile(ClassToInstrument.class).bytecodes();
-
-        if (logger.isTraceEnabled()) {
-            logger.trace("Bytecode after instrumentation:\n{}", bytecode2text(newBytecode));
-        }
-
-        Class<?> newClass = new TestLoader(Testable.class.getClassLoader()).defineClassFromBytes(
-            ClassToInstrument.class.getName() + "_NEW",
-            newBytecode
-        );
-
-        // Before checking is active, nothing should throw
-        callStaticSystemExit(newClass, 123);
-
-        getTestChecks().isActive = true;
-
-        // After checking is activated, everything should throw
-        assertThrows(TestException.class, () -> callStaticSystemExit(newClass, 123));
-    }
-
-    /**
-     * Calling a static method of a dynamically loaded class is significantly more cumbersome
-     * than calling a virtual method.
-     */
-    private static void callStaticSystemExit(Class<?> c, int status) throws NoSuchMethodException, IllegalAccessException {
-        try {
-            c.getMethod("systemExit", int.class).invoke(null, status);
-        } catch (InvocationTargetException e) {
-            Throwable cause = e.getCause();
-            if (cause instanceof TestException n) {
-                // Sometimes we're expecting this one!
-                throw n;
-            } else {
-                throw new AssertionError(cause);
-            }
-        }
-    }
-
-    static class TestLoader extends ClassLoader {
-        TestLoader(ClassLoader parent) {
-            super(parent);
-        }
-
-        public Class<?> defineClassFromBytes(String name, byte[] bytes) {
-            return defineClass(name, bytes, 0, bytes.length);
-        }
-    }
-
-    private static final Logger logger = LogManager.getLogger(InstrumenterTests.class);
-}

+ 0 - 52
distribution/tools/entitlement-agent/src/test/java/org/elasticsearch/entitlement/agent/EntitlementAgentTests.java

@@ -1,52 +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.entitlement.agent;
-
-import com.carrotsearch.randomizedtesting.annotations.SuppressForbidden;
-
-import org.elasticsearch.entitlement.runtime.api.ElasticsearchEntitlementManager;
-import org.elasticsearch.entitlement.runtime.api.NotEntitledException;
-import org.elasticsearch.entitlement.runtime.internals.EntitlementInternals;
-import org.elasticsearch.test.ESTestCase;
-import org.elasticsearch.test.ESTestCase.WithoutSecurityManager;
-import org.junit.After;
-
-/**
- * This is an end-to-end test of the agent and entitlement runtime.
- * It runs with the agent installed, and exhaustively tests every instrumented method
- * to make sure it works with the entitlement granted and throws without it.
- * The only exception is {@link System#exit}, where we can't that it works without
- * terminating the JVM.
- * <p>
- * If you're trying to debug the instrumentation code, take a look at {@code InstrumenterTests}.
- * That tests the bytecode portion without firing up an agent, which makes everything easier to troubleshoot.
- * <p>
- * See {@code build.gradle} for how we set the command line arguments for this test.
- */
-@WithoutSecurityManager
-public class EntitlementAgentTests extends ESTestCase {
-
-    public static final ElasticsearchEntitlementManager ENTITLEMENT_MANAGER = ElasticsearchEntitlementManager.get();
-
-    @After
-    public void resetEverything() {
-        EntitlementInternals.reset();
-    }
-
-    /**
-     * We can't really check that this one passes because it will just exit the JVM.
-     */
-    @SuppressForbidden("Specifically testing System.exit")
-    public void testSystemExitNotEntitled() {
-        ENTITLEMENT_MANAGER.activate();
-        assertThrows(NotEntitledException.class, () -> System.exit(123));
-    }
-
-}

+ 0 - 34
distribution/tools/entitlement-bridge/src/main/java/org/elasticsearch/entitlement/api/EntitlementProvider.java

@@ -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.entitlement.api;
-
-import java.util.List;
-import java.util.ServiceLoader;
-
-public class EntitlementProvider {
-    private static final EntitlementChecks CHECKS = lookupEntitlementChecksImplementation();
-
-    public static EntitlementChecks checks() {
-        return CHECKS;
-    }
-
-    private static EntitlementChecks lookupEntitlementChecksImplementation() {
-        List<EntitlementChecks> candidates = ServiceLoader.load(EntitlementChecks.class).stream().map(ServiceLoader.Provider::get).toList();
-        if (candidates.isEmpty()) {
-            throw new IllegalStateException("No EntitlementChecks service");
-        } else if (candidates.size() >= 2) {
-            throw new IllegalStateException(
-                "Multiple EntitlementChecks services: " + candidates.stream().map(e -> e.getClass().getSimpleName()).toList()
-            );
-        } else {
-            return candidates.get(0);
-        }
-    }
-}

+ 0 - 7
distribution/tools/entitlement-runtime/README.md

@@ -1,7 +0,0 @@
-### Entitlement runtime
-
-This module implements mechanisms to grant and check permissions under the _entitlements_ system.
-
-The entitlements system provides an alternative to the legacy `SecurityManager` system, which is deprecated for removal.
-The `entitlement-agent` tool instruments sensitive class library methods with calls to this module, in order to enforce the controls.
-

+ 0 - 19
distribution/tools/entitlement-runtime/src/main/java/module-info.java

@@ -1,19 +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".
- */
-
-module org.elasticsearch.entitlement.runtime {
-    requires org.elasticsearch.entitlement.bridge;
-    requires org.elasticsearch.server;
-
-    exports org.elasticsearch.entitlement.runtime.api;
-
-    provides org.elasticsearch.entitlement.api.EntitlementChecks
-        with
-            org.elasticsearch.entitlement.runtime.api.ElasticsearchEntitlementManager;
-}

+ 0 - 24
distribution/tools/entitlement-runtime/src/main/java/org/elasticsearch/entitlement/runtime/internals/EntitlementInternals.java

@@ -1,24 +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.entitlement.runtime.internals;
-
-/**
- * Don't export this from the module. Just don't.
- */
-public class EntitlementInternals {
-    /**
-     * When false, entitlement rules are not enforced; all operations are allowed.
-     */
-    public static volatile boolean isActive = false;
-
-    public static void reset() {
-        isActive = false;
-    }
-}

+ 0 - 10
distribution/tools/entitlement-runtime/src/main/resources/META-INF/services/org.elasticsearch.entitlement.api.EntitlementChecks

@@ -1,10 +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".
-#
-
-org.elasticsearch.entitlement.runtime.api.ElasticsearchEntitlementManager

+ 58 - 23
distribution/tools/server-cli/src/main/java/org/elasticsearch/server/cli/SystemJvmOptions.java

@@ -13,9 +13,11 @@ import org.elasticsearch.common.settings.Settings;
 import org.elasticsearch.common.util.concurrent.EsExecutors;
 import org.elasticsearch.core.UpdateForV9;
 
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
 import java.util.List;
 import java.util.Map;
-import java.util.stream.Collectors;
 import java.util.stream.Stream;
 
 final class SystemJvmOptions {
@@ -23,8 +25,8 @@ final class SystemJvmOptions {
     static List<String> systemJvmOptions(Settings nodeSettings, final Map<String, String> sysprops) {
         String distroType = sysprops.get("es.distribution.type");
         boolean isHotspot = sysprops.getOrDefault("sun.management.compiler", "").contains("HotSpot");
-
-        return Stream.concat(
+        boolean useEntitlements = Boolean.parseBoolean(sysprops.getOrDefault("es.entitlements.enabled", "false"));
+        return Stream.of(
             Stream.of(
                 /*
                  * Cache ttl in seconds for positive DNS lookups noting that this overrides the JDK security property
@@ -36,8 +38,6 @@ final class SystemJvmOptions {
                  * networkaddress.cache.negative ttl; set to -1 to cache forever.
                  */
                 "-Des.networkaddress.cache.negative.ttl=10",
-                // Allow to set the security manager.
-                "-Djava.security.manager=allow",
                 // pre-touch JVM emory pages during initialization
                 "-XX:+AlwaysPreTouch",
                 // explicitly set the stack size
@@ -62,15 +62,17 @@ final class SystemJvmOptions {
                 "-Dlog4j2.disable.jmx=true",
                 "-Dlog4j2.formatMsgNoLookups=true",
                 "-Djava.locale.providers=" + getLocaleProviders(),
-                maybeEnableNativeAccess(),
-                maybeOverrideDockerCgroup(distroType),
-                maybeSetActiveProcessorCount(nodeSettings),
-                setReplayFile(distroType, isHotspot),
                 // Pass through distribution type
                 "-Des.distribution.type=" + distroType
             ),
-            maybeWorkaroundG1Bug()
-        ).filter(e -> e.isEmpty() == false).collect(Collectors.toList());
+            maybeEnableNativeAccess(),
+            maybeOverrideDockerCgroup(distroType),
+            maybeSetActiveProcessorCount(nodeSettings),
+            maybeSetReplayFile(distroType, isHotspot),
+            maybeWorkaroundG1Bug(),
+            maybeAllowSecurityManager(),
+            maybeAttachEntitlementAgent(useEntitlements)
+        ).flatMap(s -> s).toList();
     }
 
     @UpdateForV9    // only use CLDR in v9+
@@ -97,42 +99,42 @@ final class SystemJvmOptions {
      * that cgroup statistics are available for the container this process
      * will run in.
      */
-    private static String maybeOverrideDockerCgroup(String distroType) {
+    private static Stream<String> maybeOverrideDockerCgroup(String distroType) {
         if ("docker".equals(distroType)) {
-            return "-Des.cgroups.hierarchy.override=/";
+            return Stream.of("-Des.cgroups.hierarchy.override=/");
         }
-        return "";
+        return Stream.empty();
     }
 
-    private static String setReplayFile(String distroType, boolean isHotspot) {
+    private static Stream<String> maybeSetReplayFile(String distroType, boolean isHotspot) {
         if (isHotspot == false) {
             // the replay file option is only guaranteed for hotspot vms
-            return "";
+            return Stream.empty();
         }
         String replayDir = "logs";
         if ("rpm".equals(distroType) || "deb".equals(distroType)) {
             replayDir = "/var/log/elasticsearch";
         }
-        return "-XX:ReplayDataFile=" + replayDir + "/replay_pid%p.log";
+        return Stream.of("-XX:ReplayDataFile=" + replayDir + "/replay_pid%p.log");
     }
 
     /*
      * node.processors determines thread pool sizes for Elasticsearch. When it
      * is set, we need to also tell the JVM to respect a different value
      */
-    private static String maybeSetActiveProcessorCount(Settings nodeSettings) {
+    private static Stream<String> maybeSetActiveProcessorCount(Settings nodeSettings) {
         if (EsExecutors.NODE_PROCESSORS_SETTING.exists(nodeSettings)) {
             int allocated = EsExecutors.allocatedProcessors(nodeSettings);
-            return "-XX:ActiveProcessorCount=" + allocated;
+            return Stream.of("-XX:ActiveProcessorCount=" + allocated);
         }
-        return "";
+        return Stream.empty();
     }
 
-    private static String maybeEnableNativeAccess() {
+    private static Stream<String> maybeEnableNativeAccess() {
         if (Runtime.version().feature() >= 21) {
-            return "--enable-native-access=org.elasticsearch.nativeaccess,org.apache.lucene.core";
+            return Stream.of("--enable-native-access=org.elasticsearch.nativeaccess,org.apache.lucene.core");
         }
-        return "";
+        return Stream.empty();
     }
 
     /*
@@ -145,4 +147,37 @@ final class SystemJvmOptions {
         }
         return Stream.of();
     }
+
+    private static Stream<String> maybeAllowSecurityManager() {
+        // Will become conditional on useEntitlements once entitlements can run without SM
+        return Stream.of("-Djava.security.manager=allow");
+    }
+
+    private static Stream<String> maybeAttachEntitlementAgent(boolean useEntitlements) {
+        if (useEntitlements == false) {
+            return Stream.empty();
+        }
+
+        Path dir = Path.of("lib", "entitlement-bridge");
+        if (Files.exists(dir) == false) {
+            throw new IllegalStateException("Directory for entitlement bridge jar does not exist: " + dir);
+        }
+        String bridgeJar;
+        try (var s = Files.list(dir)) {
+            var candidates = s.limit(2).toList();
+            if (candidates.size() != 1) {
+                throw new IllegalStateException("Expected one jar in " + dir + "; found " + candidates.size());
+            }
+            bridgeJar = candidates.get(0).toString();
+        } catch (IOException e) {
+            throw new IllegalStateException("Failed to list entitlement jars in: " + dir, e);
+        }
+        return Stream.of(
+            "-Des.entitlements.enabled=true",
+            "-XX:+EnableDynamicAgentLoading",
+            "-Djdk.attach.allowAttachSelf=true",
+            "--patch-module=java.base=" + bridgeJar,
+            "--add-exports=java.base/org.elasticsearch.entitlement.bridge=org.elasticsearch.entitlement"
+        );
+    }
 }

+ 1 - 1
libs/core/src/main/java/module-info.java

@@ -19,7 +19,7 @@ module org.elasticsearch.base {
         to
             org.elasticsearch.xcontent,
             org.elasticsearch.nativeaccess,
-            org.elasticsearch.entitlement.agent;
+            org.elasticsearch.entitlement;
 
     uses ModuleQualifiedExportsService;
 }

+ 11 - 0
libs/entitlement/README.md

@@ -0,0 +1,11 @@
+### Entitlement library
+
+This module implements mechanisms to grant and check permissions under the _entitlements_ system.
+
+The entitlements system provides an alternative to the legacy `SecurityManager` system, which is deprecated for removal.
+The `entitlement-agent` instruments sensitive class library methods with calls to this module, in order to enforce the controls.
+
+This feature is currently under development, and it is completely disabled by default (the agent is not loaded). To enable it, run Elasticsearch with
+```shell
+./gradlew run --entitlements
+```

+ 2 - 2
distribution/tools/entitlement-agent/README.md → libs/entitlement/agent/README.md

@@ -5,6 +5,6 @@ This is a java agent that instruments sensitive class library methods with calls
 The entitlements system provides an alternative to the legacy `SecurityManager` system, which is deprecated for removal.
 With this agent, the Elasticsearch server can retain some control over which class library methods can be invoked by which callers.
 
-This module is responsible for inserting the appropriate bytecode to achieve enforcement of the rules governed by the `entitlement-runtime` module.
+This module is responsible for inserting the appropriate bytecode to achieve enforcement of the rules governed by the main `entitlement` module.
 
-It is not responsible for permission granting or checking logic. That responsibility lies with `entitlement-runtime`.
+It is not responsible for permission granting or checking logic. That responsibility lies with the main `entitlement` module.

+ 17 - 7
distribution/tools/entitlement-agent/src/main/java/module-info.java → libs/entitlement/agent/build.gradle

@@ -6,14 +6,24 @@
  * your election, the "Elastic License 2.0", the "GNU Affero General Public
  * License v3.0 only", or the "Server Side Public License, v 1".
  */
+apply plugin: 'elasticsearch.build'
 
-import org.elasticsearch.entitlement.instrumentation.InstrumentationService;
-
-module org.elasticsearch.entitlement.agent {
-    requires java.instrument;
-    requires org.elasticsearch.base; // for @SuppressForbidden
+dependencies {
+  compileOnly project(":libs:core")
+  compileOnly project(":libs:entitlement")
+  compileOnly project(":libs:entitlement:bridge")
+}
 
-    exports org.elasticsearch.entitlement.instrumentation to org.elasticsearch.entitlement.agent.impl;
+tasks.named('jar').configure {
+  manifest {
+    attributes(
+      'Agent-Class': 'org.elasticsearch.entitlement.agent.EntitlementAgent'
+      , 'Can-Retransform-Classes': 'true'
+    )
+  }
+}
 
-    uses InstrumentationService;
+tasks.named('forbiddenApisMain').configure {
+  replaceSignatureFiles 'jdk-signatures'
 }
+

+ 49 - 0
libs/entitlement/agent/src/main/java/org/elasticsearch/entitlement/agent/EntitlementAgent.java

@@ -0,0 +1,49 @@
+/*
+ * 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.agent;
+
+import java.lang.instrument.Instrumentation;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+
+/**
+ * A Java Agent that sets up the bytecode instrumentation for the entitlement system.
+ * <p>
+ * Agents are loaded into the unnamed module, which makes module exports awkward.
+ * To work around this, we keep minimal code in the agent itself, and
+ * instead use reflection to call into the main entitlement library,
+ * which bootstraps by using {@link Module#addExports} to make a single {@code initialize}
+ * method available for us to call from here.
+ * That method does the rest.
+ */
+public class EntitlementAgent {
+
+    public static void agentmain(String agentArgs, Instrumentation inst) {
+        final Class<?> initClazz;
+        try {
+            initClazz = Class.forName("org.elasticsearch.entitlement.initialization.EntitlementInitialization");
+        } catch (ClassNotFoundException e) {
+            throw new AssertionError("entitlement agent does could not find EntitlementInitialization", e);
+        }
+
+        final Method initMethod;
+        try {
+            initMethod = initClazz.getMethod("initialize", Instrumentation.class);
+        } catch (NoSuchMethodException e) {
+            throw new AssertionError("EntitlementInitialization missing initialize method", e);
+        }
+
+        try {
+            initMethod.invoke(null, inst);
+        } catch (IllegalAccessException | InvocationTargetException e) {
+            throw new AssertionError("entitlement initialization failed", e);
+        }
+    }
+}

+ 2 - 0
libs/entitlement/asm-provider/README.md

@@ -0,0 +1,2 @@
+This module uses the ASM library to implement various things, including bytecode instrumentation.
+It is loaded using the Embedded Provider Gradle plugin.

+ 2 - 2
distribution/tools/entitlement-agent/impl/build.gradle → libs/entitlement/asm-provider/build.gradle

@@ -10,10 +10,10 @@
 apply plugin: 'elasticsearch.build'
 
 dependencies {
-  compileOnly project(':distribution:tools:entitlement-agent')
+  compileOnly project(':libs:entitlement')
   implementation 'org.ow2.asm:asm:9.7'
   testImplementation project(":test:framework")
-  testImplementation project(":distribution:tools:entitlement-bridge")
+  testImplementation project(":libs:entitlement:bridge")
   testImplementation 'org.ow2.asm:asm-util:9.7'
 }
 

+ 0 - 0
distribution/tools/entitlement-agent/impl/licenses/asm-LICENSE.txt → libs/entitlement/asm-provider/licenses/asm-LICENSE.txt


+ 0 - 0
distribution/tools/entitlement-agent/impl/licenses/asm-NOTICE.txt → libs/entitlement/asm-provider/licenses/asm-NOTICE.txt


+ 2 - 2
distribution/tools/entitlement-agent/impl/src/main/java/module-info.java → libs/entitlement/asm-provider/src/main/java/module-info.java

@@ -10,9 +10,9 @@
 import org.elasticsearch.entitlement.instrumentation.InstrumentationService;
 import org.elasticsearch.entitlement.instrumentation.impl.InstrumentationServiceImpl;
 
-module org.elasticsearch.entitlement.agent.impl {
+module org.elasticsearch.entitlement.instrumentation {
     requires org.objectweb.asm;
-    requires org.elasticsearch.entitlement.agent;
+    requires org.elasticsearch.entitlement;
 
     provides InstrumentationService with InstrumentationServiceImpl;
 }

+ 0 - 0
distribution/tools/entitlement-agent/impl/src/main/java/org/elasticsearch/entitlement/instrumentation/impl/InstrumentationServiceImpl.java → libs/entitlement/asm-provider/src/main/java/org/elasticsearch/entitlement/instrumentation/impl/InstrumentationServiceImpl.java


+ 95 - 25
distribution/tools/entitlement-agent/impl/src/main/java/org/elasticsearch/entitlement/instrumentation/impl/InstrumenterImpl.java → libs/entitlement/asm-provider/src/main/java/org/elasticsearch/entitlement/instrumentation/impl/InstrumenterImpl.java

@@ -15,8 +15,10 @@ import org.objectweb.asm.AnnotationVisitor;
 import org.objectweb.asm.ClassReader;
 import org.objectweb.asm.ClassVisitor;
 import org.objectweb.asm.ClassWriter;
+import org.objectweb.asm.FieldVisitor;
 import org.objectweb.asm.MethodVisitor;
 import org.objectweb.asm.Opcodes;
+import org.objectweb.asm.RecordComponentVisitor;
 import org.objectweb.asm.Type;
 
 import java.io.IOException;
@@ -73,7 +75,13 @@ public class InstrumenterImpl implements Instrumenter {
     }
 
     class EntitlementClassVisitor extends ClassVisitor {
-        final String className;
+
+        private static final String ENTITLEMENT_ANNOTATION = "EntitlementInstrumented";
+
+        private final String className;
+
+        private boolean isAnnotationPresent;
+        private boolean annotationNeeded = true;
 
         EntitlementClassVisitor(int api, ClassVisitor classVisitor, String className) {
             super(api, classVisitor);
@@ -85,28 +93,88 @@ public class InstrumenterImpl implements Instrumenter {
             super.visit(version, access, name + classNameSuffix, signature, superName, interfaces);
         }
 
+        @Override
+        public AnnotationVisitor visitAnnotation(String descriptor, boolean visible) {
+            if (visible && descriptor.equals(ENTITLEMENT_ANNOTATION)) {
+                isAnnotationPresent = true;
+                annotationNeeded = false;
+            }
+            return cv.visitAnnotation(descriptor, visible);
+        }
+
+        @Override
+        public void visitNestMember(String nestMember) {
+            addClassAnnotationIfNeeded();
+            super.visitNestMember(nestMember);
+        }
+
+        @Override
+        public void visitPermittedSubclass(String permittedSubclass) {
+            addClassAnnotationIfNeeded();
+            super.visitPermittedSubclass(permittedSubclass);
+        }
+
+        @Override
+        public void visitInnerClass(String name, String outerName, String innerName, int access) {
+            addClassAnnotationIfNeeded();
+            super.visitInnerClass(name, outerName, innerName, access);
+        }
+
+        @Override
+        public FieldVisitor visitField(int access, String name, String descriptor, String signature, Object value) {
+            addClassAnnotationIfNeeded();
+            return super.visitField(access, name, descriptor, signature, value);
+        }
+
+        @Override
+        public RecordComponentVisitor visitRecordComponent(String name, String descriptor, String signature) {
+            addClassAnnotationIfNeeded();
+            return super.visitRecordComponent(name, descriptor, signature);
+        }
+
         @Override
         public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) {
+            addClassAnnotationIfNeeded();
             var mv = super.visitMethod(access, name, descriptor, signature, exceptions);
-            boolean isStatic = (access & ACC_STATIC) != 0;
-            var key = new MethodKey(
-                className,
-                name,
-                Stream.of(Type.getArgumentTypes(descriptor)).map(Type::getInternalName).toList(),
-                isStatic
-            );
-            var instrumentationMethod = instrumentationMethods.get(key);
-            if (instrumentationMethod != null) {
-                // LOGGER.debug("Will instrument method {}", key);
-                return new EntitlementMethodVisitor(Opcodes.ASM9, mv, isStatic, descriptor, instrumentationMethod);
-            } else {
-                // LOGGER.trace("Will not instrument method {}", key);
+            if (isAnnotationPresent == false) {
+                boolean isStatic = (access & ACC_STATIC) != 0;
+                var key = new MethodKey(
+                    className,
+                    name,
+                    Stream.of(Type.getArgumentTypes(descriptor)).map(Type::getInternalName).toList(),
+                    isStatic
+                );
+                var instrumentationMethod = instrumentationMethods.get(key);
+                if (instrumentationMethod != null) {
+                    // LOGGER.debug("Will instrument method {}", key);
+                    return new EntitlementMethodVisitor(Opcodes.ASM9, mv, isStatic, descriptor, instrumentationMethod);
+                } else {
+                    // LOGGER.trace("Will not instrument method {}", key);
+                }
             }
             return mv;
         }
+
+        /**
+         * A class annotation can be added via visitAnnotation; we need to call visitAnnotation after all other visitAnnotation
+         * calls (in case one of them detects our annotation is already present), but before any other subsequent visit* method is called
+         * (up to visitMethod -- if no visitMethod is called, there is nothing to instrument).
+         * This includes visitNestMember, visitPermittedSubclass, visitInnerClass, visitField, visitRecordComponent and, of course,
+         * visitMethod (see {@link ClassVisitor} javadoc).
+         */
+        private void addClassAnnotationIfNeeded() {
+            if (annotationNeeded) {
+                // logger.debug("Adding {} annotation", ENTITLEMENT_ANNOTATION);
+                AnnotationVisitor av = cv.visitAnnotation(ENTITLEMENT_ANNOTATION, true);
+                if (av != null) {
+                    av.visitEnd();
+                }
+                annotationNeeded = false;
+            }
+        }
     }
 
-    static class EntitlementMethodVisitor extends MethodVisitor {
+    class EntitlementMethodVisitor extends MethodVisitor {
         private final boolean instrumentedMethodIsStatic;
         private final String instrumentedMethodDescriptor;
         private final Method instrumentationMethod;
@@ -135,21 +203,15 @@ public class InstrumenterImpl implements Instrumenter {
 
         @Override
         public void visitCode() {
-            pushEntitlementChecksObject();
+            pushEntitlementChecker();
             pushCallerClass();
             forwardIncomingArguments();
             invokeInstrumentationMethod();
             super.visitCode();
         }
 
-        private void pushEntitlementChecksObject() {
-            mv.visitMethodInsn(
-                INVOKESTATIC,
-                "org/elasticsearch/entitlement/api/EntitlementProvider",
-                "checks",
-                "()Lorg/elasticsearch/entitlement/api/EntitlementChecks;",
-                false
-            );
+        private void pushEntitlementChecker() {
+            InstrumenterImpl.this.pushEntitlementChecker(mv);
         }
 
         private void pushCallerClass() {
@@ -208,7 +270,15 @@ public class InstrumenterImpl implements Instrumenter {
         }
     }
 
-    // private static final Logger LOGGER = LogManager.getLogger(Instrumenter.class);
+    protected void pushEntitlementChecker(MethodVisitor mv) {
+        mv.visitMethodInsn(
+            INVOKESTATIC,
+            "org/elasticsearch/entitlement/bridge/EntitlementCheckerHandle",
+            "instance",
+            "()Lorg/elasticsearch/entitlement/bridge/EntitlementChecker;",
+            false
+        );
+    }
 
     public record ClassFileInfo(String fileName, byte[] bytecodes) {}
 }

+ 0 - 0
distribution/tools/entitlement-agent/impl/src/main/resources/META-INF/services/org.elasticsearch.entitlement.instrumentation.InstrumentationService → libs/entitlement/asm-provider/src/main/resources/META-INF/services/org.elasticsearch.entitlement.instrumentation.InstrumentationService


+ 0 - 0
distribution/tools/entitlement-agent/impl/src/test/java/org/elasticsearch/entitlement/instrumentation/impl/ASMUtils.java → libs/entitlement/asm-provider/src/test/java/org/elasticsearch/entitlement/instrumentation/impl/ASMUtils.java


+ 252 - 0
libs/entitlement/asm-provider/src/test/java/org/elasticsearch/entitlement/instrumentation/impl/InstrumenterTests.java

@@ -0,0 +1,252 @@
+/*
+ * 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.instrumentation.impl;
+
+import org.elasticsearch.common.Strings;
+import org.elasticsearch.entitlement.bridge.EntitlementChecker;
+import org.elasticsearch.entitlement.instrumentation.InstrumentationService;
+import org.elasticsearch.logging.LogManager;
+import org.elasticsearch.logging.Logger;
+import org.elasticsearch.test.ESTestCase;
+import org.junit.Before;
+import org.objectweb.asm.MethodVisitor;
+import org.objectweb.asm.Type;
+
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.util.Arrays;
+import java.util.stream.Collectors;
+
+import static org.elasticsearch.entitlement.instrumentation.impl.ASMUtils.bytecode2text;
+import static org.elasticsearch.entitlement.instrumentation.impl.InstrumenterImpl.getClassFileInfo;
+import static org.hamcrest.Matchers.is;
+import static org.objectweb.asm.Opcodes.INVOKESTATIC;
+
+/**
+ * This tests {@link InstrumenterImpl} in isolation, without a java agent.
+ * It causes the methods to be instrumented, and verifies that the instrumentation is called as expected.
+ * Problems with bytecode generation are easier to debug this way than in the context of an agent.
+ */
+@ESTestCase.WithoutSecurityManager
+public class InstrumenterTests extends ESTestCase {
+    final InstrumentationService instrumentationService = new InstrumentationServiceImpl();
+
+    static volatile TestEntitlementChecker testChecker;
+
+    public static TestEntitlementChecker getTestEntitlementChecker() {
+        return testChecker;
+    }
+
+    @Before
+    public void initialize() {
+        testChecker = new TestEntitlementChecker();
+    }
+
+    /**
+     * Contains all the virtual methods from {@link ClassToInstrument},
+     * allowing this test to call them on the dynamically loaded instrumented class.
+     */
+    public interface Testable {}
+
+    /**
+     * This is a placeholder for real class library methods.
+     * Without the java agent, we can't instrument the real methods, so we instrument this instead.
+     * <p>
+     * Methods of this class must have the same signature and the same static/virtual condition as the corresponding real method.
+     * They should assert that the arguments came through correctly.
+     * They must not throw {@link TestException}.
+     */
+    public static class ClassToInstrument implements Testable {
+        public static void systemExit(int status) {
+            assertEquals(123, status);
+        }
+
+        public static void anotherSystemExit(int status) {
+            assertEquals(123, status);
+        }
+    }
+
+    static final class TestException extends RuntimeException {}
+
+    /**
+     * We're not testing the permission checking logic here;
+     * only that the instrumented methods are calling the correct check methods with the correct arguments.
+     * This is a trivial implementation of {@link EntitlementChecker} that just always throws,
+     * just to demonstrate that the injected bytecodes succeed in calling these methods.
+     * It also asserts that the arguments are correct.
+     */
+    public static class TestEntitlementChecker implements EntitlementChecker {
+        /**
+         * This allows us to test that the instrumentation is correct in both cases:
+         * if the check throws, and if it doesn't.
+         */
+        volatile boolean isActive;
+
+        int checkSystemExitCallCount = 0;
+
+        @Override
+        public void checkSystemExit(Class<?> callerClass, int status) {
+            checkSystemExitCallCount++;
+            assertSame(InstrumenterTests.class, callerClass);
+            assertEquals(123, status);
+            throwIfActive();
+        }
+
+        private void throwIfActive() {
+            if (isActive) {
+                throw new TestException();
+            }
+        }
+    }
+
+    public void testClassIsInstrumented() throws Exception {
+        var classToInstrument = ClassToInstrument.class;
+        var instrumenter = createInstrumenter(classToInstrument, "systemExit");
+
+        byte[] newBytecode = instrumenter.instrumentClassFile(classToInstrument).bytecodes();
+
+        if (logger.isTraceEnabled()) {
+            logger.trace("Bytecode after instrumentation:\n{}", bytecode2text(newBytecode));
+        }
+
+        Class<?> newClass = new TestLoader(Testable.class.getClassLoader()).defineClassFromBytes(
+            ClassToInstrument.class.getName() + "_NEW",
+            newBytecode
+        );
+
+        getTestEntitlementChecker().isActive = false;
+
+        // Before checking is active, nothing should throw
+        callStaticMethod(newClass, "systemExit", 123);
+
+        getTestEntitlementChecker().isActive = true;
+
+        // After checking is activated, everything should throw
+        assertThrows(TestException.class, () -> callStaticMethod(newClass, "systemExit", 123));
+    }
+
+    public void testClassIsNotInstrumentedTwice() throws Exception {
+        var classToInstrument = ClassToInstrument.class;
+        var instrumenter = createInstrumenter(classToInstrument, "systemExit");
+
+        InstrumenterImpl.ClassFileInfo initial = getClassFileInfo(classToInstrument);
+        var internalClassName = Type.getInternalName(classToInstrument);
+
+        byte[] instrumentedBytecode = instrumenter.instrumentClass(internalClassName, initial.bytecodes());
+        byte[] instrumentedTwiceBytecode = instrumenter.instrumentClass(internalClassName, instrumentedBytecode);
+
+        logger.trace(() -> Strings.format("Bytecode after 1st instrumentation:\n%s", bytecode2text(instrumentedBytecode)));
+        logger.trace(() -> Strings.format("Bytecode after 2nd instrumentation:\n%s", bytecode2text(instrumentedTwiceBytecode)));
+
+        Class<?> newClass = new TestLoader(Testable.class.getClassLoader()).defineClassFromBytes(
+            ClassToInstrument.class.getName() + "_NEW_NEW",
+            instrumentedTwiceBytecode
+        );
+
+        getTestEntitlementChecker().isActive = true;
+        getTestEntitlementChecker().checkSystemExitCallCount = 0;
+
+        assertThrows(TestException.class, () -> callStaticMethod(newClass, "systemExit", 123));
+        assertThat(getTestEntitlementChecker().checkSystemExitCallCount, is(1));
+    }
+
+    public void testClassAllMethodsAreInstrumentedFirstPass() throws Exception {
+        var classToInstrument = ClassToInstrument.class;
+        var instrumenter = createInstrumenter(classToInstrument, "systemExit", "anotherSystemExit");
+
+        InstrumenterImpl.ClassFileInfo initial = getClassFileInfo(classToInstrument);
+        var internalClassName = Type.getInternalName(classToInstrument);
+
+        byte[] instrumentedBytecode = instrumenter.instrumentClass(internalClassName, initial.bytecodes());
+        byte[] instrumentedTwiceBytecode = instrumenter.instrumentClass(internalClassName, instrumentedBytecode);
+
+        logger.trace(() -> Strings.format("Bytecode after 1st instrumentation:\n%s", bytecode2text(instrumentedBytecode)));
+        logger.trace(() -> Strings.format("Bytecode after 2nd instrumentation:\n%s", bytecode2text(instrumentedTwiceBytecode)));
+
+        Class<?> newClass = new TestLoader(Testable.class.getClassLoader()).defineClassFromBytes(
+            ClassToInstrument.class.getName() + "_NEW_NEW",
+            instrumentedTwiceBytecode
+        );
+
+        getTestEntitlementChecker().isActive = true;
+        getTestEntitlementChecker().checkSystemExitCallCount = 0;
+
+        assertThrows(TestException.class, () -> callStaticMethod(newClass, "systemExit", 123));
+        assertThat(getTestEntitlementChecker().checkSystemExitCallCount, is(1));
+
+        assertThrows(TestException.class, () -> callStaticMethod(newClass, "anotherSystemExit", 123));
+        assertThat(getTestEntitlementChecker().checkSystemExitCallCount, is(2));
+    }
+
+    /** This test doesn't replace ClassToInstrument in-place but instead loads a separate
+     * class ClassToInstrument_NEW that contains the instrumentation. Because of this,
+     * we need to configure the Transformer to use a MethodKey and instrumentationMethod
+     * with slightly different signatures (using the common interface Testable) which
+     * is not what would happen when it's run by the agent.
+     */
+    private InstrumenterImpl createInstrumenter(Class<?> classToInstrument, String... methodNames) throws NoSuchMethodException {
+        Method v1 = EntitlementChecker.class.getMethod("checkSystemExit", Class.class, int.class);
+        var methods = Arrays.stream(methodNames).map(name -> {
+            try {
+                return instrumentationService.methodKeyForTarget(classToInstrument.getMethod(name, int.class));
+            } catch (NoSuchMethodException e) {
+                throw new RuntimeException(e);
+            }
+        }).collect(Collectors.toUnmodifiableMap(name -> name, name -> v1));
+
+        Method getter = InstrumenterTests.class.getMethod("getTestEntitlementChecker");
+        return new InstrumenterImpl("_NEW", methods) {
+            /**
+             * We're not testing the bridge library here.
+             * Just call our own getter instead.
+             */
+            @Override
+            protected void pushEntitlementChecker(MethodVisitor mv) {
+                mv.visitMethodInsn(
+                    INVOKESTATIC,
+                    Type.getInternalName(getter.getDeclaringClass()),
+                    getter.getName(),
+                    Type.getMethodDescriptor(getter),
+                    false
+                );
+            }
+        };
+    }
+
+    /**
+     * Calling a static method of a dynamically loaded class is significantly more cumbersome
+     * than calling a virtual method.
+     */
+    private static void callStaticMethod(Class<?> c, String methodName, int status) throws NoSuchMethodException, IllegalAccessException {
+        try {
+            c.getMethod(methodName, int.class).invoke(null, status);
+        } catch (InvocationTargetException e) {
+            Throwable cause = e.getCause();
+            if (cause instanceof TestException n) {
+                // Sometimes we're expecting this one!
+                throw n;
+            } else {
+                throw new AssertionError(cause);
+            }
+        }
+    }
+
+    static class TestLoader extends ClassLoader {
+        TestLoader(ClassLoader parent) {
+            super(parent);
+        }
+
+        public Class<?> defineClassFromBytes(String name, byte[] bytes) {
+            return defineClass(name, bytes, 0, bytes.length);
+        }
+    }
+
+    private static final Logger logger = LogManager.getLogger(InstrumenterTests.class);
+}

+ 1 - 1
distribution/tools/entitlement-agent/impl/src/test/resources/META-INF/services/org.elasticsearch.entitlement.api.EntitlementChecks → libs/entitlement/asm-provider/src/test/resources/META-INF/services/org.elasticsearch.entitlement.bridge.EntitlementChecker

@@ -7,4 +7,4 @@
  # License v3.0 only", or the "Server Side Public License, v 1".
 #
 
-org.elasticsearch.entitlement.instrumentation.impl.InstrumenterTests$TestEntitlementManager
+org.elasticsearch.entitlement.instrumentation.impl.InstrumenterTests$TestEntitlementChecker

+ 3 - 3
distribution/tools/entitlement-bridge/README.md → libs/entitlement/bridge/README.md

@@ -1,11 +1,11 @@
 ### Entitlement Bridge
 
 This is the code called directly from instrumented methods.
-It's a minimal code stub that is loaded into the boot classloader by the entitlement agent
+It's a minimal shim that is patched into the `java.base` module
 so that it is callable from the class library methods instrumented by the agent.
-Its job is to forward the entitlement checks to the actual runtime library,
+Its job is to forward the entitlement checks to the main library,
 which is loaded normally.
 
 It is not responsible for injecting the bytecode instrumentation (that's the agent)
-nor for implementing the permission checks (that's the runtime library).
+nor for implementing the permission checks (that's the main library).
 

+ 0 - 2
distribution/tools/entitlement-bridge/build.gradle → libs/entitlement/bridge/build.gradle

@@ -9,8 +9,6 @@
 
 apply plugin: 'elasticsearch.build'
 
-dependencies {
-}
 
 tasks.named('forbiddenApisMain').configure {
   replaceSignatureFiles 'jdk-signatures'

+ 3 - 3
distribution/tools/entitlement-bridge/src/main/java/module-info.java → libs/entitlement/bridge/src/main/java/module-info.java

@@ -7,8 +7,8 @@
  * License v3.0 only", or the "Server Side Public License, v 1".
  */
 
+// This module-info is used just to satisfy your IDE.
+// At build and run time, the bridge is patched into the java.base module.
 module org.elasticsearch.entitlement.bridge {
-    uses org.elasticsearch.entitlement.api.EntitlementChecks;
-
-    exports org.elasticsearch.entitlement.api;
+    exports org.elasticsearch.entitlement.bridge;
 }

+ 2 - 2
distribution/tools/entitlement-bridge/src/main/java/org/elasticsearch/entitlement/api/EntitlementChecks.java → libs/entitlement/bridge/src/main/java/org/elasticsearch/entitlement/bridge/EntitlementChecker.java

@@ -7,8 +7,8 @@
  * License v3.0 only", or the "Server Side Public License, v 1".
  */
 
-package org.elasticsearch.entitlement.api;
+package org.elasticsearch.entitlement.bridge;
 
-public interface EntitlementChecks {
+public interface EntitlementChecker {
     void checkSystemExit(Class<?> callerClass, int status);
 }

+ 63 - 0
libs/entitlement/bridge/src/main/java/org/elasticsearch/entitlement/bridge/EntitlementCheckerHandle.java

@@ -0,0 +1,63 @@
+/*
+ * 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.bridge;
+
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+
+/**
+ * Makes the {@link EntitlementChecker} available to injected bytecode.
+ */
+public class EntitlementCheckerHandle {
+
+    /**
+     * This is how the bytecodes injected by our instrumentation access the {@link EntitlementChecker}
+     * so they can call the appropriate check method.
+     */
+    public static EntitlementChecker instance() {
+        return Holder.instance;
+    }
+
+    /**
+     * Having a separate inner {@code Holder} class ensures that the field is initialized
+     * the first time {@link #instance()} is called, rather than the first time anyone anywhere
+     * references the {@link EntitlementCheckerHandle} class.
+     */
+    private static class Holder {
+        /**
+         * The {@code EntitlementInitialization} class is what actually instantiates it and makes it available;
+         * here, we copy it into a static final variable for maximum performance.
+         */
+        private static final EntitlementChecker instance;
+        static {
+            String initClazz = "org.elasticsearch.entitlement.initialization.EntitlementInitialization";
+            final Class<?> clazz;
+            try {
+                clazz = ClassLoader.getSystemClassLoader().loadClass(initClazz);
+            } catch (ClassNotFoundException e) {
+                throw new AssertionError("java.base cannot find entitlement initialziation", e);
+            }
+            final Method checkerMethod;
+            try {
+                checkerMethod = clazz.getMethod("checker");
+            } catch (NoSuchMethodException e) {
+                throw new AssertionError("EntitlementInitialization is missing checker() method", e);
+            }
+            try {
+                instance = (EntitlementChecker) checkerMethod.invoke(null);
+            } catch (IllegalAccessException | InvocationTargetException e) {
+                throw new AssertionError(e);
+            }
+        }
+    }
+
+    // no construction
+    private EntitlementCheckerHandle() {}
+}

+ 11 - 8
distribution/tools/entitlement-runtime/build.gradle → libs/entitlement/build.gradle

@@ -9,17 +9,20 @@
 apply plugin: 'elasticsearch.build'
 apply plugin: 'elasticsearch.publish'
 
-dependencies {
-  compileOnly project(':libs:core') // For @SuppressForbidden
-  compileOnly project(":libs:x-content") // for parsing policy files
-  compileOnly project(':server') // To access the main server module for special permission checks
-  compileOnly project(':distribution:tools:entitlement-bridge')
+apply plugin: 'elasticsearch.embedded-providers'
 
-  testImplementation project(":test:framework")
+embeddedProviders {
+  impl 'entitlement', project(':libs:entitlement:asm-provider')
 }
 
-tasks.named('forbiddenApisMain').configure {
-  replaceSignatureFiles 'jdk-signatures'
+dependencies {
+  compileOnly project(':libs:core') // For @SuppressForbidden
+  compileOnly project(':libs:logging')
+  compileOnly project(":libs:x-content") // for parsing policy files
+  compileOnly project(':libs:entitlement:bridge')
+  testImplementation(project(":test:framework")) {
+    exclude group: 'org.elasticsearch', module: 'entitlement'
+  }
 }
 
 tasks.named('forbiddenApisMain').configure {

+ 25 - 0
libs/entitlement/src/main/java/module-info.java

@@ -0,0 +1,25 @@
+/*
+ * 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".
+ */
+
+module org.elasticsearch.entitlement {
+    requires org.elasticsearch.xcontent;
+    requires org.elasticsearch.logging;
+    requires java.instrument;
+    requires org.elasticsearch.base;
+    requires jdk.attach;
+
+    requires static org.elasticsearch.entitlement.bridge; // At runtime, this will be in java.base
+
+    exports org.elasticsearch.entitlement.runtime.api;
+    exports org.elasticsearch.entitlement.instrumentation;
+    exports org.elasticsearch.entitlement.bootstrap to org.elasticsearch.server;
+    exports org.elasticsearch.entitlement.initialization to java.base;
+
+    uses org.elasticsearch.entitlement.instrumentation.InstrumentationService;
+}

+ 82 - 0
libs/entitlement/src/main/java/org/elasticsearch/entitlement/bootstrap/EntitlementBootstrap.java

@@ -0,0 +1,82 @@
+/*
+ * 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.bootstrap;
+
+import com.sun.tools.attach.AgentInitializationException;
+import com.sun.tools.attach.AgentLoadException;
+import com.sun.tools.attach.AttachNotSupportedException;
+import com.sun.tools.attach.VirtualMachine;
+
+import org.elasticsearch.core.SuppressForbidden;
+import org.elasticsearch.entitlement.initialization.EntitlementInitialization;
+import org.elasticsearch.logging.LogManager;
+import org.elasticsearch.logging.Logger;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+public class EntitlementBootstrap {
+
+    /**
+     * Activates entitlement checking. Once this method returns, calls to forbidden methods
+     * will throw {@link org.elasticsearch.entitlement.runtime.api.NotEntitledException}.
+     */
+    public static void bootstrap() {
+        logger.debug("Loading entitlement agent");
+        exportInitializationToAgent();
+        loadAgent(findAgentJar());
+    }
+
+    @SuppressForbidden(reason = "The VirtualMachine API is the only way to attach a java agent dynamically")
+    private static void loadAgent(String agentPath) {
+        try {
+            VirtualMachine vm = VirtualMachine.attach(Long.toString(ProcessHandle.current().pid()));
+            try {
+                vm.loadAgent(agentPath);
+            } finally {
+                vm.detach();
+            }
+        } catch (AttachNotSupportedException | IOException | AgentLoadException | AgentInitializationException e) {
+            throw new IllegalStateException("Unable to attach entitlement agent", e);
+        }
+    }
+
+    private static void exportInitializationToAgent() {
+        String initPkg = EntitlementInitialization.class.getPackageName();
+        // agent will live in unnamed module
+        Module unnamedModule = ClassLoader.getSystemClassLoader().getUnnamedModule();
+        EntitlementInitialization.class.getModule().addExports(initPkg, unnamedModule);
+    }
+
+    private static String findAgentJar() {
+        String propertyName = "es.entitlement.agentJar";
+        String propertyValue = System.getProperty(propertyName);
+        if (propertyValue != null) {
+            return propertyValue;
+        }
+
+        Path dir = Path.of("lib", "entitlement-agent");
+        if (Files.exists(dir) == false) {
+            throw new IllegalStateException("Directory for entitlement jar does not exist: " + dir);
+        }
+        try (var s = Files.list(dir)) {
+            var candidates = s.limit(2).toList();
+            if (candidates.size() != 1) {
+                throw new IllegalStateException("Expected one jar in " + dir + "; found " + candidates.size());
+            }
+            return candidates.get(0).toString();
+        } catch (IOException e) {
+            throw new IllegalStateException("Failed to list entitlement jars in: " + dir, e);
+        }
+    }
+
+    private static final Logger logger = LogManager.getLogger(EntitlementBootstrap.class);
+}

+ 28 - 26
distribution/tools/entitlement-agent/src/main/java/org/elasticsearch/entitlement/agent/EntitlementAgent.java → libs/entitlement/src/main/java/org/elasticsearch/entitlement/initialization/EntitlementInitialization.java

@@ -7,33 +7,42 @@
  * License v3.0 only", or the "Server Side Public License, v 1".
  */
 
-package org.elasticsearch.entitlement.agent;
+package org.elasticsearch.entitlement.initialization;
 
-import org.elasticsearch.core.SuppressForbidden;
 import org.elasticsearch.core.internal.provider.ProviderLocator;
+import org.elasticsearch.entitlement.bridge.EntitlementChecker;
 import org.elasticsearch.entitlement.instrumentation.InstrumentationService;
 import org.elasticsearch.entitlement.instrumentation.MethodKey;
+import org.elasticsearch.entitlement.instrumentation.Transformer;
+import org.elasticsearch.entitlement.runtime.api.ElasticsearchEntitlementChecker;
 
-import java.io.IOException;
 import java.lang.instrument.Instrumentation;
 import java.lang.reflect.Method;
 import java.util.Map;
 import java.util.Set;
-import java.util.jar.JarFile;
 
-public class EntitlementAgent {
+/**
+ * Called by the agent during {@code agentmain} to configure the entitlement system,
+ * instantiate and configure an {@link EntitlementChecker},
+ * make it available to the bootstrap library via {@link #checker()},
+ * and then install the {@link org.elasticsearch.entitlement.instrumentation.Instrumenter}
+ * to begin injecting our instrumentation.
+ */
+public class EntitlementInitialization {
+    private static ElasticsearchEntitlementChecker manager;
+
+    // Note: referenced by bridge reflectively
+    public static EntitlementChecker checker() {
+        return manager;
+    }
 
-    public static void premain(String agentArgs, Instrumentation inst) throws Exception {
-        // Add the bridge library (the one with the entitlement checking interface) to the bootstrap classpath.
-        // We can't actually reference the classes here for real before this point because they won't resolve.
-        var bridgeJarName = System.getProperty("es.entitlements.bridgeJar");
-        if (bridgeJarName == null) {
-            throw new IllegalArgumentException("System property es.entitlements.bridgeJar is required");
-        }
-        addJarToBootstrapClassLoader(inst, bridgeJarName);
+    // Note: referenced by agent reflectively
+    public static void initialize(Instrumentation inst) throws Exception {
+        manager = new ElasticsearchEntitlementChecker();
 
+        // TODO: Configure actual entitlement grants instead of this hardcoded one
         Method targetMethod = System.class.getMethod("exit", int.class);
-        Method instrumentationMethod = Class.forName("org.elasticsearch.entitlement.api.EntitlementChecks")
+        Method instrumentationMethod = Class.forName("org.elasticsearch.entitlement.bridge.EntitlementChecker")
             .getMethod("checkSystemExit", Class.class, int.class);
         Map<MethodKey, Method> methodMap = Map.of(INSTRUMENTER_FACTORY.methodKeyForTarget(targetMethod), instrumentationMethod);
 
@@ -41,21 +50,14 @@ public class EntitlementAgent {
         inst.retransformClasses(System.class);
     }
 
-    @SuppressForbidden(reason = "The appendToBootstrapClassLoaderSearch method takes a JarFile")
-    private static void addJarToBootstrapClassLoader(Instrumentation inst, String jarString) throws IOException {
-        inst.appendToBootstrapClassLoaderSearch(new JarFile(jarString));
-    }
-
     private static String internalName(Class<?> c) {
         return c.getName().replace('.', '/');
     }
 
-    private static final InstrumentationService INSTRUMENTER_FACTORY = (new ProviderLocator<>(
-        "entitlement-agent",
+    private static final InstrumentationService INSTRUMENTER_FACTORY = new ProviderLocator<>(
+        "entitlement",
         InstrumentationService.class,
-        "org.elasticsearch.entitlement.agent.impl",
-        Set.of("org.objectweb.nonexistent.asm")
-    )).get();
-
-    // private static final Logger LOGGER = LogManager.getLogger(EntitlementAgent.class);
+        "org.elasticsearch.entitlement.instrumentation",
+        Set.of()
+    ).get();
 }

+ 0 - 0
distribution/tools/entitlement-agent/src/main/java/org/elasticsearch/entitlement/instrumentation/InstrumentationService.java → libs/entitlement/src/main/java/org/elasticsearch/entitlement/instrumentation/InstrumentationService.java


+ 0 - 0
distribution/tools/entitlement-agent/src/main/java/org/elasticsearch/entitlement/instrumentation/Instrumenter.java → libs/entitlement/src/main/java/org/elasticsearch/entitlement/instrumentation/Instrumenter.java


+ 0 - 0
distribution/tools/entitlement-agent/src/main/java/org/elasticsearch/entitlement/instrumentation/MethodKey.java → libs/entitlement/src/main/java/org/elasticsearch/entitlement/instrumentation/MethodKey.java


+ 1 - 3
distribution/tools/entitlement-agent/src/main/java/org/elasticsearch/entitlement/agent/Transformer.java → libs/entitlement/src/main/java/org/elasticsearch/entitlement/instrumentation/Transformer.java

@@ -7,9 +7,7 @@
  * License v3.0 only", or the "Server Side Public License, v 1".
  */
 
-package org.elasticsearch.entitlement.agent;
-
-import org.elasticsearch.entitlement.instrumentation.Instrumenter;
+package org.elasticsearch.entitlement.instrumentation;
 
 import java.lang.instrument.ClassFileTransformer;
 import java.security.ProtectionDomain;

+ 16 - 22
distribution/tools/entitlement-runtime/src/main/java/org/elasticsearch/entitlement/runtime/api/ElasticsearchEntitlementManager.java → libs/entitlement/src/main/java/org/elasticsearch/entitlement/runtime/api/ElasticsearchEntitlementChecker.java

@@ -9,38 +9,24 @@
 
 package org.elasticsearch.entitlement.runtime.api;
 
-import org.elasticsearch.entitlement.api.EntitlementChecks;
-import org.elasticsearch.entitlement.api.EntitlementProvider;
+import org.elasticsearch.entitlement.bridge.EntitlementChecker;
+import org.elasticsearch.logging.LogManager;
+import org.elasticsearch.logging.Logger;
 
 import java.util.Optional;
 
-import static org.elasticsearch.entitlement.runtime.internals.EntitlementInternals.isActive;
-
 /**
- * Implementation of the {@link EntitlementChecks} interface, providing additional
+ * Implementation of the {@link EntitlementChecker} interface, providing additional
  * API methods for managing the checks.
  * The trampoline module loads this object via SPI.
  */
-public class ElasticsearchEntitlementManager implements EntitlementChecks {
-    /**
-     * @return the same instance of {@link ElasticsearchEntitlementManager} returned by {@link EntitlementProvider}.
-     */
-    public static ElasticsearchEntitlementManager get() {
-        return (ElasticsearchEntitlementManager) EntitlementProvider.checks();
-    }
-
-    /**
-     * Causes entitlements to be enforced.
-     */
-    public void activate() {
-        isActive = true;
-    }
+public class ElasticsearchEntitlementChecker implements EntitlementChecker {
+    private static final Logger logger = LogManager.getLogger(ElasticsearchEntitlementChecker.class);
 
     @Override
     public void checkSystemExit(Class<?> callerClass, int status) {
         var requestingModule = requestingModule(callerClass);
         if (isTriviallyAllowed(requestingModule)) {
-            // System.out.println(" - Trivially allowed");
             return;
         }
         // Hard-forbidden until we develop the permission granting scheme
@@ -71,7 +57,15 @@ public class ElasticsearchEntitlementManager implements EntitlementChecks {
     }
 
     private static boolean isTriviallyAllowed(Module requestingModule) {
-        return isActive == false || (requestingModule == null) || requestingModule == System.class.getModule();
+        if (requestingModule == null) {
+            logger.debug("Trivially allowed: Entire call stack is in the boot module layer");
+            return true;
+        }
+        if (requestingModule == System.class.getModule()) {
+            logger.debug("Trivially allowed: Caller is in {}", System.class.getModule().getName());
+            return true;
+        }
+        logger.trace("Not trivially allowed");
+        return false;
     }
-
 }

+ 0 - 0
distribution/tools/entitlement-runtime/src/main/java/org/elasticsearch/entitlement/runtime/api/NotEntitledException.java → libs/entitlement/src/main/java/org/elasticsearch/entitlement/runtime/api/NotEntitledException.java


+ 19 - 0
libs/entitlement/src/main/java/org/elasticsearch/entitlement/runtime/policy/Entitlement.java

@@ -0,0 +1,19 @@
+/*
+ * 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.runtime.policy;
+
+/**
+ * Marker interface to ensure that only {@link Entitlement} are
+ * part of a {@link Policy}. All entitlement classes should implement
+ * this.
+ */
+public interface Entitlement {
+
+}

+ 36 - 0
libs/entitlement/src/main/java/org/elasticsearch/entitlement/runtime/policy/ExternalEntitlement.java

@@ -0,0 +1,36 @@
+/*
+ * 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.runtime.policy;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * This annotation indicates an {@link Entitlement} is available
+ * to "external" classes such as those used in plugins. Any {@link Entitlement}
+ * using this annotation is considered parseable as part of a policy file
+ * for entitlements.
+ */
+@Target(ElementType.CONSTRUCTOR)
+@Retention(RetentionPolicy.RUNTIME)
+public @interface ExternalEntitlement {
+
+    /**
+     * This is the list of parameter names that are
+     * parseable in {@link PolicyParser#parseEntitlement(String, String)}.
+     * The number and order of parameter names much match the number and order
+     * of constructor parameters as this is how the parser will pass in the
+     * parsed values from a policy file. However, the names themselves do NOT
+     * have to match the parameter names of the constructor.
+     */
+    String[] parameterNames() default {};
+}

+ 67 - 0
libs/entitlement/src/main/java/org/elasticsearch/entitlement/runtime/policy/FileEntitlement.java

@@ -0,0 +1,67 @@
+/*
+ * 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.runtime.policy;
+
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * Describes a file entitlement with a path and actions.
+ */
+public class FileEntitlement implements Entitlement {
+
+    public static final int READ_ACTION = 0x1;
+    public static final int WRITE_ACTION = 0x2;
+
+    private final String path;
+    private final int actions;
+
+    @ExternalEntitlement(parameterNames = { "path", "actions" })
+    public FileEntitlement(String path, List<String> actionsList) {
+        this.path = path;
+        int actionsInt = 0;
+
+        for (String actionString : actionsList) {
+            if ("read".equals(actionString)) {
+                if ((actionsInt & READ_ACTION) == READ_ACTION) {
+                    throw new IllegalArgumentException("file action [read] specified multiple times");
+                }
+                actionsInt |= READ_ACTION;
+            } else if ("write".equals(actionString)) {
+                if ((actionsInt & WRITE_ACTION) == WRITE_ACTION) {
+                    throw new IllegalArgumentException("file action [write] specified multiple times");
+                }
+                actionsInt |= WRITE_ACTION;
+            } else {
+                throw new IllegalArgumentException("unknown file action [" + actionString + "]");
+            }
+        }
+
+        this.actions = actionsInt;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+        FileEntitlement that = (FileEntitlement) o;
+        return actions == that.actions && Objects.equals(path, that.path);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(path, actions);
+    }
+
+    @Override
+    public String toString() {
+        return "FileEntitlement{" + "path='" + path + '\'' + ", actions=" + actions + '}';
+    }
+}

+ 46 - 0
libs/entitlement/src/main/java/org/elasticsearch/entitlement/runtime/policy/Policy.java

@@ -0,0 +1,46 @@
+/*
+ * 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.runtime.policy;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * A holder for scoped entitlements.
+ */
+public class Policy {
+
+    public final String name;
+    public final List<Scope> scopes;
+
+    public Policy(String name, List<Scope> scopes) {
+        this.name = Objects.requireNonNull(name);
+        this.scopes = Collections.unmodifiableList(Objects.requireNonNull(scopes));
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+        Policy policy = (Policy) o;
+        return Objects.equals(name, policy.name) && Objects.equals(scopes, policy.scopes);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(name, scopes);
+    }
+
+    @Override
+    public String toString() {
+        return "Policy{" + "name='" + name + '\'' + ", scopes=" + scopes + '}';
+    }
+}

+ 176 - 0
libs/entitlement/src/main/java/org/elasticsearch/entitlement/runtime/policy/PolicyParser.java

@@ -0,0 +1,176 @@
+/*
+ * 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.runtime.policy;
+
+import org.elasticsearch.xcontent.ParseField;
+import org.elasticsearch.xcontent.XContentParser;
+import org.elasticsearch.xcontent.XContentParserConfiguration;
+import org.elasticsearch.xcontent.yaml.YamlXContent;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.UncheckedIOException;
+import java.lang.reflect.Constructor;
+import java.lang.reflect.InvocationTargetException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+import static org.elasticsearch.entitlement.runtime.policy.PolicyParserException.newPolicyParserException;
+
+/**
+ * A parser to parse policy files for entitlements.
+ */
+public class PolicyParser {
+
+    protected static final ParseField ENTITLEMENTS_PARSEFIELD = new ParseField("entitlements");
+
+    protected static final String entitlementPackageName = Entitlement.class.getPackage().getName();
+
+    protected final XContentParser policyParser;
+    protected final String policyName;
+
+    public PolicyParser(InputStream inputStream, String policyName) throws IOException {
+        this.policyParser = YamlXContent.yamlXContent.createParser(XContentParserConfiguration.EMPTY, Objects.requireNonNull(inputStream));
+        this.policyName = policyName;
+    }
+
+    public Policy parsePolicy() {
+        try {
+            if (policyParser.nextToken() != XContentParser.Token.START_OBJECT) {
+                throw newPolicyParserException("expected object <scope name>");
+            }
+            List<Scope> scopes = new ArrayList<>();
+            while (policyParser.nextToken() != XContentParser.Token.END_OBJECT) {
+                if (policyParser.currentToken() != XContentParser.Token.FIELD_NAME) {
+                    throw newPolicyParserException("expected object <scope name>");
+                }
+                String scopeName = policyParser.currentName();
+                Scope scope = parseScope(scopeName);
+                scopes.add(scope);
+            }
+            return new Policy(policyName, scopes);
+        } catch (IOException ioe) {
+            throw new UncheckedIOException(ioe);
+        }
+    }
+
+    protected Scope parseScope(String scopeName) throws IOException {
+        try {
+            if (policyParser.nextToken() != XContentParser.Token.START_OBJECT) {
+                throw newPolicyParserException(scopeName, "expected object [" + ENTITLEMENTS_PARSEFIELD.getPreferredName() + "]");
+            }
+            if (policyParser.nextToken() != XContentParser.Token.FIELD_NAME
+                || policyParser.currentName().equals(ENTITLEMENTS_PARSEFIELD.getPreferredName()) == false) {
+                throw newPolicyParserException(scopeName, "expected object [" + ENTITLEMENTS_PARSEFIELD.getPreferredName() + "]");
+            }
+            if (policyParser.nextToken() != XContentParser.Token.START_ARRAY) {
+                throw newPolicyParserException(scopeName, "expected array of <entitlement type>");
+            }
+            List<Entitlement> entitlements = new ArrayList<>();
+            while (policyParser.nextToken() != XContentParser.Token.END_ARRAY) {
+                if (policyParser.currentToken() != XContentParser.Token.START_OBJECT) {
+                    throw newPolicyParserException(scopeName, "expected object <entitlement type>");
+                }
+                if (policyParser.nextToken() != XContentParser.Token.FIELD_NAME) {
+                    throw newPolicyParserException(scopeName, "expected object <entitlement type>");
+                }
+                String entitlementType = policyParser.currentName();
+                Entitlement entitlement = parseEntitlement(scopeName, entitlementType);
+                entitlements.add(entitlement);
+                if (policyParser.nextToken() != XContentParser.Token.END_OBJECT) {
+                    throw newPolicyParserException(scopeName, "expected closing object");
+                }
+            }
+            if (policyParser.nextToken() != XContentParser.Token.END_OBJECT) {
+                throw newPolicyParserException(scopeName, "expected closing object");
+            }
+            return new Scope(scopeName, entitlements);
+        } catch (IOException ioe) {
+            throw new UncheckedIOException(ioe);
+        }
+    }
+
+    protected Entitlement parseEntitlement(String scopeName, String entitlementType) throws IOException {
+        Class<?> entitlementClass;
+        try {
+            entitlementClass = Class.forName(
+                entitlementPackageName
+                    + "."
+                    + Character.toUpperCase(entitlementType.charAt(0))
+                    + entitlementType.substring(1)
+                    + "Entitlement"
+            );
+        } catch (ClassNotFoundException cnfe) {
+            throw newPolicyParserException(scopeName, "unknown entitlement type [" + entitlementType + "]");
+        }
+        if (Entitlement.class.isAssignableFrom(entitlementClass) == false) {
+            throw newPolicyParserException(scopeName, "unknown entitlement type [" + entitlementType + "]");
+        }
+        Constructor<?> entitlementConstructor = entitlementClass.getConstructors()[0];
+        ExternalEntitlement entitlementMetadata = entitlementConstructor.getAnnotation(ExternalEntitlement.class);
+        if (entitlementMetadata == null) {
+            throw newPolicyParserException(scopeName, "unknown entitlement type [" + entitlementType + "]");
+        }
+
+        if (policyParser.nextToken() != XContentParser.Token.START_OBJECT) {
+            throw newPolicyParserException(scopeName, entitlementType, "expected entitlement parameters");
+        }
+        Map<String, Object> parsedValues = policyParser.map();
+
+        Class<?>[] parameterTypes = entitlementConstructor.getParameterTypes();
+        String[] parametersNames = entitlementMetadata.parameterNames();
+        Object[] parameterValues = new Object[parameterTypes.length];
+        for (int parameterIndex = 0; parameterIndex < parameterTypes.length; ++parameterIndex) {
+            String parameterName = parametersNames[parameterIndex];
+            Object parameterValue = parsedValues.remove(parameterName);
+            if (parameterValue == null) {
+                throw newPolicyParserException(scopeName, entitlementType, "missing entitlement parameter [" + parameterName + "]");
+            }
+            Class<?> parameterType = parameterTypes[parameterIndex];
+            if (parameterType.isAssignableFrom(parameterValue.getClass()) == false) {
+                throw newPolicyParserException(
+                    scopeName,
+                    entitlementType,
+                    "unexpected parameter type [" + parameterType.getSimpleName() + "] for entitlement parameter [" + parameterName + "]"
+                );
+            }
+            parameterValues[parameterIndex] = parameterValue;
+        }
+        if (parsedValues.isEmpty() == false) {
+            throw newPolicyParserException(scopeName, entitlementType, "extraneous entitlement parameter(s) " + parsedValues);
+        }
+
+        try {
+            return (Entitlement) entitlementConstructor.newInstance(parameterValues);
+        } catch (InvocationTargetException | InstantiationException | IllegalAccessException e) {
+            throw new IllegalStateException("internal error");
+        }
+    }
+
+    protected PolicyParserException newPolicyParserException(String message) {
+        return PolicyParserException.newPolicyParserException(policyParser.getTokenLocation(), policyName, message);
+    }
+
+    protected PolicyParserException newPolicyParserException(String scopeName, String message) {
+        return PolicyParserException.newPolicyParserException(policyParser.getTokenLocation(), policyName, scopeName, message);
+    }
+
+    protected PolicyParserException newPolicyParserException(String scopeName, String entitlementType, String message) {
+        return PolicyParserException.newPolicyParserException(
+            policyParser.getTokenLocation(),
+            policyName,
+            scopeName,
+            entitlementType,
+            message
+        );
+    }
+}

+ 92 - 0
libs/entitlement/src/main/java/org/elasticsearch/entitlement/runtime/policy/PolicyParserException.java

@@ -0,0 +1,92 @@
+/*
+ * 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.runtime.policy;
+
+import org.elasticsearch.xcontent.XContentLocation;
+
+/**
+ * An exception specifically for policy parsing errors.
+ */
+public class PolicyParserException extends RuntimeException {
+
+    public static PolicyParserException newPolicyParserException(XContentLocation location, String policyName, String message) {
+        return new PolicyParserException(
+            "[" + location.lineNumber() + ":" + location.columnNumber() + "] policy parsing error for [" + policyName + "]: " + message
+        );
+    }
+
+    public static PolicyParserException newPolicyParserException(
+        XContentLocation location,
+        String policyName,
+        String scopeName,
+        String message
+    ) {
+        if (scopeName == null) {
+            return new PolicyParserException(
+                "[" + location.lineNumber() + ":" + location.columnNumber() + "] policy parsing error for [" + policyName + "]: " + message
+            );
+        } else {
+            return new PolicyParserException(
+                "["
+                    + location.lineNumber()
+                    + ":"
+                    + location.columnNumber()
+                    + "] policy parsing error for ["
+                    + policyName
+                    + "] in scope ["
+                    + scopeName
+                    + "]: "
+                    + message
+            );
+        }
+    }
+
+    public static PolicyParserException newPolicyParserException(
+        XContentLocation location,
+        String policyName,
+        String scopeName,
+        String entitlementType,
+        String message
+    ) {
+        if (scopeName == null) {
+            return new PolicyParserException(
+                "["
+                    + location.lineNumber()
+                    + ":"
+                    + location.columnNumber()
+                    + "] policy parsing error for ["
+                    + policyName
+                    + "] for entitlement type ["
+                    + entitlementType
+                    + "]: "
+                    + message
+            );
+        } else {
+            return new PolicyParserException(
+                "["
+                    + location.lineNumber()
+                    + ":"
+                    + location.columnNumber()
+                    + "] policy parsing error for ["
+                    + policyName
+                    + "] in scope ["
+                    + scopeName
+                    + "] for entitlement type ["
+                    + entitlementType
+                    + "]: "
+                    + message
+            );
+        }
+    }
+
+    private PolicyParserException(String message) {
+        super(message);
+    }
+}

+ 46 - 0
libs/entitlement/src/main/java/org/elasticsearch/entitlement/runtime/policy/Scope.java

@@ -0,0 +1,46 @@
+/*
+ * 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.runtime.policy;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * A holder for entitlements within a single scope.
+ */
+public class Scope {
+
+    public final String name;
+    public final List<Entitlement> entitlements;
+
+    public Scope(String name, List<Entitlement> entitlements) {
+        this.name = Objects.requireNonNull(name);
+        this.entitlements = Collections.unmodifiableList(Objects.requireNonNull(entitlements));
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+        Scope scope = (Scope) o;
+        return Objects.equals(name, scope.name) && Objects.equals(entitlements, scope.entitlements);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(name, entitlements);
+    }
+
+    @Override
+    public String toString() {
+        return "Scope{" + "name='" + name + '\'' + ", entitlements=" + entitlements + '}';
+    }
+}

+ 83 - 0
libs/entitlement/src/test/java/org/elasticsearch/entitlement/runtime/policy/PolicyParserFailureTests.java

@@ -0,0 +1,83 @@
+/*
+ * 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.runtime.policy;
+
+import org.elasticsearch.test.ESTestCase;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+
+public class PolicyParserFailureTests extends ESTestCase {
+
+    public void testParserSyntaxFailures() {
+        PolicyParserException ppe = expectThrows(
+            PolicyParserException.class,
+            () -> new PolicyParser(new ByteArrayInputStream("[]".getBytes(StandardCharsets.UTF_8)), "test-failure-policy.yaml")
+                .parsePolicy()
+        );
+        assertEquals("[1:1] policy parsing error for [test-failure-policy.yaml]: expected object <scope name>", ppe.getMessage());
+    }
+
+    public void testEntitlementDoesNotExist() throws IOException {
+        PolicyParserException ppe = expectThrows(PolicyParserException.class, () -> new PolicyParser(new ByteArrayInputStream("""
+            entitlement-module-name:
+              entitlements:
+                - does_not_exist: {}
+            """.getBytes(StandardCharsets.UTF_8)), "test-failure-policy.yaml").parsePolicy());
+        assertEquals(
+            "[3:7] policy parsing error for [test-failure-policy.yaml] in scope [entitlement-module-name]: "
+                + "unknown entitlement type [does_not_exist]",
+            ppe.getMessage()
+        );
+    }
+
+    public void testEntitlementMissingParameter() throws IOException {
+        PolicyParserException ppe = expectThrows(PolicyParserException.class, () -> new PolicyParser(new ByteArrayInputStream("""
+            entitlement-module-name:
+              entitlements:
+                - file: {}
+            """.getBytes(StandardCharsets.UTF_8)), "test-failure-policy.yaml").parsePolicy());
+        assertEquals(
+            "[3:14] policy parsing error for [test-failure-policy.yaml] in scope [entitlement-module-name] "
+                + "for entitlement type [file]: missing entitlement parameter [path]",
+            ppe.getMessage()
+        );
+
+        ppe = expectThrows(PolicyParserException.class, () -> new PolicyParser(new ByteArrayInputStream("""
+            entitlement-module-name:
+              entitlements:
+                - file:
+                    path: test-path
+            """.getBytes(StandardCharsets.UTF_8)), "test-failure-policy.yaml").parsePolicy());
+        assertEquals(
+            "[5:1] policy parsing error for [test-failure-policy.yaml] in scope [entitlement-module-name] "
+                + "for entitlement type [file]: missing entitlement parameter [actions]",
+            ppe.getMessage()
+        );
+    }
+
+    public void testEntitlementExtraneousParameter() throws IOException {
+        PolicyParserException ppe = expectThrows(PolicyParserException.class, () -> new PolicyParser(new ByteArrayInputStream("""
+            entitlement-module-name:
+              entitlements:
+                - file:
+                    path: test-path
+                    actions:
+                      - read
+                    extra: test
+            """.getBytes(StandardCharsets.UTF_8)), "test-failure-policy.yaml").parsePolicy());
+        assertEquals(
+            "[8:1] policy parsing error for [test-failure-policy.yaml] in scope [entitlement-module-name] "
+                + "for entitlement type [file]: extraneous entitlement parameter(s) {extra=test}",
+            ppe.getMessage()
+        );
+    }
+}

+ 28 - 0
libs/entitlement/src/test/java/org/elasticsearch/entitlement/runtime/policy/PolicyParserTests.java

@@ -0,0 +1,28 @@
+/*
+ * 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.runtime.policy;
+
+import org.elasticsearch.test.ESTestCase;
+
+import java.io.IOException;
+import java.util.List;
+
+public class PolicyParserTests extends ESTestCase {
+
+    public void testPolicyBuilder() throws IOException {
+        Policy parsedPolicy = new PolicyParser(PolicyParserTests.class.getResourceAsStream("test-policy.yaml"), "test-policy.yaml")
+            .parsePolicy();
+        Policy builtPolicy = new Policy(
+            "test-policy.yaml",
+            List.of(new Scope("entitlement-module-name", List.of(new FileEntitlement("test/path/to/file", List.of("read", "write")))))
+        );
+        assertEquals(parsedPolicy, builtPolicy);
+    }
+}

+ 7 - 0
libs/entitlement/src/test/resources/org/elasticsearch/entitlement/runtime/policy/test-policy.yaml

@@ -0,0 +1,7 @@
+entitlement-module-name:
+  entitlements:
+    - file:
+        path: "test/path/to/file"
+        actions:
+          - "read"
+          - "write"

+ 1 - 0
server/build.gradle

@@ -39,6 +39,7 @@ dependencies {
   api project(':libs:grok')
   api project(":libs:tdigest")
   implementation project(":libs:simdvec")
+  implementation project(":libs:entitlement")
 
   // lucene
   api "org.apache.lucene:lucene-core:${versions.lucene}"

+ 1 - 0
server/src/main/java/module-info.java

@@ -31,6 +31,7 @@ module org.elasticsearch.server {
     requires org.elasticsearch.grok;
     requires org.elasticsearch.tdigest;
     requires org.elasticsearch.simdvec;
+    requires org.elasticsearch.entitlement;
 
     requires hppc;
     requires HdrHistogram;

+ 11 - 6
server/src/main/java/org/elasticsearch/bootstrap/Elasticsearch.java

@@ -30,6 +30,7 @@ import org.elasticsearch.common.util.concurrent.RunOnce;
 import org.elasticsearch.core.AbstractRefCounted;
 import org.elasticsearch.core.IOUtils;
 import org.elasticsearch.core.SuppressForbidden;
+import org.elasticsearch.entitlement.bootstrap.EntitlementBootstrap;
 import org.elasticsearch.env.Environment;
 import org.elasticsearch.index.IndexVersion;
 import org.elasticsearch.jdk.JarHell;
@@ -198,12 +199,16 @@ class Elasticsearch {
             VectorUtil.class
         );
 
-        // install SM after natives, shutdown hooks, etc.
-        org.elasticsearch.bootstrap.Security.configure(
-            nodeEnv,
-            SECURITY_FILTER_BAD_DEFAULTS_SETTING.get(args.nodeSettings()),
-            args.pidFile()
-        );
+        if (Boolean.parseBoolean(System.getProperty("es.entitlements.enabled"))) {
+            EntitlementBootstrap.bootstrap();
+        } else {
+            // install SM after natives, shutdown hooks, etc.
+            org.elasticsearch.bootstrap.Security.configure(
+                nodeEnv,
+                SECURITY_FILTER_BAD_DEFAULTS_SETTING.get(args.nodeSettings()),
+                args.pidFile()
+            );
+        }
     }
 
     private static void ensureInitialized(Class<?>... classes) {

+ 0 - 4
settings.gradle

@@ -89,10 +89,6 @@ List projects = [
   'distribution:tools:keystore-cli',
   'distribution:tools:geoip-cli',
   'distribution:tools:ansi-console',
-  'distribution:tools:entitlement-agent',
-  'distribution:tools:entitlement-agent:impl',
-  'distribution:tools:entitlement-bridge',
-  'distribution:tools:entitlement-runtime',
   'server',
   'test:framework',
   'test:fixtures:azure-fixture',