-
Notifications
You must be signed in to change notification settings - Fork 393
Add setting to ignore source security roles on CCS requests #6402
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
Merged
cwperks
merged 7 commits into
opensearch-project:main
from
sharathkanaka:ccs-remote-recompute-setting
Aug 24, 2026
Merged
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
9aebcac
Add setting to ignore source security roles on CCS requests
bc4732b
Address review: pass CCS fields via constructor, remove dead settings…
b37e8b9
Remove convenience constructor, use single 5-param constructor
df51453
Sanitize user in SecurityRequestHandler instead of role mapper
83bbf84
Fix compilation: add RemoteClusterIdentityPolicy to test constructor …
9ce7e24
Address review: move sanitize earlier, add log.debug
35bbb78
Merge branch 'main' into ccs-remote-recompute-setting
DarshitChanpura File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
203 changes: 203 additions & 0 deletions
203
...va/org/opensearch/security/privileges/int_tests/CcsIgnoreSourceSecurityRolesIntTests.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,203 @@ | ||
| /* | ||
| * SPDX-License-Identifier: Apache-2.0 | ||
| * | ||
| * The OpenSearch Contributors require contributions made to | ||
| * this file be licensed under the Apache-2.0 license or a | ||
| * compatible open source license. | ||
| * | ||
| * Modifications Copyright OpenSearch Contributors. See | ||
| * GitHub history for details. | ||
| */ | ||
|
|
||
| package org.opensearch.security.privileges.int_tests; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| import com.google.common.collect.ImmutableList; | ||
| import org.junit.ClassRule; | ||
| import org.junit.Test; | ||
|
|
||
| import org.opensearch.security.support.ConfigConstants; | ||
| import org.opensearch.test.framework.TestSecurityConfig; | ||
| import org.opensearch.test.framework.certificate.TestCertificates; | ||
| import org.opensearch.test.framework.cluster.ClusterManager; | ||
| import org.opensearch.test.framework.cluster.LocalCluster; | ||
| import org.opensearch.test.framework.cluster.TestRestClient; | ||
| import org.opensearch.test.framework.data.TestIndex; | ||
|
|
||
| import static org.hamcrest.MatcherAssert.assertThat; | ||
| import static org.opensearch.test.framework.TestSecurityConfig.AuthcDomain.AUTHC_HTTPBASIC_INTERNAL; | ||
| import static org.opensearch.test.framework.matcher.RestMatchers.isForbidden; | ||
| import static org.opensearch.test.framework.matcher.RestMatchers.isOk; | ||
|
|
||
| /** | ||
| * Integration test for the CCS remote role recomputation setting. | ||
| * | ||
| * Proves: | ||
| * 1. With flag=true, source-propagated securityRoles are stripped (CCS query denied for unmapped user) | ||
| * 2. With flag=true, roles_mapping on the remote still grants access independently (mapped user succeeds) | ||
| * 3. With flag=false, legacy union behavior is preserved (source roles propagate) | ||
| * 4. Dynamic setting update via PUT _cluster/settings works at runtime | ||
| */ | ||
| public class CcsIgnoreSourceSecurityRolesIntTests { | ||
|
|
||
| private static final String REMOTE_CLUSTER_FLAG_ON = "remote_flag_on"; | ||
| private static final String REMOTE_CLUSTER_FLAG_OFF = "remote_flag_off"; | ||
| private static final String REMOTE_CLUSTER_DYNAMIC = "remote_dynamic"; | ||
| private static final String INDEX_NAME = "index_r1"; | ||
| private static final String MAPPED_USER_NAME = "mapped_user"; | ||
| private static final String UNMAPPED_USER_NAME = "unmapped_user"; | ||
| private static final String UNLIMITED_ROLE_NAME = "unlimited_role"; | ||
| private static final String READ_ROLE_REMOTE_NAME = "read_role_remote"; | ||
|
|
||
| static final TestIndex REMOTE_INDEX = TestIndex.name(INDEX_NAME).documentCount(10).seed(1).build(); | ||
|
|
||
| // Role that grants access to ALL indices (assigned to user on local cluster via securityRoles) | ||
| static final TestSecurityConfig.Role UNLIMITED_ROLE = new TestSecurityConfig.Role(UNLIMITED_ROLE_NAME).clusterPermissions("*") | ||
| .indexPermissions("*") | ||
| .on("*"); | ||
|
|
||
| // Role that grants read-only on the remote index (mapped via roles_mapping on remote) | ||
| static final TestSecurityConfig.Role READ_ROLE_REMOTE = new TestSecurityConfig.Role(READ_ROLE_REMOTE_NAME).clusterPermissions( | ||
| "cluster_composite_ops_ro", | ||
| "cluster_monitor" | ||
| ).indexPermissions("read", "indices_monitor", "indices:admin/shards/search_shards").on(INDEX_NAME); | ||
|
|
||
| // User with unlimited access via securityRoles (source cluster), mapped via roles_mapping on remote | ||
| static final TestSecurityConfig.User MAPPED_USER = new TestSecurityConfig.User(MAPPED_USER_NAME).referencedRoles(UNLIMITED_ROLE); | ||
|
|
||
| // Second user: also has unlimited on source, but NO roles_mapping on remote | ||
| static final TestSecurityConfig.User UNMAPPED_USER = new TestSecurityConfig.User(UNMAPPED_USER_NAME).referencedRoles(UNLIMITED_ROLE); | ||
|
|
||
| // Roles mapping on remote: maps "mapped_user" -> "read_role_remote" | ||
| // Note: "unmapped_user" intentionally has NO mapping | ||
| static final TestSecurityConfig.RoleMapping READ_ROLE_MAPPING = new TestSecurityConfig.RoleMapping(READ_ROLE_REMOTE_NAME).users( | ||
| MAPPED_USER_NAME | ||
| ); | ||
|
|
||
| static final List<TestSecurityConfig.User> USERS = ImmutableList.of(MAPPED_USER, UNMAPPED_USER); | ||
|
|
||
| static final TestCertificates TEST_CERTIFICATES = new TestCertificates(); | ||
|
|
||
| // Remote cluster with flag=TRUE: ignores source securityRoles, uses only its own roles_mapping | ||
| @ClassRule | ||
| public static final LocalCluster remoteClusterFlagOn = new LocalCluster.Builder().certificates(TEST_CERTIFICATES) | ||
| .clusterManager(ClusterManager.SINGLENODE) | ||
| .clusterName(REMOTE_CLUSTER_FLAG_ON) | ||
| .authc(AUTHC_HTTPBASIC_INTERNAL) | ||
| .privilegesEvaluationType("v4") | ||
| .users(USERS) | ||
| .roles(UNLIMITED_ROLE, READ_ROLE_REMOTE) | ||
| .rolesMapping(READ_ROLE_MAPPING) | ||
| .nodeSetting(ConfigConstants.SECURITY_CCS_IGNORE_SOURCE_SECURITY_ROLES, true) | ||
| .indices(REMOTE_INDEX) | ||
| .build(); | ||
|
|
||
| // Remote cluster with flag=FALSE: legacy behavior, source securityRoles propagate through | ||
| @ClassRule | ||
| public static final LocalCluster remoteClusterFlagOff = new LocalCluster.Builder().certificates(TEST_CERTIFICATES) | ||
| .clusterManager(ClusterManager.SINGLENODE) | ||
| .clusterName(REMOTE_CLUSTER_FLAG_OFF) | ||
| .authc(AUTHC_HTTPBASIC_INTERNAL) | ||
| .privilegesEvaluationType("v4") | ||
| .users(USERS) | ||
| .roles(UNLIMITED_ROLE, READ_ROLE_REMOTE) | ||
| .nodeSetting(ConfigConstants.SECURITY_CCS_IGNORE_SOURCE_SECURITY_ROLES, false) | ||
| .indices(REMOTE_INDEX) | ||
| .build(); | ||
|
|
||
| // Dedicated remote cluster for dynamic setting test: starts with flag=FALSE, flipped to TRUE at runtime | ||
| @ClassRule | ||
| public static final LocalCluster remoteClusterDynamic = new LocalCluster.Builder().certificates(TEST_CERTIFICATES) | ||
| .clusterManager(ClusterManager.SINGLENODE) | ||
| .clusterName(REMOTE_CLUSTER_DYNAMIC) | ||
| .authc(AUTHC_HTTPBASIC_INTERNAL) | ||
| .privilegesEvaluationType("v4") | ||
| .users(USERS) | ||
| .roles(UNLIMITED_ROLE, READ_ROLE_REMOTE) | ||
| .nodeSetting(ConfigConstants.SECURITY_CCS_IGNORE_SOURCE_SECURITY_ROLES, false) | ||
| .indices(REMOTE_INDEX) | ||
| .build(); | ||
|
|
||
| // Local cluster: connects to all three remotes | ||
| @ClassRule | ||
| public static final LocalCluster localCluster = new LocalCluster.Builder().certificates(TEST_CERTIFICATES) | ||
| .clusterManager(ClusterManager.SINGLE_REMOTE_CLIENT) | ||
| .remote(REMOTE_CLUSTER_FLAG_ON, remoteClusterFlagOn) | ||
| .remote(REMOTE_CLUSTER_FLAG_OFF, remoteClusterFlagOff) | ||
| .remote(REMOTE_CLUSTER_DYNAMIC, remoteClusterDynamic) | ||
| .authc(AUTHC_HTTPBASIC_INTERNAL) | ||
| .privilegesEvaluationType("v4") | ||
| .users(USERS) | ||
| .roles(UNLIMITED_ROLE) | ||
| .doNotFailOnForbidden(true) | ||
| .build(); | ||
|
|
||
| /** | ||
| * With flag=true: source's unlimited_role is stripped. | ||
| * But remote's roles_mapping maps the user -> read_role_remote (read on index_r1). | ||
| * CCS query should SUCCEED via the remote's own roles_mapping. | ||
| */ | ||
| @Test | ||
| public void ccsQuery_withFlagOn_shouldSucceedViaRolesMapping() throws Exception { | ||
| try (TestRestClient restClient = localCluster.getRestClient(MAPPED_USER)) { | ||
| TestRestClient.HttpResponse response = restClient.get(REMOTE_CLUSTER_FLAG_ON + ":" + INDEX_NAME + "/_search"); | ||
| assertThat(response, isOk()); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * With flag=true: source's unlimited_role is stripped. | ||
| * Unmapped user has NO roles_mapping entry on remote. | ||
| * CCS query should be FORBIDDEN — proves source securityRoles are actually stripped. | ||
| */ | ||
| @Test | ||
| public void ccsQuery_withFlagOn_shouldBeForbidden_whenNoRolesMapping() throws Exception { | ||
| try (TestRestClient restClient = localCluster.getRestClient(UNMAPPED_USER)) { | ||
| TestRestClient.HttpResponse response = restClient.get(REMOTE_CLUSTER_FLAG_ON + ":" + INDEX_NAME + "/_search"); | ||
| assertThat(response, isForbidden()); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * With flag=false (legacy behavior): source's securityRoles propagate through. | ||
| * Unmapped user has unlimited_role from source — which exists on remote's roles.yml. | ||
| * CCS query should SUCCEED — proves legacy union behavior is preserved. | ||
| */ | ||
| @Test | ||
| public void ccsQuery_withFlagOff_shouldSucceed_whenSourceRolesPropagate() throws Exception { | ||
| try (TestRestClient restClient = localCluster.getRestClient(UNMAPPED_USER)) { | ||
| TestRestClient.HttpResponse response = restClient.get(REMOTE_CLUSTER_FLAG_OFF + ":" + INDEX_NAME + "/_search"); | ||
| assertThat(response, isOk()); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Dynamic setting update on a dedicated cluster: flip flag from false to true at runtime. | ||
| * CCS query that previously succeeded should now be forbidden. | ||
| * Uses a dedicated remote cluster so no other test depends on its state. | ||
| */ | ||
| @Test | ||
| public void ccsQuery_withFlagDynamicallyEnabled_shouldBeForbidden() throws Exception { | ||
| // First: confirm CCS works with flag=false (source roles propagate) | ||
| try (TestRestClient restClient = localCluster.getRestClient(UNMAPPED_USER)) { | ||
| TestRestClient.HttpResponse response = restClient.get(REMOTE_CLUSTER_DYNAMIC + ":" + INDEX_NAME + "/_search"); | ||
| assertThat(response, isOk()); | ||
| } | ||
|
|
||
| // Dynamically enable the flag on the dedicated remote cluster | ||
| try (TestRestClient remoteClient = remoteClusterDynamic.getRestClient(MAPPED_USER)) { | ||
| TestRestClient.HttpResponse updateResponse = remoteClient.putJson( | ||
| "_cluster/settings", | ||
| "{\"transient\": {\"plugins.security.ccs.ignore_source_security_roles\": true}}" | ||
| ); | ||
| assertThat(updateResponse, isOk()); | ||
| } | ||
|
|
||
| // Now the same CCS query should be forbidden (source roles stripped) | ||
| try (TestRestClient restClient = localCluster.getRestClient(UNMAPPED_USER)) { | ||
| TestRestClient.HttpResponse response = restClient.get(REMOTE_CLUSTER_DYNAMIC + ":" + INDEX_NAME + "/_search"); | ||
| assertThat(response, isForbidden()); | ||
| } | ||
| } | ||
| } | ||
86 changes: 86 additions & 0 deletions
86
...tegrationTest/java/org/opensearch/security/transport/RemoteClusterIdentityPolicyTest.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,86 @@ | ||
| /* | ||
| * SPDX-License-Identifier: Apache-2.0 | ||
| * | ||
| * The OpenSearch Contributors require contributions made to | ||
| * this file be licensed under the Apache-2.0 license or a | ||
| * compatible open source license. | ||
| * | ||
| * Modifications Copyright OpenSearch Contributors. See | ||
| * GitHub history for details. | ||
| */ | ||
|
|
||
| package org.opensearch.security.transport; | ||
|
|
||
| import java.util.Arrays; | ||
|
|
||
| import com.google.common.collect.ImmutableSet; | ||
| import org.junit.Test; | ||
|
|
||
| import org.opensearch.common.settings.Settings; | ||
| import org.opensearch.common.util.concurrent.ThreadContext; | ||
| import org.opensearch.security.support.ConfigConstants; | ||
| import org.opensearch.security.user.User; | ||
|
|
||
| import static org.junit.Assert.assertEquals; | ||
| import static org.junit.Assert.assertSame; | ||
|
|
||
| public class RemoteClusterIdentityPolicyTest { | ||
|
|
||
| @Test | ||
| public void sanitize_flagOnAndCcsRequest_stripsSecurityRoles() { | ||
| RemoteClusterIdentityPolicy policy = new RemoteClusterIdentityPolicy(true); | ||
| ThreadContext threadContext = createCcsThreadContext(); | ||
| User user = new User("alice").withSecurityRoles(Arrays.asList("all_access")); | ||
|
|
||
| User result = policy.sanitize(user, threadContext); | ||
|
|
||
| assertEquals(ImmutableSet.of(), result.getSecurityRoles()); | ||
| assertEquals("alice", result.getName()); | ||
| } | ||
|
|
||
| @Test | ||
| public void sanitize_flagOffAndCcsRequest_returnsUnchanged() { | ||
| RemoteClusterIdentityPolicy policy = new RemoteClusterIdentityPolicy(false); | ||
| ThreadContext threadContext = createCcsThreadContext(); | ||
| User user = new User("alice").withSecurityRoles(Arrays.asList("all_access")); | ||
|
|
||
| User result = policy.sanitize(user, threadContext); | ||
|
|
||
| assertSame(user, result); | ||
| } | ||
|
|
||
| @Test | ||
| public void sanitize_flagOnAndNonCcsRequest_returnsUnchanged() { | ||
| RemoteClusterIdentityPolicy policy = new RemoteClusterIdentityPolicy(true); | ||
| ThreadContext threadContext = new ThreadContext(Settings.EMPTY); // no CCS transient | ||
|
|
||
| User user = new User("alice").withSecurityRoles(Arrays.asList("all_access")); | ||
|
|
||
| User result = policy.sanitize(user, threadContext); | ||
|
|
||
| assertSame(user, result); | ||
| } | ||
|
|
||
| @Test | ||
| public void sanitize_dynamicUpdate_changesMapBehavior() { | ||
| RemoteClusterIdentityPolicy policy = new RemoteClusterIdentityPolicy(false); | ||
| ThreadContext threadContext = createCcsThreadContext(); | ||
| User user = new User("alice").withSecurityRoles(Arrays.asList("all_access")); | ||
|
|
||
| // Flag off: user unchanged | ||
| assertSame(user, policy.sanitize(user, threadContext)); | ||
|
|
||
| // Simulate dynamic settings update | ||
| policy.setIgnoreSourceSecurityRoles(true); | ||
|
|
||
| // Flag on: securityRoles stripped | ||
| User result = policy.sanitize(user, threadContext); | ||
| assertEquals(ImmutableSet.of(), result.getSecurityRoles()); | ||
| } | ||
|
|
||
| private static ThreadContext createCcsThreadContext() { | ||
| ThreadContext threadContext = new ThreadContext(Settings.EMPTY); | ||
| threadContext.putTransient(ConfigConstants.OPENDISTRO_SECURITY_SSL_TRANSPORT_TRUSTED_CLUSTER_REQUEST, Boolean.TRUE); | ||
| return threadContext; | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.