Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .github/workflows/security-notifications-test-workflow.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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/*
13 changes: 8 additions & 5 deletions notifications/notifications/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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}"
Expand Down Expand Up @@ -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"
}
}
}
}
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ResourceProvider> {
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)
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand All @@ -16,10 +17,16 @@ import org.opensearch.notifications.settings.PluginSettings
internal object UserAccessManager : UserAccess {
const val ADMIN_ROLE = "all_access"

private fun isResourceSharingEnabled(): Boolean {
val client = ResourceSharingClientAccessor.getResourceSharingClient()
return client != null && client.isFeatureEnabledForType("notification_config")
}

/**
* {@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.",
Expand All @@ -32,7 +39,7 @@ internal object UserAccessManager : UserAccess {
* {@inheritDoc}
*/
override fun getAllAccessInfo(user: User?): List<String> {
if (user == null) { // Filtering is disabled
if (isResourceSharingEnabled() || user == null) {
return listOf()
}
return user.backendRoles
Expand All @@ -42,7 +49,7 @@ internal object UserAccessManager : UserAccess {
* {@inheritDoc}
*/
override fun getSearchAccessInfo(user: User?): List<String> {
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
Expand All @@ -52,11 +59,9 @@ internal object UserAccessManager : UserAccess {
* {@inheritDoc}
*/
override fun doesUserHaveAccess(user: User?, access: List<String>): 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 }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Copyright OpenSearch Contributors
# SPDX-License-Identifier: Apache-2.0

org.opensearch.notifications.NotificationsResourceSharingExtension
Original file line number Diff line number Diff line change
@@ -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'
Original file line number Diff line number Diff line change
@@ -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())
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/*
* 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 = "TopSecret_1234%Alice"
private val bobUser = "rs_bob"
private val bobPassword = "TopSecret_1234%Bobby"
private var aliceClient: RestClient? = null
private var bobClient: RestClient? = null

@Before
fun setupUsers() {
if (aliceClient != null) return
createUser(aliceUser, alicePassword, arrayOf("engineering"))
createUserRolesMapping(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))
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)
}
}
Loading
Loading