Skip to content

Commit d33d20b

Browse files
authored
Validate role templates before saving role mapping (#52636) (#54059)
Role names are now compiled from role templates before role mapping is saved. This serves as validation for role templates to prevent malformed and invalid scripts to be persisted, which could later break authentication. Resolves: #48773
1 parent 5ce7c99 commit d33d20b

File tree

5 files changed

+176
-27
lines changed

5 files changed

+176
-27
lines changed

x-pack/docs/en/rest-api/security/create-role-mappings.asciidoc

+27-27
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ Creates and updates role mappings.
2323
[[security-api-put-role-mapping-desc]]
2424
==== {api-description-title}
2525

26-
Role mappings define which roles are assigned to each user. Each mapping has
26+
Role mappings define which roles are assigned to each user. Each mapping has
2727
_rules_ that identify users and a list of _roles_ that are granted to those users.
2828

2929
The role mapping APIs are generally the preferred way to manage role mappings
@@ -37,6 +37,31 @@ roles API>> or <<roles-management-file,roles files>>.
3737

3838
For more information, see <<mapping-roles>>.
3939

40+
[[_role_templates]]
41+
===== Role templates
42+
43+
The most common use for role mappings is to create a mapping from a known value
44+
on the user to a fixed role name. For example, all users in the
45+
`cn=admin,dc=example,dc=com` LDAP group should be given the `superuser` role in
46+
{es}. The `roles` field is used for this purpose.
47+
48+
For more complex needs, it is possible to use Mustache templates to dynamically
49+
determine the names of the roles that should be granted to the user. The
50+
`role_templates` field is used for this purpose.
51+
52+
NOTE: To use role templates successfully, the relevant scripting feature must be
53+
enabled. Otherwise, all attempts to create a role mapping with role templates
54+
fail. See <<allowed-script-types-setting>>.
55+
56+
All of the <<role-mapping-resources,user fields>> that are available in the
57+
role mapping `rules` are also available in the role templates. Thus it is possible
58+
to assign a user to a role that reflects their `username`, their `groups`, or the
59+
name of the `realm` to which they authenticated.
60+
61+
By default a template is evaluated to produce a single string that is the name
62+
of the role which should be assigned to the user. If the `format` of the template
63+
is set to `"json"` then the template is expected to produce a JSON string or an
64+
array of JSON strings for the role names.
4065

4166
[[security-api-put-role-mapping-path-params]]
4267
==== {api-path-parms-title}
@@ -77,31 +102,7 @@ _Exactly one of `roles` or `role_templates` must be specified_.
77102
`rules`::
78103
(Required, object) The rules that determine which users should be matched by the
79104
mapping. A rule is a logical condition that is expressed by using a JSON DSL.
80-
See <<role-mapping-resources>>.
81-
82-
==== Role Templates
83-
84-
The most common use for role mappings is to create a mapping from a known value
85-
on the user to a fixed role name.
86-
For example, all users in the `cn=admin,dc=example,dc=com` LDAP group should be
87-
given the `superuser` role in {es}.
88-
The `roles` field is used for this purpose.
89-
90-
For more complex needs it is possible to use Mustache templates to dynamically
91-
determine the names of the roles that should be granted to the user.
92-
The `role_templates` field is used for this purpose.
93-
94-
All of the <<role-mapping-resources,user fields>> that are available in the
95-
role mapping `rules` are also available in the role templates. Thus it is possible
96-
to assign a user to a role that reflects their `username`, their `groups` or the
97-
name of the `realm` to which they authenticated.
98-
99-
By default a template is evaluated to produce a single string that is the name
100-
of the role which should be assigned to the user. If the `format` of the template
101-
is set to `"json"` then the template is expected to produce a JSON string, or an
102-
array of JSON strings for the role name(s).
103-
104-
The Examples section below demonstrates the use of templated role names.
105+
See <<role-mapping-resources>>.
105106

106107
[[security-api-put-role-mapping-example]]
107108
==== {api-examples-title}
@@ -339,4 +340,3 @@ POST /_security/role_mapping/mapping9
339340
<1> Because it is not possible to specify both `roles` and `role_templates` in
340341
the same role mapping, we can apply a "fixed name" role by using a template
341342
that has no substitutions.
342-

x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/support/mapper/TemplateRoleName.java

+12
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,18 @@ public List<String> getRoleNames(ScriptService scriptService, ExpressionModel mo
9797
}
9898
}
9999

100+
public void validate(ScriptService scriptService) {
101+
try {
102+
parseTemplate(scriptService, Collections.emptyMap());
103+
} catch (IllegalArgumentException e) {
104+
throw e;
105+
} catch (IOException e) {
106+
throw new UncheckedIOException(e);
107+
} catch (Exception e) {
108+
throw new IllegalArgumentException(e);
109+
}
110+
}
111+
100112
private List<String> convertJsonToList(String evaluation) throws IOException {
101113
final XContentParser parser = XContentFactory.xContent(XContentType.JSON).createParser(NamedXContentRegistry.EMPTY,
102114
LoggingDeprecationHandler.INSTANCE, evaluation);

x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authc/support/mapper/TemplateRoleNameTests.java

+117
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@
66

77
package org.elasticsearch.xpack.core.security.authc.support.mapper;
88

9+
import org.elasticsearch.cluster.ClusterChangedEvent;
10+
import org.elasticsearch.cluster.ClusterState;
11+
import org.elasticsearch.cluster.metadata.MetaData;
912
import org.elasticsearch.common.Strings;
1013
import org.elasticsearch.common.bytes.BytesArray;
1114
import org.elasticsearch.common.bytes.BytesReference;
@@ -17,8 +20,11 @@
1720
import org.elasticsearch.common.xcontent.NamedXContentRegistry;
1821
import org.elasticsearch.common.xcontent.XContentParser;
1922
import org.elasticsearch.common.xcontent.XContentType;
23+
import org.elasticsearch.script.ScriptException;
24+
import org.elasticsearch.script.ScriptMetaData;
2025
import org.elasticsearch.script.ScriptModule;
2126
import org.elasticsearch.script.ScriptService;
27+
import org.elasticsearch.script.StoredScriptSource;
2228
import org.elasticsearch.script.mustache.MustacheScriptEngine;
2329
import org.elasticsearch.test.ESTestCase;
2430
import org.elasticsearch.test.EqualsHashCodeTestUtils;
@@ -31,8 +37,11 @@
3137
import java.util.Collections;
3238

3339
import static org.hamcrest.Matchers.contains;
40+
import static org.hamcrest.Matchers.containsString;
3441
import static org.hamcrest.Matchers.equalTo;
3542
import static org.hamcrest.Matchers.notNullValue;
43+
import static org.mockito.Mockito.mock;
44+
import static org.mockito.Mockito.when;
3645

3746
public class TemplateRoleNameTests extends ESTestCase {
3847

@@ -116,4 +125,112 @@ public void tryEquals(TemplateRoleName original) {
116125
};
117126
EqualsHashCodeTestUtils.checkEqualsAndHashCode(original, copy, mutate);
118127
}
128+
129+
public void testValidate() {
130+
final ScriptService scriptService = new ScriptService(Settings.EMPTY,
131+
Collections.singletonMap(MustacheScriptEngine.NAME, new MustacheScriptEngine()), ScriptModule.CORE_CONTEXTS);
132+
133+
final TemplateRoleName plainString = new TemplateRoleName(new BytesArray("{ \"source\":\"heroes\" }"), Format.STRING);
134+
plainString.validate(scriptService);
135+
136+
final TemplateRoleName user = new TemplateRoleName(new BytesArray("{ \"source\":\"_user_{{username}}\" }"), Format.STRING);
137+
user.validate(scriptService);
138+
139+
final TemplateRoleName groups = new TemplateRoleName(new BytesArray("{ \"source\":\"{{#tojson}}groups{{/tojson}}\" }"),
140+
Format.JSON);
141+
groups.validate(scriptService);
142+
143+
final TemplateRoleName notObject = new TemplateRoleName(new BytesArray("heroes"), Format.STRING);
144+
expectThrows(IllegalArgumentException.class, () -> notObject.validate(scriptService));
145+
146+
final TemplateRoleName invalidField = new TemplateRoleName(new BytesArray("{ \"foo\":\"heroes\" }"), Format.STRING);
147+
expectThrows(IllegalArgumentException.class, () -> invalidField.validate(scriptService));
148+
}
149+
150+
public void testValidateWillPassWithEmptyContext() {
151+
final ScriptService scriptService = new ScriptService(Settings.EMPTY,
152+
Collections.singletonMap(MustacheScriptEngine.NAME, new MustacheScriptEngine()), ScriptModule.CORE_CONTEXTS);
153+
154+
final BytesReference template = new BytesArray("{ \"source\":\"" +
155+
"{{username}}/{{dn}}/{{realm}}/{{metadata}}" +
156+
"{{#realm}}" +
157+
" {{name}}/{{type}}" +
158+
"{{/realm}}" +
159+
"{{#toJson}}groups{{/toJson}}" +
160+
"{{^groups}}{{.}}{{/groups}}" +
161+
"{{#metadata}}" +
162+
" {{#first}}" +
163+
" <li><strong>{{name}}</strong></li>" +
164+
" {{/first}}" +
165+
" {{#link}}" +
166+
" <li><a href=\\\"{{url}}\\\">{{name}}</a></li>" +
167+
" {{/link}}" +
168+
" {{#toJson}}subgroups{{/toJson}}" +
169+
" {{something-else}}" +
170+
"{{/metadata}}\" }");
171+
final TemplateRoleName templateRoleName = new TemplateRoleName(template, Format.STRING);
172+
templateRoleName.validate(scriptService);
173+
}
174+
175+
public void testValidateWillFailForSyntaxError() {
176+
final ScriptService scriptService = new ScriptService(Settings.EMPTY,
177+
Collections.singletonMap(MustacheScriptEngine.NAME, new MustacheScriptEngine()), ScriptModule.CORE_CONTEXTS);
178+
179+
final BytesReference template = new BytesArray("{ \"source\":\" {{#not-closed}} {{other-variable}} \" }");
180+
181+
final IllegalArgumentException e = expectThrows(IllegalArgumentException.class,
182+
() -> new TemplateRoleName(template, Format.STRING).validate(scriptService));
183+
assertTrue(e.getCause() instanceof ScriptException);
184+
}
185+
186+
public void testValidationWillFailWhenInlineScriptIsNotEnabled() {
187+
final Settings settings = Settings.builder().put("script.allowed_types", ScriptService.ALLOW_NONE).build();
188+
final ScriptService scriptService = new ScriptService(settings,
189+
Collections.singletonMap(MustacheScriptEngine.NAME, new MustacheScriptEngine()), ScriptModule.CORE_CONTEXTS);
190+
final BytesReference inlineScript = new BytesArray("{ \"source\":\"\" }");
191+
final IllegalArgumentException e = expectThrows(IllegalArgumentException.class,
192+
() -> new TemplateRoleName(inlineScript, Format.STRING).validate(scriptService));
193+
assertThat(e.getMessage(), containsString("[inline]"));
194+
}
195+
196+
public void testValidateWillFailWhenStoredScriptIsNotEnabled() {
197+
final Settings settings = Settings.builder().put("script.allowed_types", ScriptService.ALLOW_NONE).build();
198+
final ScriptService scriptService = new ScriptService(settings,
199+
Collections.singletonMap(MustacheScriptEngine.NAME, new MustacheScriptEngine()), ScriptModule.CORE_CONTEXTS);
200+
final ClusterChangedEvent clusterChangedEvent = mock(ClusterChangedEvent.class);
201+
final ClusterState clusterState = mock(ClusterState.class);
202+
final MetaData metaData = mock(MetaData.class);
203+
final StoredScriptSource storedScriptSource = mock(StoredScriptSource.class);
204+
final ScriptMetaData scriptMetaData = new ScriptMetaData.Builder(null).storeScript("foo", storedScriptSource).build();
205+
when(clusterChangedEvent.state()).thenReturn(clusterState);
206+
when(clusterState.metaData()).thenReturn(metaData);
207+
when(metaData.custom(ScriptMetaData.TYPE)).thenReturn(scriptMetaData);
208+
when(storedScriptSource.getLang()).thenReturn("mustache");
209+
when(storedScriptSource.getSource()).thenReturn("");
210+
when(storedScriptSource.getOptions()).thenReturn(Collections.emptyMap());
211+
scriptService.applyClusterState(clusterChangedEvent);
212+
213+
final BytesReference storedScript = new BytesArray("{ \"id\":\"foo\" }");
214+
final IllegalArgumentException e = expectThrows(IllegalArgumentException.class,
215+
() -> new TemplateRoleName(storedScript, Format.STRING).validate(scriptService));
216+
assertThat(e.getMessage(), containsString("[stored]"));
217+
}
218+
219+
public void testValidateWillFailWhenStoredScriptIsNotFound() {
220+
final ScriptService scriptService = new ScriptService(Settings.EMPTY,
221+
Collections.singletonMap(MustacheScriptEngine.NAME, new MustacheScriptEngine()), ScriptModule.CORE_CONTEXTS);
222+
final ClusterChangedEvent clusterChangedEvent = mock(ClusterChangedEvent.class);
223+
final ClusterState clusterState = mock(ClusterState.class);
224+
final MetaData metaData = mock(MetaData.class);
225+
final ScriptMetaData scriptMetaData = new ScriptMetaData.Builder(null).build();
226+
when(clusterChangedEvent.state()).thenReturn(clusterState);
227+
when(clusterState.metaData()).thenReturn(metaData);
228+
when(metaData.custom(ScriptMetaData.TYPE)).thenReturn(scriptMetaData);
229+
scriptService.applyClusterState(clusterChangedEvent);
230+
231+
final BytesReference storedScript = new BytesArray("{ \"id\":\"foo\" }");
232+
final IllegalArgumentException e = expectThrows(IllegalArgumentException.class,
233+
() -> new TemplateRoleName(storedScript, Format.STRING).validate(scriptService));
234+
assertThat(e.getMessage(), containsString("unable to find script"));
235+
}
119236
}

x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/support/mapper/NativeRoleMappingStore.java

+5
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
import org.elasticsearch.xpack.core.security.action.rolemapping.DeleteRoleMappingRequest;
3535
import org.elasticsearch.xpack.core.security.action.rolemapping.PutRoleMappingRequest;
3636
import org.elasticsearch.xpack.core.security.authc.support.mapper.ExpressionRoleMapping;
37+
import org.elasticsearch.xpack.core.security.authc.support.mapper.TemplateRoleName;
3738
import org.elasticsearch.xpack.core.security.authc.support.mapper.expressiondsl.ExpressionModel;
3839
import org.elasticsearch.xpack.core.security.client.SecurityClient;
3940
import org.elasticsearch.xpack.core.security.index.RestrictedIndicesNames;
@@ -167,6 +168,10 @@ protected ExpressionRoleMapping buildMapping(String id, BytesReference source) {
167168
* Stores (create or update) a single mapping in the index
168169
*/
169170
public void putRoleMapping(PutRoleMappingRequest request, ActionListener<Boolean> listener) {
171+
// Validate all templates before storing the role mapping
172+
for (TemplateRoleName templateRoleName : request.getRoleTemplates()) {
173+
templateRoleName.validate(scriptService);
174+
}
170175
modifyMapping(request.getName(), this::innerPutMapping, request, listener);
171176
}
172177

x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/support/mapper/NativeRoleMappingStoreTests.java

+15
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import org.elasticsearch.xpack.core.security.action.realm.ClearRealmCacheAction;
2525
import org.elasticsearch.xpack.core.security.action.realm.ClearRealmCacheRequest;
2626
import org.elasticsearch.xpack.core.security.action.realm.ClearRealmCacheResponse;
27+
import org.elasticsearch.xpack.core.security.action.rolemapping.PutRoleMappingRequest;
2728
import org.elasticsearch.xpack.core.security.authc.AuthenticationResult;
2829
import org.elasticsearch.xpack.core.security.authc.RealmConfig;
2930
import org.elasticsearch.xpack.core.security.authc.support.UsernamePasswordToken;
@@ -205,6 +206,20 @@ public void testCacheIsNotClearedIfNoRealmsAreAttached() {
205206
assertEquals(0, numInvalidation.get());
206207
}
207208

209+
public void testPutRoleMappingWillValidateTemplateRoleNamesBeforeSave() {
210+
final PutRoleMappingRequest putRoleMappingRequest = mock(PutRoleMappingRequest.class);
211+
final TemplateRoleName templateRoleName = mock(TemplateRoleName.class);
212+
final ScriptService scriptService = mock(ScriptService.class);
213+
when(putRoleMappingRequest.getRoleTemplates()).thenReturn(Collections.singletonList(templateRoleName));
214+
doAnswer(invocationOnMock -> {
215+
throw new IllegalArgumentException();
216+
}).when(templateRoleName).validate(scriptService);
217+
218+
final NativeRoleMappingStore nativeRoleMappingStore =
219+
new NativeRoleMappingStore(Settings.EMPTY, mock(Client.class), mock(SecurityIndexManager.class), scriptService);
220+
expectThrows(IllegalArgumentException.class, () -> nativeRoleMappingStore.putRoleMapping(putRoleMappingRequest, null));
221+
}
222+
208223
private NativeRoleMappingStore buildRoleMappingStoreForInvalidationTesting(AtomicInteger invalidationCounter, boolean attachRealm) {
209224
final Settings settings = Settings.builder().put("path.home", createTempDir()).build();
210225

0 commit comments

Comments
 (0)