Skip to content
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
- Make security plugin aware of FIPS build param (-Pcrypto.standard=FIPS-140-3) ([#5952](https://github.com/opensearch-project/security/pull/5952))
- Hardens input validation for resource sharing APIs ([#5831](https://github.com/opensearch-project/security/pull/5831)
- Optimize getFieldFilter to only return a predicate when index has FLS restrictions for user ([#5777](https://github.com/opensearch-project/security/pull/5777))
- Make `plugins.security.dfm_empty_overrides_all` dynamically toggleable ([#6016](https://github.com/opensearch-project/security/pull/6016)
- Performance optimizations for building internal authorization data structures upon config updates ([#5988](https://github.com/opensearch-project/security/pull/5988))
- Make encryption_key optional for obo token authenticator ([#6017](https://github.com/opensearch-project/security/pull/6017)
- Enable basic authentication for gRPC transport ([#6005](https://github.com/opensearch-project/security/pull/6005))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
/*
* 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.
*/

package org.opensearch.security.dlsfls;

import java.io.IOException;

import org.junit.BeforeClass;
import org.junit.ClassRule;
import org.junit.Test;

import org.opensearch.test.framework.TestSecurityConfig.Role;
import org.opensearch.test.framework.TestSecurityConfig.User;
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.cluster.TestRestClient.HttpResponse;

import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.is;
import static org.opensearch.test.framework.TestSecurityConfig.AuthcDomain.AUTHC_HTTPBASIC_INTERNAL;
import static org.opensearch.test.framework.TestSecurityConfig.Role.ALL_ACCESS;

/**
* Integration tests verifying that plugins.security.dfm_empty_overrides_all can be toggled
* dynamically via the cluster settings API without requiring a node restart.
*
* <p>The setting controls whether a role without a DLS/FLS rule overrides roles that do have
* restrictions. When true, a role with no restriction on an index grants full access even if
* another mapped role restricts it.
*/
public class DfmEmptyOverridesAllDynamicSettingTest {

static final String INDEX = "dfm_test_index";

static final User ADMIN_USER = new User("admin").roles(ALL_ACCESS);

/**
* User with two roles:
* - one role that applies a DLS filter (only docs where dept=sales)
* - one role with a wildcard index pattern and NO DLS rule (unrestricted)
*
* When dfm_empty_overrides_all=true the unrestricted role wins and the user sees all docs.
* When dfm_empty_overrides_all=false the restricted role wins and the user sees only sales docs.
*/
static final User MIXED_ROLE_USER = new User("mixed_role_user").roles(
new Role("dls_restricted_role").clusterPermissions("cluster_composite_ops_ro")
.indexPermissions("read")
.dls("{\"term\":{\"dept\":\"sales\"}}")
.on(INDEX),
new Role("unrestricted_wildcard_role").clusterPermissions("cluster_composite_ops_ro").indexPermissions("read").on("*")
);

@ClassRule
public static final LocalCluster cluster = new LocalCluster.Builder().clusterManager(ClusterManager.SINGLENODE)
.anonymousAuth(false)
.authc(AUTHC_HTTPBASIC_INTERNAL)
.users(ADMIN_USER, MIXED_ROLE_USER)
.build();

@BeforeClass
public static void createTestData() throws IOException {
try (TestRestClient client = cluster.getRestClient(ADMIN_USER)) {
client.putJson(INDEX + "/_doc/1?refresh=true", "{\"dept\":\"sales\",\"value\":1}");
client.putJson(INDEX + "/_doc/2?refresh=true", "{\"dept\":\"engineering\",\"value\":2}");
client.putJson(INDEX + "/_doc/3?refresh=true", "{\"dept\":\"marketing\",\"value\":3}");
}
}

private void setDfmEmptyOverridesAll(boolean enabled) throws IOException {
try (TestRestClient client = cluster.getRestClient(ADMIN_USER)) {
HttpResponse response = client.putJson(
"_cluster/settings",
String.format("{\"persistent\":{\"plugins.security.dfm_empty_overrides_all\":%b}}", enabled)
);
assertThat("Failed to update cluster setting", response.getStatusCode(), is(200));
}
}

private int countHits(TestRestClient client) throws IOException {
HttpResponse response = client.get(INDEX + "/_search?size=10");
assertThat("Search failed", response.getStatusCode(), is(200));
return response.getIntFromJsonBody("/hits/total/value");
}

@Test
public void testSettingFalse_restrictedRoleWins_userSeesOnlySalesDocs() throws IOException {
setDfmEmptyOverridesAll(false);
try (TestRestClient client = cluster.getRestClient(MIXED_ROLE_USER)) {
int hits = countHits(client);
assertThat("With dfm_empty_overrides_all=false, DLS filter should apply and only sales doc visible", hits, is(1));
}
}

@Test
public void testSettingTrue_unrestrictedRoleWins_userSeesAllDocs() throws IOException {
setDfmEmptyOverridesAll(true);
try (TestRestClient client = cluster.getRestClient(MIXED_ROLE_USER)) {
int hits = countHits(client);
assertThat("With dfm_empty_overrides_all=true, unrestricted role should override DLS and all docs visible", hits, is(3));
}
}

@Test
public void testDynamicToggle_fromTrueToFalse_restrictionApplied() throws IOException {
setDfmEmptyOverridesAll(true);
try (TestRestClient client = cluster.getRestClient(MIXED_ROLE_USER)) {
assertThat("Expected all docs visible when setting is true", countHits(client), is(3));
}

setDfmEmptyOverridesAll(false);
try (TestRestClient client = cluster.getRestClient(MIXED_ROLE_USER)) {
assertThat("Expected only sales doc visible after toggling setting to false", countHits(client), is(1));
}
}

@Test
public void testDynamicToggle_fromFalseToTrue_restrictionLifted() throws IOException {
setDfmEmptyOverridesAll(false);
try (TestRestClient client = cluster.getRestClient(MIXED_ROLE_USER)) {
assertThat("Expected only sales doc visible when setting is false", countHits(client), is(1));
}

setDfmEmptyOverridesAll(true);
try (TestRestClient client = cluster.getRestClient(MIXED_ROLE_USER)) {
assertThat("Expected all docs visible after toggling setting to true", countHits(client), is(3));
}
}

@Test
public void testAdminUser_alwaysSeesAllDocs_regardlessOfSetting() throws IOException {
setDfmEmptyOverridesAll(false);
try (TestRestClient client = cluster.getRestClient(ADMIN_USER)) {
assertThat("Admin should always see all docs", countHits(client), is(3));
}

setDfmEmptyOverridesAll(true);
try (TestRestClient client = cluster.getRestClient(ADMIN_USER)) {
assertThat("Admin should always see all docs", countHits(client), is(3));
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/*
* 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.
*/

package org.opensearch.security.dlsfls;

import java.util.List;
import java.util.Map;

import org.junit.ClassRule;
import org.junit.Test;

import org.opensearch.test.framework.TestSecurityConfig.Role;
import org.opensearch.test.framework.TestSecurityConfig.User;
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.cluster.TestRestClient.HttpResponse;

import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.is;
import static org.opensearch.security.support.ConfigConstants.SECURITY_RESTAPI_ROLES_ENABLED;
import static org.opensearch.test.framework.TestSecurityConfig.AuthcDomain.AUTHC_HTTPBASIC_INTERNAL;
import static org.opensearch.test.framework.TestSecurityConfig.Role.ALL_ACCESS;

/**
* Verifies that plugins.security.dfm_empty_overrides_all (a Sensitive setting) can only be
* updated by users whose role is listed in plugins.security.restapi.roles_enabled, and that
* a user with only cluster:admin/settings/put is denied with 403.
*/
public class SensitiveClusterSettingsAccessTest {

static final String DFM_SETTING_BODY = "{\"persistent\":{\"plugins.security.dfm_empty_overrides_all\":true}}";

static final User SECURITY_ADMIN = new User("security_admin").roles(ALL_ACCESS);

static final Role SETTINGS_ONLY_ROLE = new Role("settings_only_role").clusterPermissions(
"cluster:admin/settings/put",
"cluster:admin/settings/update"
);
static final User SETTINGS_USER = new User("settings_user").roles(SETTINGS_ONLY_ROLE);

@ClassRule
public static final LocalCluster cluster = new LocalCluster.Builder().clusterManager(ClusterManager.SINGLENODE)
.anonymousAuth(false)
.authc(AUTHC_HTTPBASIC_INTERNAL)
.users(SECURITY_ADMIN, SETTINGS_USER)
.nodeSettings(Map.of(SECURITY_RESTAPI_ROLES_ENABLED, List.of("user_" + SECURITY_ADMIN.getName() + "__" + ALL_ACCESS.getName())))
.build();

@Test
public void adminCertUser_canUpdateSensitiveSetting() {
try (TestRestClient client = cluster.getRestClient(cluster.getAdminCertificate())) {
HttpResponse response = client.putJson("_cluster/settings", DFM_SETTING_BODY);
assertThat(response.getStatusCode(), is(200));
}
}

@Test
public void securityAdmin_canUpdateSensitiveSetting() {
try (TestRestClient client = cluster.getRestClient(SECURITY_ADMIN)) {
HttpResponse response = client.putJson("_cluster/settings", DFM_SETTING_BODY);
assertThat(response.getStatusCode(), is(200));
}
}

@Test
public void userWithOnlyClusterSettingsPerm_cannotUpdateSensitiveSetting() {
try (TestRestClient client = cluster.getRestClient(SETTINGS_USER)) {
HttpResponse response = client.putJson("_cluster/settings", DFM_SETTING_BODY);
assertThat(response.getStatusCode(), is(403));
}
}

@Test
public void userWithOnlyClusterSettingsPerm_cannotUpdateMixedPayloadContainingSensitiveSetting() {
try (TestRestClient client = cluster.getRestClient(SETTINGS_USER)) {
HttpResponse response = client.putJson(
"_cluster/settings",
"{\"persistent\":{\"indices.recovery.max_bytes_per_sec\":\"50mb\",\"plugins.security.dfm_empty_overrides_all\":true}}"
);
assertThat(response.getStatusCode(), is(403));
}
}

@Test
public void userWithOnlyClusterSettingsPerm_canStillUpdateNonSensitiveSetting() {
try (TestRestClient client = cluster.getRestClient(SETTINGS_USER)) {
// indices.recovery.max_bytes_per_sec is a core Dynamic setting with no Sensitive property
HttpResponse response = client.putJson(
"_cluster/settings",
"{\"transient\":{\"indices.recovery.max_bytes_per_sec\":\"50mb\"}}"
);
assertThat(response.getStatusCode(), is(200));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -1643,9 +1643,7 @@ public List<Setting<?>> getSettings() {
Property.Filtered
)
);
settings.add(
Setting.boolSetting(ConfigConstants.SECURITY_DFM_EMPTY_OVERRIDES_ALL, false, Property.NodeScope, Property.Filtered)
);
settings.add(SecuritySettings.DFM_EMPTY_OVERRIDES_ALL_SETTING);
settings.add(Setting.groupSetting(ConfigConstants.SECURITY_AUTHCZ_REST_IMPERSONATION_USERS + ".", Property.NodeScope)); // not
// filtered
// here
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,15 @@ public DlsFlsValveImpl(
clusterService.getClusterSettings().addSettingsUpdateConsumer(SecuritySettings.DLS_WRITE_BLOCKED, newDlsWriteBlockedEnabled -> {
dlsWriteBlockedEnabled = newDlsWriteBlockedEnabled;
});
clusterService.getClusterSettings()
.addSettingsUpdateConsumer(SecuritySettings.DFM_EMPTY_OVERRIDES_ALL_SETTING, newDfmEmptyOverridesAll -> {
DlsFlsProcessedConfig config = dlsFlsBaseContext.config();
if (config != null) {
config.getDocumentPrivileges().setDfmEmptyOverridesAll(newDfmEmptyOverridesAll);
config.getFieldPrivileges().setDfmEmptyOverridesAll(newDfmEmptyOverridesAll);
config.getFieldMasking().setDfmEmptyOverridesAll(newDfmEmptyOverridesAll);
}
});
}
this.resourceSharingEnabledSetting = resourceSharingEnabledSetting;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
import java.util.UUID;
import java.util.function.Consumer;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import com.google.common.annotations.VisibleForTesting;
import com.google.common.collect.ImmutableSet;
Expand All @@ -43,6 +44,8 @@
import org.opensearch.ResourceAlreadyExistsException;
import org.opensearch.action.ActionRequest;
import org.opensearch.action.DocWriteRequest.OpType;
import org.opensearch.action.admin.cluster.settings.ClusterUpdateSettingsAction;
import org.opensearch.action.admin.cluster.settings.ClusterUpdateSettingsRequest;
import org.opensearch.action.admin.cluster.snapshots.restore.RestoreSnapshotRequest;
import org.opensearch.action.admin.indices.alias.Alias;
import org.opensearch.action.admin.indices.alias.IndicesAliasesRequest;
Expand Down Expand Up @@ -123,6 +126,7 @@ public class SecurityFilter implements ActionFilter {
private final UserInjector userInjector;
private final ResourceAccessEvaluator resourceAccessEvaluator;
private final ThreadContextUserInfo threadContextUserInfo;
private final Set<String> restApiAllowedRoles;

public SecurityFilter(
final Settings settings,
Expand Down Expand Up @@ -153,6 +157,7 @@ public SecurityFilter(
this.rolesInjector = new RolesInjector(auditLog);
this.userInjector = new UserInjector(settings, threadPool, auditLog, xffResolver);
this.resourceAccessEvaluator = resourceAccessEvaluator;
this.restApiAllowedRoles = Set.copyOf(settings.getAsList(ConfigConstants.SECURITY_RESTAPI_ROLES_ENABLED));
this.threadContextUserInfo = new ThreadContextUserInfo(
threadPool.getThreadContext(),
privilegesConfiguration,
Expand Down Expand Up @@ -436,6 +441,9 @@ private <Request extends ActionRequest, Response extends ActionResponse> void ap
}

// not a resource‐sharing request → fall back into the normal PrivilegesEvaluator
if (checkSensitiveSettingsAccess(action, request, context, listener)) {
return;
}
PrivilegesEvaluatorResponse pres = eval.evaluate(context);

if (log.isDebugEnabled()) {
Expand Down Expand Up @@ -520,6 +528,37 @@ private <Request extends ActionRequest, Response extends ActionResponse> void ap
}
}

private <Request extends ActionRequest, Response extends ActionResponse> boolean checkSensitiveSettingsAccess(
String action,
Request request,
PrivilegesEvaluationContext context,
ActionListener<Response> listener
) {
if (!ClusterUpdateSettingsAction.NAME.equals(action)) {
return false;
}
ClusterUpdateSettingsRequest settingsRequest = (ClusterUpdateSettingsRequest) request;
boolean touchesSensitiveSetting = Stream.concat(
settingsRequest.transientSettings().keySet().stream(),
settingsRequest.persistentSettings().keySet().stream()
).anyMatch(key -> cs.getClusterSettings().isSensitiveSetting(key));

if (!touchesSensitiveSetting) {
return false;
}
if (Collections.disjoint(restApiAllowedRoles, context.getMappedRoles())) {
log.debug("User {} does not have a role permitted to update sensitive cluster settings", context.getUser().getName());
listener.onFailure(
new OpenSearchSecurityException(
"User " + context.getUser().getName() + " does not have permission to update sensitive cluster settings",
RestStatus.FORBIDDEN
)
);
return true;
}
return false;
}

private static boolean isUserAdmin(User user, final AdminDNs adminDns) {
return user != null && adminDns.isAdmin(user);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ abstract class AbstractRuleBasedPrivileges<SingleRule, JoinedRule extends Abstra
/**
* Corresponds to the settings flag plugins.security.dfm_empty_overrides_all.
*/
private final boolean dfmEmptyOverridesAll;
private volatile boolean dfmEmptyOverridesAll;

/**
* Corresponds to the setting plugins.security.privileges_evaluation.precomputed_privileges.enabled
Expand All @@ -111,6 +111,10 @@ public AbstractRuleBasedPrivileges(
this.statefulRules = this.statefulIndexEnabled ? new StatefulRules<>(compiledRoles, indexMetadata, roleToRuleFunction) : null;
}

public void setDfmEmptyOverridesAll(boolean dfmEmptyOverridesAll) {
this.dfmEmptyOverridesAll = dfmEmptyOverridesAll;
}

/**
* Returns true if the user identified in the PrivilegesEvaluationContext does not have any restrictions in any case,
* independently of the indices they are requesting.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,12 @@ public class SecuritySettings {
Setting.Property.NodeScope,
Setting.Property.Dynamic
);

public static final Setting<Boolean> DFM_EMPTY_OVERRIDES_ALL_SETTING = Setting.boolSetting(
ConfigConstants.SECURITY_DFM_EMPTY_OVERRIDES_ALL,
false,
Setting.Property.NodeScope,
Setting.Property.Dynamic,
Setting.Property.Sensitive
);
}
Loading