Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix remote cluster credential secure settings reload #111535

Merged
Merged
Show file tree
Hide file tree
Changes from 12 commits
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
5 changes: 5 additions & 0 deletions docs/changelog/111535.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
pr: 111535
summary: Fix remote cluster credential secure settings reload
area: Authorization
type: bug
issues: []
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ protected NodesReloadSecureSettingsResponse.NodeResponse nodeOperation(
final List<Exception> exceptions = new ArrayList<>();
// broadcast the new settings object (with the open embedded keystore) to all reloadable plugins
pluginsService.filterPlugins(ReloadablePlugin.class).forEach(p -> {
logger.debug("Reloading plugin [" + p.getClass().getSimpleName() + "]");
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Seems useful for future debugging...

try {
p.reload(settingsWithKeystore);
} catch (final Exception e) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,12 @@ public abstract class AbstractRemoteClusterSecurityTestCase extends ESRestTestCa
protected static final String USER = "test_user";
protected static final SecureString PASS = new SecureString("x-pack-test-password".toCharArray());
protected static final String REMOTE_SEARCH_USER = "remote_search_user";
protected static final String MANAGE_USER = "manage_user";
protected static final String REMOTE_METRIC_USER = "remote_metric_user";
protected static final String REMOTE_TRANSFORM_USER = "remote_transform_user";
protected static final String REMOTE_SEARCH_ROLE = "remote_search";
protected static final String REMOTE_CLUSTER_ALIAS = "my_remote_cluster";
private static final String KEYSTORE_PASSWORD = "keystore-password";
static final String KEYSTORE_PASSWORD = "keystore-password";

protected static LocalClusterConfigProvider commonClusterConfig = cluster -> cluster.module("analysis-common")
.keystorePassword(KEYSTORE_PASSWORD)
Expand Down Expand Up @@ -217,7 +218,7 @@ protected void removeRemoteClusterCredentials(String clusterAlias, MutableSettin
}

@SuppressWarnings("unchecked")
private void reloadSecureSettings() throws IOException {
protected void reloadSecureSettings() throws IOException {
final Request request = new Request("POST", "/_nodes/reload_secure_settings");
request.setJsonEntity("{\"secure_settings_password\":\"" + KEYSTORE_PASSWORD + "\"}");
final Response reloadResponse = adminClient().performRequest(request);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,12 @@
import java.util.Map;
import java.util.stream.Collectors;

import static org.hamcrest.Matchers.anEmptyMap;
import static org.hamcrest.Matchers.containsInAnyOrder;
import static org.hamcrest.Matchers.instanceOf;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.not;
import static org.hamcrest.Matchers.nullValue;

// account for slow stored secure settings updates (involves removing and re-creating the keystore)
@TimeoutSuite(millis = 10 * TimeUnits.MINUTE)
Expand Down Expand Up @@ -78,6 +83,7 @@ public class RemoteClusterSecurityReloadCredentialsRestIT extends AbstractRemote
})
.rolesFile(Resource.fromClasspath("roles.yml"))
.user(REMOTE_SEARCH_USER, PASS.toString(), "read_remote_shared_logs", false)
.user(MANAGE_USER, PASS.toString(), "manage_role", false)
.build();
}

Expand Down Expand Up @@ -237,4 +243,28 @@ private Response performRequestWithRemoteSearchUser(final Request request) throw
return client().performRequest(request);
}

@Override
@SuppressWarnings("unchecked")
protected void reloadSecureSettings() throws IOException {
final Request request = new Request("POST", "/_nodes/reload_secure_settings");
request.setJsonEntity("{\"secure_settings_password\":\"" + KEYSTORE_PASSWORD + "\"}");
// execute as user with only `manage` cluster privilege
final Response reloadResponse = performRequestWithManageUser(request);
assertOK(reloadResponse);
final Map<String, Object> map = entityAsMap(reloadResponse);
assertThat(map.get("nodes"), instanceOf(Map.class));
final Map<String, Object> nodes = (Map<String, Object>) map.get("nodes");
assertThat(nodes, is(not(anEmptyMap())));
for (Map.Entry<String, Object> entry : nodes.entrySet()) {
assertThat(entry.getValue(), instanceOf(Map.class));
final Map<String, Object> node = (Map<String, Object>) entry.getValue();
assertThat(node.get("reload_exception"), nullValue());
}
}

private Response performRequestWithManageUser(final Request request) throws IOException {
request.setOptions(RequestOptions.DEFAULT.toBuilder().addHeader("Authorization", headerFromRandomAuthMethod(MANAGE_USER, PASS)));
return client().performRequest(request);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,6 @@ ccr_user_role:
- names: [ 'leader-index', 'shared-*', 'metrics-*' ]
privileges: [ 'cross_cluster_replication' ]
clusters: [ "*" ]

manage_role:
cluster: [ 'manage' ]
Original file line number Diff line number Diff line change
Expand Up @@ -2202,13 +2202,19 @@ private void reloadRemoteClusterCredentials(Settings settingsWithKeystore) {
return;
}

final PlainActionFuture<ActionResponse.Empty> future = new UnsafePlainActionFuture<>(ThreadPool.Names.GENERIC);
getClient().execute(
ActionTypes.RELOAD_REMOTE_CLUSTER_CREDENTIALS_ACTION,
new TransportReloadRemoteClusterCredentialsAction.Request(settingsWithKeystore),
future
);
future.actionGet();
// Run this action in system context -- it was authorized upstream and should not be tied to end-user permissions
final ThreadContext ctx = getClient().threadPool().getThreadContext();
assert ctx != null : "Thread context must be set for reload call";
try (ThreadContext.StoredContext ignore = ctx.stashContext()) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Can we stash the context and mark as system context from TransportNodesReloadSecureSettingsAction instead of here ? It feels off that we can call this method directly from anywhere that we can reference Security#reload and the work will be done in the system context, possibly having never run any authz.

Since the expectation is that work is implicitly allowed as a sub action from TransportNodesReloadSecureSettingsAction, I think having TransportNodesReloadSecureSettingsAction mark it as the system context is more correct since. It would also apply to all other sub reload actions, but that I think is also more correct since the permissions to use TransportNodesReloadSecureSettingsAction need to converge to a single permission.

Alternatively, I think we could change RELOAD_REMOTE_CLUSTER_CREDENTIALS_ACTION to an actual "internal:" action, helping to re-inforce that this is sub action / implementation detail of another action. But that would I prefer keeping the action name as-is and ensuring that the flip to system context is a bit more isolated to it's usage.

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 included it here primarily because it's an implementation detail that it's an action to begin with -- most other reload calls happen at the "service" layer and are not subject to authz at all -- it would be the same here if we had access to the right services within the Security plugin (we'd just call RemoteClusterService.reload(...) without the action workaround, but unfortunately, RemoteClusterService is not available in Security. To me it actually feels more isolated to have it here.

However, I don't feel strongly -- TransportNodesReloadSecureSettingsAction is a fine place to switch to system context (though we'll have to double-check that we're not somehow switching back out of it along the way) -- let me know if you strongly prefer TransportNodesReloadSecureSettingsAction instead and I pull the switch over there.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Discussed on Slack -- switching to system context in TransportNodesReloadSecureSettingsAction has several downsides.

Since Security#reload is a public method that could (in theory) be called in a context that did not ensure proper authz it's however better to also avoid the system context switch inside reload.

To accommodate this, we've decided to simply rename the action to have a prefix that's covered by manage. Since it's a local-only action there are not BWC concerns with this.

ctx.markAsSystemContext();
final PlainActionFuture<ActionResponse.Empty> future = new UnsafePlainActionFuture<>(ThreadPool.Names.GENERIC);
getClient().execute(
ActionTypes.RELOAD_REMOTE_CLUSTER_CREDENTIALS_ACTION,
new TransportReloadRemoteClusterCredentialsAction.Request(settingsWithKeystore),
future
);
future.actionGet();
}
}

public Map<String, String> getAuthContextForSlowLog() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@
import org.elasticsearch.action.ActionListener;
import org.elasticsearch.action.ActionModule;
import org.elasticsearch.action.ActionResponse;
import org.elasticsearch.action.support.PlainActionFuture;
import org.elasticsearch.client.internal.Client;
import org.elasticsearch.cluster.ClusterName;
import org.elasticsearch.cluster.ClusterState;
Expand Down Expand Up @@ -960,8 +959,11 @@ public <T> List<T> loadExtensions(Class<T> extensionPointType) {
public void testReload() throws Exception {
final Settings settings = Settings.builder().put("xpack.security.enabled", true).put("path.home", createTempDir()).build();

final PlainActionFuture<ActionResponse.Empty> value = new PlainActionFuture<>();
final ThreadPool threadPool = mock(ThreadPool.class);
threadContext = new ThreadContext(Settings.EMPTY);
when(threadPool.getThreadContext()).thenReturn(threadContext);
final Client mockedClient = mock(Client.class);
when(mockedClient.threadPool()).thenReturn(threadPool);

final JwtRealm mockedJwtRealm = mock(JwtRealm.class);
final List<ReloadableSecurityComponent> reloadableComponents = List.of(mockedJwtRealm);
Expand Down Expand Up @@ -992,11 +994,17 @@ protected List<ReloadableSecurityComponent> getReloadableSecurityComponents() {
verify(mockedJwtRealm).reload(same(inputSettings));
}

public void testReloadWithFailures() throws Exception {
public void testReloadWithFailures() {
final Settings settings = Settings.builder().put("xpack.security.enabled", true).put("path.home", createTempDir()).build();

final boolean failRemoteClusterCredentialsReload = randomBoolean();

final ThreadPool threadPool = mock(ThreadPool.class);
threadContext = new ThreadContext(Settings.EMPTY);
when(threadPool.getThreadContext()).thenReturn(threadContext);
final Client mockedClient = mock(Client.class);
when(mockedClient.threadPool()).thenReturn(threadPool);

final JwtRealm mockedJwtRealm = mock(JwtRealm.class);
final List<ReloadableSecurityComponent> reloadableComponents = List.of(mockedJwtRealm);
if (failRemoteClusterCredentialsReload) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
package org.elasticsearch.password_protected_keystore;

import org.elasticsearch.client.Request;
import org.elasticsearch.client.RequestOptions;
import org.elasticsearch.client.Response;
import org.elasticsearch.client.ResponseException;
import org.elasticsearch.common.settings.SecureString;
import org.elasticsearch.common.settings.Settings;
import org.elasticsearch.common.util.concurrent.ThreadContext;
Expand All @@ -18,6 +20,7 @@
import org.elasticsearch.xcontent.ObjectPath;
import org.junit.ClassRule;

import java.io.IOException;
import java.util.Map;

import static org.hamcrest.Matchers.anyOf;
Expand All @@ -39,6 +42,7 @@ public class ReloadSecureSettingsWithPasswordProtectedKeystoreRestIT extends ESR
.name("javaRestTest")
.keystore(nodeSpec -> Map.of("xpack.security.transport.ssl.secure_key_passphrase", "transport-password"))
.setting("xpack.security.enabled", "true")
.setting("xpack.ml.enabled", "false")
.setting("xpack.security.authc.anonymous.roles", "anonymous")
.setting("xpack.security.transport.ssl.enabled", "true")
.setting("xpack.security.transport.ssl.certificate", "transport.crt")
Expand All @@ -50,6 +54,9 @@ public class ReloadSecureSettingsWithPasswordProtectedKeystoreRestIT extends ESR
.configFile("ca.crt", Resource.fromClasspath("ssl/ca.crt"))
.user("admin_user", "admin-password")
.user("test-user", "test-user-password", "user_role", false)
.user("manage-user", "test-user-password", "manage_role", false)
.user("manage-security-user", "test-user-password", "manage_security_role", false)
.user("monitor-user", "test-user-password", "monitor_role", false)
.build();

@Override
Expand All @@ -74,6 +81,33 @@ public void testReloadSecureSettingsWithCorrectPassword() throws Exception {
}
}

@SuppressWarnings("unchecked")
public void testReloadSecureSettingsWithDifferentPrivileges() throws Exception {
final Request request = new Request("POST", "/_nodes/reload_secure_settings");
request.setJsonEntity("{\"secure_settings_password\":\"" + KEYSTORE_PASSWORD + "\"}");
final Response response = performRequestWithUser("manage-user", request);
final Map<String, Object> map = entityAsMap(response);
assertThat(ObjectPath.eval("cluster_name", map), equalTo("javaRestTest"));
assertThat(map.get("nodes"), instanceOf(Map.class));
final Map<String, Object> nodes = (Map<String, Object>) map.get("nodes");
assertThat(nodes.size(), equalTo(NUM_NODES));
for (Map.Entry<String, Object> entry : nodes.entrySet()) {
assertThat(entry.getValue(), instanceOf(Map.class));
final Map<String, Object> node = (Map<String, Object>) entry.getValue();
assertThat(node.get("reload_exception"), nullValue());
}
expectThrows403(() -> {
final Request innerRequest = new Request("POST", "/_nodes/reload_secure_settings");
innerRequest.setJsonEntity("{\"secure_settings_password\":\"" + KEYSTORE_PASSWORD + "\"}");
performRequestWithUser("manage-security-user", innerRequest);
});
expectThrows403(() -> {
final Request innerRequest = new Request("POST", "/_nodes/reload_secure_settings");
innerRequest.setJsonEntity("{\"secure_settings_password\":\"" + KEYSTORE_PASSWORD + "\"}");
performRequestWithUser("monitor-user", request);
});
}

@SuppressWarnings("unchecked")
public void testReloadSecureSettingsWithIncorrectPassword() throws Exception {
final Request request = new Request("POST", "_nodes/reload_secure_settings");
Expand Down Expand Up @@ -136,4 +170,16 @@ protected Settings restAdminSettings() {
String token = basicAuthHeaderValue("admin_user", new SecureString("admin-password".toCharArray()));
return Settings.builder().put(ThreadContext.PREFIX + ".Authorization", token).build();
}

private static void expectThrows403(ThrowingRunnable runnable) {
assertThat(expectThrows(ResponseException.class, runnable).getResponse().getStatusLine().getStatusCode(), equalTo(403));
}

private Response performRequestWithUser(final String username, final Request request) throws IOException {
request.setOptions(
RequestOptions.DEFAULT.toBuilder()
.addHeader("Authorization", basicAuthHeaderValue(username, new SecureString("test-user-password")))
);
return client().performRequest(request);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,15 @@
user_role:
cluster: [ALL]
indices: []

manage_role:
cluster: [MANAGE]
indices: []

manage_security_role:
cluster: [MANAGE_SECURITY]
indices: []

monitor_role:
cluster: [MONITOR]
indices: []