diff --git a/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/resource/DefaultRolesTests.java b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/resource/DefaultRolesTests.java new file mode 100644 index 0000000000..3b5b547b48 --- /dev/null +++ b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/resource/DefaultRolesTests.java @@ -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 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 body = response.bodyAsMap(); + Map role = (Map) 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 body = response.bodyAsMap(); + Map role = (Map) body.get("sample_full_access"); + assertThat(role, is(notNullValue())); + + var clusterPerms = (java.util.List) role.get("cluster_permissions"); + assertThat("Should have cluster permissions", clusterPerms, is(notNullValue())); + assertThat(clusterPerms.contains("cluster:admin/sample-resource-plugin/*"), is(true)); + } + } +} diff --git a/sample-resource-plugin/src/main/resources/default-roles.yml b/sample-resource-plugin/src/main/resources/default-roles.yml new file mode 100644 index 0000000000..34b12ac01f --- /dev/null +++ b/sample-resource-plugin/src/main/resources/default-roles.yml @@ -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" diff --git a/spi/src/main/java/org/opensearch/security/spi/SecurityConfigExtension.java b/spi/src/main/java/org/opensearch/security/spi/SecurityConfigExtension.java new file mode 100644 index 0000000000..3cf4cdf254 --- /dev/null +++ b/spi/src/main/java/org/opensearch/security/spi/SecurityConfigExtension.java @@ -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. + * + *

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.

+ * + *

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.

+ * + * @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 +} diff --git a/spi/src/main/java/org/opensearch/security/spi/resources/ResourceSharingExtension.java b/spi/src/main/java/org/opensearch/security/spi/resources/ResourceSharingExtension.java index 336f75f172..9ab7f8339f 100644 --- a/spi/src/main/java/org/opensearch/security/spi/resources/ResourceSharingExtension.java +++ b/spi/src/main/java/org/opensearch/security/spi/resources/ResourceSharingExtension.java @@ -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. diff --git a/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java b/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java index f91a26c40f..9e342bcd8f 100644 --- a/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java +++ b/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java @@ -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; @@ -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; @@ -2486,12 +2489,26 @@ public Optional getSecureSettingFactory(Settings settings @Override public void loadExtensions(ExtensionLoader loader) { - // discover & register extensions and their types + // discover & register resource-sharing extensions and their types Set 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 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 pluginRoles = PluginDefaultRolesHelper.loadDefaultRoles(configExts); + DynamicConfigFactory.setPluginDefaultRoles(pluginRoles); } public static class GuiceHolder implements LifecycleComponent { diff --git a/src/main/java/org/opensearch/security/resources/PluginDefaultRolesHelper.java b/src/main/java/org/opensearch/security/resources/PluginDefaultRolesHelper.java new file mode 100644 index 0000000000..93f3da00e4 --- /dev/null +++ b/src/main/java/org/opensearch/security/resources/PluginDefaultRolesHelper.java @@ -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). + * + *

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.

+ * + * @param extensions the set of discovered SecurityConfigExtension implementations + * @return merged SecurityDynamicConfiguration containing all plugin-provided roles + */ + public static SecurityDynamicConfiguration loadDefaultRoles(Set extensions) { + SecurityDynamicConfiguration merged = SecurityDynamicConfiguration.empty(CType.ROLES); + Set 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 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; + } +} diff --git a/src/main/java/org/opensearch/security/securityconf/DynamicConfigFactory.java b/src/main/java/org/opensearch/security/securityconf/DynamicConfigFactory.java index f89c215899..62bd52cf88 100644 --- a/src/main/java/org/opensearch/security/securityconf/DynamicConfigFactory.java +++ b/src/main/java/org/opensearch/security/securityconf/DynamicConfigFactory.java @@ -74,6 +74,7 @@ public class DynamicConfigFactory implements Initializable, ConfigurationChangeL public static final EventBusBuilder EVENT_BUS_BUILDER = EventBus.builder(); private static SecurityDynamicConfiguration staticRoles = SecurityDynamicConfiguration.empty(CType.ROLES); + private static SecurityDynamicConfiguration pluginDefaultRoles = SecurityDynamicConfiguration.empty(CType.ROLES); private static SecurityDynamicConfiguration staticActionGroups = SecurityDynamicConfiguration.empty(CType.ACTIONGROUPS); private static SecurityDynamicConfiguration staticTenants = SecurityDynamicConfiguration.empty(CType.TENANTS); private static final AllowlistingSettings defaultAllowlistingSettings = new AllowlistingSettings(); @@ -81,6 +82,7 @@ public class DynamicConfigFactory implements Initializable, ConfigurationChangeL static void resetStatics() { staticRoles = SecurityDynamicConfiguration.empty(CType.ROLES); + pluginDefaultRoles = SecurityDynamicConfiguration.empty(CType.ROLES); staticActionGroups = SecurityDynamicConfiguration.empty(CType.ACTIONGROUPS); staticTenants = SecurityDynamicConfiguration.empty(CType.TENANTS); } @@ -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 roles) { + pluginDefaultRoles = roles; + } + + @SuppressWarnings("unchecked") public final static SecurityDynamicConfiguration addStatics(SecurityDynamicConfiguration 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) pluginDefaultRoles.deepClone()); + } } if (original.getCType() == CType.TENANTS && !staticTenants.getCEntries().isEmpty()) { @@ -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(),