From 777020beae45120ea56fa370f5cb93f49783e8a1 Mon Sep 17 00:00:00 2001 From: Darshit Chanpura Date: Fri, 19 Jun 2026 23:56:14 -0700 Subject: [PATCH 01/19] Onboards notifications plugin to centralized resource authz Signed-off-by: Darshit Chanpura --- .../security-notifications-test-workflow.yml | 6 +- notifications/notifications/build.gradle | 13 +- .../NotificationsResourceSharingExtension.kt | 25 ++++ .../ResourceSharingClientAccessor.kt | 29 +++++ .../security/UserAccessManager.kt | 14 ++- ...ity.spi.resources.ResourceSharingExtension | 4 + .../main/resources/resource-action-groups.yml | 25 ++++ .../notifications/ResourceSharingTests.kt | 66 ++++++++++ .../ResourceSharingNotificationIT.kt | 116 ++++++++++++++++++ 9 files changed, 286 insertions(+), 12 deletions(-) create mode 100644 notifications/notifications/src/main/kotlin/org/opensearch/notifications/NotificationsResourceSharingExtension.kt create mode 100644 notifications/notifications/src/main/kotlin/org/opensearch/notifications/ResourceSharingClientAccessor.kt create mode 100644 notifications/notifications/src/main/resources/META-INF/services/org.opensearch.security.spi.resources.ResourceSharingExtension create mode 100644 notifications/notifications/src/main/resources/resource-action-groups.yml create mode 100644 notifications/notifications/src/test/java/org/opensearch/notifications/ResourceSharingTests.kt create mode 100644 notifications/notifications/src/test/kotlin/org/opensearch/integtest/ResourceSharingNotificationIT.kt diff --git a/.github/workflows/security-notifications-test-workflow.yml b/.github/workflows/security-notifications-test-workflow.yml index ed51a8c1071..94fc47b1b58 100644 --- a/.github/workflows/security-notifications-test-workflow.yml +++ b/.github/workflows/security-notifications-test-workflow.yml @@ -17,6 +17,8 @@ jobs: strategy: matrix: java: [ 21 ] + # empty string = resource sharing disabled, flag = enabled + resource_sharing_flag: ["", "-Dresource_sharing.enabled=true"] needs: Get-CI-Image-Tag # This job runs on Linux runs-on: ubuntu-latest @@ -46,12 +48,12 @@ jobs: run: | cd notifications chown -R 1000:1000 `pwd` - su `id -un 1000` -c "./gradlew integTest -Dsecurity=true -Dhttps=true --tests '*IT'" + su `id -un 1000` -c "./gradlew integTest -Dsecurity=true -Dhttps=true ${{ matrix.resource_sharing_flag }} --tests '*IT'" - name: Upload failed logs uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 if: failure() with: - name: logs + name: logs-${{ matrix.resource_sharing_flag != '' && 'resource-sharing' || 'no-resource-sharing' }} overwrite: 'true' path: build/testclusters/integTest-*/logs/* diff --git a/notifications/notifications/build.gradle b/notifications/notifications/build.gradle index 277b055dd8a..e559ae115ef 100644 --- a/notifications/notifications/build.gradle +++ b/notifications/notifications/build.gradle @@ -46,7 +46,7 @@ opensearchplugin { name 'opensearch-notifications' description 'OpenSearch Notifications Plugin' classname 'org.opensearch.notifications.NotificationPlugin' - extendedPlugins = ['opensearch-notifications-core'] + extendedPlugins = ['opensearch-notifications-core', 'opensearch-security;optional=true'] } publishing { @@ -182,10 +182,9 @@ dependencies { // implementation "com.fasterxml.jackson.core:jackson-databind:${versions.jackson_databind}" // implementation "com.fasterxml.jackson.core:jackson-annotations:${versions.jackson_annotations}" - // Needed for security tests - if (securityEnabled) { - opensearchPlugin "org.opensearch.plugin:opensearch-security:${opensearch_build}@zip" - } + // Resource sharing + compileOnly group: 'org.opensearch', name:'opensearch-security-spi', version:"${opensearch_build}" + opensearchPlugin "org.opensearch.plugin:opensearch-security:${opensearch_build}@zip" compileOnly "tools.jackson.core:jackson-databind:${versions.jackson3_databind}" compileOnly "tools.jackson.core:jackson-core:${versions.jackson3}" @@ -282,6 +281,9 @@ afterEvaluate { node.setting("plugins.security.check_snapshot_restore_write_privileges", "true") node.setting("plugins.security.restapi.roles_enabled", "[\"all_access\", \"security_rest_api_access\"]") node.setting("plugins.security.system_indices.enabled", "true") + if (System.getProperty("resource_sharing.enabled") == "true") { + node.setting "plugins.security.experimental.resource_sharing.enabled", "true" + } } } } @@ -322,6 +324,7 @@ integTest { systemProperty "security", System.getProperty("security") systemProperty "user", System.getProperty("user", "admin") systemProperty "password", System.getProperty("password", "admin") + systemProperty "resource_sharing.enabled", System.getProperty("resource_sharing.enabled") // Tell the test JVM if the cluster JVM is running under a debugger so that tests can use longer timeouts for // requests. The 'doFirst' delays reading the debug setting on the cluster till execution time. doFirst { diff --git a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/NotificationsResourceSharingExtension.kt b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/NotificationsResourceSharingExtension.kt new file mode 100644 index 00000000000..43db3b68229 --- /dev/null +++ b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/NotificationsResourceSharingExtension.kt @@ -0,0 +1,25 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +package org.opensearch.notifications + +import org.opensearch.notifications.index.NotificationConfigIndex +import org.opensearch.security.spi.resources.ResourceProvider +import org.opensearch.security.spi.resources.ResourceSharingExtension +import org.opensearch.security.spi.resources.client.ResourceSharingClient + +class NotificationsResourceSharingExtension : ResourceSharingExtension { + override fun getResourceProviders(): Set { + return setOf( + object : ResourceProvider { + override fun resourceType(): String = "notification_config" + override fun resourceIndexName(): String = NotificationConfigIndex.INDEX_NAME + } + ) + } + + override fun assignResourceSharingClient(client: ResourceSharingClient?) { + ResourceSharingClientAccessor.setResourceSharingClient(client) + } +} diff --git a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/ResourceSharingClientAccessor.kt b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/ResourceSharingClientAccessor.kt new file mode 100644 index 00000000000..3323d0d2d25 --- /dev/null +++ b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/ResourceSharingClientAccessor.kt @@ -0,0 +1,29 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +package org.opensearch.notifications + +import org.opensearch.security.spi.resources.client.ResourceSharingClient + +/** + * Accessor for resource sharing client + */ +object ResourceSharingClientAccessor { + + @Volatile + private var client: ResourceSharingClient? = null + + @JvmStatic + fun setResourceSharingClient(client: ResourceSharingClient?) { + this.client = client + } + + @JvmStatic + fun getResourceSharingClient(): ResourceSharingClient? = client + + @JvmStatic + fun clear() { + client = null + } +} diff --git a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/security/UserAccessManager.kt b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/security/UserAccessManager.kt index 59e6976fdd3..b1b55bd057d 100644 --- a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/security/UserAccessManager.kt +++ b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/security/UserAccessManager.kt @@ -8,6 +8,7 @@ package org.opensearch.notifications.security import org.opensearch.OpenSearchStatusException import org.opensearch.commons.authuser.User import org.opensearch.core.rest.RestStatus +import org.opensearch.notifications.ResourceSharingClientAccessor import org.opensearch.notifications.settings.PluginSettings /** @@ -16,10 +17,15 @@ import org.opensearch.notifications.settings.PluginSettings internal object UserAccessManager : UserAccess { const val ADMIN_ROLE = "all_access" + private fun isResourceSharingEnabled(): Boolean { + return ResourceSharingClientAccessor.getResourceSharingClient() != null + } + /** * {@inheritDoc} */ override fun validateUser(user: User?) { + if (isResourceSharingEnabled()) return if (PluginSettings.isRbacEnabled() && user?.backendRoles.isNullOrEmpty()) { throw OpenSearchStatusException( "User doesn't have backend roles configured. Contact administrator.", @@ -32,7 +38,7 @@ internal object UserAccessManager : UserAccess { * {@inheritDoc} */ override fun getAllAccessInfo(user: User?): List { - if (user == null) { // Filtering is disabled + if (isResourceSharingEnabled() || user == null) { return listOf() } return user.backendRoles @@ -42,7 +48,7 @@ internal object UserAccessManager : UserAccess { * {@inheritDoc} */ override fun getSearchAccessInfo(user: User?): List { - if (user == null || !PluginSettings.isRbacEnabled() || user.roles.contains(ADMIN_ROLE)) { // Filtering is disabled + if (isResourceSharingEnabled() || user == null || !PluginSettings.isRbacEnabled() || user.roles.contains(ADMIN_ROLE)) { return listOf() } return user.backendRoles @@ -52,11 +58,9 @@ internal object UserAccessManager : UserAccess { * {@inheritDoc} */ override fun doesUserHaveAccess(user: User?, access: List): Boolean { - if (user == null || !PluginSettings.isRbacEnabled()) { // Filtering is disabled + if (isResourceSharingEnabled() || user == null || !PluginSettings.isRbacEnabled()) { return true } - // User has access to resource if resource is public i.e. no access roles attached, user is an admin user or there is any intersection - // between user backend roles and access roles return access.isEmpty() || user.roles.contains(ADMIN_ROLE) || user.backendRoles.any { it in access } } } diff --git a/notifications/notifications/src/main/resources/META-INF/services/org.opensearch.security.spi.resources.ResourceSharingExtension b/notifications/notifications/src/main/resources/META-INF/services/org.opensearch.security.spi.resources.ResourceSharingExtension new file mode 100644 index 00000000000..04d560d7e66 --- /dev/null +++ b/notifications/notifications/src/main/resources/META-INF/services/org.opensearch.security.spi.resources.ResourceSharingExtension @@ -0,0 +1,4 @@ +# Copyright OpenSearch Contributors +# SPDX-License-Identifier: Apache-2.0 + +org.opensearch.notifications.NotificationsResourceSharingExtension diff --git a/notifications/notifications/src/main/resources/resource-action-groups.yml b/notifications/notifications/src/main/resources/resource-action-groups.yml new file mode 100644 index 00000000000..8140511f52e --- /dev/null +++ b/notifications/notifications/src/main/resources/resource-action-groups.yml @@ -0,0 +1,25 @@ +# For resource-access-management +resource_types: + notification_config: + notifications_read_only: + allowed_actions: + - 'cluster:admin/opensearch/notifications/configs/get' + - 'cluster:admin/opensearch/notifications/channels/get' + - 'cluster:admin/opensearch/notifications/features' + + notifications_read_write: + allowed_actions: + - 'cluster:admin/opensearch/notifications/configs/*' + - 'cluster:admin/opensearch/notifications/channels/get' + - 'cluster:admin/opensearch/notifications/features' + - 'cluster:admin/opensearch/notifications/feature/send' + - 'cluster:admin/opensearch/notifications/test_notification' + + notifications_full_access: + allowed_actions: + - 'cluster:admin/opensearch/notifications/configs/*' + - 'cluster:admin/opensearch/notifications/channels/get' + - 'cluster:admin/opensearch/notifications/features' + - 'cluster:admin/opensearch/notifications/feature/send' + - 'cluster:admin/opensearch/notifications/test_notification' + - 'cluster:admin/security/resource/share' diff --git a/notifications/notifications/src/test/java/org/opensearch/notifications/ResourceSharingTests.kt b/notifications/notifications/src/test/java/org/opensearch/notifications/ResourceSharingTests.kt new file mode 100644 index 00000000000..c137526c0ac --- /dev/null +++ b/notifications/notifications/src/test/java/org/opensearch/notifications/ResourceSharingTests.kt @@ -0,0 +1,66 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.notifications + +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.Mockito.mock +import org.opensearch.notifications.index.NotificationConfigIndex +import org.opensearch.security.spi.resources.client.ResourceSharingClient +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertSame + +class ResourceSharingTests { + + @BeforeEach + fun setup() { + ResourceSharingClientAccessor.clear() + } + + @Test + fun `get client returns null when not set`() { + assertNull(ResourceSharingClientAccessor.getResourceSharingClient()) + } + + @Test + fun `set and get client`() { + val mockClient = mock(ResourceSharingClient::class.java) + ResourceSharingClientAccessor.setResourceSharingClient(mockClient) + assertSame(mockClient, ResourceSharingClientAccessor.getResourceSharingClient()) + } + + @Test + fun `clear resets client to null`() { + val mockClient = mock(ResourceSharingClient::class.java) + ResourceSharingClientAccessor.setResourceSharingClient(mockClient) + ResourceSharingClientAccessor.clear() + assertNull(ResourceSharingClientAccessor.getResourceSharingClient()) + } + + @Test + fun `extension returns one provider`() { + val extension = NotificationsResourceSharingExtension() + val providers = extension.getResourceProviders() + assertEquals(1, providers.size) + } + + @Test + fun `provider has correct type and index`() { + val extension = NotificationsResourceSharingExtension() + val provider = extension.getResourceProviders().first() + assertEquals("notification_config", provider.resourceType()) + assertEquals(NotificationConfigIndex.INDEX_NAME, provider.resourceIndexName()) + } + + @Test + fun `assignResourceSharingClient sets client`() { + val extension = NotificationsResourceSharingExtension() + val mockClient = mock(ResourceSharingClient::class.java) + extension.assignResourceSharingClient(mockClient) + assertSame(mockClient, ResourceSharingClientAccessor.getResourceSharingClient()) + } +} diff --git a/notifications/notifications/src/test/kotlin/org/opensearch/integtest/ResourceSharingNotificationIT.kt b/notifications/notifications/src/test/kotlin/org/opensearch/integtest/ResourceSharingNotificationIT.kt new file mode 100644 index 00000000000..6dd9d20a08d --- /dev/null +++ b/notifications/notifications/src/test/kotlin/org/opensearch/integtest/ResourceSharingNotificationIT.kt @@ -0,0 +1,116 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.integtest + +import org.junit.After +import org.junit.Assert +import org.junit.Before +import org.junit.BeforeClass +import org.opensearch.client.Request +import org.opensearch.client.ResponseException +import org.opensearch.client.RestClient +import org.opensearch.commons.notifications.model.ConfigType +import org.opensearch.commons.rest.SecureRestClientBuilder +import org.opensearch.notifications.NotificationPlugin +import org.opensearch.rest.RestRequest + +/** + * Integration tests for Resource Sharing feature with Notifications plugin. + * Only runs when both security and resource_sharing are enabled. + */ +class ResourceSharingNotificationIT : PluginRestTestCase() { + + companion object { + @BeforeClass + @JvmStatic + fun setup() { + org.junit.Assume.assumeTrue(System.getProperty("https", "false")!!.toBoolean()) + org.junit.Assume.assumeTrue(System.getProperty("resource_sharing.enabled", "false")!!.toBoolean()) + } + } + + private val aliceUser = "rs_alice" + private val alicePassword = "Rs_alice123!" + private val bobUser = "rs_bob" + private val bobPassword = "Rs_bob1234!" + private var aliceClient: RestClient? = null + private var bobClient: RestClient? = null + + @Before + fun setupUsers() { + if (aliceClient != null) return + createUserWithRoles(aliceUser, alicePassword, "alerting_full_access", "engineering") + aliceClient = SecureRestClientBuilder(clusterHosts.toTypedArray(), isHttps(), aliceUser, alicePassword) + .setSocketTimeout(60000).build() + + createUserWithRoles(bobUser, bobPassword, "alerting_full_access", "marketing") + bobClient = SecureRestClientBuilder(clusterHosts.toTypedArray(), isHttps(), bobUser, bobPassword) + .setSocketTimeout(60000).build() + } + + @After + fun cleanupClients() { + aliceClient?.close() + bobClient?.close() + aliceClient = null + bobClient = null + } + + fun `test config created by alice is not visible to bob`() { + val configId = createConfig(configType = ConfigType.SLACK, client = aliceClient!!) + + // Bob should not be able to get Alice's config + val exception = Assert.assertThrows(ResponseException::class.java) { + executeRequest( + RestRequest.Method.GET.name, + "${NotificationPlugin.PLUGIN_BASE_URI}/configs/$configId", + "", + 0, + bobClient!! + ) + } + Assert.assertTrue( + exception.message!!.contains("no permissions") || exception.response.statusLine.statusCode == 403 + ) + } + + fun `test config created by alice is visible after sharing`() { + val configId = createConfig(configType = ConfigType.SLACK, client = aliceClient!!) + + // Share with bob + shareResource(aliceClient!!, configId, "notification_config", "notifications_read_only", bobUser) + Thread.sleep(2000) + + // Bob should now be able to get the config + val response = executeRequest( + RestRequest.Method.GET.name, + "${NotificationPlugin.PLUGIN_BASE_URI}/configs/$configId", + "", + 200, + bobClient!! + ) + Assert.assertNotNull(response) + } + + private fun shareResource(client: RestClient, resourceId: String, resourceType: String, accessLevel: String, user: String) { + val request = Request("PUT", "/_plugins/_security/api/resource/share") + request.setJsonEntity( + """ + { + "resource_id": "$resourceId", + "resource_type": "$resourceType", + "share_with": { + "$accessLevel": { + "users": ["$user"] + } + } + } + """.trimIndent() + ) + val response = client.performRequest(request) + Assert.assertEquals(200, response.statusLine.statusCode) + } +} From 0318a4531d54674529683472de6e5cef9e1b28a1 Mon Sep 17 00:00:00 2001 From: Darshit Chanpura Date: Sat, 20 Jun 2026 00:18:10 -0700 Subject: [PATCH 02/19] Fix security IT Signed-off-by: Darshit Chanpura --- .../opensearch/integtest/ResourceSharingNotificationIT.kt | 8 ++++---- .../org/opensearch/integtest/SecurityNotificationIT.kt | 2 ++ 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/notifications/notifications/src/test/kotlin/org/opensearch/integtest/ResourceSharingNotificationIT.kt b/notifications/notifications/src/test/kotlin/org/opensearch/integtest/ResourceSharingNotificationIT.kt index 6dd9d20a08d..12ba4725d55 100644 --- a/notifications/notifications/src/test/kotlin/org/opensearch/integtest/ResourceSharingNotificationIT.kt +++ b/notifications/notifications/src/test/kotlin/org/opensearch/integtest/ResourceSharingNotificationIT.kt @@ -33,20 +33,20 @@ class ResourceSharingNotificationIT : PluginRestTestCase() { } private val aliceUser = "rs_alice" - private val alicePassword = "Rs_alice123!" + private val alicePassword = "TopSecret_1234%Alice" private val bobUser = "rs_bob" - private val bobPassword = "Rs_bob1234!" + private val bobPassword = "TopSecret_1234%Bobby" private var aliceClient: RestClient? = null private var bobClient: RestClient? = null @Before fun setupUsers() { if (aliceClient != null) return - createUserWithRoles(aliceUser, alicePassword, "alerting_full_access", "engineering") + createUserWithRoles(aliceUser, alicePassword, ALL_ACCESS_ROLE, "engineering") aliceClient = SecureRestClientBuilder(clusterHosts.toTypedArray(), isHttps(), aliceUser, alicePassword) .setSocketTimeout(60000).build() - createUserWithRoles(bobUser, bobPassword, "alerting_full_access", "marketing") + createUserWithRoles(bobUser, bobPassword, ALL_ACCESS_ROLE, "marketing") bobClient = SecureRestClientBuilder(clusterHosts.toTypedArray(), isHttps(), bobUser, bobPassword) .setSocketTimeout(60000).build() } diff --git a/notifications/notifications/src/test/kotlin/org/opensearch/integtest/SecurityNotificationIT.kt b/notifications/notifications/src/test/kotlin/org/opensearch/integtest/SecurityNotificationIT.kt index fd1854513a9..bf2afd1d973 100644 --- a/notifications/notifications/src/test/kotlin/org/opensearch/integtest/SecurityNotificationIT.kt +++ b/notifications/notifications/src/test/kotlin/org/opensearch/integtest/SecurityNotificationIT.kt @@ -28,6 +28,8 @@ class SecurityNotificationIT : PluginRestTestCase() { fun setup() { // things to execute once and keep around for the class org.junit.Assume.assumeTrue(System.getProperty("https", "false")!!.toBoolean()) + // Skip these tests when resource sharing is enabled - ResourceSharingNotificationIT covers that path + org.junit.Assume.assumeFalse(System.getProperty("resource_sharing.enabled", "false")!!.toBoolean()) } } From 28f0ab398da53af23debf05de7fb31030aefaa3c Mon Sep 17 00:00:00 2001 From: Darshit Chanpura Date: Sat, 20 Jun 2026 00:33:44 -0700 Subject: [PATCH 03/19] Fix test Signed-off-by: Darshit Chanpura --- .../opensearch/integtest/ResourceSharingNotificationIT.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/notifications/notifications/src/test/kotlin/org/opensearch/integtest/ResourceSharingNotificationIT.kt b/notifications/notifications/src/test/kotlin/org/opensearch/integtest/ResourceSharingNotificationIT.kt index 12ba4725d55..6bd6fe644be 100644 --- a/notifications/notifications/src/test/kotlin/org/opensearch/integtest/ResourceSharingNotificationIT.kt +++ b/notifications/notifications/src/test/kotlin/org/opensearch/integtest/ResourceSharingNotificationIT.kt @@ -42,11 +42,13 @@ class ResourceSharingNotificationIT : PluginRestTestCase() { @Before fun setupUsers() { if (aliceClient != null) return - createUserWithRoles(aliceUser, alicePassword, ALL_ACCESS_ROLE, "engineering") + createUser(aliceUser, alicePassword, arrayOf("engineering")) + createUserRolesMapping(ALL_ACCESS_ROLE, arrayOf(aliceUser)) aliceClient = SecureRestClientBuilder(clusterHosts.toTypedArray(), isHttps(), aliceUser, alicePassword) .setSocketTimeout(60000).build() - createUserWithRoles(bobUser, bobPassword, ALL_ACCESS_ROLE, "marketing") + createUser(bobUser, bobPassword, arrayOf("marketing")) + createUserRolesMapping(ALL_ACCESS_ROLE, arrayOf(bobUser)) bobClient = SecureRestClientBuilder(clusterHosts.toTypedArray(), isHttps(), bobUser, bobPassword) .setSocketTimeout(60000).build() } From c529ff7ca3d5e860157886a2a0f4f4a1f94e2efa Mon Sep 17 00:00:00 2001 From: Darshit Chanpura Date: Sat, 20 Jun 2026 15:06:24 -0700 Subject: [PATCH 04/19] Fix sec test failures Signed-off-by: Darshit Chanpura --- .../org/opensearch/notifications/security/UserAccessManager.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/security/UserAccessManager.kt b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/security/UserAccessManager.kt index b1b55bd057d..f77d063a3f1 100644 --- a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/security/UserAccessManager.kt +++ b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/security/UserAccessManager.kt @@ -18,7 +18,8 @@ internal object UserAccessManager : UserAccess { const val ADMIN_ROLE = "all_access" private fun isResourceSharingEnabled(): Boolean { - return ResourceSharingClientAccessor.getResourceSharingClient() != null + val client = ResourceSharingClientAccessor.getResourceSharingClient() + return client != null && client.isFeatureEnabledForType("notification_config") } /** From 88167f5b9cec7f228415790571bf553384b1e414 Mon Sep 17 00:00:00 2001 From: Darshit Chanpura Date: Mon, 20 Jul 2026 13:46:22 -0700 Subject: [PATCH 05/19] Fix resource sharing IT failures and add index mapping for shared principals - Use adminClient() (cert-based) for security REST API calls and _cat/indices in tests, since resource sharing restricts the password-based admin user - Add all_shared_principals field to notifications-config-mapping (schema v3) for resource sharing framework queries - Add protected_types setting for notification_config in test cluster Signed-off-by: Darshit Chanpura --- notifications/notifications/build.gradle | 1 + .../notifications-config-mapping.yml | 6 +++-- .../integtest/PluginRestTestCase.kt | 22 +++++++++---------- 3 files changed, 16 insertions(+), 13 deletions(-) diff --git a/notifications/notifications/build.gradle b/notifications/notifications/build.gradle index e559ae115ef..0af9386597c 100644 --- a/notifications/notifications/build.gradle +++ b/notifications/notifications/build.gradle @@ -283,6 +283,7 @@ afterEvaluate { node.setting("plugins.security.system_indices.enabled", "true") if (System.getProperty("resource_sharing.enabled") == "true") { node.setting "plugins.security.experimental.resource_sharing.enabled", "true" + node.setting "plugins.security.experimental.resource_sharing.protected_types", "[\"notification_config\"]" } } } diff --git a/notifications/notifications/src/main/resources/notifications-config-mapping.yml b/notifications/notifications/src/main/resources/notifications-config-mapping.yml index 073d0f987b6..f2c3de12eba 100644 --- a/notifications/notifications/src/main/resources/notifications-config-mapping.yml +++ b/notifications/notifications/src/main/resources/notifications-config-mapping.yml @@ -8,7 +8,7 @@ # "dynamic" is set to "false" so that only specified fields are indexed instead of all fields. dynamic: false _meta: - schema_version: 2 + schema_version: 3 properties: metadata: type: object @@ -142,4 +142,6 @@ properties: type: text fields: keyword: - type: keyword \ No newline at end of file + type: keyword + all_shared_principals: + type: keyword \ No newline at end of file diff --git a/notifications/notifications/src/test/kotlin/org/opensearch/integtest/PluginRestTestCase.kt b/notifications/notifications/src/test/kotlin/org/opensearch/integtest/PluginRestTestCase.kt index 5eca041385c..59350ca0de2 100644 --- a/notifications/notifications/src/test/kotlin/org/opensearch/integtest/PluginRestTestCase.kt +++ b/notifications/notifications/src/test/kotlin/org/opensearch/integtest/PluginRestTestCase.kt @@ -67,7 +67,7 @@ abstract class PluginRestTestCase : OpenSearchRestTestCase() { if (preservePluginIndicesAfterTest()) return val pluginIndices = listOf(".opensearch-notifications-config") - val response = client().performRequest(Request("GET", "/_cat/indices?format=json&expand_wildcards=all")) + val response = adminClient().performRequest(Request("GET", "/_cat/indices?format=json&expand_wildcards=all")) val xContentType = MediaType.fromMediaType(response.entity.contentType) xContentType.xContent().createParser( NamedXContentRegistry.EMPTY, @@ -173,12 +173,12 @@ abstract class PluginRestTestCase : OpenSearchRestTestCase() { "\"attributes\": {\n" + "}} " request.setJsonEntity(entity) - client().performRequest(request) + adminClient().performRequest(request) } fun deleteUser(name: String) { val request = Request(RestRequest.Method.DELETE.name, "/_plugins/_security/api/internalusers/$name") - executeRequest(request, RestStatus.OK.status) + adminClient().performRequest(request) } fun createUserRolesMapping(role: String, users: Array) { @@ -190,7 +190,7 @@ abstract class PluginRestTestCase : OpenSearchRestTestCase() { " \"users\" : [$usersStr]\n" + "}" request.setJsonEntity(entity) - client().performRequest(request) + adminClient().performRequest(request) } fun addPatchUserRolesMapping(role: String, users: Array) { @@ -204,7 +204,7 @@ abstract class PluginRestTestCase : OpenSearchRestTestCase() { "}]" request.setJsonEntity(entity) - client().performRequest(request) + adminClient().performRequest(request) } fun removePatchUserRolesMapping(role: String, users: Array) { @@ -218,12 +218,12 @@ abstract class PluginRestTestCase : OpenSearchRestTestCase() { "}]" request.setJsonEntity(entity) - client().performRequest(request) + adminClient().performRequest(request) } fun deleteUserRolesMapping(role: String) { val request = Request("DELETE", "/_plugins/_security/api/rolesmapping/$role") - client().performRequest(request) + adminClient().performRequest(request) } fun createCustomRole(name: String, clusterPermissions: String?) { @@ -236,12 +236,12 @@ abstract class PluginRestTestCase : OpenSearchRestTestCase() { } """.trimIndent() request.setJsonEntity(entity) - client().performRequest(request) + adminClient().performRequest(request) } fun deleteCustomRole(name: String) { val request = Request("DELETE", "/_plugins/_security/api/roles/$name") - client().performRequest(request) + adminClient().performRequest(request) } fun createUserWithRoles(user: String, password: String, role: String, backendRole: String) { @@ -368,8 +368,8 @@ abstract class PluginRestTestCase : OpenSearchRestTestCase() { @Throws(IOException::class) protected open fun wipeAllClusterSettings() { - updateClusterSettings(ClusterSetting("persistent", "*", null)) - updateClusterSettings(ClusterSetting("transient", "*", null)) + updateClusterSettings(ClusterSetting("persistent", "*", null), adminClient()) + updateClusterSettings(ClusterSetting("transient", "*", null), adminClient()) } protected fun getCurrentMappingsSchemaVersion(): Int { From b9da3ca4814637c129a42d9c3d73cb3c2fb4f563 Mon Sep 17 00:00:00 2001 From: Darshit Chanpura Date: Mon, 20 Jul 2026 14:14:31 -0700 Subject: [PATCH 06/19] Rename resource-action-groups.yml to resource-access-levels.yml Aligns with the security plugin rename (opensearch-project/security#6018). Also marks notifications_read_only as the default access level for migration API usage. Signed-off-by: Darshit Chanpura --- .../{resource-action-groups.yml => resource-access-levels.yml} | 1 + 1 file changed, 1 insertion(+) rename notifications/notifications/src/main/resources/{resource-action-groups.yml => resource-access-levels.yml} (98%) diff --git a/notifications/notifications/src/main/resources/resource-action-groups.yml b/notifications/notifications/src/main/resources/resource-access-levels.yml similarity index 98% rename from notifications/notifications/src/main/resources/resource-action-groups.yml rename to notifications/notifications/src/main/resources/resource-access-levels.yml index 8140511f52e..f21b2a4da9e 100644 --- a/notifications/notifications/src/main/resources/resource-action-groups.yml +++ b/notifications/notifications/src/main/resources/resource-access-levels.yml @@ -2,6 +2,7 @@ resource_types: notification_config: notifications_read_only: + default: true allowed_actions: - 'cluster:admin/opensearch/notifications/configs/get' - 'cluster:admin/opensearch/notifications/channels/get' From a21c5e0cb62cde33c5dac06cc091e78f230383e1 Mon Sep 17 00:00:00 2001 From: Darshit Chanpura Date: Mon, 20 Jul 2026 14:41:35 -0700 Subject: [PATCH 07/19] Fix Jackson 3.x version conflict and update schema version assertions - Add resolution strategy for tools.jackson dependencies to resolve conflict between security plugin (3.2.1) and compileOnly (3.2.0) - Update test assertions from schema_version 2 to 3 Signed-off-by: Darshit Chanpura --- notifications/notifications/build.gradle | 6 ++++++ .../integtest/config/CreateNotificationConfigIT.kt | 4 ++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/notifications/notifications/build.gradle b/notifications/notifications/build.gradle index 0af9386597c..ee91f1871a0 100644 --- a/notifications/notifications/build.gradle +++ b/notifications/notifications/build.gradle @@ -129,6 +129,12 @@ configurations.all { : versions.jackson details.useVersion version } + if (details.requested.group.startsWith('tools.jackson')) { + def version = details.requested.module.name == 'jackson-databind' + ? versions.jackson3_databind + : versions.jackson3 + details.useVersion version + } } force "commons-logging:commons-logging:1.3.5" force "commons-codec:commons-codec:1.17.1" diff --git a/notifications/notifications/src/test/kotlin/org/opensearch/integtest/config/CreateNotificationConfigIT.kt b/notifications/notifications/src/test/kotlin/org/opensearch/integtest/config/CreateNotificationConfigIT.kt index be7a8cfeb59..627cfaf46ec 100644 --- a/notifications/notifications/src/test/kotlin/org/opensearch/integtest/config/CreateNotificationConfigIT.kt +++ b/notifications/notifications/src/test/kotlin/org/opensearch/integtest/config/CreateNotificationConfigIT.kt @@ -314,7 +314,7 @@ class CreateNotificationConfigIT : PluginRestTestCase() { Assert.assertEquals(0, getCurrentMappingsSchemaVersion()) createConfig() - Assert.assertEquals(2, getCurrentMappingsSchemaVersion()) + Assert.assertEquals(3, getCurrentMappingsSchemaVersion()) } fun `test _meta field not exists in current mappings`() { @@ -366,6 +366,6 @@ class CreateNotificationConfigIT : PluginRestTestCase() { Assert.assertEquals(1, getCurrentMappingsSchemaVersion()) createConfig() - Assert.assertEquals(2, getCurrentMappingsSchemaVersion()) + Assert.assertEquals(3, getCurrentMappingsSchemaVersion()) } } From 0e6da0ad14b552fbda818f98368efc1eb103dd19 Mon Sep 17 00:00:00 2001 From: Darshit Chanpura Date: Mon, 20 Jul 2026 14:50:25 -0700 Subject: [PATCH 08/19] Simplify Jackson 3.x resolution and make security plugin zip conditional - Use single version (versions.jackson3) for all tools.jackson modules to resolve SNAPSHOT drift between opensearch-core and remote-metadata-sdk - Keep security plugin zip conditional on securityEnabled since it's only needed for integration test clusters Signed-off-by: Darshit Chanpura --- notifications/notifications/build.gradle | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/notifications/notifications/build.gradle b/notifications/notifications/build.gradle index ee91f1871a0..eab2bed891c 100644 --- a/notifications/notifications/build.gradle +++ b/notifications/notifications/build.gradle @@ -130,10 +130,7 @@ configurations.all { details.useVersion version } if (details.requested.group.startsWith('tools.jackson')) { - def version = details.requested.module.name == 'jackson-databind' - ? versions.jackson3_databind - : versions.jackson3 - details.useVersion version + details.useVersion versions.jackson3 } } force "commons-logging:commons-logging:1.3.5" @@ -190,7 +187,9 @@ dependencies { // Resource sharing compileOnly group: 'org.opensearch', name:'opensearch-security-spi', version:"${opensearch_build}" - opensearchPlugin "org.opensearch.plugin:opensearch-security:${opensearch_build}@zip" + if (securityEnabled) { + opensearchPlugin "org.opensearch.plugin:opensearch-security:${opensearch_build}@zip" + } compileOnly "tools.jackson.core:jackson-databind:${versions.jackson3_databind}" compileOnly "tools.jackson.core:jackson-core:${versions.jackson3}" From a1cb145e7cfbe424c45036aa6d25211f45c0ba7d Mon Sep 17 00:00:00 2001 From: Darshit Chanpura Date: Mon, 20 Jul 2026 15:20:22 -0700 Subject: [PATCH 09/19] Skip backend-roles access tests when resource sharing is enabled These tests exercise the legacy RBAC backend-roles filtering path which is intentionally bypassed when resource sharing is enabled. Signed-off-by: Darshit Chanpura --- .../test/kotlin/org/opensearch/integtest/EmailGroupAccessIT.kt | 2 +- .../org/opensearch/integtest/EmailNotificationAccessIT.kt | 2 +- .../org/opensearch/integtest/SlackNotificationAccessIT.kt | 2 +- .../test/kotlin/org/opensearch/integtest/SmtpAccountAccessIT.kt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/notifications/notifications/src/test/kotlin/org/opensearch/integtest/EmailGroupAccessIT.kt b/notifications/notifications/src/test/kotlin/org/opensearch/integtest/EmailGroupAccessIT.kt index 36e4f183adc..dc8acc4327e 100644 --- a/notifications/notifications/src/test/kotlin/org/opensearch/integtest/EmailGroupAccessIT.kt +++ b/notifications/notifications/src/test/kotlin/org/opensearch/integtest/EmailGroupAccessIT.kt @@ -28,8 +28,8 @@ class EmailGroupAccessIT : PluginRestTestCase() { @BeforeClass @JvmStatic fun setup() { - // things to execute once and keep around for the class org.junit.Assume.assumeTrue(System.getProperty("https", "false")!!.toBoolean()) + org.junit.Assume.assumeFalse(System.getProperty("resource_sharing.enabled", "false")!!.toBoolean()) } } diff --git a/notifications/notifications/src/test/kotlin/org/opensearch/integtest/EmailNotificationAccessIT.kt b/notifications/notifications/src/test/kotlin/org/opensearch/integtest/EmailNotificationAccessIT.kt index db28d3dea78..9ea645c8349 100644 --- a/notifications/notifications/src/test/kotlin/org/opensearch/integtest/EmailNotificationAccessIT.kt +++ b/notifications/notifications/src/test/kotlin/org/opensearch/integtest/EmailNotificationAccessIT.kt @@ -25,8 +25,8 @@ class EmailNotificationAccessIT : PluginRestTestCase() { @BeforeClass @JvmStatic fun setup() { - // things to execute once and keep around for the class org.junit.Assume.assumeTrue(System.getProperty("https", "false")!!.toBoolean()) + org.junit.Assume.assumeFalse(System.getProperty("resource_sharing.enabled", "false")!!.toBoolean()) } } diff --git a/notifications/notifications/src/test/kotlin/org/opensearch/integtest/SlackNotificationAccessIT.kt b/notifications/notifications/src/test/kotlin/org/opensearch/integtest/SlackNotificationAccessIT.kt index 1b4390858c6..07e0bc7ed99 100644 --- a/notifications/notifications/src/test/kotlin/org/opensearch/integtest/SlackNotificationAccessIT.kt +++ b/notifications/notifications/src/test/kotlin/org/opensearch/integtest/SlackNotificationAccessIT.kt @@ -26,8 +26,8 @@ class SlackNotificationAccessIT : PluginRestTestCase() { @BeforeClass @JvmStatic fun setup() { - // things to execute once and keep around for the class org.junit.Assume.assumeTrue(System.getProperty("https", "false")!!.toBoolean()) + org.junit.Assume.assumeFalse(System.getProperty("resource_sharing.enabled", "false")!!.toBoolean()) } } diff --git a/notifications/notifications/src/test/kotlin/org/opensearch/integtest/SmtpAccountAccessIT.kt b/notifications/notifications/src/test/kotlin/org/opensearch/integtest/SmtpAccountAccessIT.kt index 6bee8cbb297..ffa25ed7914 100644 --- a/notifications/notifications/src/test/kotlin/org/opensearch/integtest/SmtpAccountAccessIT.kt +++ b/notifications/notifications/src/test/kotlin/org/opensearch/integtest/SmtpAccountAccessIT.kt @@ -28,8 +28,8 @@ class SmtpAccountAccessIT : PluginRestTestCase() { @BeforeClass @JvmStatic fun setup() { - // things to execute once and keep around for the class org.junit.Assume.assumeTrue(System.getProperty("https", "false")!!.toBoolean()) + org.junit.Assume.assumeFalse(System.getProperty("resource_sharing.enabled", "false")!!.toBoolean()) } } From 4271036c654263175a63f962438aa091ee1c2551 Mon Sep 17 00:00:00 2001 From: Darshit Chanpura Date: Mon, 20 Jul 2026 16:03:18 -0700 Subject: [PATCH 10/19] Fix resource sharing IT: filter tests and fix role mapping - Only run ResourceSharingNotificationIT when resource sharing is enabled; exclude all other ITs since they use the admin user which cannot access protected resource types - Use addPatchUserRolesMapping instead of createUserRolesMapping to avoid overwriting the all_access role mapping Signed-off-by: Darshit Chanpura --- notifications/notifications/build.gradle | 10 ++++++++++ .../integtest/ResourceSharingNotificationIT.kt | 4 ++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/notifications/notifications/build.gradle b/notifications/notifications/build.gradle index eab2bed891c..0d855ac8a15 100644 --- a/notifications/notifications/build.gradle +++ b/notifications/notifications/build.gradle @@ -358,6 +358,16 @@ integTest { } } + if (System.getProperty("resource_sharing.enabled") != "true") { + filter { + excludeTestsMatching "org.opensearch.integtest.ResourceSharingNotificationIT" + } + } else { + filter { + includeTestsMatching "org.opensearch.integtest.ResourceSharingNotificationIT" + } + } + if (System.getProperty("tests.rest.bwcsuite") == null) { filter { excludeTestsMatching "org.opensearch.integtest.bwc.*IT" diff --git a/notifications/notifications/src/test/kotlin/org/opensearch/integtest/ResourceSharingNotificationIT.kt b/notifications/notifications/src/test/kotlin/org/opensearch/integtest/ResourceSharingNotificationIT.kt index 6bd6fe644be..131341f08fe 100644 --- a/notifications/notifications/src/test/kotlin/org/opensearch/integtest/ResourceSharingNotificationIT.kt +++ b/notifications/notifications/src/test/kotlin/org/opensearch/integtest/ResourceSharingNotificationIT.kt @@ -43,12 +43,12 @@ class ResourceSharingNotificationIT : PluginRestTestCase() { fun setupUsers() { if (aliceClient != null) return createUser(aliceUser, alicePassword, arrayOf("engineering")) - createUserRolesMapping(ALL_ACCESS_ROLE, arrayOf(aliceUser)) + addPatchUserRolesMapping(ALL_ACCESS_ROLE, arrayOf(aliceUser)) aliceClient = SecureRestClientBuilder(clusterHosts.toTypedArray(), isHttps(), aliceUser, alicePassword) .setSocketTimeout(60000).build() createUser(bobUser, bobPassword, arrayOf("marketing")) - createUserRolesMapping(ALL_ACCESS_ROLE, arrayOf(bobUser)) + addPatchUserRolesMapping(ALL_ACCESS_ROLE, arrayOf(bobUser)) bobClient = SecureRestClientBuilder(clusterHosts.toTypedArray(), isHttps(), bobUser, bobPassword) .setSocketTimeout(60000).build() } From a9e27c33e8eb7f81b9927c0db52101dca0b08607 Mon Sep 17 00:00:00 2001 From: Darshit Chanpura Date: Mon, 20 Jul 2026 16:26:56 -0700 Subject: [PATCH 11/19] Fix JSON Patch path in addPatchUserRolesMapping Add leading slash to path field per RFC 6902 ("/users" not "users"). Signed-off-by: Darshit Chanpura --- .../kotlin/org/opensearch/integtest/PluginRestTestCase.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/notifications/notifications/src/test/kotlin/org/opensearch/integtest/PluginRestTestCase.kt b/notifications/notifications/src/test/kotlin/org/opensearch/integtest/PluginRestTestCase.kt index ab7aea3c8fc..0e7723d0d1d 100644 --- a/notifications/notifications/src/test/kotlin/org/opensearch/integtest/PluginRestTestCase.kt +++ b/notifications/notifications/src/test/kotlin/org/opensearch/integtest/PluginRestTestCase.kt @@ -206,7 +206,7 @@ abstract class PluginRestTestCase : OpenSearchRestTestCase() { val entity = "[{\n" + " \"op\" : \"add\",\n" + - " \"path\" : \"users\",\n" + + " \"path\" : \"/users\",\n" + " \"value\" : [$usersStr]\n" + "}]" @@ -220,7 +220,7 @@ abstract class PluginRestTestCase : OpenSearchRestTestCase() { val entity = "[{\n" + " \"op\" : \"remove\",\n" + - " \"path\" : \"users\",\n" + + " \"path\" : \"/users\",\n" + " \"value\" : [$usersStr]\n" + "}]" From 5bbb02d4f84d5a42e926fe39e90b015e4e761e33 Mon Sep 17 00:00:00 2001 From: Darshit Chanpura Date: Mon, 20 Jul 2026 17:01:38 -0700 Subject: [PATCH 12/19] Integrate ResourceSharingClient for access control enforcement - Add verifyResourceAccess() to UserAccessManager that calls ResourceSharingClient.verifyAccess() for GET, UPDATE, DELETE ops - Add suspendUntilCallback utility for bridging ActionListener to coroutines - Use adminClient() for refreshAllIndices since protected resource indices restrict the admin user - Fix ResourceSharingNotificationIT to use proper non-admin roles and verify access control enforcement Signed-off-by: Darshit Chanpura --- .../index/ConfigIndexingActions.kt | 7 ++++ .../security/UserAccessManager.kt | 17 ++++++++ .../notifications/util/SuspendUtils.kt | 9 +++++ .../integtest/PluginRestTestCase.kt | 2 +- .../ResourceSharingNotificationIT.kt | 40 +++++++++++-------- 5 files changed, 57 insertions(+), 18 deletions(-) diff --git a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/index/ConfigIndexingActions.kt b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/index/ConfigIndexingActions.kt index ec6ba961767..0787b0ac12a 100644 --- a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/index/ConfigIndexingActions.kt +++ b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/index/ConfigIndexingActions.kt @@ -38,6 +38,7 @@ import org.opensearch.notifications.metrics.Metrics import org.opensearch.notifications.model.DocMetadata import org.opensearch.notifications.model.NotificationConfigDoc import org.opensearch.notifications.security.UserAccess +import org.opensearch.notifications.security.UserAccessManager import java.time.Instant /** * NotificationConfig indexing operation actions. @@ -226,6 +227,7 @@ object ConfigIndexingActions { suspend fun update(request: UpdateNotificationConfigRequest, user: User?): UpdateNotificationConfigResponse { log.info("$LOG_PREFIX:NotificationConfig-update ${request.configId}") userAccess.validateUser(user) + UserAccessManager.verifyResourceAccess(request.configId, "cluster:admin/opensearch/notifications/configs/update") validateConfig(request.notificationConfig, user) val currentConfigDoc = operations.getNotificationConfig(request.configId) currentConfigDoc @@ -282,6 +284,7 @@ object ConfigIndexingActions { */ private suspend fun info(configId: String, user: User?): GetNotificationConfigResponse { log.info("$LOG_PREFIX:NotificationConfig-info $configId") + UserAccessManager.verifyResourceAccess(configId, "cluster:admin/opensearch/notifications/configs/get") val configDoc = operations.getNotificationConfig(configId) configDoc ?: run { @@ -404,6 +407,7 @@ object ConfigIndexingActions { private suspend fun delete(configId: String, user: User?): DeleteNotificationConfigResponse { log.info("$LOG_PREFIX:NotificationConfig-delete $configId") userAccess.validateUser(user) + UserAccessManager.verifyResourceAccess(configId, "cluster:admin/opensearch/notifications/configs/delete") val currentConfigDoc = operations.getNotificationConfig(configId) currentConfigDoc ?: run { @@ -441,6 +445,9 @@ object ConfigIndexingActions { private suspend fun delete(configIds: Set, user: User?): DeleteNotificationConfigResponse { log.info("$LOG_PREFIX:NotificationConfig-delete $configIds") userAccess.validateUser(user) + configIds.forEach { id -> + UserAccessManager.verifyResourceAccess(id, "cluster:admin/opensearch/notifications/configs/delete") + } val configDocs = operations.getNotificationConfigs(configIds) if (configDocs.size != configIds.size) { val mutableSet = configIds.toMutableSet() diff --git a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/security/UserAccessManager.kt b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/security/UserAccessManager.kt index 91f55ca240b..d62444ae163 100644 --- a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/security/UserAccessManager.kt +++ b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/security/UserAccessManager.kt @@ -11,6 +11,7 @@ import org.opensearch.core.rest.RestStatus import org.opensearch.notifications.ResourceSharingClientAccessor import org.opensearch.notifications.settings.FilterByBackendRolesAccessStrategy import org.opensearch.notifications.settings.PluginSettings +import org.opensearch.notifications.util.SuspendUtils.Companion.suspendUntilCallback /** * Class for checking/filtering user access. @@ -79,4 +80,20 @@ internal object UserAccessManager : UserAccess { } return access.isEmpty() || user.roles.contains(ADMIN_ROLE) || checkUserBackendRolesAccess(user.backendRoles, access) } + + suspend fun verifyResourceAccess(resourceId: String, action: String) { + val client = ResourceSharingClientAccessor.getResourceSharingClient() ?: return + if (!client.isFeatureEnabledForType(RESOURCE_TYPE)) return + val hasAccess = suspendUntilCallback { listener -> + client.verifyAccess(resourceId, RESOURCE_TYPE, action, listener) + } + if (!hasAccess) { + throw OpenSearchStatusException( + "no permissions for [$action] on resource [$resourceId]", + RestStatus.FORBIDDEN + ) + } + } + + private const val RESOURCE_TYPE = "notification_config" } diff --git a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/util/SuspendUtils.kt b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/util/SuspendUtils.kt index 94f0022bb4d..f4589f1d52c 100644 --- a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/util/SuspendUtils.kt +++ b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/util/SuspendUtils.kt @@ -33,5 +33,14 @@ class SuspendUtils { } return finalValue } + + suspend fun suspendUntilCallback(block: (ActionListener) -> Unit): T = + suspendCancellableCoroutine { cont -> + block(object : ActionListener { + override fun onResponse(response: T) = cont.resume(response) + + override fun onFailure(e: Exception) = cont.resumeWithException(e) + }) + } } } diff --git a/notifications/notifications/src/test/kotlin/org/opensearch/integtest/PluginRestTestCase.kt b/notifications/notifications/src/test/kotlin/org/opensearch/integtest/PluginRestTestCase.kt index 0e7723d0d1d..7a0faa14220 100644 --- a/notifications/notifications/src/test/kotlin/org/opensearch/integtest/PluginRestTestCase.kt +++ b/notifications/notifications/src/test/kotlin/org/opensearch/integtest/PluginRestTestCase.kt @@ -556,7 +556,7 @@ abstract class PluginRestTestCase : OpenSearchRestTestCase() { } } refreshRequest.setOptions(requestOptions) - client().performRequest(refreshRequest) + adminClient().performRequest(refreshRequest) } protected class ClusterSetting(val type: String, val name: String, var value: Any?) { diff --git a/notifications/notifications/src/test/kotlin/org/opensearch/integtest/ResourceSharingNotificationIT.kt b/notifications/notifications/src/test/kotlin/org/opensearch/integtest/ResourceSharingNotificationIT.kt index 131341f08fe..5ef911b98c2 100644 --- a/notifications/notifications/src/test/kotlin/org/opensearch/integtest/ResourceSharingNotificationIT.kt +++ b/notifications/notifications/src/test/kotlin/org/opensearch/integtest/ResourceSharingNotificationIT.kt @@ -10,10 +10,10 @@ import org.junit.Assert import org.junit.Before import org.junit.BeforeClass import org.opensearch.client.Request -import org.opensearch.client.ResponseException import org.opensearch.client.RestClient import org.opensearch.commons.notifications.model.ConfigType import org.opensearch.commons.rest.SecureRestClientBuilder +import org.opensearch.core.rest.RestStatus import org.opensearch.notifications.NotificationPlugin import org.opensearch.rest.RestRequest @@ -32,6 +32,7 @@ class ResourceSharingNotificationIT : PluginRestTestCase() { } } + private val notificationsFullAccessRole = "notifications_full_access" private val aliceUser = "rs_alice" private val alicePassword = "TopSecret_1234%Alice" private val bobUser = "rs_bob" @@ -42,13 +43,13 @@ class ResourceSharingNotificationIT : PluginRestTestCase() { @Before fun setupUsers() { if (aliceClient != null) return + createCustomRole(notificationsFullAccessRole, "cluster:admin/opensearch/notifications/*") createUser(aliceUser, alicePassword, arrayOf("engineering")) - addPatchUserRolesMapping(ALL_ACCESS_ROLE, arrayOf(aliceUser)) + createUser(bobUser, bobPassword, arrayOf("marketing")) + createUserRolesMapping(notificationsFullAccessRole, arrayOf(aliceUser, bobUser)) + aliceClient = SecureRestClientBuilder(clusterHosts.toTypedArray(), isHttps(), aliceUser, alicePassword) .setSocketTimeout(60000).build() - - createUser(bobUser, bobPassword, arrayOf("marketing")) - addPatchUserRolesMapping(ALL_ACCESS_ROLE, arrayOf(bobUser)) bobClient = SecureRestClientBuilder(clusterHosts.toTypedArray(), isHttps(), bobUser, bobPassword) .setSocketTimeout(60000).build() } @@ -64,18 +65,23 @@ class ResourceSharingNotificationIT : PluginRestTestCase() { fun `test config created by alice is not visible to bob`() { val configId = createConfig(configType = ConfigType.SLACK, client = aliceClient!!) - // Bob should not be able to get Alice's config - val exception = Assert.assertThrows(ResponseException::class.java) { - executeRequest( - RestRequest.Method.GET.name, - "${NotificationPlugin.PLUGIN_BASE_URI}/configs/$configId", - "", - 0, - bobClient!! - ) - } - Assert.assertTrue( - exception.message!!.contains("no permissions") || exception.response.statusLine.statusCode == 403 + // Alice can access her own config + val aliceResponse = executeRequest( + RestRequest.Method.GET.name, + "${NotificationPlugin.PLUGIN_BASE_URI}/configs/$configId", + "", + RestStatus.OK.status, + aliceClient!! + ) + Assert.assertNotNull(aliceResponse) + + // Bob should not be able to access Alice's config + executeRequest( + RestRequest.Method.GET.name, + "${NotificationPlugin.PLUGIN_BASE_URI}/configs/$configId", + "", + RestStatus.FORBIDDEN.status, + bobClient!! ) } From 7e9f03ae8fc7d6274c5bf917794ae58eaafd785b Mon Sep 17 00:00:00 2001 From: Darshit Chanpura Date: Mon, 20 Jul 2026 20:48:52 -0700 Subject: [PATCH 13/19] Address review comments: extract RESOURCE_TYPE constant, add verifyAccess for multi-ID ops - Extract RESOURCE_TYPE constant in NotificationsResourceSharingExtension and reference it from UserAccessManager - Add verifyResourceAccess for multi-ID info() and multi-ID delete() - For getAll(): DLS filter applied by security plugin handles search filtering automatically - For create: security plugin's ResourceIndexListener.postIndex() registers resource ownership automatically on index write Signed-off-by: Darshit Chanpura --- .../notifications/NotificationsResourceSharingExtension.kt | 7 ++++++- .../notifications/index/ConfigIndexingActions.kt | 3 +++ .../opensearch/notifications/security/UserAccessManager.kt | 5 ++--- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/NotificationsResourceSharingExtension.kt b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/NotificationsResourceSharingExtension.kt index 43db3b68229..23bf40c3640 100644 --- a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/NotificationsResourceSharingExtension.kt +++ b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/NotificationsResourceSharingExtension.kt @@ -10,10 +10,15 @@ import org.opensearch.security.spi.resources.ResourceSharingExtension import org.opensearch.security.spi.resources.client.ResourceSharingClient class NotificationsResourceSharingExtension : ResourceSharingExtension { + + companion object { + const val RESOURCE_TYPE = "notification_config" + } + override fun getResourceProviders(): Set { return setOf( object : ResourceProvider { - override fun resourceType(): String = "notification_config" + override fun resourceType(): String = RESOURCE_TYPE override fun resourceIndexName(): String = NotificationConfigIndex.INDEX_NAME } ) diff --git a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/index/ConfigIndexingActions.kt b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/index/ConfigIndexingActions.kt index 0787b0ac12a..b1639a92375 100644 --- a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/index/ConfigIndexingActions.kt +++ b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/index/ConfigIndexingActions.kt @@ -313,6 +313,9 @@ object ConfigIndexingActions { */ private suspend fun info(configIds: Set, user: User?): GetNotificationConfigResponse { log.info("$LOG_PREFIX:NotificationConfig-info $configIds") + configIds.forEach { id -> + UserAccessManager.verifyResourceAccess(id, "cluster:admin/opensearch/notifications/configs/get") + } val configDocs = operations.getNotificationConfigs(configIds) if (configDocs.size != configIds.size) { val mutableSet = configIds.toMutableSet() diff --git a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/security/UserAccessManager.kt b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/security/UserAccessManager.kt index d62444ae163..e1b001dfbf2 100644 --- a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/security/UserAccessManager.kt +++ b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/security/UserAccessManager.kt @@ -8,6 +8,7 @@ package org.opensearch.notifications.security import org.opensearch.OpenSearchStatusException import org.opensearch.commons.authuser.User import org.opensearch.core.rest.RestStatus +import org.opensearch.notifications.NotificationsResourceSharingExtension.Companion.RESOURCE_TYPE import org.opensearch.notifications.ResourceSharingClientAccessor import org.opensearch.notifications.settings.FilterByBackendRolesAccessStrategy import org.opensearch.notifications.settings.PluginSettings @@ -21,7 +22,7 @@ internal object UserAccessManager : UserAccess { private fun isResourceSharingEnabled(): Boolean { val client = ResourceSharingClientAccessor.getResourceSharingClient() - return client != null && client.isFeatureEnabledForType("notification_config") + return client != null && client.isFeatureEnabledForType(RESOURCE_TYPE) } /** @@ -94,6 +95,4 @@ internal object UserAccessManager : UserAccess { ) } } - - private const val RESOURCE_TYPE = "notification_config" } From 238667eeca63b473c56f3385a37a5e7fbb9bac14 Mon Sep 17 00:00:00 2001 From: Darshit Chanpura Date: Mon, 20 Jul 2026 21:08:44 -0700 Subject: [PATCH 14/19] Remove explicit verifyAccess calls; rely on security plugin's DocRequest-based authz Direct resource access control is handled automatically by the security plugin's ResourceAccessEvaluator when request classes implement DocRequest. The verifyAccess client method is only needed for transitive access checks. Request classes in common-utils need to implement DocRequest as a follow-up to enable transport-level enforcement. Simplified integration tests to validate framework registration and basic CRUD with resource sharing enabled. Signed-off-by: Darshit Chanpura --- .../index/ConfigIndexingActions.kt | 10 ---- .../security/UserAccessManager.kt | 15 ----- .../notifications/util/SuspendUtils.kt | 9 --- .../ResourceSharingNotificationIT.kt | 57 +++++++------------ 4 files changed, 22 insertions(+), 69 deletions(-) diff --git a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/index/ConfigIndexingActions.kt b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/index/ConfigIndexingActions.kt index b1639a92375..ec6ba961767 100644 --- a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/index/ConfigIndexingActions.kt +++ b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/index/ConfigIndexingActions.kt @@ -38,7 +38,6 @@ import org.opensearch.notifications.metrics.Metrics import org.opensearch.notifications.model.DocMetadata import org.opensearch.notifications.model.NotificationConfigDoc import org.opensearch.notifications.security.UserAccess -import org.opensearch.notifications.security.UserAccessManager import java.time.Instant /** * NotificationConfig indexing operation actions. @@ -227,7 +226,6 @@ object ConfigIndexingActions { suspend fun update(request: UpdateNotificationConfigRequest, user: User?): UpdateNotificationConfigResponse { log.info("$LOG_PREFIX:NotificationConfig-update ${request.configId}") userAccess.validateUser(user) - UserAccessManager.verifyResourceAccess(request.configId, "cluster:admin/opensearch/notifications/configs/update") validateConfig(request.notificationConfig, user) val currentConfigDoc = operations.getNotificationConfig(request.configId) currentConfigDoc @@ -284,7 +282,6 @@ object ConfigIndexingActions { */ private suspend fun info(configId: String, user: User?): GetNotificationConfigResponse { log.info("$LOG_PREFIX:NotificationConfig-info $configId") - UserAccessManager.verifyResourceAccess(configId, "cluster:admin/opensearch/notifications/configs/get") val configDoc = operations.getNotificationConfig(configId) configDoc ?: run { @@ -313,9 +310,6 @@ object ConfigIndexingActions { */ private suspend fun info(configIds: Set, user: User?): GetNotificationConfigResponse { log.info("$LOG_PREFIX:NotificationConfig-info $configIds") - configIds.forEach { id -> - UserAccessManager.verifyResourceAccess(id, "cluster:admin/opensearch/notifications/configs/get") - } val configDocs = operations.getNotificationConfigs(configIds) if (configDocs.size != configIds.size) { val mutableSet = configIds.toMutableSet() @@ -410,7 +404,6 @@ object ConfigIndexingActions { private suspend fun delete(configId: String, user: User?): DeleteNotificationConfigResponse { log.info("$LOG_PREFIX:NotificationConfig-delete $configId") userAccess.validateUser(user) - UserAccessManager.verifyResourceAccess(configId, "cluster:admin/opensearch/notifications/configs/delete") val currentConfigDoc = operations.getNotificationConfig(configId) currentConfigDoc ?: run { @@ -448,9 +441,6 @@ object ConfigIndexingActions { private suspend fun delete(configIds: Set, user: User?): DeleteNotificationConfigResponse { log.info("$LOG_PREFIX:NotificationConfig-delete $configIds") userAccess.validateUser(user) - configIds.forEach { id -> - UserAccessManager.verifyResourceAccess(id, "cluster:admin/opensearch/notifications/configs/delete") - } val configDocs = operations.getNotificationConfigs(configIds) if (configDocs.size != configIds.size) { val mutableSet = configIds.toMutableSet() diff --git a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/security/UserAccessManager.kt b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/security/UserAccessManager.kt index e1b001dfbf2..a36a39fd5c3 100644 --- a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/security/UserAccessManager.kt +++ b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/security/UserAccessManager.kt @@ -12,7 +12,6 @@ import org.opensearch.notifications.NotificationsResourceSharingExtension.Compan import org.opensearch.notifications.ResourceSharingClientAccessor import org.opensearch.notifications.settings.FilterByBackendRolesAccessStrategy import org.opensearch.notifications.settings.PluginSettings -import org.opensearch.notifications.util.SuspendUtils.Companion.suspendUntilCallback /** * Class for checking/filtering user access. @@ -81,18 +80,4 @@ internal object UserAccessManager : UserAccess { } return access.isEmpty() || user.roles.contains(ADMIN_ROLE) || checkUserBackendRolesAccess(user.backendRoles, access) } - - suspend fun verifyResourceAccess(resourceId: String, action: String) { - val client = ResourceSharingClientAccessor.getResourceSharingClient() ?: return - if (!client.isFeatureEnabledForType(RESOURCE_TYPE)) return - val hasAccess = suspendUntilCallback { listener -> - client.verifyAccess(resourceId, RESOURCE_TYPE, action, listener) - } - if (!hasAccess) { - throw OpenSearchStatusException( - "no permissions for [$action] on resource [$resourceId]", - RestStatus.FORBIDDEN - ) - } - } } diff --git a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/util/SuspendUtils.kt b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/util/SuspendUtils.kt index f4589f1d52c..94f0022bb4d 100644 --- a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/util/SuspendUtils.kt +++ b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/util/SuspendUtils.kt @@ -33,14 +33,5 @@ class SuspendUtils { } return finalValue } - - suspend fun suspendUntilCallback(block: (ActionListener) -> Unit): T = - suspendCancellableCoroutine { cont -> - block(object : ActionListener { - override fun onResponse(response: T) = cont.resume(response) - - override fun onFailure(e: Exception) = cont.resumeWithException(e) - }) - } } } diff --git a/notifications/notifications/src/test/kotlin/org/opensearch/integtest/ResourceSharingNotificationIT.kt b/notifications/notifications/src/test/kotlin/org/opensearch/integtest/ResourceSharingNotificationIT.kt index 5ef911b98c2..c3b3ff043e0 100644 --- a/notifications/notifications/src/test/kotlin/org/opensearch/integtest/ResourceSharingNotificationIT.kt +++ b/notifications/notifications/src/test/kotlin/org/opensearch/integtest/ResourceSharingNotificationIT.kt @@ -62,34 +62,40 @@ class ResourceSharingNotificationIT : PluginRestTestCase() { bobClient = null } - fun `test config created by alice is not visible to bob`() { + fun `test create and get notification config with resource sharing enabled`() { val configId = createConfig(configType = ConfigType.SLACK, client = aliceClient!!) - // Alice can access her own config - val aliceResponse = executeRequest( + val response = executeRequest( RestRequest.Method.GET.name, "${NotificationPlugin.PLUGIN_BASE_URI}/configs/$configId", "", RestStatus.OK.status, aliceClient!! ) - Assert.assertNotNull(aliceResponse) - - // Bob should not be able to access Alice's config - executeRequest( - RestRequest.Method.GET.name, - "${NotificationPlugin.PLUGIN_BASE_URI}/configs/$configId", - "", - RestStatus.FORBIDDEN.status, - bobClient!! - ) + Assert.assertNotNull(response) } - fun `test config created by alice is visible after sharing`() { + fun `test share resource with another user`() { val configId = createConfig(configType = ConfigType.SLACK, client = aliceClient!!) // Share with bob - shareResource(aliceClient!!, configId, "notification_config", "notifications_read_only", bobUser) + val shareRequest = Request("PUT", "/_plugins/_security/api/resource/share") + shareRequest.setJsonEntity( + """ + { + "resource_id": "$configId", + "resource_type": "notification_config", + "share_with": { + "notifications_read_only": { + "users": ["$bobUser"] + } + } + } + """.trimIndent() + ) + val shareResponse = aliceClient!!.performRequest(shareRequest) + Assert.assertEquals(200, shareResponse.statusLine.statusCode) + Thread.sleep(2000) // Bob should now be able to get the config @@ -97,28 +103,9 @@ class ResourceSharingNotificationIT : PluginRestTestCase() { RestRequest.Method.GET.name, "${NotificationPlugin.PLUGIN_BASE_URI}/configs/$configId", "", - 200, + RestStatus.OK.status, bobClient!! ) Assert.assertNotNull(response) } - - private fun shareResource(client: RestClient, resourceId: String, resourceType: String, accessLevel: String, user: String) { - val request = Request("PUT", "/_plugins/_security/api/resource/share") - request.setJsonEntity( - """ - { - "resource_id": "$resourceId", - "resource_type": "$resourceType", - "share_with": { - "$accessLevel": { - "users": ["$user"] - } - } - } - """.trimIndent() - ) - val response = client.performRequest(request) - Assert.assertEquals(200, response.statusLine.statusCode) - } } From af0c926b2558a8ad7ce6c1af7594023997e95877 Mon Sep 17 00:00:00 2001 From: Darshit Chanpura Date: Mon, 20 Jul 2026 21:53:58 -0700 Subject: [PATCH 15/19] Add verifyAccess for multi-ID operations and tighten integration tests - Add verifyResourceAccess calls for multi-ID info() and delete() since DocRequest.id() returns null for these (transport-level check skipped) - Update tests to exercise security plugin's transport-level interception: owner access, non-owner denial, and share-then-access flow - Use adminClient() for refreshAllIndices (protected index restriction) Signed-off-by: Darshit Chanpura --- .../index/ConfigIndexingActions.kt | 7 +++++ .../security/UserAccessManager.kt | 31 +++++++++++++++++++ .../ResourceSharingNotificationIT.kt | 26 ++++++++++++++-- 3 files changed, 62 insertions(+), 2 deletions(-) diff --git a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/index/ConfigIndexingActions.kt b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/index/ConfigIndexingActions.kt index ec6ba961767..f7178445cfe 100644 --- a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/index/ConfigIndexingActions.kt +++ b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/index/ConfigIndexingActions.kt @@ -38,6 +38,7 @@ import org.opensearch.notifications.metrics.Metrics import org.opensearch.notifications.model.DocMetadata import org.opensearch.notifications.model.NotificationConfigDoc import org.opensearch.notifications.security.UserAccess +import org.opensearch.notifications.security.UserAccessManager import java.time.Instant /** * NotificationConfig indexing operation actions. @@ -310,6 +311,9 @@ object ConfigIndexingActions { */ private suspend fun info(configIds: Set, user: User?): GetNotificationConfigResponse { log.info("$LOG_PREFIX:NotificationConfig-info $configIds") + configIds.forEach { id -> + UserAccessManager.verifyResourceAccess(id, "cluster:admin/opensearch/notifications/configs/get") + } val configDocs = operations.getNotificationConfigs(configIds) if (configDocs.size != configIds.size) { val mutableSet = configIds.toMutableSet() @@ -441,6 +445,9 @@ object ConfigIndexingActions { private suspend fun delete(configIds: Set, user: User?): DeleteNotificationConfigResponse { log.info("$LOG_PREFIX:NotificationConfig-delete $configIds") userAccess.validateUser(user) + configIds.forEach { id -> + UserAccessManager.verifyResourceAccess(id, "cluster:admin/opensearch/notifications/configs/delete") + } val configDocs = operations.getNotificationConfigs(configIds) if (configDocs.size != configIds.size) { val mutableSet = configIds.toMutableSet() diff --git a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/security/UserAccessManager.kt b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/security/UserAccessManager.kt index a36a39fd5c3..9c39449e030 100644 --- a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/security/UserAccessManager.kt +++ b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/security/UserAccessManager.kt @@ -5,13 +5,17 @@ package org.opensearch.notifications.security +import kotlinx.coroutines.suspendCancellableCoroutine import org.opensearch.OpenSearchStatusException import org.opensearch.commons.authuser.User +import org.opensearch.core.action.ActionListener import org.opensearch.core.rest.RestStatus import org.opensearch.notifications.NotificationsResourceSharingExtension.Companion.RESOURCE_TYPE import org.opensearch.notifications.ResourceSharingClientAccessor import org.opensearch.notifications.settings.FilterByBackendRolesAccessStrategy import org.opensearch.notifications.settings.PluginSettings +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException /** * Class for checking/filtering user access. @@ -80,4 +84,31 @@ internal object UserAccessManager : UserAccess { } return access.isEmpty() || user.roles.contains(ADMIN_ROLE) || checkUserBackendRolesAccess(user.backendRoles, access) } + + /** + * Verify resource access via the security plugin's ResourceSharingClient. + * Used for multi-ID requests where DocRequest.id() returns null and the + * transport-level ResourceAccessEvaluator is skipped. + */ + suspend fun verifyResourceAccess(resourceId: String, action: String) { + val client = ResourceSharingClientAccessor.getResourceSharingClient() ?: return + if (!client.isFeatureEnabledForType(RESOURCE_TYPE)) return + val hasAccess = suspendCancellableCoroutine { cont -> + client.verifyAccess( + resourceId, + RESOURCE_TYPE, + action, + object : ActionListener { + override fun onResponse(response: Boolean) = cont.resume(response) + override fun onFailure(e: Exception) = cont.resumeWithException(e) + } + ) + } + if (!hasAccess) { + throw OpenSearchStatusException( + "no permissions for [$action] on resource [$resourceId]", + RestStatus.FORBIDDEN + ) + } + } } diff --git a/notifications/notifications/src/test/kotlin/org/opensearch/integtest/ResourceSharingNotificationIT.kt b/notifications/notifications/src/test/kotlin/org/opensearch/integtest/ResourceSharingNotificationIT.kt index c3b3ff043e0..49b94295f65 100644 --- a/notifications/notifications/src/test/kotlin/org/opensearch/integtest/ResourceSharingNotificationIT.kt +++ b/notifications/notifications/src/test/kotlin/org/opensearch/integtest/ResourceSharingNotificationIT.kt @@ -62,7 +62,7 @@ class ResourceSharingNotificationIT : PluginRestTestCase() { bobClient = null } - fun `test create and get notification config with resource sharing enabled`() { + fun `test owner can access their own config`() { val configId = createConfig(configType = ConfigType.SLACK, client = aliceClient!!) val response = executeRequest( @@ -75,9 +75,31 @@ class ResourceSharingNotificationIT : PluginRestTestCase() { Assert.assertNotNull(response) } - fun `test share resource with another user`() { + fun `test non-owner cannot access config by ID`() { val configId = createConfig(configType = ConfigType.SLACK, client = aliceClient!!) + // Bob should be denied access to Alice's config + executeRequest( + RestRequest.Method.GET.name, + "${NotificationPlugin.PLUGIN_BASE_URI}/configs/$configId", + "", + RestStatus.FORBIDDEN.status, + bobClient!! + ) + } + + fun `test config becomes accessible after sharing`() { + val configId = createConfig(configType = ConfigType.SLACK, client = aliceClient!!) + + // Bob cannot access before sharing + executeRequest( + RestRequest.Method.GET.name, + "${NotificationPlugin.PLUGIN_BASE_URI}/configs/$configId", + "", + RestStatus.FORBIDDEN.status, + bobClient!! + ) + // Share with bob val shareRequest = Request("PUT", "/_plugins/_security/api/resource/share") shareRequest.setJsonEntity( From a4a77dccdc0e16d4c551b84111dd56804b08d7e2 Mon Sep 17 00:00:00 2001 From: Darshit Chanpura Date: Tue, 21 Jul 2026 14:37:54 -0700 Subject: [PATCH 16/19] Comprehensive resource sharing integration tests Covers the full access level hierarchy with extensive assertions: - Owner access: create and retrieve own resources - Unshared: full cluster role is insufficient without resource-level access - read_only: can GET; cannot UPDATE, DELETE, or SHARE - read_write: can GET, UPDATE, DELETE; cannot SHARE; owner sees updates - full_access: can GET, UPDATE, DELETE, SHARE; share chain works All users have identical cluster-level permissions to prove that resource-level access control (via resource-access-levels.yml) is the enforcing layer. Signed-off-by: Darshit Chanpura --- .../ResourceSharingNotificationIT.kt | 236 ++++++++++++++++-- 1 file changed, 210 insertions(+), 26 deletions(-) diff --git a/notifications/notifications/src/test/kotlin/org/opensearch/integtest/ResourceSharingNotificationIT.kt b/notifications/notifications/src/test/kotlin/org/opensearch/integtest/ResourceSharingNotificationIT.kt index 49b94295f65..aa2a816cc00 100644 --- a/notifications/notifications/src/test/kotlin/org/opensearch/integtest/ResourceSharingNotificationIT.kt +++ b/notifications/notifications/src/test/kotlin/org/opensearch/integtest/ResourceSharingNotificationIT.kt @@ -20,6 +20,11 @@ import org.opensearch.rest.RestRequest /** * Integration tests for Resource Sharing feature with Notifications plugin. * Only runs when both security and resource_sharing are enabled. + * + * All users have the same cluster-level role (notifications_full_access) which grants + * all notification actions + resource sharing. The tests verify that resource-level + * access control (via access levels in resource-access-levels.yml) correctly restricts + * what shared users can do regardless of their cluster permissions. */ class ResourceSharingNotificationIT : PluginRestTestCase() { @@ -37,32 +42,40 @@ class ResourceSharingNotificationIT : PluginRestTestCase() { private val alicePassword = "TopSecret_1234%Alice" private val bobUser = "rs_bob" private val bobPassword = "TopSecret_1234%Bobby" + private val charlieUser = "rs_charlie" + private val charliePassword = "TopSecret_1234%Charlie" private var aliceClient: RestClient? = null private var bobClient: RestClient? = null + private var charlieClient: RestClient? = null @Before fun setupUsers() { if (aliceClient != null) return - createCustomRole(notificationsFullAccessRole, "cluster:admin/opensearch/notifications/*") + createNotificationsRole() createUser(aliceUser, alicePassword, arrayOf("engineering")) createUser(bobUser, bobPassword, arrayOf("marketing")) - createUserRolesMapping(notificationsFullAccessRole, arrayOf(aliceUser, bobUser)) + createUser(charlieUser, charliePassword, arrayOf("sales")) + createUserRolesMapping(notificationsFullAccessRole, arrayOf(aliceUser, bobUser, charlieUser)) aliceClient = SecureRestClientBuilder(clusterHosts.toTypedArray(), isHttps(), aliceUser, alicePassword) .setSocketTimeout(60000).build() bobClient = SecureRestClientBuilder(clusterHosts.toTypedArray(), isHttps(), bobUser, bobPassword) .setSocketTimeout(60000).build() + charlieClient = SecureRestClientBuilder(clusterHosts.toTypedArray(), isHttps(), charlieUser, charliePassword) + .setSocketTimeout(60000).build() } @After fun cleanupClients() { aliceClient?.close() bobClient?.close() + charlieClient?.close() aliceClient = null bobClient = null + charlieClient = null } - fun `test owner can access their own config`() { + fun `test owner can create and access their own config`() { val configId = createConfig(configType = ConfigType.SLACK, client = aliceClient!!) val response = executeRequest( @@ -75,10 +88,10 @@ class ResourceSharingNotificationIT : PluginRestTestCase() { Assert.assertNotNull(response) } - fun `test non-owner cannot access config by ID`() { + fun `test non-owner with full cluster role cannot access unshared config`() { val configId = createConfig(configType = ConfigType.SLACK, client = aliceClient!!) - // Bob should be denied access to Alice's config + // Bob has full cluster permissions but no resource-level access executeRequest( RestRequest.Method.GET.name, "${NotificationPlugin.PLUGIN_BASE_URI}/configs/$configId", @@ -86,48 +99,219 @@ class ResourceSharingNotificationIT : PluginRestTestCase() { RestStatus.FORBIDDEN.status, bobClient!! ) + + // Bob cannot update + executeRequest( + RestRequest.Method.PUT.name, + "${NotificationPlugin.PLUGIN_BASE_URI}/configs/$configId", + buildUpdateJson("attempted update"), + RestStatus.FORBIDDEN.status, + bobClient!! + ) + + // Bob cannot delete + executeRequest( + RestRequest.Method.DELETE.name, + "${NotificationPlugin.PLUGIN_BASE_URI}/configs/$configId", + "", + RestStatus.FORBIDDEN.status, + bobClient!! + ) } - fun `test config becomes accessible after sharing`() { + fun `test read_only access grants only read`() { val configId = createConfig(configType = ConfigType.SLACK, client = aliceClient!!) + shareResource(aliceClient!!, configId, "notifications_read_only", bobUser) + Thread.sleep(2000) - // Bob cannot access before sharing - executeRequest( + // Bob can read + val response = executeRequest( RestRequest.Method.GET.name, "${NotificationPlugin.PLUGIN_BASE_URI}/configs/$configId", "", + RestStatus.OK.status, + bobClient!! + ) + Assert.assertNotNull(response) + + // Bob cannot update + executeRequest( + RestRequest.Method.PUT.name, + "${NotificationPlugin.PLUGIN_BASE_URI}/configs/$configId", + buildUpdateJson("read_only user update attempt"), RestStatus.FORBIDDEN.status, bobClient!! ) - // Share with bob + // Bob cannot delete + executeRequest( + RestRequest.Method.DELETE.name, + "${NotificationPlugin.PLUGIN_BASE_URI}/configs/$configId", + "", + RestStatus.FORBIDDEN.status, + bobClient!! + ) + + // Bob cannot share further val shareRequest = Request("PUT", "/_plugins/_security/api/resource/share") - shareRequest.setJsonEntity( - """ - { - "resource_id": "$configId", - "resource_type": "notification_config", - "share_with": { - "notifications_read_only": { - "users": ["$bobUser"] - } - } - } - """.trimIndent() + shareRequest.setJsonEntity(buildShareJson(configId, "notifications_read_only", charlieUser)) + executeRequest(shareRequest, RestStatus.FORBIDDEN.status, bobClient!!) + } + + fun `test read_write access grants read update and delete but not share`() { + val configId = createConfig(configType = ConfigType.SLACK, client = aliceClient!!) + shareResource(aliceClient!!, configId, "notifications_read_write", bobUser) + Thread.sleep(2000) + + // Bob can read + executeRequest( + RestRequest.Method.GET.name, + "${NotificationPlugin.PLUGIN_BASE_URI}/configs/$configId", + "", + RestStatus.OK.status, + bobClient!! + ) + + // Bob can update + executeRequest( + RestRequest.Method.PUT.name, + "${NotificationPlugin.PLUGIN_BASE_URI}/configs/$configId", + buildUpdateJson("updated by bob"), + RestStatus.OK.status, + bobClient!! + ) + + // Alice sees Bob's update + val aliceGetResponse = executeRequest( + RestRequest.Method.GET.name, + "${NotificationPlugin.PLUGIN_BASE_URI}/configs/$configId", + "", + RestStatus.OK.status, + aliceClient!! ) - val shareResponse = aliceClient!!.performRequest(shareRequest) - Assert.assertEquals(200, shareResponse.statusLine.statusCode) + val configName = aliceGetResponse.get("config_list").asJsonArray[0].asJsonObject + .get("config").asJsonObject.get("name").asString + Assert.assertEquals("updated by bob", configName) + // Bob cannot share — read_write does not include share action + val shareRequest = Request("PUT", "/_plugins/_security/api/resource/share") + shareRequest.setJsonEntity(buildShareJson(configId, "notifications_read_only", charlieUser)) + executeRequest(shareRequest, RestStatus.FORBIDDEN.status, bobClient!!) + + // Bob can delete + executeRequest( + RestRequest.Method.DELETE.name, + "${NotificationPlugin.PLUGIN_BASE_URI}/configs/$configId", + "", + RestStatus.OK.status, + bobClient!! + ) + } + + fun `test full_access grants all operations including share`() { + val configId = createConfig(configType = ConfigType.SLACK, client = aliceClient!!) + shareResource(aliceClient!!, configId, "notifications_full_access", bobUser) Thread.sleep(2000) - // Bob should now be able to get the config - val response = executeRequest( + // Bob can read + executeRequest( RestRequest.Method.GET.name, "${NotificationPlugin.PLUGIN_BASE_URI}/configs/$configId", "", RestStatus.OK.status, bobClient!! ) - Assert.assertNotNull(response) + + // Bob can update + executeRequest( + RestRequest.Method.PUT.name, + "${NotificationPlugin.PLUGIN_BASE_URI}/configs/$configId", + buildUpdateJson("full access update by bob"), + RestStatus.OK.status, + bobClient!! + ) + + // Bob can share further with charlie at read_only level + shareResource(bobClient!!, configId, "notifications_read_only", charlieUser) + Thread.sleep(2000) + + // Charlie can read (shared by bob, not the owner) + val charlieResponse = executeRequest( + RestRequest.Method.GET.name, + "${NotificationPlugin.PLUGIN_BASE_URI}/configs/$configId", + "", + RestStatus.OK.status, + charlieClient!! + ) + Assert.assertNotNull(charlieResponse) + + // Charlie cannot delete (read_only access) + executeRequest( + RestRequest.Method.DELETE.name, + "${NotificationPlugin.PLUGIN_BASE_URI}/configs/$configId", + "", + RestStatus.FORBIDDEN.status, + charlieClient!! + ) + + // Bob can delete (full_access) + executeRequest( + RestRequest.Method.DELETE.name, + "${NotificationPlugin.PLUGIN_BASE_URI}/configs/$configId", + "", + RestStatus.OK.status, + bobClient!! + ) + } + + private fun buildUpdateJson(name: String): String { + return """ + { + "config":{ + "name":"$name", + "description":"updated config", + "config_type":"slack", + "is_enabled":true, + "slack":{"url":"https://hooks.slack.com/services/updated_url"} + } + } + """.trimIndent() + } + + private fun buildShareJson(resourceId: String, accessLevel: String, user: String): String { + return """ + { + "resource_id": "$resourceId", + "resource_type": "notification_config", + "share_with": { + "$accessLevel": { + "users": ["$user"] + } + } + } + """.trimIndent() + } + + private fun shareResource(client: RestClient, resourceId: String, accessLevel: String, user: String) { + val request = Request("PUT", "/_plugins/_security/api/resource/share") + request.setJsonEntity(buildShareJson(resourceId, accessLevel, user)) + val response = client.performRequest(request) + Assert.assertEquals(200, response.statusLine.statusCode) + } + + private fun createNotificationsRole() { + val request = Request("PUT", "/_plugins/_security/api/roles/$notificationsFullAccessRole") + request.setJsonEntity( + """ + { + "cluster_permissions": [ + "cluster:admin/opensearch/notifications/*", + "cluster:admin/security/resource/*" + ], + "tenant_permissions": [] + } + """.trimIndent() + ) + adminClient().performRequest(request) } } From 7068c36e02db47871ae9298a5d2afe8fe390473a Mon Sep 17 00:00:00 2001 From: Darshit Chanpura Date: Tue, 21 Jul 2026 14:54:17 -0700 Subject: [PATCH 17/19] Comprehensive resource sharing integration tests Tests cover the full access level hierarchy: - Owner: can GET, UPDATE, SHARE, DELETE own resources - Non-owner (no sharing): cannot GET, UPDATE, DELETE, or SHARE even with full cluster-level permissions - read_only level: can GET; cannot UPDATE, DELETE, SHARE; config unchanged after failed update attempt - read_write level: can GET, UPDATE, DELETE; cannot SHARE; owner sees updates made by shared user; non-owner can delete - full_access level: can GET, UPDATE, DELETE, SHARE; share chain works (Alice->Bob->Charlie); Charlie at read_only cannot delete TODOs for follow-up (require PluginClient integration): - List isolation test (DLS-based search filtering) - Revoke access test (revoke API contract) Signed-off-by: Darshit Chanpura --- .../ResourceSharingNotificationIT.kt | 73 ++++++++++++++++++- 1 file changed, 70 insertions(+), 3 deletions(-) diff --git a/notifications/notifications/src/test/kotlin/org/opensearch/integtest/ResourceSharingNotificationIT.kt b/notifications/notifications/src/test/kotlin/org/opensearch/integtest/ResourceSharingNotificationIT.kt index aa2a816cc00..193ae4f7fc4 100644 --- a/notifications/notifications/src/test/kotlin/org/opensearch/integtest/ResourceSharingNotificationIT.kt +++ b/notifications/notifications/src/test/kotlin/org/opensearch/integtest/ResourceSharingNotificationIT.kt @@ -75,19 +75,45 @@ class ResourceSharingNotificationIT : PluginRestTestCase() { charlieClient = null } - fun `test owner can create and access their own config`() { + // --- Owner tests --- + + fun `test owner can perform all operations on their own config`() { val configId = createConfig(configType = ConfigType.SLACK, client = aliceClient!!) - val response = executeRequest( + // Owner can read + val getResponse = executeRequest( RestRequest.Method.GET.name, "${NotificationPlugin.PLUGIN_BASE_URI}/configs/$configId", "", RestStatus.OK.status, aliceClient!! ) - Assert.assertNotNull(response) + Assert.assertNotNull(getResponse) + + // Owner can update + executeRequest( + RestRequest.Method.PUT.name, + "${NotificationPlugin.PLUGIN_BASE_URI}/configs/$configId", + buildUpdateJson("alice updated her own config"), + RestStatus.OK.status, + aliceClient!! + ) + + // Owner can share + shareResource(aliceClient!!, configId, "notifications_read_only", bobUser) + + // Owner can delete + executeRequest( + RestRequest.Method.DELETE.name, + "${NotificationPlugin.PLUGIN_BASE_URI}/configs/$configId", + "", + RestStatus.OK.status, + aliceClient!! + ) } + // --- Non-owner without sharing --- + fun `test non-owner with full cluster role cannot access unshared config`() { val configId = createConfig(configType = ConfigType.SLACK, client = aliceClient!!) @@ -117,8 +143,17 @@ class ResourceSharingNotificationIT : PluginRestTestCase() { RestStatus.FORBIDDEN.status, bobClient!! ) + + // Bob cannot share a resource they have no access to + val shareRequest = Request("PUT", "/_plugins/_security/api/resource/share") + shareRequest.setJsonEntity(buildShareJson(configId, "notifications_read_only", charlieUser)) + executeRequest(shareRequest, RestStatus.FORBIDDEN.status, bobClient!!) } + // TODO: Add list isolation test once PluginClient is integrated for DLS-based search filtering + + // --- read_only access level --- + fun `test read_only access grants only read`() { val configId = createConfig(configType = ConfigType.SLACK, client = aliceClient!!) shareResource(aliceClient!!, configId, "notifications_read_only", bobUser) @@ -143,6 +178,18 @@ class ResourceSharingNotificationIT : PluginRestTestCase() { bobClient!! ) + // Verify config is unchanged after failed update attempt + val afterFailedUpdate = executeRequest( + RestRequest.Method.GET.name, + "${NotificationPlugin.PLUGIN_BASE_URI}/configs/$configId", + "", + RestStatus.OK.status, + aliceClient!! + ) + val configName = afterFailedUpdate.get("config_list").asJsonArray[0].asJsonObject + .get("config").asJsonObject.get("name").asString + Assert.assertNotEquals("read_only user update attempt", configName) + // Bob cannot delete executeRequest( RestRequest.Method.DELETE.name, @@ -158,6 +205,8 @@ class ResourceSharingNotificationIT : PluginRestTestCase() { executeRequest(shareRequest, RestStatus.FORBIDDEN.status, bobClient!!) } + // --- read_write access level --- + fun `test read_write access grants read update and delete but not share`() { val configId = createConfig(configType = ConfigType.SLACK, client = aliceClient!!) shareResource(aliceClient!!, configId, "notifications_read_write", bobUser) @@ -208,6 +257,8 @@ class ResourceSharingNotificationIT : PluginRestTestCase() { ) } + // --- full_access level --- + fun `test full_access grants all operations including share`() { val configId = createConfig(configType = ConfigType.SLACK, client = aliceClient!!) shareResource(aliceClient!!, configId, "notifications_full_access", bobUser) @@ -231,6 +282,18 @@ class ResourceSharingNotificationIT : PluginRestTestCase() { bobClient!! ) + // Alice sees Bob's update + val aliceGetResponse = executeRequest( + RestRequest.Method.GET.name, + "${NotificationPlugin.PLUGIN_BASE_URI}/configs/$configId", + "", + RestStatus.OK.status, + aliceClient!! + ) + val updatedName = aliceGetResponse.get("config_list").asJsonArray[0].asJsonObject + .get("config").asJsonObject.get("name").asString + Assert.assertEquals("full access update by bob", updatedName) + // Bob can share further with charlie at read_only level shareResource(bobClient!!, configId, "notifications_read_only", charlieUser) Thread.sleep(2000) @@ -264,6 +327,10 @@ class ResourceSharingNotificationIT : PluginRestTestCase() { ) } + // TODO: Add revoke access test once the revoke API contract is finalized + + // --- Helpers --- + private fun buildUpdateJson(name: String): String { return """ { From 378666ad301ffca01fc442a243162673eef33742 Mon Sep 17 00:00:00 2001 From: Darshit Chanpura Date: Tue, 21 Jul 2026 15:48:00 -0700 Subject: [PATCH 18/19] Add PluginClient, IdentityAwarePlugin integration, and revoke test - Implement PluginClient (FilterClient that runs as plugin's subject) for DLS-based search filtering in future - NotificationPlugin now implements IdentityAwarePlugin and receives PluginSubject for the PluginClient - Add revoke access test: verifies PATCH /_plugins/_security/api/resource/share with "revoke" field removes previously granted access - 6 comprehensive tests now pass: owner CRUD, non-owner denial, read_only, read_write, full_access, and revoke TODO: Wire PluginClient into search path for DLS-based list isolation Signed-off-by: Darshit Chanpura --- .../notifications/NotificationPlugin.kt | 14 ++++- .../notifications/util/PluginClient.kt | 62 +++++++++++++++++++ .../ResourceSharingNotificationIT.kt | 47 +++++++++++++- 3 files changed, 119 insertions(+), 4 deletions(-) create mode 100644 notifications/notifications/src/main/kotlin/org/opensearch/notifications/util/PluginClient.kt diff --git a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/NotificationPlugin.kt b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/NotificationPlugin.kt index 1530305bfea..95e1cb7edd0 100644 --- a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/NotificationPlugin.kt +++ b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/NotificationPlugin.kt @@ -21,6 +21,7 @@ import org.opensearch.core.common.io.stream.NamedWriteableRegistry import org.opensearch.core.xcontent.NamedXContentRegistry import org.opensearch.env.Environment import org.opensearch.env.NodeEnvironment +import org.opensearch.identity.PluginSubject import org.opensearch.indices.SystemIndexDescriptor import org.opensearch.notifications.action.CreateNotificationConfigAction import org.opensearch.notifications.action.DeleteNotificationConfigAction @@ -47,8 +48,10 @@ import org.opensearch.notifications.settings.PluginSettings.REMOTE_METADATA_SERV import org.opensearch.notifications.settings.PluginSettings.REMOTE_METADATA_STORE_TYPE import org.opensearch.notifications.spi.NotificationCore import org.opensearch.notifications.spi.NotificationCoreExtension +import org.opensearch.notifications.util.PluginClient import org.opensearch.notifications.util.SecureIndexClient import org.opensearch.plugins.ActionPlugin +import org.opensearch.plugins.IdentityAwarePlugin import org.opensearch.plugins.Plugin import org.opensearch.plugins.SystemIndexPlugin import org.opensearch.remote.metadata.client.impl.SdkClientFactory @@ -72,9 +75,10 @@ import java.util.function.Supplier * Entry point of the OpenSearch Notifications plugin * This class initializes the rest handlers. */ -class NotificationPlugin : ActionPlugin, Plugin(), NotificationCoreExtension, SystemIndexPlugin { +class NotificationPlugin : ActionPlugin, Plugin(), NotificationCoreExtension, SystemIndexPlugin, IdentityAwarePlugin { lateinit var clusterService: ClusterService // initialized in createComponents() + private var pluginClient: PluginClient? = null internal companion object { private val log by logger(NotificationPlugin::class.java) @@ -146,10 +150,16 @@ class NotificationPlugin : ActionPlugin, Plugin(), NotificationCoreExtension, Sy client.threadPool().executor(ThreadPool.Names.GENERIC) ) PluginSettings.addSettingsUpdateConsumer(clusterService) + val pluginClientInstance = PluginClient(client) + this.pluginClient = pluginClientInstance NotificationConfigIndex.initialize(sdkClient, client, clusterService) ConfigIndexingActions.initialize(NotificationConfigIndex, UserAccessManager) SendMessageActionHelper.initialize(NotificationConfigIndex, UserAccessManager) - return listOf(sdkClient) + return listOf(sdkClient, pluginClientInstance) + } + + override fun assignSubject(pluginSubject: PluginSubject) { + pluginClient?.setSubject(pluginSubject) } /** diff --git a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/util/PluginClient.kt b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/util/PluginClient.kt new file mode 100644 index 00000000000..7f5034f3e1f --- /dev/null +++ b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/util/PluginClient.kt @@ -0,0 +1,62 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +package org.opensearch.notifications.util + +import org.opensearch.action.ActionRequest +import org.opensearch.action.ActionType +import org.opensearch.commons.utils.logger +import org.opensearch.core.action.ActionListener +import org.opensearch.core.action.ActionResponse +import org.opensearch.identity.Subject +import org.opensearch.transport.client.Client +import org.opensearch.transport.client.FilterClient + +class PluginClient(delegate: Client) : FilterClient(delegate) { + + private var subject: Subject? = null + + companion object { + private val log by logger(PluginClient::class.java) + } + + fun setSubject(subject: Subject) { + this.subject = subject + } + + @Suppress("UNCHECKED_CAST") + override fun doExecute( + action: ActionType, + request: Request, + listener: ActionListener + ) { + val currentSubject = subject + ?: error("PluginClient is not initialized.") + + val storedContext = threadPool().threadContext.newStoredContext(false) + + try { + currentSubject.runAs { + super.doExecute( + action, + request, + object : ActionListener { + override fun onResponse(response: Response) { + storedContext.restore() + listener.onResponse(response) + } + + override fun onFailure(e: Exception) { + storedContext.restore() + listener.onFailure(e) + } + } + ) + } + } catch (e: Exception) { + storedContext.restore() + listener.onFailure(e) + } + } +} diff --git a/notifications/notifications/src/test/kotlin/org/opensearch/integtest/ResourceSharingNotificationIT.kt b/notifications/notifications/src/test/kotlin/org/opensearch/integtest/ResourceSharingNotificationIT.kt index 193ae4f7fc4..ab6d557d5e8 100644 --- a/notifications/notifications/src/test/kotlin/org/opensearch/integtest/ResourceSharingNotificationIT.kt +++ b/notifications/notifications/src/test/kotlin/org/opensearch/integtest/ResourceSharingNotificationIT.kt @@ -150,7 +150,7 @@ class ResourceSharingNotificationIT : PluginRestTestCase() { executeRequest(shareRequest, RestStatus.FORBIDDEN.status, bobClient!!) } - // TODO: Add list isolation test once PluginClient is integrated for DLS-based search filtering + // TODO: Add DLS-based list isolation test once search path uses PluginClient for DLS filtering // --- read_only access level --- @@ -327,7 +327,50 @@ class ResourceSharingNotificationIT : PluginRestTestCase() { ) } - // TODO: Add revoke access test once the revoke API contract is finalized + // --- Revoke access --- + + fun `test revoking access removes permissions`() { + val configId = createConfig(configType = ConfigType.SLACK, client = aliceClient!!) + shareResource(aliceClient!!, configId, "notifications_read_only", bobUser) + Thread.sleep(2000) + + // Bob can read while shared + executeRequest( + RestRequest.Method.GET.name, + "${NotificationPlugin.PLUGIN_BASE_URI}/configs/$configId", + "", + RestStatus.OK.status, + bobClient!! + ) + + // Alice revokes Bob's access via PATCH with "revoke" field + val revokeRequest = Request("PATCH", "/_plugins/_security/api/resource/share") + revokeRequest.setJsonEntity( + """ + { + "resource_id": "$configId", + "resource_type": "notification_config", + "revoke": { + "notifications_read_only": { + "users": ["$bobUser"] + } + } + } + """.trimIndent() + ) + val revokeResponse = aliceClient!!.performRequest(revokeRequest) + Assert.assertEquals(200, revokeResponse.statusLine.statusCode) + Thread.sleep(2000) + + // Bob can no longer access + executeRequest( + RestRequest.Method.GET.name, + "${NotificationPlugin.PLUGIN_BASE_URI}/configs/$configId", + "", + RestStatus.FORBIDDEN.status, + bobClient!! + ) + } // --- Helpers --- From 5c84ce8f0e8e7de6b0689aa07a0539c703927c59 Mon Sep 17 00:00:00 2001 From: Darshit Chanpura Date: Tue, 21 Jul 2026 15:52:28 -0700 Subject: [PATCH 19/19] Wire PluginClient into search path for DLS-based list filtering - Create a separate SdkClient backed by PluginClient for search operations, so the security plugin sees the user identity and applies DLS filtering on all_shared_principals - NotificationConfigIndex.getAllNotificationConfigs now uses searchSdkClient which preserves user context for DLS - Add list isolation test: users only see their own configs and configs explicitly shared with them All 7 resource sharing integration tests pass: - Owner CRUD + share - Non-owner denial - read_only access (GET only) - read_write access (GET/UPDATE/DELETE, no share) - full_access (all ops + share chain) - Revoke access - List isolation via DLS Signed-off-by: Darshit Chanpura --- .../notifications/NotificationPlugin.kt | 15 ++++- .../index/NotificationConfigIndex.kt | 8 ++- .../ResourceSharingNotificationIT.kt | 57 ++++++++++++++++++- 3 files changed, 75 insertions(+), 5 deletions(-) diff --git a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/NotificationPlugin.kt b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/NotificationPlugin.kt index 95e1cb7edd0..1c1925bcf4f 100644 --- a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/NotificationPlugin.kt +++ b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/NotificationPlugin.kt @@ -152,7 +152,20 @@ class NotificationPlugin : ActionPlugin, Plugin(), NotificationCoreExtension, Sy PluginSettings.addSettingsUpdateConsumer(clusterService) val pluginClientInstance = PluginClient(client) this.pluginClient = pluginClientInstance - NotificationConfigIndex.initialize(sdkClient, client, clusterService) + val searchSdkClient = SdkClientFactory.createSdkClient( + pluginClientInstance, + xContentRegistry, + mapOf( + REMOTE_METADATA_TYPE_KEY to REMOTE_METADATA_STORE_TYPE.get(settings), + REMOTE_METADATA_ENDPOINT_KEY to REMOTE_METADATA_ENDPOINT.get(settings), + REMOTE_METADATA_REGION_KEY to REMOTE_METADATA_REGION.get(settings), + REMOTE_METADATA_SERVICE_NAME_KEY to REMOTE_METADATA_SERVICE_NAME.get(settings), + TENANT_AWARE_KEY to MULTI_TENANCY_ENABLED.get(settings).toString(), + TENANT_ID_FIELD_KEY to "tenant_id" + ), + client.threadPool().executor(ThreadPool.Names.GENERIC) + ) + NotificationConfigIndex.initialize(sdkClient, searchSdkClient, client, clusterService) ConfigIndexingActions.initialize(NotificationConfigIndex, UserAccessManager) SendMessageActionHelper.initialize(NotificationConfigIndex, UserAccessManager) return listOf(sdkClient, pluginClientInstance) diff --git a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/index/NotificationConfigIndex.kt b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/index/NotificationConfigIndex.kt index 8a4db936e61..6cb80c61061 100644 --- a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/index/NotificationConfigIndex.kt +++ b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/index/NotificationConfigIndex.kt @@ -83,6 +83,7 @@ internal object NotificationConfigIndex : ConfigOperations { private lateinit var client: Client private lateinit var clusterService: ClusterService private lateinit var sdkClient: SdkClient + private lateinit var searchSdkClient: SdkClient private val searchHitParser = object : SearchResults.SearchHitParser { override fun parse(searchHit: SearchHit): NotificationConfigInfo { @@ -105,10 +106,11 @@ internal object NotificationConfigIndex : ConfigOperations { /** * {@inheritDoc} */ - fun initialize(sdkClient: SdkClient, client: Client, clusterService: ClusterService) { + fun initialize(sdkClient: SdkClient, searchSdkClient: SdkClient, client: Client, clusterService: ClusterService) { NotificationConfigIndex.client = SecureIndexClient(client) NotificationConfigIndex.clusterService = clusterService NotificationConfigIndex.sdkClient = sdkClient + NotificationConfigIndex.searchSdkClient = searchSdkClient } private fun getSchemaVersionFromIndexMapping(indexMapping: Map?): Int { @@ -290,8 +292,8 @@ internal object NotificationConfigIndex : ConfigOperations { .searchSourceBuilder(sourceBuilder) .build() - val response: SearchResponse = sdkClient.suspendUntilTimeout(PluginSettings.operationTimeoutMs) { - sdkClient.searchDataObjectAsync(searchRequest).whenComplete(it) + val response: SearchResponse = searchSdkClient.suspendUntilTimeout(PluginSettings.operationTimeoutMs) { + searchSdkClient.searchDataObjectAsync(searchRequest).whenComplete(it) } val result = NotificationConfigSearchResult(request.fromIndex.toLong(), response, searchHitParser) log.info( diff --git a/notifications/notifications/src/test/kotlin/org/opensearch/integtest/ResourceSharingNotificationIT.kt b/notifications/notifications/src/test/kotlin/org/opensearch/integtest/ResourceSharingNotificationIT.kt index ab6d557d5e8..4fe0f2836ed 100644 --- a/notifications/notifications/src/test/kotlin/org/opensearch/integtest/ResourceSharingNotificationIT.kt +++ b/notifications/notifications/src/test/kotlin/org/opensearch/integtest/ResourceSharingNotificationIT.kt @@ -150,7 +150,52 @@ class ResourceSharingNotificationIT : PluginRestTestCase() { executeRequest(shareRequest, RestStatus.FORBIDDEN.status, bobClient!!) } - // TODO: Add DLS-based list isolation test once search path uses PluginClient for DLS filtering + // --- Isolation: DLS-based search filtering --- + + fun `test user only sees own and shared configs in list`() { + val aliceConfigId = createConfig(nameSubstring = "alice-config", configType = ConfigType.SLACK, client = aliceClient!!) + val bobConfigId = createConfig(nameSubstring = "bob-config", configType = ConfigType.SLACK, client = bobClient!!) + + // Alice should only see her own config + val aliceList = executeRequest( + RestRequest.Method.GET.name, + "${NotificationPlugin.PLUGIN_BASE_URI}/configs", + "", + RestStatus.OK.status, + aliceClient!! + ) + val aliceConfigIds = extractConfigIds(aliceList) + Assert.assertTrue("Alice should see her own config", aliceConfigIds.contains(aliceConfigId)) + Assert.assertFalse("Alice should NOT see Bob's config", aliceConfigIds.contains(bobConfigId)) + + // Bob should only see his own config + val bobList = executeRequest( + RestRequest.Method.GET.name, + "${NotificationPlugin.PLUGIN_BASE_URI}/configs", + "", + RestStatus.OK.status, + bobClient!! + ) + val bobConfigIds = extractConfigIds(bobList) + Assert.assertTrue("Bob should see his own config", bobConfigIds.contains(bobConfigId)) + Assert.assertFalse("Bob should NOT see Alice's config", bobConfigIds.contains(aliceConfigId)) + + // Share Alice's config with Bob + shareResource(aliceClient!!, aliceConfigId, "notifications_read_only", bobUser) + Thread.sleep(2000) + + // Now Bob should see both + val bobListAfterShare = executeRequest( + RestRequest.Method.GET.name, + "${NotificationPlugin.PLUGIN_BASE_URI}/configs", + "", + RestStatus.OK.status, + bobClient!! + ) + val bobConfigIdsAfter = extractConfigIds(bobListAfterShare) + Assert.assertTrue("Bob should see his own config", bobConfigIdsAfter.contains(bobConfigId)) + Assert.assertTrue("Bob should see shared config from Alice", bobConfigIdsAfter.contains(aliceConfigId)) + } // --- read_only access level --- @@ -374,6 +419,16 @@ class ResourceSharingNotificationIT : PluginRestTestCase() { // --- Helpers --- + private fun extractConfigIds(response: com.google.gson.JsonObject): List { + val ids = mutableListOf() + if (response.has("config_list")) { + response.getAsJsonArray("config_list").forEach { item -> + ids.add(item.asJsonObject.get("config_id").asString) + } + } + return ids + } + private fun buildUpdateJson(name: String): String { return """ {