Skip to content

Commit e0c4c4d

Browse files
authored
[Entitlements] Allow policy overrides via system properties (#124489)
This PR adds the ability to override entitlement policies for Elasticsearch plugins and modules via a system property. The system property is in the form es.entitlements.policy.<plugin name>, and accepts a versioned policy: versions: - version1 - versionN policy: <a standard entitlement policy> For example: versions: - 9.1.0 policy: ALL-UNNAMED: - set_https_connection_properties - outbound_network - files: - relative_path: ".config/gcloud" relative_to: home mode: read The versioned policy needs to be base64 encoded. For example, to pass the above policy to a test cluster via gradle run: ./gradlew run --debug-jvm -Dtests.jvm.argline="-Des.entitlements.policy.repository-gcs=dmVyc2lvbnM6CiAgLSA5LjEuMApwb2xpY3k6CiAgQUxMLVVOTkFNRUQ6CiAgICAtIHNldF9odHRwc19jb25uZWN0aW9uX3Byb3BlcnRpZXMKICAgIC0gb3V0Ym91bmRfbmV0d29yawogICAgLSBmaWxlczoKICAgICAgLSByZWxhdGl2ZV9wYXRoOiAiLmNvbmZpZy9nY2xvdWQiCiAgICAgICAgcmVsYXRpdmVfdG86IGhvbWUKICAgICAgICBtb2RlOiByZWFkCg==" The versions listed in the policy are string-matched against Build.version().current(); it is possible to specify any number of versions. If the list is empty/there is no versions field, the policy is assumed to match any Elasticsearch versions. The override policy specified for any given plugin replaces the embedded policy for that plugin. See how EntitlementsAllowedViaOverrideIT replaces an empty policy for the entitlement-test-plugin with a policy that allows load_native_libraries and access to files in the test read_dir. Also tested manually with an override with a different version, with an override with an invalid policy and with a valid override (see command above). Relates to ES-11009
1 parent 7b30cd2 commit e0c4c4d

File tree

8 files changed

+511
-18
lines changed

8 files changed

+511
-18
lines changed
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the "Elastic License
4+
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
5+
* Public License v 1"; you may not use this file except in compliance with, at
6+
* your election, the "Elastic License 2.0", the "GNU Affero General Public
7+
* License v3.0 only", or the "Server Side Public License, v 1".
8+
*/
9+
10+
package org.elasticsearch.entitlement.qa;
11+
12+
import com.carrotsearch.randomizedtesting.annotations.Name;
13+
import com.carrotsearch.randomizedtesting.annotations.ParametersFactory;
14+
15+
import org.elasticsearch.core.Strings;
16+
import org.junit.ClassRule;
17+
18+
import java.nio.charset.StandardCharsets;
19+
import java.nio.file.Path;
20+
import java.util.Base64;
21+
import java.util.Map;
22+
import java.util.stream.Stream;
23+
24+
import static org.elasticsearch.entitlement.qa.EntitlementsTestRule.ENTITLEMENT_QA_TEST_MODULE_NAME;
25+
import static org.elasticsearch.entitlement.qa.EntitlementsTestRule.ENTITLEMENT_TEST_PLUGIN_NAME;
26+
27+
public class EntitlementsAllowedViaOverrideIT extends AbstractEntitlementsIT {
28+
29+
private static Map<String, String> createPolicyOverrideSystemProperty(Path tempDir) {
30+
String policyOverride = Strings.format("""
31+
policy:
32+
%s:
33+
- load_native_libraries
34+
- files:
35+
- path: %s
36+
mode: read
37+
""", ENTITLEMENT_QA_TEST_MODULE_NAME, tempDir.resolve("read_dir"));
38+
var encodedPolicyOverride = new String(Base64.getEncoder().encode(policyOverride.getBytes(StandardCharsets.UTF_8)));
39+
return Map.of("es.entitlements.policy." + ENTITLEMENT_TEST_PLUGIN_NAME, encodedPolicyOverride);
40+
}
41+
42+
@ClassRule
43+
public static EntitlementsTestRule testRule = new EntitlementsTestRule(
44+
true,
45+
null,
46+
EntitlementsAllowedViaOverrideIT::createPolicyOverrideSystemProperty
47+
);
48+
49+
public EntitlementsAllowedViaOverrideIT(@Name("actionName") String actionName) {
50+
super(actionName, true);
51+
}
52+
53+
@ParametersFactory
54+
public static Iterable<Object[]> data() {
55+
return Stream.of("runtime_load_library", "fileList").map(action -> new Object[] { action }).toList();
56+
}
57+
58+
@Override
59+
protected String getTestRestCluster() {
60+
return testRule.cluster.getHttpAddresses();
61+
}
62+
}

libs/entitlement/qa/src/javaRestTest/java/org/elasticsearch/entitlement/qa/EntitlementsTestRule.java

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,17 +48,27 @@ class EntitlementsTestRule implements TestRule {
4848
)
4949
);
5050
};
51+
public static final String ENTITLEMENT_QA_TEST_MODULE_NAME = "org.elasticsearch.entitlement.qa.test";
52+
public static final String ENTITLEMENT_TEST_PLUGIN_NAME = "entitlement-test-plugin";
5153

5254
interface PolicyBuilder {
5355
void build(XContentBuilder builder, Path tempDir) throws IOException;
5456
}
5557

58+
interface TempDirSystemPropertyProvider {
59+
Map<String, String> get(Path tempDir);
60+
}
61+
5662
final TemporaryFolder testDir;
5763
final ElasticsearchCluster cluster;
5864
final TestRule ruleChain;
5965

60-
@SuppressWarnings("this-escape")
6166
EntitlementsTestRule(boolean modular, PolicyBuilder policyBuilder) {
67+
this(modular, policyBuilder, tempDir -> Map.of());
68+
}
69+
70+
@SuppressWarnings("this-escape")
71+
EntitlementsTestRule(boolean modular, PolicyBuilder policyBuilder, TempDirSystemPropertyProvider tempDirSystemPropertyProvider) {
6272
testDir = new TemporaryFolder();
6373
var tempDirSetup = new ExternalResource() {
6474
@Override
@@ -72,9 +82,10 @@ protected void before() throws Throwable {
7282
};
7383
cluster = ElasticsearchCluster.local()
7484
.module("entitled", spec -> buildEntitlements(spec, "org.elasticsearch.entitlement.qa.entitled", ENTITLED_POLICY))
75-
.module("entitlement-test-plugin", spec -> setupEntitlements(spec, modular, policyBuilder))
85+
.module(ENTITLEMENT_TEST_PLUGIN_NAME, spec -> setupEntitlements(spec, modular, policyBuilder))
7686
.systemProperty("es.entitlements.enabled", "true")
7787
.systemProperty("es.entitlements.testdir", () -> testDir.getRoot().getAbsolutePath())
88+
.systemProperties(spec -> tempDirSystemPropertyProvider.get(testDir.getRoot().toPath()))
7889
.setting("xpack.security.enabled", "false")
7990
// Logs in libs/entitlement/qa/build/test-results/javaRestTest/TEST-org.elasticsearch.entitlement.qa.EntitlementsXXX.xml
8091
// .setting("logger.org.elasticsearch.entitlement", "DEBUG")
@@ -108,14 +119,14 @@ private void buildEntitlements(PluginInstallSpec spec, String moduleName, Policy
108119
}
109120

110121
private void setupEntitlements(PluginInstallSpec spec, boolean modular, PolicyBuilder policyBuilder) {
111-
String moduleName = modular ? "org.elasticsearch.entitlement.qa.test" : "ALL-UNNAMED";
122+
String moduleName = modular ? ENTITLEMENT_QA_TEST_MODULE_NAME : "ALL-UNNAMED";
112123
if (policyBuilder != null) {
113124
buildEntitlements(spec, moduleName, policyBuilder);
114125
}
115126

116127
if (modular == false) {
117128
spec.withPropertiesOverride(old -> {
118-
String props = old.replace("modulename=org.elasticsearch.entitlement.qa.test", "");
129+
String props = old.replace("modulename=" + ENTITLEMENT_QA_TEST_MODULE_NAME, "");
119130
System.out.println("Using plugin properties:\n" + props);
120131
return Resource.fromString(props);
121132
});

libs/entitlement/src/main/java/org/elasticsearch/entitlement/runtime/policy/PolicyParser.java

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,12 @@
3333
import java.lang.reflect.Modifier;
3434
import java.util.ArrayList;
3535
import java.util.Arrays;
36+
import java.util.HashSet;
3637
import java.util.List;
3738
import java.util.Locale;
3839
import java.util.Map;
3940
import java.util.Objects;
41+
import java.util.Set;
4042
import java.util.function.Function;
4143
import java.util.function.Predicate;
4244
import java.util.stream.Collectors;
@@ -97,6 +99,58 @@ public PolicyParser(InputStream inputStream, String policyName, boolean isExtern
9799
this.externalEntitlements = externalEntitlements;
98100
}
99101

102+
public VersionedPolicy parseVersionedPolicy() {
103+
Set<String> versions = Set.of();
104+
Policy policy = emptyPolicy();
105+
try {
106+
if (policyParser.nextToken() != XContentParser.Token.START_OBJECT) {
107+
throw newPolicyParserException("expected object <versioned policy>");
108+
}
109+
110+
while (policyParser.nextToken() != XContentParser.Token.END_OBJECT) {
111+
if (policyParser.currentToken() == XContentParser.Token.FIELD_NAME) {
112+
if (policyParser.currentName().equals("versions")) {
113+
versions = parseVersions();
114+
} else if (policyParser.currentName().equals("policy")) {
115+
policy = parsePolicy();
116+
} else {
117+
throw newPolicyParserException("expected either <version> or <policy> field");
118+
}
119+
} else {
120+
throw newPolicyParserException("expected either <version> or <policy> field");
121+
}
122+
}
123+
124+
return new VersionedPolicy(policy, versions);
125+
} catch (IOException ioe) {
126+
throw new UncheckedIOException(ioe);
127+
}
128+
}
129+
130+
private Policy emptyPolicy() {
131+
return new Policy(policyName, List.of());
132+
}
133+
134+
private Set<String> parseVersions() throws IOException {
135+
try {
136+
if (policyParser.nextToken() != XContentParser.Token.START_ARRAY) {
137+
throw newPolicyParserException("expected array of <versions>");
138+
}
139+
Set<String> versions = new HashSet<>();
140+
while (policyParser.nextToken() != XContentParser.Token.END_ARRAY) {
141+
if (policyParser.currentToken() == XContentParser.Token.VALUE_STRING) {
142+
String version = policyParser.text();
143+
versions.add(version);
144+
} else {
145+
throw newPolicyParserException("expected <version>");
146+
}
147+
}
148+
return versions;
149+
} catch (IOException ioe) {
150+
throw new UncheckedIOException(ioe);
151+
}
152+
}
153+
100154
public Policy parsePolicy() {
101155
try {
102156
if (policyParser.nextToken() != XContentParser.Token.START_OBJECT) {

libs/entitlement/src/main/java/org/elasticsearch/entitlement/runtime/policy/PolicyParserUtils.java

Lines changed: 68 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,22 @@
1010
package org.elasticsearch.entitlement.runtime.policy;
1111

1212
import org.elasticsearch.core.Strings;
13+
import org.elasticsearch.logging.LogManager;
14+
import org.elasticsearch.logging.Logger;
1315

16+
import java.io.ByteArrayInputStream;
1417
import java.io.IOException;
1518
import java.lang.module.ModuleFinder;
1619
import java.lang.module.ModuleReference;
1720
import java.nio.file.Files;
1821
import java.nio.file.Path;
1922
import java.nio.file.StandardOpenOption;
23+
import java.util.Base64;
2024
import java.util.Collection;
2125
import java.util.HashMap;
2226
import java.util.List;
2327
import java.util.Map;
28+
import java.util.Optional;
2429
import java.util.Set;
2530
import java.util.stream.Collectors;
2631

@@ -29,6 +34,8 @@
2934

3035
public class PolicyParserUtils {
3136

37+
private static final Logger logger = LogManager.getLogger(PolicyParserUtils.class);
38+
3239
public record PluginData(Path pluginPath, boolean isModular, boolean isExternalPlugin) {
3340
public PluginData {
3441
requireNonNull(pluginPath);
@@ -37,41 +44,89 @@ public record PluginData(Path pluginPath, boolean isModular, boolean isExternalP
3744

3845
private static final String POLICY_FILE_NAME = "entitlement-policy.yaml";
3946

40-
public static Map<String, Policy> createPluginPolicies(Collection<PluginData> pluginData) throws IOException {
47+
public static final String POLICY_OVERRIDE_PREFIX = "es.entitlements.policy.";
48+
49+
public static Map<String, Policy> createPluginPolicies(Collection<PluginData> pluginData, Map<String, String> overrides, String version)
50+
throws IOException {
4151
Map<String, Policy> pluginPolicies = new HashMap<>(pluginData.size());
4252
for (var entry : pluginData) {
4353
Path pluginRoot = entry.pluginPath();
4454
String pluginName = pluginRoot.getFileName().toString();
45-
46-
final Policy policy = loadPluginPolicy(pluginRoot, entry.isModular(), pluginName, entry.isExternalPlugin());
47-
48-
pluginPolicies.put(pluginName, policy);
55+
final Set<String> moduleNames = getModuleNames(pluginRoot, entry.isModular());
56+
57+
var overriddenPolicy = parsePolicyOverrideIfExists(overrides, version, entry.isExternalPlugin(), pluginName, moduleNames);
58+
if (overriddenPolicy.isPresent()) {
59+
pluginPolicies.put(pluginName, overriddenPolicy.get());
60+
} else {
61+
Path policyFile = pluginRoot.resolve(POLICY_FILE_NAME);
62+
var policy = parsePolicyIfExists(pluginName, policyFile, entry.isExternalPlugin());
63+
validatePolicyScopes(pluginName, policy, moduleNames, policyFile.toString());
64+
pluginPolicies.put(pluginName, policy);
65+
}
4966
}
5067
return pluginPolicies;
5168
}
5269

53-
private static Policy loadPluginPolicy(Path pluginRoot, boolean isModular, String pluginName, boolean isExternalPlugin)
54-
throws IOException {
55-
Path policyFile = pluginRoot.resolve(POLICY_FILE_NAME);
70+
static Optional<Policy> parsePolicyOverrideIfExists(
71+
Map<String, String> overrides,
72+
String version,
73+
boolean externalPlugin,
74+
String pluginName,
75+
Set<String> moduleNames
76+
) {
77+
var policyOverride = overrides.get(pluginName);
78+
if (policyOverride != null) {
79+
try {
80+
var versionedPolicy = decodeOverriddenPluginPolicy(policyOverride, pluginName, externalPlugin);
81+
validatePolicyScopes(pluginName, versionedPolicy.policy(), moduleNames, "<override>");
82+
83+
// Empty versions defaults to "any"
84+
if (versionedPolicy.versions().isEmpty() || versionedPolicy.versions().contains(version)) {
85+
logger.info("Using policy override for plugin [{}]", pluginName);
86+
return Optional.of(versionedPolicy.policy());
87+
} else {
88+
logger.warn(
89+
"Found a policy override with version mismatch. The override will not be applied. "
90+
+ "Plugin [{}]; policy versions [{}]; current version [{}]",
91+
pluginName,
92+
String.join(",", versionedPolicy.versions()),
93+
version
94+
);
95+
}
96+
} catch (Exception ex) {
97+
logger.warn(
98+
Strings.format(
99+
"Found a policy override with invalid content. The override will not be applied. Plugin [%s]",
100+
pluginName
101+
),
102+
ex
103+
);
104+
}
105+
}
106+
return Optional.empty();
107+
}
56108

57-
final Set<String> moduleNames = getModuleNames(pluginRoot, isModular);
58-
final Policy policy = parsePolicyIfExists(pluginName, policyFile, isExternalPlugin);
109+
static VersionedPolicy decodeOverriddenPluginPolicy(String base64String, String pluginName, boolean isExternalPlugin)
110+
throws IOException {
111+
byte[] policyDefinition = Base64.getDecoder().decode(base64String);
112+
return new PolicyParser(new ByteArrayInputStream(policyDefinition), pluginName, isExternalPlugin).parseVersionedPolicy();
113+
}
59114

115+
private static void validatePolicyScopes(String pluginName, Policy policy, Set<String> moduleNames, String policyLocation) {
60116
// TODO: should this check actually be part of the parser?
61117
for (Scope scope : policy.scopes()) {
62118
if (moduleNames.contains(scope.moduleName()) == false) {
63119
throw new IllegalStateException(
64120
Strings.format(
65-
"Invalid module name in policy: plugin [%s] does not have module [%s]; available modules [%s]; policy file [%s]",
121+
"Invalid module name in policy: plugin [%s] does not have module [%s]; available modules [%s]; policy path [%s]",
66122
pluginName,
67123
scope.moduleName(),
68124
String.join(", ", moduleNames),
69-
policyFile
125+
policyLocation
70126
)
71127
);
72128
}
73129
}
74-
return policy;
75130
}
76131

77132
private static Policy parsePolicyIfExists(String pluginName, Path policyFile, boolean isExternalPlugin) throws IOException {
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the "Elastic License
4+
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
5+
* Public License v 1"; you may not use this file except in compliance with, at
6+
* your election, the "Elastic License 2.0", the "GNU Affero General Public
7+
* License v3.0 only", or the "Server Side Public License, v 1".
8+
*/
9+
10+
package org.elasticsearch.entitlement.runtime.policy;
11+
12+
import java.util.Set;
13+
14+
/**
15+
* A Policy and associated versions to which the policy applies
16+
*/
17+
public record VersionedPolicy(Policy policy, Set<String> versions) {}

0 commit comments

Comments
 (0)