Skip to content

Add setting to ignore source security roles on CCS requests - #6402

Open
sharathkanaka wants to merge 7 commits into
opensearch-project:mainfrom
sharathkanaka:ccs-remote-recompute-setting
Open

Add setting to ignore source security roles on CCS requests#6402
sharathkanaka wants to merge 7 commits into
opensearch-project:mainfrom
sharathkanaka:ccs-remote-recompute-setting

Conversation

@sharathkanaka

Copy link
Copy Markdown

Description

  • Category: Enhancement

  • Why these changes are required?

On cross-cluster search (CCS) requests, the remote cluster inherits the source cluster's pre-computed security roles for the user. This prevents the remote cluster from independently controlling what permissions CCS users receive based on its own configuration.

  • What is the old behavior before changes and new behavior after changes?

Old behavior: Remote cluster always unions source-propagated securityRoles into its own role mapping result for CCS requests. The remote cannot independently control what permissions a CCS user receives.

New behavior: A new cluster setting plugins.security.ccs.ignore_source_security_roles (default: false) allows users to skip source cluster propagated securityRoles on CCS requests. When enabled, the remote cluster evaluates access through its own roles_mapping.yml. This gives the remote cluster independent control over CCS user permissions.

Issues Resolved

Resolves #6401

Not a backport. No new permissions introduced.

Testing

  • Unit tests (4): ConfigurableRoleMapperTest.CcsSkipSourceSecurityRolesTest : covers flag on/off with and without CCS request context
  • Integration tests (3): CcsIgnoreSourceSecurityRolesIntTests : end-to-end CCS with two remote clusters

Check List

  • New functionality includes testing
  • New functionality has been documented
  • New Roles/Permissions have a corresponding security dashboards plugin PR
  • API changes companion pull request created
  • Commits are signed per the DCO using --signoff

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.
For more information on following Developer Certificate of Origin and signing off your commits, please check here.


Note: Documentation will be added in a follow-up PR to the documentation-website repo once this change is merged.

@github-actions

Copy link
Copy Markdown
Contributor

PR Code Analyzer ❗

AI-powered 'Code-Diff-Analyzer' found issues on commit 31cd053.

Hard block: Issues at High severity or above will block this PR from merging.

PathLineSeverityDescription
src/main/java/org/opensearch/security/privileges/ConfigurableRoleMapper.java46mediumThe `activeConfiguration` field was changed from `private` to package-private (no access modifier). This allows any class in the same package to call `.set()` on the AtomicReference, replacing the active role-mapping configuration at runtime. The change appears motivated by test access needs, but it widens the attack surface: any code co-located in `org.opensearch.security.privileges` can silently swap the compiled role configuration without going through the normal subscription/update path.

The table above displays the top 10 most important findings.

Total: 1 | Critical: 0 | High: 0 | Medium: 1 | Low: 0


Pull Requests Author(s): Please update your Pull Request according to the report above.

Repository Maintainer(s): You can bypass diff analyzer by adding label skip-diff-analyzer after reviewing the changes carefully, then re-run failed actions. To re-enable the analyzer, remove the label, then re-run all actions.


⚠️ Note: The Code-Diff-Analyzer helps protect against potentially harmful code patterns. Please ensure you have thoroughly reviewed the changes beforehand.

Thanks.

@github-actions

github-actions Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

PR Reviewer Guide 🔍

(Review updated until commit 35bbb78)

Here are some key observations to aid the review process:

🧪 PR contains tests
🔒 No security concerns identified
✅ No TODO sections
🔀 No multiple PR themes
⚡ Recommended focus areas for review

Behavior Change in User Restoration

The refactor changes when user is deserialized. Previously, user was only deserialized from userHeader inside the persistent-subject block when shouldUseUserHeader was true, or later in the else branch (user != null ? user : this.userFactory.fromSerializedBase64(userHeader)). Now user is always deserialized upfront whenever userHeader != null, and the previous fallback user = user != null ? user : this.userFactory.fromSerializedBase64(userHeader) was removed. This should still work because user is always assigned when userHeader != null, but the persistent-subject storage logic changed: previously, when shouldUseUserHeader was false and authUsrHdr was null, no persistent subject was stored; now, if shouldUseUserHeader is false but user (from userHeader) is non-null and authUser is null, the code enters neither branch — verify this matches prior semantics for CCS requests where only userHeader is present without authUsrHdr.

// Deserialize and sanitize users.
User user = null;
if (userHeader != null) {
    user = this.userFactory.fromSerializedBase64(userHeader);
    user = remoteClusterIdentityPolicy.sanitize(user, getThreadContext());
}
User authUser = null;
if (authUsrHdr != null) {
    authUser = this.userFactory.fromSerializedBase64(authUsrHdr);
    authUser = remoteClusterIdentityPolicy.sanitize(authUser, getThreadContext());
}

// Store persistent subject (if not already set)
if (getThreadContext().getPersistent(ConfigConstants.OPENDISTRO_SECURITY_AUTHENTICATED_USER) == null) {
    if (Boolean.parseBoolean(shouldUseUserHeader) && user != null) {
        getThreadContext().putPersistent(
            ConfigConstants.OPENDISTRO_SECURITY_AUTHENTICATED_USER,
            new UserSubjectImpl(getThreadPool(), user)
        );
    } else if (authUser != null) {
        getThreadContext().putPersistent(
            ConfigConstants.OPENDISTRO_SECURITY_AUTHENTICATED_USER,
            new UserSubjectImpl(getThreadPool(), authUser)
        );
    }
}
Initial Setting Read Bypasses Dynamic Updates Before Listener Fires

The RemoteClusterIdentityPolicy is initialized with settings.getAsBoolean(...) from static node settings, not from clusterService.getClusterSettings().get(...). If the setting was previously updated dynamically and persisted, that value may not be reflected in the constructor argument. Consider reading from cluster settings to get the current effective value on startup.

RemoteClusterIdentityPolicy remoteClusterIdentityPolicy = new RemoteClusterIdentityPolicy(
    settings.getAsBoolean(ConfigConstants.SECURITY_CCS_IGNORE_SOURCE_SECURITY_ROLES, false)
);
clusterService.getClusterSettings()
    .addSettingsUpdateConsumer(SecuritySettings.CCS_IGNORE_SOURCE_SECURITY_ROLES_SETTING, newValue -> {
        log.info("CCS ignore source security roles dynamically set to {}", newValue);
        remoteClusterIdentityPolicy.setIgnoreSourceSecurityRoles(newValue);
    });

@github-actions

github-actions Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

PR Code Suggestions ✨

Latest suggestions up to 35bbb78

Explore these optional code suggestions:

CategorySuggestion                                                                                                                                    Impact
Possible issue
Guard against null user before sanitize

sanitize may be called with a null User if fromSerializedBase64 returns null,
causing a NullPointerException inside sanitize when it accesses user.getName() or
user.withoutSecurityRoles(). Guard the call so sanitize is only invoked on non-null
users, or make sanitize null-safe.

src/main/java/org/opensearch/security/transport/SecurityRequestHandler.java [181-189]

 User user = null;
 if (userHeader != null) {
     user = this.userFactory.fromSerializedBase64(userHeader);
-    user = remoteClusterIdentityPolicy.sanitize(user, getThreadContext());
+    if (user != null) {
+        user = remoteClusterIdentityPolicy.sanitize(user, getThreadContext());
+    }
 }
 User authUser = null;
 if (authUsrHdr != null) {
     authUser = this.userFactory.fromSerializedBase64(authUsrHdr);
-    authUser = remoteClusterIdentityPolicy.sanitize(authUser, getThreadContext());
+    if (authUser != null) {
+        authUser = remoteClusterIdentityPolicy.sanitize(authUser, getThreadContext());
+    }
 }
Suggestion importance[1-10]: 4

__

Why: fromSerializedBase64 is unlikely to return null in normal flow, but adding a null-guard is defensive and low-cost. Minor improvement in robustness.

Low

Previous suggestions

Suggestions up to commit 9ce7e24
CategorySuggestion                                                                                                                                    Impact
General
Ensure dynamic setting is reset after test

Using a transient cluster setting means the update is lost on cluster restart and
can leak state across concurrent tests sharing JVM/class rules. Prefer persistent
scoped to this dedicated cluster, and reset the setting to null in an @After/finally
block to avoid affecting subsequent runs of the test class.

src/integrationTest/java/org/opensearch/security/privileges/int_tests/CcsIgnoreSourceSecurityRolesIntTests.java [190-194]

 TestRestClient.HttpResponse updateResponse = remoteClient.putJson(
     "_cluster/settings",
-    "{\"transient\": {\"plugins.security.ccs.ignore_source_security_roles\": true}}"
+    "{\"persistent\": {\"plugins.security.ccs.ignore_source_security_roles\": true}}"
 );
 assertThat(updateResponse, isOk());
Suggestion importance[1-10]: 5

__

Why: Reasonable point about test isolation, but since the cluster is dedicated to this test and class rules tear it down, the impact is limited. Switching to persistent doesn't inherently solve leakage either without a cleanup step.

Low
Handle null user input safely

sanitize will throw NullPointerException if user is null (call sites in
SecurityRequestHandler guard with a null check, but the method itself is called
after non-null deserialization results are not guaranteed). Add a null-guard to make
the method robust and safe against future call sites.

src/main/java/org/opensearch/security/transport/RemoteClusterIdentityPolicy.java [40-46]

 User sanitize(User user, ThreadContext threadContext) {
+    if (user == null) {
+        return null;
+    }
     if (ignoreSourceSecurityRoles && HeaderHelper.isRemoteClusterNodeRequest(threadContext)) {
         log.debug("Stripping source-propagated securityRoles for CCS user [{}]", user.getName());
         return user.withoutSecurityRoles();
     }
     return user;
 }
Suggestion importance[1-10]: 3

__

Why: Adding a null-check is defensive, but existing call sites already guard against null user. This is a minor robustness improvement of low impact.

Low
Possible issue
Guard against null policy reference

remoteClusterIdentityPolicy may be null in some constructor paths (e.g., test code
passes null). Guard against a NullPointerException when sanitizing, or ensure a
non-null default policy is always used. Otherwise, a null policy will break request
handling on the transport path.

src/main/java/org/opensearch/security/transport/SecurityRequestHandler.java [181-189]

 User user = null;
 if (userHeader != null) {
     user = this.userFactory.fromSerializedBase64(userHeader);
-    user = remoteClusterIdentityPolicy.sanitize(user, getThreadContext());
+    if (remoteClusterIdentityPolicy != null) {
+        user = remoteClusterIdentityPolicy.sanitize(user, getThreadContext());
+    }
 }
 User authUser = null;
 if (authUsrHdr != null) {
     authUser = this.userFactory.fromSerializedBase64(authUsrHdr);
-    authUser = remoteClusterIdentityPolicy.sanitize(authUser, getThreadContext());
+    if (remoteClusterIdentityPolicy != null) {
+        authUser = remoteClusterIdentityPolicy.sanitize(authUser, getThreadContext());
+    }
 }
Suggestion importance[1-10]: 4

__

Why: In production code paths, remoteClusterIdentityPolicy is injected via the plugin and should not be null; only a test passes null. Adding null guards in production code for this is defensive but of low impact.

Low
Suggestions up to commit 83bbf84
CategorySuggestion                                                                                                                                    Impact
Possible issue
Guard sanitize call against null user

remoteClusterIdentityPolicy.sanitize is invoked unconditionally, but user may still
be null if both the pre-existing user and userHeader deserialization produced null.
Calling sanitize with a null user would throw an NPE inside
user.withoutSecurityRoles(). Guard the call with a null check.

src/main/java/org/opensearch/security/transport/SecurityRequestHandler.java [212-215]

 if (user == null) {
     user = this.userFactory.fromSerializedBase64(userHeader);
 }
-user = remoteClusterIdentityPolicy.sanitize(user, getThreadContext());
+if (user != null) {
+    user = remoteClusterIdentityPolicy.sanitize(user, getThreadContext());
+}
 getThreadContext().putTransient(ConfigConstants.OPENDISTRO_SECURITY_USER, user);
Suggestion importance[1-10]: 7

__

Why: Valid defensive check: if userFactory.fromSerializedBase64(userHeader) returns null, calling sanitize would NPE inside user.withoutSecurityRoles(). The guard is a reasonable safety improvement, though the original code also stored a potentially null user in the thread context.

Medium
Suggestions up to commit df51453
CategorySuggestion                                                                                                                                    Impact
Possible issue
Guard against null user before sanitizing

After deserialization, user could still be null if userHeader is null/empty, and
passing a null User to sanitize will cause a NullPointerException inside
user.withoutSecurityRoles(). Guard the sanitize call to only run when user is
non-null.

src/main/java/org/opensearch/security/transport/SecurityRequestHandler.java [212-215]

 if (user == null) {
     user = this.userFactory.fromSerializedBase64(userHeader);
 }
-user = remoteClusterIdentityPolicy.sanitize(user, getThreadContext());
+if (user != null) {
+    user = remoteClusterIdentityPolicy.sanitize(user, getThreadContext());
+}
 getThreadContext().putTransient(ConfigConstants.OPENDISTRO_SECURITY_USER, user);
Suggestion importance[1-10]: 7

__

Why: Valid defensive check: if userHeader is null/empty, fromSerializedBase64 could return null and sanitize would NPE when calling user.withoutSecurityRoles(). This is a reasonable safeguard, though in practice the else-branch may already imply a non-null user context.

Medium
Suggestions up to commit b37e8b9
CategorySuggestion                                                                                                                                    Impact
Possible issue
Use admin client to update cluster settings

MAPPED_USER is only granted read_role_remote via roles_mapping on
remoteClusterFlagOn, not on remoteClusterDynamic (no rolesMapping(...) is configured
there). Even if it were, that role lacks the cluster permissions needed to update
cluster settings. Use an admin user (or a user with cluster:admin/settings/update)
to perform the settings update, otherwise this call will fail with 403 and the
test's premise collapses.

src/integrationTest/java/org/opensearch/security/privileges/int_tests/CcsIgnoreSourceSecurityRolesIntTests.java [189-195]

-try (TestRestClient remoteClient = remoteClusterDynamic.getRestClient(MAPPED_USER)) {
+try (TestRestClient remoteClient = remoteClusterDynamic.getAdminCertRestClient()) {
     TestRestClient.HttpResponse updateResponse = remoteClient.putJson(
         "_cluster/settings",
         "{\"transient\": {\"plugins.security.ccs.ignore_source_security_roles\": true}}"
     );
     assertThat(updateResponse, isOk());
 }
Suggestion importance[1-10]: 8

__

Why: Correctly identifies that MAPPED_USER lacks cluster admin permissions and no roles_mapping on remoteClusterDynamic, which would cause the settings update to fail with 403, breaking the test.

Medium
Verify role-mapping cache handles CCS context

HeaderHelper.isRemoteClusterNodeRequest typically checks a transient header
indicating trusted cluster request. However, this check occurs in map() which may
also be invoked during REST request handling on the same node, where the transient
header would be absent — that's fine. But note that role mapping results may be
cached upstream by BackendRegistry; if the cache key does not incorporate the
"trusted cluster request" state, stripping may leak or fail across mixed requests.
Ensure the cache is either invalidated or keyed on request origin.

src/main/java/org/opensearch/security/privileges/ConfigurableRoleMapper.java [232-234]

 boolean skipSourceSecurityRoles = ccsIgnoreSourceSecurityRoles.get()
     && threadContext != null
     && HeaderHelper.isRemoteClusterNodeRequest(threadContext);
+// NOTE: Ensure upstream role-mapping caches account for CCS request context,
+// otherwise cached results from a non-CCS request may leak source roles into a CCS request.
Suggestion importance[1-10]: 6

__

Why: Raises a legitimate concern about caching potentially leaking source roles across mixed CCS/non-CCS requests, but is speculative and only asks for verification without a concrete fix.

Low
Ensure dynamic setting propagates to compiled config

The dynamic setting update only mutates the AtomicBoolean held by the
ConfigurableRoleMapper, but the active CompiledConfiguration captured its own
reference at construction time. If a new AtomicBoolean is ever assigned (or if the
reference is not truly shared), the compiled configuration won't see the update.
Verify the AtomicBoolean reference is shared, or rebuild the active configuration on
update to guarantee dynamic behavior.

src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java [1602-1607]

+ConfigurableRoleMapper configurableRoleMapper = new ConfigurableRoleMapper(cr, settings, threadPool.getThreadContext());
+clusterService.getClusterSettings()
+    .addSettingsUpdateConsumer(SecuritySettings.CCS_IGNORE_SOURCE_SECURITY_ROLES_SETTING, newValue -> {
+        log.info("CCS ignore source security roles dynamically set to {}", newValue);
+        configurableRoleMapper.setCcsIgnoreSourceSecurityRoles(newValue);
+    });
 
-
Suggestion importance[1-10]: 5

__

Why: The suggestion only asks the user to verify that the AtomicBoolean reference is truly shared. Looking at the code, the same reference is passed to the CompiledConfiguration, so this is likely already correct, making this a low-impact verification request.

Low
General
Preserve backward-compatible constructor overload

Changing the public constructor signature of ConfigurableRoleMapper from
(ConfigurationRepository, Settings) to (ConfigurationRepository, Settings,
ThreadContext) is a breaking API change for any external caller (e.g., tests or
extensions). Consider retaining the old constructor as a delegating overload passing
a null/default ThreadContext, so callers not using CCS-skip behavior are unaffected.

src/main/java/org/opensearch/security/privileges/ConfigurableRoleMapper.java [84-86]

+public ConfigurableRoleMapper(ConfigurationRepository configurationRepository, Settings settings) {
+    this(configurationRepository, settings, null);
+}
+
 public ConfigurableRoleMapper(ConfigurationRepository configurationRepository, Settings settings, ThreadContext threadContext) {
     this(configurationRepository, ResolutionMode.fromSettings(settings), threadContext, settings);
 }
Suggestion importance[1-10]: 4

__

Why: Suggests preserving backward compatibility for an internal plugin class. The impact depends on whether external callers exist, and adding a null ThreadContext could mask bugs if callers accidentally use the old constructor.

Low
Suggestions up to commit bc4732b
CategorySuggestion                                                                                                                                    Impact
Possible issue
Use admin client for cluster settings update

Updating cluster settings requires cluster-manage permissions, but MAPPED_USER is
only granted read on index_r1 via READ_ROLE_REMOTE (and its source unlimited_role is
stripped by the flag=false-to-true test setup only after this call). Use an admin
client or a user with cluster:admin/settings/update privileges to avoid a flaky 403
on the settings update; alternatively use
remoteClusterDynamic.getAdminCertRestClient() if available.

src/integrationTest/java/org/opensearch/security/privileges/int_tests/CcsIgnoreSourceSecurityRolesIntTests.java [189-195]

-// Dynamically enable the flag on the dedicated remote cluster
-try (TestRestClient remoteClient = remoteClusterDynamic.getRestClient(MAPPED_USER)) {
+// Dynamically enable the flag on the dedicated remote cluster using an admin client
+try (TestRestClient remoteClient = remoteClusterDynamic.getAdminCertRestClient()) {
     TestRestClient.HttpResponse updateResponse = remoteClient.putJson(
         "_cluster/settings",
         "{\"transient\": {\"plugins.security.ccs.ignore_source_security_roles\": true}}"
     );
     assertThat(updateResponse, isOk());
 }
Suggestion importance[1-10]: 6

__

Why: Valid concern: MAPPED_USER only has read permissions on the index, not cluster settings update permissions, which could cause the test to fail. Using an admin client is a reasonable fix, though it depends on whether the flag stripping applies before the settings update call.

Low
Verify correct CCS request detection helper

HeaderHelper.isRemoteClusterNodeRequest checks for a specific transient marker, but
the tests set OPENDISTRO_SECURITY_SSL_TRANSPORT_TRUSTED_CLUSTER_REQUEST. Verify that
these two markers refer to the same thread-context key; otherwise the tests will
pass against a stub while production code paths (which set a different key) will not
trigger the skip logic, leading to a silent regression.

src/main/java/org/opensearch/security/privileges/ConfigurableRoleMapper.java [243-245]

 boolean skipSourceSecurityRoles = ccsIgnoreSourceSecurityRoles.get()
     && threadContext != null
-    && HeaderHelper.isRemoteClusterNodeRequest(threadContext);
+    && HeaderHelper.isTrustedClusterRequest(threadContext);
Suggestion importance[1-10]: 6

__

Why: Potentially valid concern about whether isRemoteClusterNodeRequest uses the same transient key as OPENDISTRO_SECURITY_SSL_TRANSPORT_TRUSTED_CLUSTER_REQUEST. If they differ, the logic would fail in production despite tests passing. This warrants verification.

Low
General
Guard against replacing shared AtomicBoolean reference

The AtomicBoolean ccsIgnoreSourceSecurityRoles is captured by reference into each
CompiledConfiguration built inside the subscribeOnChange callback, but
setCcsIgnoreSourceSecurityRoles updates the outer AtomicBoolean. Confirm that the
reference passed into CompiledConfiguration is the same instance
(this.ccsIgnoreSourceSecurityRoles) rather than a copy, so dynamic setting changes
actually propagate—currently this works, but any refactor that replaces the
AtomicBoolean instance (e.g., this.ccsIgnoreSourceSecurityRoles = new
AtomicBoolean(...)) would silently break dynamic updates. Consider making the field
final and only mutating via .set() to enforce this invariant.

src/main/java/org/opensearch/security/privileges/ConfigurableRoleMapper.java [49]

-public ConfigurableRoleMapper(
-    ConfigurationRepository configurationRepository,
-    ResolutionMode resolutionMode,
-    ThreadContext threadContext,
-    Settings settings
-) {
-    this.threadContext = threadContext;
-    this.ccsIgnoreSourceSecurityRoles.set(settings.getAsBoolean(ConfigConstants.SECURITY_CCS_IGNORE_SOURCE_SECURITY_ROLES, false));
+private final AtomicBoolean ccsIgnoreSourceSecurityRoles = new AtomicBoolean(false);
+// ...
+public void setCcsIgnoreSourceSecurityRoles(boolean value) {
+    this.ccsIgnoreSourceSecurityRoles.set(value);
+}
Suggestion importance[1-10]: 3

__

Why: The field is already effectively final and passed by reference; the suggestion mainly asks to verify an invariant and recommends declaring final, which is a minor defensive improvement.

Low

@cwperks

cwperks commented Aug 17, 2026

Copy link
Copy Markdown
Member

Failing CI checks will be resolved by #6407. There was a breaking change from core that this repo needs to react to.

@github-actions

Copy link
Copy Markdown
Contributor

PR Reviewer Guide 🔍

Here are some key observations to aid the review process:

🧪 PR contains tests
🔒 No security concerns identified
✅ No TODO sections
🔀 No multiple PR themes
⚡ Recommended focus areas for review

Missing Trusted Cluster Context Check in Unit Tests

The map(user, caller, skipSourceSecurityRoles) overload accepts the caller's decision directly, but the top-level map(User, TransportAddress) gates the behavior on OPENDISTRO_SECURITY_SSL_TRANSPORT_TRUSTED_CLUSTER_REQUEST. The unit tests in CcsSkipSourceSecurityRolesTest call the compiled map(...) with skipSourceSecurityRoles=true directly without setting the thread-context flag, so they do not actually verify that non-CCS requests are unaffected. Consider adding a test that calls the outer mapper.map(user, caller) without the trusted-cluster transient set to confirm security roles are preserved for non-CCS requests even when the setting is enabled.

@Override
public ImmutableSet<String> map(User user, TransportAddress caller) {
    CompiledConfiguration activeConfiguration = this.activeConfiguration.get();

    if (activeConfiguration != null) {
        boolean isTrustedClusterRequest = threadContext != null
            && Boolean.TRUE.equals(threadContext.getTransient(ConfigConstants.OPENDISTRO_SECURITY_SSL_TRANSPORT_TRUSTED_CLUSTER_REQUEST));
        boolean ignoreSourceRoles = ccsIgnoreSourceSecurityRoles.get();

        return activeConfiguration.map(user, caller, isTrustedClusterRequest && ignoreSourceRoles);
    } else {
        return ImmutableSet.of();
    }
}
Possible Behavior Change

The ConfigurableRoleMapper construction moved out of the InjectedRoleMapper constructor call and is now created separately; the diff shows InjectedRoleMapper being called with configurableRoleMapper and threadPool.getThreadContext(). Verify the constructor signature of InjectedRoleMapper matches (previously it took a single ConfigurableRoleMapper positional argument plus thread context) - if any wiring is subtly changed, role mapping in production paths could regress.

ConfigurableRoleMapper configurableRoleMapper = new ConfigurableRoleMapper(cr, settings, threadPool.getThreadContext());
clusterService.getClusterSettings()
    .addSettingsUpdateConsumer(SecuritySettings.CCS_IGNORE_SOURCE_SECURITY_ROLES_SETTING, newValue -> {
        log.info("CCS ignore source security roles dynamically set to {}", newValue);
        configurableRoleMapper.setCcsIgnoreSourceSecurityRoles(newValue);
    });
RoleMapper roleMapper = new RolesInjector.InjectedRoleMapper(
    configurableRoleMapper,
    threadPool.getThreadContext()
);
this.roleMapper = roleMapper;
Test Ordering Dependency

ccsQuery_withFlagDynamicallyEnabled_shouldBeForbidden mutates the persistent cluster setting on remoteClusterFlagOff and relies on the finally block to reset it. If the test fails between setting the flag and the reset (e.g. assertion failure before finally runs — this is guarded by try/finally, but a reset failure is silently swallowed), other tests running against remoteClusterFlagOff (like ccsQuery_withFlagOff_shouldSucceed_whenSourceRolesPropagate) may become order-dependent and flaky. Consider using a dedicated cluster for the dynamic-update test or asserting reset success.

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_FLAG_OFF + ":" + INDEX_NAME + "/_search");
        assertThat(response, isOk());
    }

    // Dynamically enable the flag on the remote cluster
    try (TestRestClient remoteClient = remoteClusterFlagOff.getRestClient(MAPPED_USER)) {
        TestRestClient.HttpResponse updateResponse = remoteClient.putJson(
            "_cluster/settings",
            "{\"persistent\": {\"plugins.security.ccs.ignore_source_security_roles\": true}}"
        );
        assertThat(updateResponse, isOk());
    }

    try {
        // 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_FLAG_OFF + ":" + INDEX_NAME + "/_search");
            assertThat(response, isForbidden());
        }
    } finally {
        // Reset the flag to not affect other tests
        try (TestRestClient remoteClient = remoteClusterFlagOff.getRestClient(MAPPED_USER)) {
            remoteClient.putJson(
                "_cluster/settings",
                "{\"persistent\": {\"plugins.security.ccs.ignore_source_security_roles\": false}}"
            );
        }
    }
}

Comment thread src/main/java/org/opensearch/security/privileges/ConfigurableRoleMapper.java Outdated
@sharathkanaka
sharathkanaka force-pushed the ccs-remote-recompute-setting branch from 4477bd4 to 5c35095 Compare August 17, 2026 18:41
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 5c35095

@sharathkanaka
sharathkanaka force-pushed the ccs-remote-recompute-setting branch from 5c35095 to 1e309ce Compare August 17, 2026 18:58
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 1e309ce

Signed-off-by: Sharath Kanaka <sharatcr@amazon.com>
@sharathkanaka
sharathkanaka force-pushed the ccs-remote-recompute-setting branch from 1e309ce to 9aebcac Compare August 17, 2026 19:30
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 9aebcac

@sharathkanaka
sharathkanaka requested a review from cwperks August 17, 2026 20:07
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 9ab6473

Comment thread src/main/java/org/opensearch/security/privileges/ConfigurableRoleMapper.java Outdated
@codecov

codecov Bot commented Aug 17, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.96970% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 75.45%. Comparing base (217abd6) to head (35bbb78).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
...rch/security/transport/SecurityRequestHandler.java 90.00% 0 Missing and 1 partial ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #6402      +/-   ##
==========================================
+ Coverage   75.42%   75.45%   +0.02%     
==========================================
  Files         456      457       +1     
  Lines       30252    30283      +31     
  Branches     4574     4576       +2     
==========================================
+ Hits        22818    22849      +31     
+ Misses       5300     5298       -2     
- Partials     2134     2136       +2     
Files with missing lines Coverage Δ
.../opensearch/security/OpenSearchSecurityPlugin.java 84.17% <100.00%> (+0.16%) ⬆️
...g/opensearch/security/support/ConfigConstants.java 96.55% <ø> (ø)
.../opensearch/security/support/SecuritySettings.java 98.14% <100.00%> (+0.03%) ⬆️
...ecurity/transport/RemoteClusterIdentityPolicy.java 100.00% <100.00%> (ø)
...search/security/transport/SecurityInterceptor.java 79.34% <100.00%> (+0.11%) ⬆️
...c/main/java/org/opensearch/security/user/User.java 82.85% <100.00%> (+0.50%) ⬆️
...rch/security/transport/SecurityRequestHandler.java 59.76% <90.00%> (+1.83%) ⬆️

... and 8 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Comment thread src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java Outdated
Comment thread src/main/java/org/opensearch/security/privileges/ConfigurableRoleMapper.java Outdated
Comment thread src/main/java/org/opensearch/security/privileges/ConfigurableRoleMapper.java Outdated
… field, add dedicated test cluster

Signed-off-by: Sharath Kanaka <sharatcr@amazon.com>
@sharathkanaka
sharathkanaka force-pushed the ccs-remote-recompute-setting branch from 9ab6473 to bc4732b Compare August 17, 2026 23:58
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit bc4732b

Comment thread src/main/java/org/opensearch/security/privileges/ConfigurableRoleMapper.java Outdated
Comment thread src/main/java/org/opensearch/security/privileges/ConfigurableRoleMapper.java Outdated
Signed-off-by: Sharath Kanaka <sharatcr@amazon.com>
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit b37e8b9

Signed-off-by: Sharath Kanaka <sharatcr@amazon.com>
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit df51453

@sharathkanaka
sharathkanaka requested a review from cwperks August 18, 2026 20:42
…calls

Signed-off-by: Sharath Kanaka <sharatcr@amazon.com>
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 83bbf84

Comment thread src/main/java/org/opensearch/security/transport/SecurityRequestHandler.java Outdated
Signed-off-by: Sharath Kanaka <sharatcr@amazon.com>
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 9ce7e24

@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 35bbb78

@DarshitChanpura
DarshitChanpura dismissed their stale review August 19, 2026 18:14

added one more comment.

@DarshitChanpura

Copy link
Copy Markdown
Member

Minor / defense-in-depth (not a blocker): the sanitize happens at receive time on the transient + persistent user, but on a multi-node remote cluster the coordinator's onward fan-out reuses the original OPENDISTRO_SECURITY_USER_HEADER. In SecurityInterceptor.sendRequestDecorate the incoming header is copied into the forwarded headerMap (it's in the copy allowlist), and ensureCorrectHeaders only rebuilds USER_HEADER when it's absent — so it's reused as-is rather than re-derived from the sanitized user. On the receiving data node the hop is intra-cluster (INTERCLUSTER_REQUEST, not TRUSTED_CLUSTER_REQUEST), so RemoteClusterIdentityPolicy.sanitize no-ops and the source securityRoles are present again.

The feature's core gate is unaffected — the remote coordinator authorizes with the sanitized user, so access control still works on multi-node clusters. This is purely about the source roles physically still reaching remote data nodes, i.e. the "header identity state" mentioned earlier in the thread that isn't rewritten.

Suggestions (optional for this PR, could be a follow-up):

  • Also rewrite/clear OPENDISTRO_SECURITY_USER_HEADER / OPENDISTRO_SECURITY_AUTHENTICATED_USER_HEADER from the sanitized user so onward propagation stays sanitized.
  • Add a multi-node remote to the integration test — all three remotes are currently ClusterManager.SINGLENODE, so coordinator == data node and this cross-node path is never exercised.

@DarshitChanpura DarshitChanpura left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Approving as non-blocker can be addressed in a follow-up PR.

@sharathkanaka

Copy link
Copy Markdown
Author

Darshit, replying to your comment on Coordinator to Data node. That is a good callout. Though no more authorization happens in data node, the user will re-appear with the security roles.
The current implementation has one logic, where it strips out if the orignal requests is CCS, why should that not apply for intra cluster nodes? I mean when data node receives the request, that should sanitize user as well right? regardless authz happens or not, its about correctness, we do not want to have user objects in inconsistent state across nodes.
We can pass the _opendistro_security_ccs_origin header in the headerMap will make the data node to behave same way?

@DarshitChanpura

Copy link
Copy Markdown
Member

Reason it doesn't sanitize on the intra-cluster hop today: sanitize is gated on HeaderHelper.isRemoteClusterNodeRequest, which only returns true for TRUSTED_CLUSTER_REQUEST. The data-node hop comes in as INTERCLUSTER_REQUEST, so it no-ops and the source securityRoles re-appear. In theory it should sanitize the same way a single node does, but a test proving it strengthens the claim and future-proofs the behavioral change. We also filter out headers not part of the map before serializing and sending across, so to preserve the CCS-origin signal we'd need to pass it in the map. Adding as a suggestion since I'm not completely sure there's no edge case here leading to inconsistent state — fine to address in a follow-up.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add a flag to allow remote cluster to independently compute mapped roles for CCS

3 participants