Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
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.sample.resource;

import java.util.Map;

import com.carrotsearch.randomizedtesting.annotations.ThreadLeakScope;
import org.apache.http.HttpStatus;
import org.junit.ClassRule;
import org.junit.Test;
import org.junit.runner.RunWith;

import org.opensearch.test.framework.cluster.LocalCluster;
import org.opensearch.test.framework.cluster.TestRestClient;

import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.hasKey;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.notNullValue;
import static org.opensearch.sample.resource.TestUtils.newCluster;

/**
* Tests that plugin-provided default-roles.yml roles are loaded as static roles
* and visible via the security roles API.
*/
@RunWith(com.carrotsearch.randomizedtesting.RandomizedRunner.class)
@ThreadLeakScope(ThreadLeakScope.Scope.NONE)
public class DefaultRolesTests {

private static final String ROLES_ENDPOINT = "_plugins/_security/api/roles";

@ClassRule
public static LocalCluster cluster = newCluster(true, true);

@Test
@SuppressWarnings("unchecked")
public void testPluginDefaultRolesAreVisibleViaRolesApi() {
try (TestRestClient client = cluster.getRestClient(cluster.getAdminCertificate())) {
// List all roles and verify plugin-provided roles are present
TestRestClient.HttpResponse response = client.get(ROLES_ENDPOINT);
response.assertStatusCode(HttpStatus.SC_OK);

Map<String, Object> roles = response.bodyAsMap();
assertThat("sample_full_access role should be present", roles, hasKey("sample_full_access"));
assertThat("sample_read_access role should be present", roles, hasKey("sample_read_access"));
}
}

@Test
@SuppressWarnings("unchecked")
public void testPluginDefaultRoleIsStaticAndReserved() {
try (TestRestClient client = cluster.getRestClient(cluster.getAdminCertificate())) {
// Get a specific plugin-provided role
TestRestClient.HttpResponse response = client.get(ROLES_ENDPOINT + "/sample_full_access");
response.assertStatusCode(HttpStatus.SC_OK);

Map<String, Object> body = response.bodyAsMap();
Map<String, Object> role = (Map<String, Object>) body.get("sample_full_access");
assertThat("Role should exist in response", role, is(notNullValue()));
assertThat("Role should be static", role.get("static"), is(true));
assertThat("Role should be reserved", role.get("reserved"), is(true));
}
}

@Test
public void testPluginDefaultRoleCannotBeModifiedByNonAdmin() {
try (TestRestClient client = cluster.getRestClient(TestUtils.FULL_ACCESS_USER)) {
// Attempt to delete a plugin-provided static role as non-admin — should be forbidden
TestRestClient.HttpResponse response = client.delete(ROLES_ENDPOINT + "/sample_full_access");
assertThat(
"Deleting a static role should be forbidden for non-admin",
response.getStatusCode(),
equalTo(HttpStatus.SC_FORBIDDEN)
);
}
}

@Test
@SuppressWarnings("unchecked")
public void testPluginDefaultRoleHasCorrectPermissions() {
try (TestRestClient client = cluster.getRestClient(cluster.getAdminCertificate())) {
TestRestClient.HttpResponse response = client.get(ROLES_ENDPOINT + "/sample_full_access");
response.assertStatusCode(HttpStatus.SC_OK);

Map<String, Object> body = response.bodyAsMap();
Map<String, Object> role = (Map<String, Object>) body.get("sample_full_access");
assertThat(role, is(notNullValue()));

var clusterPerms = (java.util.List<String>) role.get("cluster_permissions");
assertThat("Should have cluster permissions", clusterPerms, is(notNullValue()));
assertThat(clusterPerms.contains("cluster:admin/sample-resource-plugin/*"), is(true));
}
}
}
20 changes: 20 additions & 0 deletions sample-resource-plugin/src/main/resources/default-roles.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
_meta:
type: "roles"
config_version: 2

sample_full_access:
reserved: true
hidden: false
static: true
description: "Provide full access to the sample resource plugin"
cluster_permissions:
- "cluster:admin/sample-resource-plugin/*"

sample_read_access:
reserved: true
hidden: false
static: true
description: "Provide read access to the sample resource plugin"
cluster_permissions:
- "cluster:admin/sample-resource-plugin/get"
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/*
* 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.spi;

/**
* Extension point for OpenSearch plugins that want to contribute static security configuration
* (e.g. default roles, action groups) to the security plugin.
*
* <p>Plugins implement this interface and place a {@code default-roles.yml} file in their
* classpath resources. The security plugin discovers implementations via
* {@link org.opensearch.plugins.ExtensionAwarePlugin#loadExtensions} and loads the
* YAML files from each plugin's classloader.</p>
*
* <p>Static roles contributed by plugins are held in-memory only (never persisted to the
* security index) and take precedence over entries in the security plugin's own
* {@code roles.yml} when a name collision exists.</p>
*
* @opensearch.experimental
*/
public interface SecurityConfigExtension {

// Marker interface for now — the security plugin discovers implementations
// and reads default-roles.yml from the implementing class's classloader.
//
// Future additions may include methods like:
// String defaultRolesResourcePath(); // override the file name
// String defaultActionGroupsResourcePath(); // plugin-provided action groups
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,17 @@

import java.util.Set;

import org.opensearch.security.spi.SecurityConfigExtension;
import org.opensearch.security.spi.resources.client.ResourceSharingClient;

/**
* This interface should be implemented by all the plugins that define one or more resources and need access control over those resources.
* Extends {@link SecurityConfigExtension} so resource-sharing plugins can also contribute static security configuration
* (e.g. default roles via {@code default-roles.yml}).
*
* @opensearch.experimental
*/
public interface ResourceSharingExtension {
public interface ResourceSharingExtension extends SecurityConfigExtension {

/**
* Returns the set of {@link ResourceProvider} instances for the resources defined by the plugin.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@
import org.opensearch.security.privileges.actionlevel.RoleBasedActionPrivileges;
import org.opensearch.security.privileges.dlsfls.DlsFlsBaseContext;
import org.opensearch.security.resolver.IndexResolverReplacer;
import org.opensearch.security.resources.PluginDefaultRolesHelper;
import org.opensearch.security.resources.ResourceAccessControlClient;
import org.opensearch.security.resources.ResourceAccessHandler;
import org.opensearch.security.resources.ResourceActionGroupsHelper;
Expand All @@ -199,9 +200,11 @@
import org.opensearch.security.rest.SecurityWhoAmIAction;
import org.opensearch.security.rest.TenantInfoAction;
import org.opensearch.security.securityconf.DynamicConfigFactory;
import org.opensearch.security.securityconf.impl.SecurityDynamicConfiguration;
import org.opensearch.security.securityconf.impl.v7.RoleV7;
import org.opensearch.security.setting.OpensearchDynamicSetting;
import org.opensearch.security.setting.TransportPassiveAuthSetting;
import org.opensearch.security.spi.SecurityConfigExtension;
import org.opensearch.security.spi.resources.ResourceSharingExtension;
import org.opensearch.security.spi.resources.client.ResourceSharingClient;
import org.opensearch.security.ssl.ExternalSecurityKeyStore;
Expand Down Expand Up @@ -2486,12 +2489,26 @@ public Optional<SecureSettingsFactory> getSecureSettingFactory(Settings settings

@Override
public void loadExtensions(ExtensionLoader loader) {
// discover & register extensions and their types
// discover & register resource-sharing extensions and their types
Set<ResourceSharingExtension> exts = new HashSet<>(loader.loadExtensions(ResourceSharingExtension.class));
resourcePluginInfo.setResourceSharingExtensions(exts);

// load action-groups in memory
ResourceActionGroupsHelper.loadActionGroupsConfig(resourcePluginInfo);

// ResourceSharingExtension extends SecurityConfigExtension, so all resource-sharing
// plugins are also config extensions. Collect them along with any standalone
// SecurityConfigExtension implementations (plugins that only bring roles, not resources).
Set<SecurityConfigExtension> configExts = new HashSet<>(exts);
try {
configExts.addAll(loader.loadExtensions(SecurityConfigExtension.class));
} catch (Exception e) {
// No standalone SecurityConfigExtension implementations found — that's fine
}

// load plugin-provided default roles into the static roles pool
SecurityDynamicConfiguration<RoleV7> pluginRoles = PluginDefaultRolesHelper.loadDefaultRoles(configExts);
DynamicConfigFactory.setPluginDefaultRoles(pluginRoles);
}

public static class GuiceHolder implements LifecycleComponent {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/*
* 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.resources;

import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.HashSet;
import java.util.Set;

import com.fasterxml.jackson.databind.JsonNode;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;

import org.opensearch.security.DefaultObjectMapper;
import org.opensearch.security.securityconf.DynamicConfigFactory;
import org.opensearch.security.securityconf.impl.CType;
import org.opensearch.security.securityconf.impl.SecurityDynamicConfiguration;
import org.opensearch.security.securityconf.impl.v7.RoleV7;
import org.opensearch.security.spi.SecurityConfigExtension;

/**
* Loads {@code default-roles.yml} from each plugin that implements {@link SecurityConfigExtension}.
* Roles are merged into the static roles pool in {@link DynamicConfigFactory}, with plugin-provided
* definitions taking precedence over entries in the security plugin's own {@code static_roles.yml}.
*/
public class PluginDefaultRolesHelper {

private static final Logger log = LogManager.getLogger(PluginDefaultRolesHelper.class);
private static final String DEFAULT_ROLES_FILE = "default-roles.yml";

/**
* Loads default roles from all discovered {@link SecurityConfigExtension} implementations
* and merges them into a single configuration. Plugin roles override any existing entries
* with the same name (last writer wins across plugins, but all plugins win over static_roles.yml).
*
* <p>Multiple extensions from the same plugin (sharing a classloader) will resolve to the
* same {@code default-roles.yml} URL — these are deduplicated so the file is only loaded once.</p>
*
* @param extensions the set of discovered SecurityConfigExtension implementations
* @return merged SecurityDynamicConfiguration containing all plugin-provided roles
*/
public static SecurityDynamicConfiguration<RoleV7> loadDefaultRoles(Set<SecurityConfigExtension> extensions) {
SecurityDynamicConfiguration<RoleV7> merged = SecurityDynamicConfiguration.empty(CType.ROLES);
Set<String> processedUrls = new HashSet<>();

for (SecurityConfigExtension ext : extensions) {
URL url = ext.getClass().getClassLoader().getResource(DEFAULT_ROLES_FILE);
if (url == null) {
log.debug("{} not found for {}", DEFAULT_ROLES_FILE, ext.getClass().getName());
continue;
}

// Deduplicate: multiple extensions from the same plugin share a classloader
if (!processedUrls.add(url.toString())) {
log.debug("{} already loaded from {} (shared classloader), skipping", DEFAULT_ROLES_FILE, ext.getClass().getName());
continue;
}

try (var in = url.openStream()) {
String yaml = new String(in.readAllBytes(), StandardCharsets.UTF_8);
JsonNode node = DefaultObjectMapper.YAML_MAPPER.readTree(yaml);
if (node == null || node.isEmpty()) {
log.debug("Empty {} for {}", DEFAULT_ROLES_FILE, ext.getClass().getName());
continue;
}

SecurityDynamicConfiguration<RoleV7> pluginRoles = SecurityDynamicConfiguration.fromNode(node, CType.ROLES, 2, 0, 0);

// Mark all plugin-provided roles as static and reserved
for (var entry : pluginRoles.getCEntries().entrySet()) {
entry.getValue().setStatic(true);
entry.getValue().setReserved(true);
}

log.info(
"Loaded {} default role(s) from {}: {}",
pluginRoles.getCEntries().size(),
ext.getClass().getName(),
pluginRoles.getCEntries().keySet()
);

merged.add(pluginRoles);
} catch (Exception e) {
log.warn("Failed to load/parse {} from {}: {}", DEFAULT_ROLES_FILE, ext.getClass().getName(), e.toString());
}
}

return merged;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -74,13 +74,15 @@ public class DynamicConfigFactory implements Initializable, ConfigurationChangeL

public static final EventBusBuilder EVENT_BUS_BUILDER = EventBus.builder();
private static SecurityDynamicConfiguration<RoleV7> staticRoles = SecurityDynamicConfiguration.empty(CType.ROLES);
private static SecurityDynamicConfiguration<RoleV7> pluginDefaultRoles = SecurityDynamicConfiguration.empty(CType.ROLES);
private static SecurityDynamicConfiguration<ActionGroupsV7> staticActionGroups = SecurityDynamicConfiguration.empty(CType.ACTIONGROUPS);
private static SecurityDynamicConfiguration<TenantV7> staticTenants = SecurityDynamicConfiguration.empty(CType.TENANTS);
private static final AllowlistingSettings defaultAllowlistingSettings = new AllowlistingSettings();
private static final AuditConfig defaultAuditConfig = AuditConfig.from(Settings.EMPTY);

static void resetStatics() {
staticRoles = SecurityDynamicConfiguration.empty(CType.ROLES);
pluginDefaultRoles = SecurityDynamicConfiguration.empty(CType.ROLES);
staticActionGroups = SecurityDynamicConfiguration.empty(CType.ACTIONGROUPS);
staticTenants = SecurityDynamicConfiguration.empty(CType.TENANTS);
}
Expand All @@ -102,13 +104,29 @@ private void loadStaticConfig() throws IOException {
staticTenants = SecurityDynamicConfiguration.fromNode(staticTenantsJsonNode, CType.TENANTS, 2, 0, 0);
}

/**
* Sets plugin-provided default roles. These take precedence over static_roles.yml entries.
* Called from {@link org.opensearch.security.OpenSearchSecurityPlugin#loadExtensions} after
* discovering {@link org.opensearch.security.spi.SecurityConfigExtension} implementations.
*/
public static void setPluginDefaultRoles(SecurityDynamicConfiguration<RoleV7> roles) {
pluginDefaultRoles = roles;
}

@SuppressWarnings("unchecked")
public final static <T> SecurityDynamicConfiguration<T> addStatics(SecurityDynamicConfiguration<T> original) {
if (original.getCType() == CType.ACTIONGROUPS && !staticActionGroups.getCEntries().isEmpty()) {
original.add(staticActionGroups.deepClone());
}

if (original.getCType() == CType.ROLES && !staticRoles.getCEntries().isEmpty()) {
original.add(staticRoles.deepClone());
if (original.getCType() == CType.ROLES) {
if (!staticRoles.getCEntries().isEmpty()) {
original.add(staticRoles.deepClone());
}
// Plugin default roles override static_roles.yml entries (putAll semantics)
if (!pluginDefaultRoles.getCEntries().isEmpty()) {
original.add((SecurityDynamicConfiguration<T>) pluginDefaultRoles.deepClone());
}
}

if (original.getCType() == CType.TENANTS && !staticTenants.getCEntries().isEmpty()) {
Expand Down Expand Up @@ -244,6 +262,9 @@ public void onChange(ConfigurationMap typeToConfig) {
mergeStaticConfigWithWarning("action groups", actionGroups, staticActionGroups, log);
mergeStaticConfigWithWarning("tenants", tenants, staticTenants, log);

// Plugin-provided default roles override both dynamic and static_roles.yml entries
mergeStaticConfigWithWarning("plugin default roles", roles, pluginDefaultRoles, log);

log.debug(
"Static configuration loaded (total roles: {}/total action groups: {}/total tenants: {})",
roles.getCEntries().size(),
Expand Down
Loading