Skip to content

Commit 8f0d91b

Browse files
authored
Backport use origin for feature reset (#88728)
1 parent 8c118e1 commit 8f0d91b

File tree

5 files changed

+242
-23
lines changed

5 files changed

+242
-23
lines changed

docs/changelog/88622.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
pr: 88622
2+
summary: Use origin for the client when running _features/_reset
3+
area: Infra/Core
4+
type: bug
5+
issues:
6+
- 88617

qa/system-indices/src/main/java/org/elasticsearch/system/indices/SystemIndicesQA.java

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
import java.util.List;
3939
import java.util.function.Supplier;
4040

41+
import static org.elasticsearch.action.admin.cluster.node.tasks.get.GetTaskAction.TASKS_ORIGIN;
4142
import static org.elasticsearch.index.mapper.MapperService.SINGLE_MAPPING_NAME;
4243
import static org.elasticsearch.rest.RestRequest.Method.POST;
4344
import static org.elasticsearch.rest.RestRequest.Method.PUT;
@@ -73,15 +74,15 @@ public Collection<SystemIndexDescriptor> getSystemIndexDescriptors(Settings sett
7374
.put(IndexMetadata.SETTING_AUTO_EXPAND_REPLICAS, "0-1")
7475
.build()
7576
)
76-
.setOrigin("net-new")
77+
.setOrigin(TASKS_ORIGIN)
7778
.setVersionMetaKey("version")
7879
.setPrimaryIndex(".net-new-system-index-" + Version.CURRENT.major)
7980
.build(),
8081
SystemIndexDescriptor.builder()
8182
.setIndexPattern(INTERNAL_UNMANAGED_INDEX_NAME)
8283
.setDescription("internal unmanaged system index")
8384
.setType(SystemIndexDescriptor.Type.INTERNAL_UNMANAGED)
84-
.setOrigin("qa")
85+
.setOrigin(TASKS_ORIGIN)
8586
.setVersionMetaKey("version")
8687
.setPrimaryIndex(".internal-unmanaged-index-" + Version.CURRENT.major)
8788
.setAliasName(".internal-unmanaged-alias")
@@ -98,7 +99,7 @@ public Collection<SystemIndexDescriptor> getSystemIndexDescriptors(Settings sett
9899
.put(IndexMetadata.SETTING_AUTO_EXPAND_REPLICAS, "0-1")
99100
.build()
100101
)
101-
.setOrigin("qa")
102+
.setOrigin(TASKS_ORIGIN)
102103
.setVersionMetaKey("version")
103104
.setPrimaryIndex(".internal-managed-index-" + Version.CURRENT.major)
104105
.setAliasName(".internal-managed-alias")

server/src/main/java/org/elasticsearch/indices/SystemIndices.java

Lines changed: 76 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,23 @@
88

99
package org.elasticsearch.indices;
1010

11+
import org.apache.logging.log4j.LogManager;
12+
import org.apache.logging.log4j.Logger;
1113
import org.apache.logging.log4j.message.ParameterizedMessage;
1214
import org.apache.lucene.util.automaton.Automata;
1315
import org.apache.lucene.util.automaton.Automaton;
1416
import org.apache.lucene.util.automaton.CharacterRunAutomaton;
1517
import org.apache.lucene.util.automaton.MinimizationOperations;
1618
import org.apache.lucene.util.automaton.Operations;
1719
import org.elasticsearch.action.ActionListener;
20+
import org.elasticsearch.action.admin.cluster.snapshots.features.ResetFeatureStateResponse;
1821
import org.elasticsearch.action.admin.cluster.snapshots.features.ResetFeatureStateResponse.ResetFeatureStateStatus;
1922
import org.elasticsearch.action.admin.indices.delete.DeleteIndexAction;
2023
import org.elasticsearch.action.admin.indices.delete.DeleteIndexRequest;
24+
import org.elasticsearch.action.support.GroupedActionListener;
2125
import org.elasticsearch.action.support.master.AcknowledgedResponse;
2226
import org.elasticsearch.client.internal.Client;
27+
import org.elasticsearch.client.internal.OriginSettingClient;
2328
import org.elasticsearch.cluster.metadata.IndexMetadata;
2429
import org.elasticsearch.cluster.metadata.Metadata;
2530
import org.elasticsearch.cluster.service.ClusterService;
@@ -66,6 +71,8 @@ public class SystemIndices {
6671

6772
private static final Automaton EMPTY = Automata.makeEmpty();
6873

74+
private static final Logger logger = LogManager.getLogger(SystemIndices.class);
75+
6976
/**
7077
* This is the source for non-plugin system features.
7178
*/
@@ -288,6 +295,11 @@ public ExecutorSelector getExecutorSelector() {
288295
* @return The matching {@link SystemIndexDescriptor} or {@code null} if no descriptor is found
289296
*/
290297
public @Nullable SystemIndexDescriptor findMatchingDescriptor(String name) {
298+
return findMatchingDescriptor(indexDescriptors, name);
299+
}
300+
301+
@Nullable
302+
static SystemIndexDescriptor findMatchingDescriptor(SystemIndexDescriptor[] indexDescriptors, String name) {
291303
SystemIndexDescriptor matchingDescriptor = null;
292304
for (SystemIndexDescriptor systemIndexDescriptor : indexDescriptors) {
293305
if (systemIndexDescriptor.matchesIndexPattern(name)) {
@@ -798,6 +810,27 @@ public MigrationCompletionHandler getPostMigrationFunction() {
798810
return postMigrationFunction;
799811
}
800812

813+
private static void cleanUpFeatureForIndices(
814+
String name,
815+
Client client,
816+
String[] indexNames,
817+
final ActionListener<ResetFeatureStateStatus> listener
818+
) {
819+
DeleteIndexRequest deleteIndexRequest = new DeleteIndexRequest();
820+
deleteIndexRequest.indices(indexNames);
821+
client.execute(DeleteIndexAction.INSTANCE, deleteIndexRequest, new ActionListener<>() {
822+
@Override
823+
public void onResponse(AcknowledgedResponse acknowledgedResponse) {
824+
listener.onResponse(ResetFeatureStateStatus.success(name));
825+
}
826+
827+
@Override
828+
public void onFailure(Exception e) {
829+
listener.onResponse(ResetFeatureStateStatus.failure(name, e));
830+
}
831+
});
832+
}
833+
801834
/**
802835
* Clean up the state of a feature
803836
* @param indexDescriptors List of descriptors of a feature's system indices
@@ -808,39 +841,66 @@ public MigrationCompletionHandler getPostMigrationFunction() {
808841
* @param listener A listener to return success or failure of cleanup
809842
*/
810843
public static void cleanUpFeature(
811-
Collection<? extends IndexPatternMatcher> indexDescriptors,
844+
Collection<SystemIndexDescriptor> indexDescriptors,
812845
Collection<? extends IndexPatternMatcher> associatedIndexDescriptors,
813846
String name,
814847
ClusterService clusterService,
815848
Client client,
816-
ActionListener<ResetFeatureStateStatus> listener
849+
final ActionListener<ResetFeatureStateStatus> listener
817850
) {
818851
Metadata metadata = clusterService.state().getMetadata();
819852

820-
List<String> allIndices = Stream.concat(indexDescriptors.stream(), associatedIndexDescriptors.stream())
853+
List<String> associatedIndices = associatedIndexDescriptors.stream()
821854
.map(descriptor -> descriptor.getMatchingIndices(metadata))
822855
.flatMap(List::stream)
823856
.toList();
824857

825-
if (allIndices.isEmpty()) {
826-
// if no actual indices match the pattern, we can stop here
858+
final int taskCount = ((associatedIndices.size() > 0) ? 1 : 0) + (int) indexDescriptors.stream()
859+
.filter(id -> id.getMatchingIndices(metadata).isEmpty() == false)
860+
.count();
861+
862+
// check if there's nothing to do and take an early out
863+
if (taskCount == 0) {
827864
listener.onResponse(ResetFeatureStateStatus.success(name));
828865
return;
829866
}
830867

831-
DeleteIndexRequest deleteIndexRequest = new DeleteIndexRequest();
832-
deleteIndexRequest.indices(allIndices.toArray(Strings.EMPTY_ARRAY));
833-
client.execute(DeleteIndexAction.INSTANCE, deleteIndexRequest, new ActionListener<>() {
834-
@Override
835-
public void onResponse(AcknowledgedResponse acknowledgedResponse) {
836-
listener.onResponse(ResetFeatureStateStatus.success(name));
837-
}
868+
GroupedActionListener<ResetFeatureStateStatus> groupedListener = new GroupedActionListener<>(
869+
ActionListener.wrap(listenerResults -> {
870+
List<ResetFeatureStateStatus> errors = listenerResults.stream()
871+
.filter(status -> status.getStatus() == ResetFeatureStateResponse.ResetFeatureStateStatus.Status.FAILURE)
872+
.collect(Collectors.toList());
838873

839-
@Override
840-
public void onFailure(Exception e) {
841-
listener.onResponse(ResetFeatureStateStatus.failure(name, e));
874+
if (errors.isEmpty()) {
875+
listener.onResponse(ResetFeatureStateStatus.success(name));
876+
} else {
877+
StringBuilder exceptions = new StringBuilder("[");
878+
exceptions.append(errors.stream().map(e -> e.getException().getMessage()).collect(Collectors.joining(", ")));
879+
exceptions.append(']');
880+
errors.forEach(e -> logger.warn(() -> "error while resetting feature [" + name + "]", e.getException()));
881+
listener.onResponse(ResetFeatureStateStatus.failure(name, new Exception(exceptions.toString())));
882+
}
883+
}, listener::onFailure),
884+
taskCount
885+
);
886+
887+
// Send cleanup for the associated indices, they don't need special origin since they are not protected
888+
if (associatedIndices.isEmpty() == false) {
889+
cleanUpFeatureForIndices(name, client, associatedIndices.toArray(Strings.EMPTY_ARRAY), groupedListener);
890+
}
891+
892+
// One descriptor at a time, create an originating client and clean up the feature
893+
for (var indexDescriptor : indexDescriptors) {
894+
List<String> matchingIndices = indexDescriptor.getMatchingIndices(metadata);
895+
896+
if (matchingIndices.isEmpty() == false) {
897+
final Client clientWithOrigin = (indexDescriptor.getOrigin() == null)
898+
? client
899+
: new OriginSettingClient(client, indexDescriptor.getOrigin());
900+
901+
cleanUpFeatureForIndices(name, clientWithOrigin, matchingIndices.toArray(Strings.EMPTY_ARRAY), groupedListener);
842902
}
843-
});
903+
}
844904
}
845905

846906
// No-op pre-migration function to be used as the default in case none are provided.

x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/LocalStateCompositeXPackPlugin.java

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -719,10 +719,16 @@ public void cleanUpFeature(
719719
List<SystemIndexPlugin> systemPlugins = filterPlugins(SystemIndexPlugin.class);
720720

721721
GroupedActionListener<ResetFeatureStateResponse.ResetFeatureStateStatus> allListeners = new GroupedActionListener<>(
722-
ActionListener.wrap(
723-
listenerResults -> finalListener.onResponse(ResetFeatureStateStatus.success(getFeatureName())),
724-
finalListener::onFailure
725-
),
722+
ActionListener.wrap(listenerResults -> {
723+
// If the clean-up produced only one result, use that to pass along. In most
724+
// cases it should be 1-1 mapping of feature to response. Passing back success
725+
// prevents us from writing validation tests on this API.
726+
if (listenerResults != null && listenerResults.size() == 1) {
727+
finalListener.onResponse(listenerResults.stream().findFirst().get());
728+
} else {
729+
finalListener.onResponse(ResetFeatureStateStatus.success(getFeatureName()));
730+
}
731+
}, finalListener::onFailure),
726732
systemPlugins.size()
727733
);
728734
systemPlugins.forEach(plugin -> plugin.cleanUpFeature(clusterService, client, allListeners));
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
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; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
package org.elasticsearch.integration;
9+
10+
import org.elasticsearch.action.ActionListener;
11+
import org.elasticsearch.action.admin.cluster.snapshots.features.ResetFeatureStateAction;
12+
import org.elasticsearch.action.admin.cluster.snapshots.features.ResetFeatureStateRequest;
13+
import org.elasticsearch.action.admin.cluster.snapshots.features.ResetFeatureStateResponse;
14+
import org.elasticsearch.common.settings.SecureString;
15+
import org.elasticsearch.test.SecurityIntegTestCase;
16+
import org.elasticsearch.test.SecuritySettingsSource;
17+
import org.elasticsearch.test.SecuritySettingsSourceField;
18+
import org.elasticsearch.test.TestSecurityClient;
19+
import org.elasticsearch.xpack.core.security.user.User;
20+
import org.junit.Before;
21+
22+
import java.util.Collections;
23+
24+
import static org.elasticsearch.xpack.core.security.authc.support.UsernamePasswordToken.BASIC_AUTH_HEADER;
25+
import static org.elasticsearch.xpack.core.security.authc.support.UsernamePasswordToken.basicAuthHeaderValue;
26+
import static org.hamcrest.Matchers.containsString;
27+
28+
/**
29+
* These tests ensure that the Feature Reset API works for users with default superuser and manage roles.
30+
* This can be complex due to restrictions on system indices and the need to use the correct origin for
31+
* each index. See also https://github.com/elastic/elasticsearch/issues/88617
32+
*/
33+
public class SecurityFeatureResetTests extends SecurityIntegTestCase {
34+
private static final SecureString SUPER_USER_PASSWD = SecuritySettingsSourceField.TEST_PASSWORD_SECURE_STRING;
35+
36+
@Override
37+
protected boolean addMockHttpTransport() {
38+
return false; // enable http
39+
}
40+
41+
@Before
42+
public void setupForTests() throws Exception {
43+
// adds a dummy user to the native realm to force .security index creation
44+
new TestSecurityClient(getRestClient(), SecuritySettingsSource.SECURITY_REQUEST_OPTIONS).putUser(
45+
new User("dummy_user", "missing_role"),
46+
SecuritySettingsSourceField.TEST_PASSWORD_SECURE_STRING
47+
);
48+
assertSecurityIndexActive();
49+
}
50+
51+
@Override
52+
protected String configUsers() {
53+
final String usersPasswHashed = new String(getFastStoredHashAlgoForTests().hash(SUPER_USER_PASSWD));
54+
return super.configUsers()
55+
+ "su:"
56+
+ usersPasswHashed
57+
+ "\n"
58+
+ "manager:"
59+
+ usersPasswHashed
60+
+ "\n"
61+
+ "usr:"
62+
+ usersPasswHashed
63+
+ "\n";
64+
}
65+
66+
@Override
67+
protected String configUsersRoles() {
68+
return super.configUsersRoles() + """
69+
superuser:su
70+
role1:manager
71+
role2:usr""";
72+
}
73+
74+
@Override
75+
protected String configRoles() {
76+
return super.configRoles() + """
77+
%s
78+
role1:
79+
cluster: [ manage ]
80+
indices:
81+
- names: '*'
82+
privileges: [ manage ]
83+
role2:
84+
cluster: [ monitor ]
85+
indices:
86+
- names: '*'
87+
privileges: [ read ]
88+
""";
89+
}
90+
91+
public void testFeatureResetSuperuser() {
92+
assertResetSuccessful("su", SUPER_USER_PASSWD);
93+
}
94+
95+
public void testFeatureResetManageRole() {
96+
assertResetSuccessful("manager", SUPER_USER_PASSWD);
97+
}
98+
99+
public void testFeatureResetNoManageRole() {
100+
final ResetFeatureStateRequest req = new ResetFeatureStateRequest();
101+
102+
client().filterWithHeader(Collections.singletonMap(BASIC_AUTH_HEADER, basicAuthHeaderValue("usr", SUPER_USER_PASSWD)))
103+
.admin()
104+
.cluster()
105+
.execute(ResetFeatureStateAction.INSTANCE, req, new ActionListener<>() {
106+
@Override
107+
public void onResponse(ResetFeatureStateResponse response) {
108+
fail("Shouldn't reach here");
109+
}
110+
111+
@Override
112+
public void onFailure(Exception e) {
113+
assertThat(
114+
e.getMessage(),
115+
containsString("action [cluster:admin/features/reset] is unauthorized for user [usr] with roles [role2]")
116+
);
117+
}
118+
});
119+
120+
// Manually delete the security index, reset shouldn't work
121+
deleteSecurityIndex();
122+
}
123+
124+
private void assertResetSuccessful(String user, SecureString password) {
125+
final ResetFeatureStateRequest req = new ResetFeatureStateRequest();
126+
127+
client().filterWithHeader(Collections.singletonMap(BASIC_AUTH_HEADER, basicAuthHeaderValue(user, password)))
128+
.admin()
129+
.cluster()
130+
.execute(ResetFeatureStateAction.INSTANCE, req, new ActionListener<>() {
131+
@Override
132+
public void onResponse(ResetFeatureStateResponse response) {
133+
long failures = response.getFeatureStateResetStatuses()
134+
.stream()
135+
.filter(status -> status.getStatus() == ResetFeatureStateResponse.ResetFeatureStateStatus.Status.FAILURE)
136+
.count();
137+
assertEquals(0, failures);
138+
}
139+
140+
@Override
141+
public void onFailure(Exception e) {
142+
fail("Shouldn't reach here");
143+
}
144+
});
145+
}
146+
}

0 commit comments

Comments
 (0)