Skip to content

Commit 2b33703

Browse files
committed
Security: fix dynamic mapping updates with aliases (#30787)
This commit fixes an issue with dynamic mapping updates when an index operation is performed against an alias and when the user only has permissions to the alias. Dynamic mapping updates resolve the concrete index early to prevent issues so the information about the alias that the triggering operation was being executed against is lost. When security is enabled and a user only has privileges to the alias, this dynamic mapping update would be rejected as it is executing against the concrete index and not the alias. In order to handle this situation, the security code needs to look at the concrete index and the authorized indices of the user; if the concrete index is not authorized the code will attempt to find an alias that the user has permissions to update the mappings of. Closes #30597
1 parent 3d71b3f commit 2b33703

File tree

3 files changed

+165
-10
lines changed

3 files changed

+165
-10
lines changed

x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolver.java

Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,14 @@
1414
import org.elasticsearch.action.fieldcaps.FieldCapabilitiesRequest;
1515
import org.elasticsearch.action.search.SearchRequest;
1616
import org.elasticsearch.action.support.IndicesOptions;
17+
import org.elasticsearch.cluster.metadata.AliasMetaData;
1718
import org.elasticsearch.cluster.metadata.AliasOrIndex;
1819
import org.elasticsearch.cluster.metadata.IndexMetaData;
1920
import org.elasticsearch.cluster.metadata.IndexNameExpressionResolver;
2021
import org.elasticsearch.cluster.metadata.MetaData;
2122
import org.elasticsearch.cluster.service.ClusterService;
23+
import org.elasticsearch.common.Strings;
24+
import org.elasticsearch.common.collect.ImmutableOpenMap;
2225
import org.elasticsearch.common.regex.Regex;
2326
import org.elasticsearch.common.settings.ClusterSettings;
2427
import org.elasticsearch.common.settings.Settings;
@@ -35,14 +38,15 @@
3538
import java.util.HashSet;
3639
import java.util.List;
3740
import java.util.Map;
41+
import java.util.Optional;
3842
import java.util.Set;
3943
import java.util.SortedMap;
4044
import java.util.concurrent.CopyOnWriteArraySet;
4145
import java.util.stream.Collectors;
4246

4347
import static org.elasticsearch.xpack.core.security.authz.IndicesAndAliasesResolverField.NO_INDEX_PLACEHOLDER;
4448

45-
public class IndicesAndAliasesResolver {
49+
class IndicesAndAliasesResolver {
4650

4751
//`*,-*` what we replace indices with if we need Elasticsearch to return empty responses without throwing exception
4852
private static final String[] NO_INDICES_ARRAY = new String[] { "*", "-*" };
@@ -51,7 +55,7 @@ public class IndicesAndAliasesResolver {
5155
private final IndexNameExpressionResolver nameExpressionResolver;
5256
private final RemoteClusterResolver remoteClusterResolver;
5357

54-
public IndicesAndAliasesResolver(Settings settings, ClusterService clusterService) {
58+
IndicesAndAliasesResolver(Settings settings, ClusterService clusterService) {
5559
this.nameExpressionResolver = new IndexNameExpressionResolver(settings);
5660
this.remoteClusterResolver = new RemoteClusterResolver(settings, clusterService.getClusterSettings());
5761
}
@@ -85,7 +89,7 @@ public IndicesAndAliasesResolver(Settings settings, ClusterService clusterServic
8589
* Otherwise, <em>N</em> will be added to the <em>local</em> index list.
8690
*/
8791

88-
public ResolvedIndices resolve(TransportRequest request, MetaData metaData, AuthorizedIndices authorizedIndices) {
92+
ResolvedIndices resolve(TransportRequest request, MetaData metaData, AuthorizedIndices authorizedIndices) {
8993
if (request instanceof IndicesAliasesRequest) {
9094
ResolvedIndices.Builder resolvedIndicesBuilder = new ResolvedIndices.Builder();
9195
IndicesAliasesRequest indicesAliasesRequest = (IndicesAliasesRequest) request;
@@ -116,7 +120,7 @@ ResolvedIndices resolveIndicesAndAliases(IndicesRequest indicesRequest, MetaData
116120
*/
117121
assert indicesRequest.indices() == null || indicesRequest.indices().length == 0
118122
: "indices are: " + Arrays.toString(indicesRequest.indices()); // Arrays.toString() can handle null values - all good
119-
resolvedIndicesBuilder.addLocal(((PutMappingRequest) indicesRequest).getConcreteIndex().getName());
123+
resolvedIndicesBuilder.addLocal(getPutMappingIndexOrAlias((PutMappingRequest) indicesRequest, authorizedIndices, metaData));
120124
} else if (indicesRequest instanceof IndicesRequest.Replaceable) {
121125
IndicesRequest.Replaceable replaceable = (IndicesRequest.Replaceable) indicesRequest;
122126
final boolean replaceWildcards = indicesRequest.indicesOptions().expandWildcardsOpen()
@@ -213,7 +217,48 @@ ResolvedIndices resolveIndicesAndAliases(IndicesRequest indicesRequest, MetaData
213217
return resolvedIndicesBuilder.build();
214218
}
215219

216-
public static boolean allowsRemoteIndices(IndicesRequest request) {
220+
/**
221+
* Special handling of the value to authorize for a put mapping request. Dynamic put mapping
222+
* requests use a concrete index, but we allow permissions to be defined on aliases so if the
223+
* request's concrete index is not in the list of authorized indices, then we need to look to
224+
* see if this can be authorized against an alias
225+
*/
226+
static String getPutMappingIndexOrAlias(PutMappingRequest request, AuthorizedIndices authorizedIndices, MetaData metaData) {
227+
final String concreteIndexName = request.getConcreteIndex().getName();
228+
final List<String> authorizedIndicesList = authorizedIndices.get();
229+
230+
// validate that the concrete index exists, otherwise there is no remapping that we could do
231+
final AliasOrIndex aliasOrIndex = metaData.getAliasAndIndexLookup().get(concreteIndexName);
232+
final String resolvedAliasOrIndex;
233+
if (aliasOrIndex == null) {
234+
resolvedAliasOrIndex = concreteIndexName;
235+
} else if (aliasOrIndex.isAlias()) {
236+
throw new IllegalStateException("concrete index [" + concreteIndexName + "] is an alias but should not be");
237+
} else if (authorizedIndicesList.contains(concreteIndexName)) {
238+
// user is authorized to put mappings for this index
239+
resolvedAliasOrIndex = concreteIndexName;
240+
} else {
241+
// the user is not authorized to put mappings for this index, but could have been
242+
// authorized for a write using an alias that triggered a dynamic mapping update
243+
ImmutableOpenMap<String, List<AliasMetaData>> foundAliases =
244+
metaData.findAliases(Strings.EMPTY_ARRAY, new String[] { concreteIndexName });
245+
List<AliasMetaData> aliasMetaData = foundAliases.get(concreteIndexName);
246+
if (aliasMetaData != null) {
247+
Optional<String> foundAlias = aliasMetaData.stream()
248+
.map(AliasMetaData::alias)
249+
.filter(authorizedIndicesList::contains)
250+
.filter(aliasName -> metaData.getAliasAndIndexLookup().get(aliasName).getIndices().size() == 1)
251+
.findFirst();
252+
resolvedAliasOrIndex = foundAlias.orElse(concreteIndexName);
253+
} else {
254+
resolvedAliasOrIndex = concreteIndexName;
255+
}
256+
}
257+
258+
return resolvedAliasOrIndex;
259+
}
260+
261+
static boolean allowsRemoteIndices(IndicesRequest request) {
217262
return request instanceof SearchRequest || request instanceof FieldCapabilitiesRequest
218263
|| request instanceof GraphExploreRequest;
219264
}

x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/IndicesAndAliasesResolverTests.java

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,12 @@
3939
import org.elasticsearch.cluster.metadata.MetaData;
4040
import org.elasticsearch.cluster.service.ClusterService;
4141
import org.elasticsearch.common.Strings;
42+
import org.elasticsearch.common.UUIDs;
4243
import org.elasticsearch.common.collect.Tuple;
4344
import org.elasticsearch.common.regex.Regex;
4445
import org.elasticsearch.common.settings.ClusterSettings;
4546
import org.elasticsearch.common.settings.Settings;
47+
import org.elasticsearch.index.Index;
4648
import org.elasticsearch.index.IndexNotFoundException;
4749
import org.elasticsearch.search.internal.ShardSearchTransportRequest;
4850
import org.elasticsearch.test.ESTestCase;
@@ -150,7 +152,10 @@ public void setup() {
150152
new IndicesPrivileges[] { IndicesPrivileges.builder().indices(authorizedIndices).privileges("all").build() }, null));
151153
roleMap.put("dash", new RoleDescriptor("dash", null,
152154
new IndicesPrivileges[] { IndicesPrivileges.builder().indices(dashIndices).privileges("all").build() }, null));
153-
roleMap.put("test", new RoleDescriptor("role", new String[] { "monitor" }, null, null));
155+
roleMap.put("test", new RoleDescriptor("test", new String[] { "monitor" }, null, null));
156+
roleMap.put("alias_read_write", new RoleDescriptor("alias_read_write", null,
157+
new IndicesPrivileges[] { IndicesPrivileges.builder().indices("barbaz", "foofoobar").privileges("read", "write").build() },
158+
null));
154159
roleMap.put(ReservedRolesStore.SUPERUSER_ROLE_DESCRIPTOR.getName(), ReservedRolesStore.SUPERUSER_ROLE_DESCRIPTOR);
155160
final FieldPermissionsCache fieldPermissionsCache = new FieldPermissionsCache(Settings.EMPTY);
156161
doAnswer((i) -> {
@@ -652,7 +657,7 @@ public void testResolveWildcardsIndicesAliasesRequestNoMatchingIndices() {
652657
request.addAliasAction(AliasActions.add().alias("alias2").index("bar*"));
653658
request.addAliasAction(AliasActions.add().alias("alias3").index("non_matching_*"));
654659
//if a single operation contains wildcards and ends up being resolved to no indices, it makes the whole request fail
655-
expectThrows(IndexNotFoundException.class,
660+
expectThrows(IndexNotFoundException.class,
656661
() -> resolveIndices(request, buildAuthorizedIndices(user, IndicesAliasesAction.NAME)));
657662
}
658663

@@ -1181,10 +1186,10 @@ public void testIndicesExists() {
11811186
assertNoIndices(request, resolveIndices(request,
11821187
buildAuthorizedIndices(userNoIndices, IndicesExistsAction.NAME)));
11831188
}
1184-
1189+
11851190
{
11861191
IndicesExistsRequest request = new IndicesExistsRequest("does_not_exist");
1187-
1192+
11881193
assertNoIndices(request, resolveIndices(request,
11891194
buildAuthorizedIndices(user, IndicesExistsAction.NAME)));
11901195
}
@@ -1229,7 +1234,7 @@ public void testNonXPackUserAccessingSecurityIndex() {
12291234
List<String> indices = resolveIndices(request, authorizedIndices).getLocal();
12301235
assertThat(indices, not(hasItem(SecurityLifecycleServiceField.SECURITY_INDEX_NAME)));
12311236
}
1232-
1237+
12331238
{
12341239
IndicesAliasesRequest aliasesRequest = new IndicesAliasesRequest();
12351240
aliasesRequest.addAliasAction(AliasActions.add().alias("security_alias1").index("*"));
@@ -1318,6 +1323,21 @@ public void testAliasDateMathExpressionNotSupported() {
13181323
assertThat(request.aliases(), arrayContainingInAnyOrder("<datetime-{now/M}>"));
13191324
}
13201325

1326+
public void testDynamicPutMappingRequestFromAlias() {
1327+
PutMappingRequest request = new PutMappingRequest(Strings.EMPTY_ARRAY).setConcreteIndex(new Index("foofoo", UUIDs.base64UUID()));
1328+
User user = new User("alias-writer", "alias_read_write");
1329+
AuthorizedIndices authorizedIndices = buildAuthorizedIndices(user, PutMappingAction.NAME);
1330+
1331+
String putMappingIndexOrAlias = IndicesAndAliasesResolver.getPutMappingIndexOrAlias(request, authorizedIndices, metaData);
1332+
assertEquals("barbaz", putMappingIndexOrAlias);
1333+
1334+
// multiple indices map to an alias so we can only return the concrete index
1335+
final String index = randomFrom("foo", "foobar");
1336+
request = new PutMappingRequest(Strings.EMPTY_ARRAY).setConcreteIndex(new Index(index, UUIDs.base64UUID()));
1337+
putMappingIndexOrAlias = IndicesAndAliasesResolver.getPutMappingIndexOrAlias(request, authorizedIndices, metaData);
1338+
assertEquals(index, putMappingIndexOrAlias);
1339+
}
1340+
13211341
// TODO with the removal of DeleteByQuery is there another way to test resolving a write action?
13221342

13231343

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
---
2+
setup:
3+
- skip:
4+
features: headers
5+
6+
- do:
7+
cluster.health:
8+
wait_for_status: yellow
9+
10+
- do:
11+
xpack.security.put_role:
12+
name: "alias_write_role"
13+
body: >
14+
{
15+
"indices": [
16+
{ "names": ["write_alias"], "privileges": ["write"] }
17+
]
18+
}
19+
20+
- do:
21+
xpack.security.put_user:
22+
username: "test_user"
23+
body: >
24+
{
25+
"password" : "x-pack-test-password",
26+
"roles" : [ "alias_write_role" ],
27+
"full_name" : "user with privileges to write via alias"
28+
}
29+
30+
- do:
31+
indices.create:
32+
index: write_index_1
33+
body:
34+
settings:
35+
index:
36+
number_of_shards: 1
37+
number_of_replicas: 0
38+
39+
- do:
40+
indices.put_alias:
41+
index: write_index_1
42+
name: write_alias
43+
44+
---
45+
teardown:
46+
- do:
47+
xpack.security.delete_user:
48+
username: "test_user"
49+
ignore: 404
50+
51+
- do:
52+
xpack.security.delete_role:
53+
name: "alias_write_role"
54+
ignore: 404
55+
56+
- do:
57+
indices.delete_alias:
58+
index: "write_index_1"
59+
name: [ "write_alias" ]
60+
ignore: 404
61+
62+
- do:
63+
indices.delete:
64+
index: [ "write_index_1" ]
65+
ignore: 404
66+
67+
---
68+
"Test indexing documents into an alias with dynamic mappings":
69+
70+
- do:
71+
headers: { Authorization: "Basic dGVzdF91c2VyOngtcGFjay10ZXN0LXBhc3N3b3Jk" } # test_user
72+
create:
73+
id: 1
74+
index: write_alias
75+
type: doc
76+
body: >
77+
{
78+
"name" : "doc1"
79+
}
80+
81+
- do:
82+
headers: { Authorization: "Basic dGVzdF91c2VyOngtcGFjay10ZXN0LXBhc3N3b3Jk" } # test_user
83+
create:
84+
id: 2
85+
index: write_alias
86+
type: doc
87+
body: >
88+
{
89+
"name2" : "doc2"
90+
}

0 commit comments

Comments
 (0)