Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,8 @@ private static PolicyManager createPolicyManager() {
EntitlementBootstrap.bootstrapArgs().sourcePaths(),
ENTITLEMENTS_MODULE,
pathLookup,
bootstrapArgs.suppressFailureLogClasses()
bootstrapArgs.suppressFailureLogClasses(),
new EntitlementsCacheImpl()
);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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.initialization;

import org.elasticsearch.entitlement.runtime.policy.EntitlementsCache;

import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Function;

import static java.util.Objects.requireNonNull;

/**
* The production {@link EntitlementsCache}. (Tests use {@code EntitlementCacheForTesting}.)
*/
final class EntitlementsCacheImpl extends ConcurrentHashMap<Module, EntitlementsCache.ModuleEntitlements> implements EntitlementsCache {
@Override
public ModuleEntitlements computeIfAbsent(Class<?> key, Function<? super Class<?>, ? extends ModuleEntitlements> mappingFunction) {
// We cache per module rather than per class to make the cache smaller and increase the hit ratio
return computeIfAbsent(key.getModule(), m -> requireNonNull(mappingFunction.apply(key)));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/*
* 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.entitlement.runtime.policy.entitlements.Entitlement;
import org.elasticsearch.logging.Logger;

import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Stream;

/**
* Quickly provides entitlement info for a given class. Performance-critical.
*/
public interface EntitlementsCache {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know we've described this as a cache, but it's not really. We happen to lazily populate it, but it's a system of record: what are the entitlements available for a given class. The fact we lazily populate it is an implementation detail. Can we make this more of a lookup? So rather than "computeIfAbsent", just lookup for a given class. How that get's populated is an implementation detail, in prod we will use a lazily populated map, in tests a map that we clear between tests (maybe).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah that would be nice. I initially avoided the name "cache" but I think we still want a lazily-initialized map of modules that have default entitlements, since the returned ModuleEntitlements object is module-specific. So I went with "cache" after all.

Definitely open to improvements here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The picture I'm getting here is that you want this new swappable class to do two things:

  • IsTriviallyAllowed
  • getEntitlements

The testing and production versions of this can share code to whatever extent that makes sense.

Is that what you're picturing?

Let me capture some thoughts on this...

In the current scheme, getEntitlements occurs in two steps:

  1. Requesting class -> PolicyScope
  2. PolicyScope -> ModuleEntitlements

#1 is different in production vs testing, and #2 is not, which is why I made an effort to leave #2 inside PolicyManager.

Similarly, isTriviallyAllowed has two parts:

  1. The actual trivially-allowed rules from production
  2. Additional allowances for the test framework

My first try at this refactoring was attempting to make these distinctions as clear as possible, so it was obvious what's the same vs what differs. I thought folks diving into this in the future might want to know when they're in the "mock world" where things are potentially less realistic.

If we simply make isTriviallyAllowed and getEntitlements swappable, then that distinction is not as clear, though the code probably ends up simpler.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO wholly replacing these methods makes the code more clear. Having the implementation split across a pluggable thing and PolicyManager makes it more difficult to know where to look. eg by having a clear place where isTriviallyAllowed is implemented (on this interface) a dev can locate the code to walk through easier. That isn't to say isTriviallyAllowed shouldn't be split, we certainly don't want to duplicate production checks in tests, but eg having the test impl of this interface extend the production one (or even just having the prod class be the base and the test variant extends it) and then calling super would make things more clear, IMO.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't mean "make the code more clear" in the abstract. I meant specifically clarifying the distinction between what's the same versus what's different in tests.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The problem is, as clean as it might appear to swap out these two methods for testing, that does not align with our understanding of what's actually being swapped out. For testing, we do not want wholly novel implementations of these two methods, because they will be mostly the same between prod and test.

The requirements are:

  • Each test should have its own file structure represented in the permissions, and if there are multiple nodes in the test, they should all have their files represented
  • Tests require some additional classes to be trivially allowed
  • Tests need to map Class -> Scope differently due to the lack of modules

The code should look like what I just wrote, or else it's not clearly describing our understanding of the situation.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree it should be clear what additions/changes the test code is doing relative to production. What you described can be done through inheritance of the production class and calling super, right?

/**
* @return true if entitlement checking should be bypassed for the given class;
* false if the normal built-in "trivially allowed" rules apply.
*/
default boolean isAlwaysAllowed(Class<?> key) {
return false;
}

ModuleEntitlements computeIfAbsent(Class<?> key, Function<? super Class<?>, ? extends ModuleEntitlements> mappingFunction);

/**
* This class contains all the entitlements by type, plus the {@link FileAccessTree} for the special case of filesystem entitlements.
* <p>
* We use layers when computing {@link ModuleEntitlements}; first, we check whether the module we are building it for is in the
* server layer ({@link PolicyManager#SERVER_LAYER_MODULES}) (*).
* If it is, we use the server policy, using the same caller class module name as the scope, and read the entitlements for that scope.
* Otherwise, we use the {@code PluginResolver} to identify the correct plugin layer and find the policy for it (if any).
* If the plugin is modular, we again use the same caller class module name as the scope, and read the entitlements for that scope.
* If it's not, we use the single {@code ALL-UNNAMED} scope – in this case there is one scope and all entitlements apply
* to all the plugin code.
* </p>
* <p>
* (*) implementation detail: this is currently done in an indirect way: we know the module is not in the system layer
* (otherwise the check would have been already trivially allowed), so we just check that the module is named, and it belongs to the
* boot {@link ModuleLayer}. We might want to change this in the future to make it more consistent/easier to maintain.
* </p>
*
* @param componentName the plugin name or else one of the special component names like "(server)".
*/
record ModuleEntitlements(
String componentName,
Map<Class<? extends Entitlement>, List<Entitlement>> entitlementsByType,
FileAccessTree fileAccess,
Logger logger
) {

public ModuleEntitlements {
entitlementsByType = Map.copyOf(entitlementsByType);
}

public boolean hasEntitlement(Class<? extends Entitlement> entitlementClass) {
return entitlementsByType.containsKey(entitlementClass);
}

public <E extends Entitlement> Stream<E> getEntitlements(Class<E> entitlementClass) {
var entitlements = entitlementsByType.get(entitlementClass);
if (entitlements == null) {
return Stream.empty();
}
return entitlements.stream().map(entitlementClass::cast);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import org.elasticsearch.core.SuppressForbidden;
import org.elasticsearch.entitlement.instrumentation.InstrumentationService;
import org.elasticsearch.entitlement.runtime.api.NotEntitledException;
import org.elasticsearch.entitlement.runtime.policy.EntitlementsCache.ModuleEntitlements;
import org.elasticsearch.entitlement.runtime.policy.FileAccessTree.ExclusiveFileEntitlement;
import org.elasticsearch.entitlement.runtime.policy.FileAccessTree.ExclusivePath;
import org.elasticsearch.entitlement.runtime.policy.entitlements.CreateClassLoaderEntitlement;
Expand Down Expand Up @@ -121,7 +122,7 @@
* All these methods start in the same way: the components identified in the previous section are used to establish if and how to check:
* If the caller class belongs to {@link PolicyManager#SYSTEM_LAYER_MODULES}, no check is performed (the call is trivially allowed, see
* {@link PolicyManager#isTriviallyAllowed}).
* Otherwise, we lazily compute and create a {@link PolicyManager.ModuleEntitlements} record (see
* Otherwise, we lazily compute and create a {@link ModuleEntitlements} record (see
* {@link PolicyManager#computeEntitlements}). The record is cached so it can be used in following checks, stored in a
* {@code Module -> ModuleEntitlement} map.
* </p>
Expand Down Expand Up @@ -181,49 +182,6 @@ public enum ComponentKind {
}
}

/**
* This class contains all the entitlements by type, plus the {@link FileAccessTree} for the special case of filesystem entitlements.
* <p>
* We use layers when computing {@link ModuleEntitlements}; first, we check whether the module we are building it for is in the
* server layer ({@link PolicyManager#SERVER_LAYER_MODULES}) (*).
* If it is, we use the server policy, using the same caller class module name as the scope, and read the entitlements for that scope.
* Otherwise, we use the {@code PluginResolver} to identify the correct plugin layer and find the policy for it (if any).
* If the plugin is modular, we again use the same caller class module name as the scope, and read the entitlements for that scope.
* If it's not, we use the single {@code ALL-UNNAMED} scope – in this case there is one scope and all entitlements apply
* to all the plugin code.
* </p>
* <p>
* (*) implementation detail: this is currently done in an indirect way: we know the module is not in the system layer
* (otherwise the check would have been already trivially allowed), so we just check that the module is named, and it belongs to the
* boot {@link ModuleLayer}. We might want to change this in the future to make it more consistent/easier to maintain.
* </p>
*
* @param componentName the plugin name or else one of the special component names like "(server)".
*/
record ModuleEntitlements(
String componentName,
Map<Class<? extends Entitlement>, List<Entitlement>> entitlementsByType,
FileAccessTree fileAccess,
Logger logger
) {

ModuleEntitlements {
entitlementsByType = Map.copyOf(entitlementsByType);
}

public boolean hasEntitlement(Class<? extends Entitlement> entitlementClass) {
return entitlementsByType.containsKey(entitlementClass);
}

public <E extends Entitlement> Stream<E> getEntitlements(Class<E> entitlementClass) {
var entitlements = entitlementsByType.get(entitlementClass);
if (entitlements == null) {
return Stream.empty();
}
return entitlements.stream().map(entitlementClass::cast);
}
}

private FileAccessTree getDefaultFileAccess(Path componentPath) {
return FileAccessTree.withoutExclusivePaths(FilesEntitlement.EMPTY, pathLookup, componentPath);
}
Expand All @@ -249,8 +207,6 @@ ModuleEntitlements policyEntitlements(String componentName, Path componentPath,
);
}

final Map<Module, ModuleEntitlements> moduleEntitlementsMap = new ConcurrentHashMap<>();

private final Map<String, List<Entitlement>> serverEntitlements;
private final List<Entitlement> apmAgentEntitlements;
private final Map<String, Map<String, List<Entitlement>>> pluginsEntitlements;
Expand Down Expand Up @@ -303,6 +259,8 @@ private static Set<Module> findSystemLayerModules() {
*/
private final List<ExclusivePath> exclusivePaths;

final EntitlementsCache moduleEntitlementsCache;

public PolicyManager(
Policy serverPolicy,
List<Entitlement> apmAgentEntitlements,
Expand All @@ -311,7 +269,8 @@ public PolicyManager(
Map<String, Path> sourcePaths,
Module entitlementsModule,
PathLookup pathLookup,
Set<Class<?>> suppressFailureLogClasses
Set<Class<?>> suppressFailureLogClasses,
EntitlementsCache moduleEntitlementsCache
) {
this.serverEntitlements = buildScopeEntitlementsMap(requireNonNull(serverPolicy));
this.apmAgentEntitlements = apmAgentEntitlements;
Expand All @@ -323,6 +282,7 @@ public PolicyManager(
this.entitlementsModule = entitlementsModule;
this.pathLookup = requireNonNull(pathLookup);
this.mutedClasses = suppressFailureLogClasses;
this.moduleEntitlementsCache = moduleEntitlementsCache;

List<ExclusiveFileEntitlement> exclusiveFileEntitlements = new ArrayList<>();
for (var e : serverEntitlements.entrySet()) {
Expand Down Expand Up @@ -723,7 +683,7 @@ private void checkEntitlementPresent(Class<?> callerClass, Class<? extends Entit
}

ModuleEntitlements getEntitlements(Class<?> requestingClass) {
return moduleEntitlementsMap.computeIfAbsent(requestingClass.getModule(), m -> computeEntitlements(requestingClass));
return moduleEntitlementsCache.computeIfAbsent(requestingClass, this::computeEntitlements);
}

private ModuleEntitlements computeEntitlements(Class<?> requestingClass) {
Expand Down Expand Up @@ -827,7 +787,7 @@ Optional<StackFrame> findRequestingFrame(Stream<StackFrame> frames) {
/**
* @return true if permission is granted regardless of the entitlement
*/
private static boolean isTriviallyAllowed(Class<?> requestingClass) {
private boolean isTriviallyAllowed(Class<?> requestingClass) {
if (generalLogger.isTraceEnabled()) {
generalLogger.trace("Stack trace for upcoming trivially-allowed check", new Exception());
}
Expand All @@ -843,6 +803,10 @@ private static boolean isTriviallyAllowed(Class<?> requestingClass) {
generalLogger.debug("Entitlement trivially allowed from system module [{}]", requestingClass.getModule().getName());
return true;
}
if (moduleEntitlementsCache.isAlwaysAllowed(requestingClass)) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My thinking was to move the logic in isTriviallyAllowed, so that the test infra can add "is this a test class". Otherwise this additional condition is only ever exercised in tests.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. A branch that's dead in production feels wrong.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a little awkward because isTriviallyAllowed checks SYSTEM_LAYER_MODULES. PolicyManager seems like a good home for logic that's the same in production and unit tests.

Let me try ending it with an unconditional call to moduleEntitlementsCache and see if you like how that looks.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why can't SYSTEM_LAYER_MODULES be defined inside this lookup?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It could I guess. That seemed like part of PolicyManager's responsibility, along with similar things like DEFAULT_FILESYSTEM_CLASS and MODULES_EXCLUDED_FROM_SYSTEM_MODULES, but yeah, it could go elsewhere.

generalLogger.debug("Entitlement trivially allowed by the EntitlementsCache");
return true;
}
generalLogger.trace("Entitlement not trivially allowed");
return false;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/*
* 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.concurrent.ConcurrentHashMap;

/**
* When testing, we don't use modules, so we cache per-class.
*/
public class EntitlementsCacheForTesting extends ConcurrentHashMap<Class<?>, EntitlementsCache.ModuleEntitlements>
implements
EntitlementsCache {
@Override
public boolean isAlwaysAllowed(Class<?> key) {
return key.getPackageName().startsWith("org.gradle") || key.getPackageName().startsWith("org.junit");
}
}
Loading