diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ae0f69f472..fdd5d35efd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -116,7 +116,7 @@ jobs: ./build/reports/ report-coverage: - needs: ["test-windows", "test-linux", "integration-tests-windows", "integration-tests-linux", "spi-tests-linux", "spi-tests-windows"] + needs: ["test-windows", "test-linux", "integration-tests-windows", "integration-tests-linux", "spi-tests-linux", "spi-tests-windows", "sample-plugin-integration-tests-linux", "sample-plugin-integration-tests-windows"] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -304,6 +304,93 @@ jobs: path: | ./build/reports/ + sample-plugin-integration-tests-linux: + name: sample-plugin-integration-tests + needs: [ "Get-CI-Image-Tag" ] + strategy: + fail-fast: false + matrix: + jdk: [ 21 ] + platform: [ ubuntu-latest ] + runs-on: ubuntu-latest + container: + # using the same image which is used by opensearch-build team to build the OpenSearch Distribution + # this image tag is subject to change as more dependencies and updates will arrive over time + image: ${{ needs.Get-CI-Image-Tag.outputs.ci-image-version-linux }} + # need to switch to root so that github actions can install runner binary on container without permission issues. + options: ${{ needs.Get-CI-Image-Tag.outputs.ci-image-start-options }} + + steps: + - name: Run start commands + run: ${{ needs.Get-CI-Image-Tag.outputs.ci-image-start-command }} + + - name: Set up JDK for build and test + uses: actions/setup-java@v4 + with: + distribution: temurin # Temurin is a distribution of adoptium + java-version: ${{ matrix.jdk }} + + - name: Checkout security + uses: actions/checkout@v4 + + - name: Publish components to Maven Local + run: | + ./gradlew clean \ + :opensearch-security-spi:publishToMavenLocal \ + -Dbuild.snapshot=false + + - name: Run SampleResourcePlugin Integration Tests + uses: gradle/gradle-build-action@v3 + with: + arguments: | + :opensearch-sample-resource-plugin:integrationTest -Dbuild.snapshot=false + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: sample-plugin-integration-${{ matrix.platform }}-JDK${{ matrix.jdk }}-reports + path: | + ./build/reports/ + + sample-plugin-integration-tests-windows: + name: sample-plugin-integration-tests + strategy: + fail-fast: false + matrix: + jdk: [21] + platform: [windows-latest] + runs-on: ${{ matrix.platform }} + + steps: + - name: Set up JDK for build and test + uses: actions/setup-java@v4 + with: + distribution: temurin # Temurin is a distribution of adoptium + java-version: ${{ matrix.jdk }} + + - name: Checkout security + uses: actions/checkout@v4 + + - name: Publish components to Maven Local + run: | + gradlew.bat clean ^ + :opensearch-security-spi:publishToMavenLocal ^ + -Dbuild.snapshot=false + shell: cmd + + - name: Run SampleResourcePlugin Integration Tests + uses: gradle/gradle-build-action@v3 + with: + arguments: | + :opensearch-sample-resource-plugin:integrationTest -Dbuild.snapshot=false + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: sample-plugin-integration-${{ matrix.platform }}-JDK${{ matrix.jdk }}-reports + path: | + ./build/reports/ + resource-tests: env: CI_ENVIRONMENT: resource-test diff --git a/build.gradle b/build.gradle index 5f7c2e5e18..8d83fb7de4 100644 --- a/build.gradle +++ b/build.gradle @@ -487,6 +487,7 @@ configurations { force "org.mockito:mockito-core:5.17.0" force "net.bytebuddy:byte-buddy:1.15.11" force "org.ow2.asm:asm:9.7.1" + force "com.google.j2objc:j2objc-annotations:3.0.0" } } @@ -584,7 +585,7 @@ sourceSets { //add new task that runs integration tests task integrationTest(type: Test) { filter { - excludeTestsMatching 'org.opensearch.sample.*ResourcePlugin*' + excludeTestsMatching 'org.opensearch.sample.*Resource*' } doFirst { // Only run resources tests on resource-test CI environments or locally diff --git a/sample-resource-plugin/README.md b/sample-resource-plugin/README.md new file mode 100644 index 0000000000..93aa070407 --- /dev/null +++ b/sample-resource-plugin/README.md @@ -0,0 +1,187 @@ +# Resource Sharing and Access Control Plugin + +This plugin demonstrates resource sharing and access control functionality, providing sample resource APIs and marking it as a resource sharing plugin via resource-sharing-spi. The access control is implemented on Security plugin and will be performed under the hood. +At present only admin and resource owners can modify/delete the resource + +## PreRequisites + +Publish SPI to local maven before proceeding: +```shell +./gradlew clean :opensearch-security-spi:publishToMavenLocal +``` + +System index feature must be enabled to prevent direct access to resource. Add the following setting in case it has not already been enabled. +```yml +plugins.security.system_indices.enabled: true +``` + +## Features + +- Create, update, get, delete SampleResource, as well as share and revoke access to a resource. + +## Installation + +1. Clone the repository: + ```bash + git clone git@github.com:opensearch-project/security.git + ``` + +2. Navigate to the project directory: + ```bash + cd sample-resource-plugin + ``` + +3. Build and deploy the plugin: + ```bash + $ ./gradlew clean build -x test -x integrationTest -x spotbugsIntegrationTest + $ ./bin/opensearch-plugin install file: /sample-resource-plugin/build/distributions/opensearch-sample-resource-plugin-.zip + ``` + + +## User setup: +1. **No Index-Level Permissions Required** + - **Resource access is controlled at the cluster level**. + - Users **do not** need explicit index-level permissions to access shared resources. + +2. **Sample Role Configurations** + - Below are **two sample roles** demonstrating how to configure permissions in `roles.yml`: + + ```yaml + sample_full_access: + cluster_permissions: + - 'cluster:admin/sample-resource-plugin/*' + + sample_read_access: + cluster_permissions: + - 'cluster:admin/sample-resource-plugin/get' + ``` + +4. **Interaction Rules** + - If a **user is not the resource owner**, they must: + - Be assigned **a role with `sample_read_access`** permissions. + - **Have the resource shared with them** via the resource-sharing API. + - A user **without** the necessary `sample-resource-plugin` cluster permissions: + - **Cannot access the resource**, even if it is shared with them. + - A user **with `sample-resource-plugin` permissions** but **without a shared resource**: + - **Cannot access the resource**, since resource-level access control applies. + + +## API Endpoints + +The plugin exposes the following six API endpoints: + +### 1. Create Resource +- **Endpoint:** `POST /_plugins/sample_resource_sharing/create` +- **Description:** Creates a new resource. Behind the scenes a resource sharing entry will be created if security plugin is installed and feature is enabled. +- **Request Body:** + ```json + { + "name": "" + } + ``` +- **Response:** + ```json + { + "message": "Created resource: 9UdrWpUB99GNznAOkx43" + } + ``` + +### 2. Update Resource +- **Endpoint:** `POST /_plugins/sample_resource_sharing/update/{resourceId}` +- **Description:** Updates a resource if current user has access to it. +- **Request Body:** + ```json + { + "name": "" + } + ``` +- **Response:** + ```json + { + "message": "Resource updated successfully." + } + ``` + +### 3. Delete Resource +- **Endpoint:** `DELETE /_plugins/sample_resource_sharing/delete/{resource_id}` +- **Description:** Deletes a specified resource owned by the requesting user. +- **Response:** + ```json + { + "message": "Resource deleted successfully." + } + ``` + +### 4. Get Resource +- **Endpoint:** `GET /_plugins/sample_resource_sharing/get/{resource_id}` +- **Description:** Get a specified resource owned by or shared_with the requesting user, if the user has access to the resource, else fails. +- **Response:** + ```json + { + "resources" : [{ + "name" : "", + "description" : null, + "attributes" : null + }] + } + ``` +- **Endpoint:** `GET /_plugins/sample_resource_sharing/get` +- **Description:** Get all resources owned by or shared with the requesting user. +- **Response:** + ```json + { + "resources" : [{ + "name" : "", + "description" : null, + "attributes" : null + }] + } + ``` + +### 5. Share Resource +- **Endpoint:** `POST /_plugins/sample_resource_sharing/share/{resource_id}` +- **Description:** Shares a resource with the intended entities. At present, only admin and resource owners can share the resource. +- **Request Body:** + ```json + { + "share_with": { + "users": [ "sample_user" ] + } + } + ``` +- **Response:** + ```json + { + "share_with": { + "default": { + "users": [ "sample_user" ] + } + } + } + ``` + +### 6. Revoke Resource Access +- **Endpoint:** `POST /_plugins/sample_resource_sharing/revoke/{resource_id}` +- **Description:** Shares a resource with the intended entities. At present, only admin and resource owners can share the resource. +- **Request Body:** + ```json + { + "entities_to_revoke": { + "users": [ "sample_user" ] + } + } + ``` +- **Response:** + ```json + { + "share_with" : { } + } + ``` + +## License + +This code is licensed under the Apache 2.0 License. + +## Copyright + +Copyright OpenSearch Contributors. diff --git a/sample-resource-plugin/build.gradle b/sample-resource-plugin/build.gradle new file mode 100644 index 0000000000..4f76c517a0 --- /dev/null +++ b/sample-resource-plugin/build.gradle @@ -0,0 +1,120 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +plugins { + id "org.gradle.test-retry" +} +apply plugin: 'opensearch.opensearchplugin' +apply plugin: 'opensearch.testclusters' + +java { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 +} + +opensearchplugin { + name 'opensearch-sample-resource-plugin' + description 'Sample plugin that extends OpenSearch Resource Plugin' + classname 'org.opensearch.sample.SampleResourcePlugin' + extendedPlugins = ['opensearch-security;optional=true'] +} + +dependencyLicenses.enabled = false +thirdPartyAudit.enabled = false +loggerUsageCheck.enabled = false +validateNebulaPom.enabled = false +testingConventions.enabled = false +tasks.configureEach { task -> + if(task.name.contains("forbiddenApisIntegrationTest")) { + task.enabled = false + } +} + +ext { + projectSubstitutions = [:] + licenseFile = rootProject.file('LICENSE.txt') + noticeFile = rootProject.file('NOTICE.txt') + opensearch_version = System.getProperty("opensearch.version", "3.0.0-beta1-SNAPSHOT") + isSnapshot = "true" == System.getProperty("build.snapshot", "true") + buildVersionQualifier = System.getProperty("build.version_qualifier", "beta1") + + version_tokens = opensearch_version.tokenize('-') + opensearch_build = version_tokens[0] + '.0' + + if (buildVersionQualifier) { + opensearch_build += "-${buildVersionQualifier}" + } + if (isSnapshot) { + opensearch_build += "-SNAPSHOT" + } +} + +repositories { + mavenLocal() + mavenCentral() + maven { url "https://aws.oss.sonatype.org/content/repositories/snapshots" } +} + +configurations.all { + resolutionStrategy { + force 'com.carrotsearch.randomizedtesting:randomizedtesting-runner:2.8.2', + 'org.hamcrest:hamcrest:2.2', + 'org.apache.httpcomponents:httpclient:4.5.14', + 'org.apache.httpcomponents:httpcore:4.4.16', + 'org.mockito:mockito-core:5.15.2', + 'net.bytebuddy:byte-buddy:1.15.11', + 'commons-codec:commons-codec:1.16.1', + 'com.fasterxml.jackson.core:jackson-databind:2.18.2' + } +} + +dependencies { + // Main implementation dependencies + compileOnly group: 'org.opensearch', name:'opensearch-security-spi', version:"${opensearch_build}" + compileOnly "com.fasterxml.jackson.core:jackson-databind:${versions.jackson_databind}" + + // Integration test dependencies + integrationTestImplementation rootProject.sourceSets.integrationTest.output + integrationTestImplementation rootProject.sourceSets.main.output +} + +sourceSets { + integrationTest { + java { + srcDir file('src/integrationTest/java') + compileClasspath += sourceSets.main.output + runtimeClasspath += sourceSets.main.output + } + resources { + srcDir file('src/integrationTest/resources') + } + } +} + +tasks.register("integrationTest", Test) { + doFirst { + retry { + failOnPassedAfterRetry = false + maxRetries = 5 + maxFailures = 5 + } + } + description = 'Run integration tests for the subproject.' + group = 'verification' + + testClassesDirs = sourceSets.integrationTest.output.classesDirs + classpath = sourceSets.integrationTest.runtimeClasspath + +} + +// Ensure integrationTest task depends on the root project's compile task +tasks.named("integrationTest").configure { + dependsOn rootProject.tasks.named("compileIntegrationTestJava") +} + +tasks.named("integrationTest") { + minHeapSize = "512m" + maxHeapSize = "2g" +} diff --git a/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/AbstractSampleResourcePluginFeatureEnabledTests.java b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/AbstractSampleResourcePluginFeatureEnabledTests.java new file mode 100644 index 0000000000..3240a1d3fb --- /dev/null +++ b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/AbstractSampleResourcePluginFeatureEnabledTests.java @@ -0,0 +1,276 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.sample; + +import org.apache.http.HttpStatus; +import org.awaitility.Awaitility; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import org.opensearch.sample.resource.client.ResourceSharingClientAccessor; +import org.opensearch.security.resources.ResourcePluginInfo; +import org.opensearch.security.spi.resources.ResourceAccessActionGroups; +import org.opensearch.security.spi.resources.ResourceProvider; +import org.opensearch.test.framework.TestSecurityConfig; +import org.opensearch.test.framework.cluster.LocalCluster; +import org.opensearch.test.framework.cluster.TestRestClient; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.opensearch.sample.utils.Constants.RESOURCE_INDEX_NAME; +import static org.opensearch.security.resources.ResourceSharingConstants.OPENSEARCH_RESOURCE_SHARING_INDEX; +import static org.opensearch.test.framework.TestSecurityConfig.User.USER_ADMIN; + +/** + * This abstract class defines common tests between different feature flag scenarios + */ +public abstract class AbstractSampleResourcePluginFeatureEnabledTests extends AbstractSampleResourcePluginTests { + + protected abstract LocalCluster getLocalCluster(); + + protected abstract TestSecurityConfig.User getSharedUser(); + + private static LocalCluster cluster; + + ResourcePluginInfo resourcePluginInfo; + + private static TestSecurityConfig.User sharedUser; + + @Before + public void setup() { + cluster = getLocalCluster(); + sharedUser = getSharedUser(); + resourcePluginInfo = cluster.nodes().getFirst().getInjectable(ResourcePluginInfo.class); + } + + @After + public void clearIndices() { + try (TestRestClient client = cluster.getRestClient(cluster.getAdminCertificate())) { + client.delete(RESOURCE_INDEX_NAME); + client.delete(OPENSEARCH_RESOURCE_SHARING_INDEX); + resourcePluginInfo.getResourceIndicesMutable().remove(RESOURCE_INDEX_NAME); + resourcePluginInfo.getResourceProvidersMutable().remove(RESOURCE_INDEX_NAME); + } + } + + @Test + public void testPluginInstalledCorrectly() { + try (TestRestClient client = cluster.getRestClient(USER_ADMIN)) { + TestRestClient.HttpResponse pluginsResponse = client.get("_cat/plugins"); + assertThat(pluginsResponse.getBody(), containsString("org.opensearch.security.OpenSearchSecurityPlugin")); + assertThat(pluginsResponse.getBody(), containsString("org.opensearch.sample.SampleResourcePlugin")); + } + } + + @Test + public void testCreateUpdateDeleteSampleResource() throws Exception { + String resourceId; + String resourceSharingDocId; + // create sample resource + try (TestRestClient client = cluster.getRestClient(USER_ADMIN)) { + String sampleResource = """ + {"name":"sample"} + """; + + TestRestClient.HttpResponse response = client.putJson(SAMPLE_RESOURCE_CREATE_ENDPOINT, sampleResource); + response.assertStatusCode(HttpStatus.SC_OK); + + resourceId = response.getTextFromJsonBody("/message").split(":")[1].trim(); + } + + // Create an entry in resource-sharing index + try (TestRestClient client = cluster.getRestClient(cluster.getAdminCertificate())) { + // Since test framework doesn't yet allow loading ex tensions we need to create a resource sharing entry manually + String json = """ + { + "source_idx": ".sample_resource_sharing_plugin", + "resource_id": "%s", + "created_by": { + "user": "admin" + } + } + """.formatted(resourceId); + + TestRestClient.HttpResponse response = client.postJson(OPENSEARCH_RESOURCE_SHARING_INDEX + "/_doc", json); + assertThat(response.getStatusReason(), containsString("Created")); + resourceSharingDocId = response.bodyAsJsonNode().get("_id").asText(); + // Also update the in-memory map and get + resourcePluginInfo.getResourceIndicesMutable().add(RESOURCE_INDEX_NAME); + ResourceProvider provider = new ResourceProvider(SampleResource.class.getCanonicalName(), RESOURCE_INDEX_NAME); + resourcePluginInfo.getResourceProvidersMutable().put(RESOURCE_INDEX_NAME, provider); + + ResourceSharingClientAccessor.setResourceSharingClient(createResourceAccessControlClient(cluster)); + + Awaitility.await() + .alias("Wait until resource data is populated") + .until(() -> client.get(SAMPLE_RESOURCE_GET_ENDPOINT + "/" + resourceId).getStatusCode(), equalTo(200)); + response = client.get(SAMPLE_RESOURCE_GET_ENDPOINT + "/" + resourceId); + response.assertStatusCode(HttpStatus.SC_OK); + assertThat(response.getBody(), containsString("sample")); + // Wait until resource-sharing entry is successfully created + Awaitility.await() + .alias("Wait until resource-sharing data is populated") + .until( + () -> client.get(OPENSEARCH_RESOURCE_SHARING_INDEX + "/_search").bodyAsJsonNode().get("hits").get("hits").size(), + equalTo(1) + ); + } + + // Update sample resource (admin should be able to update resource) + try (TestRestClient client = cluster.getRestClient(USER_ADMIN)) { + String sampleResourceUpdated = """ + {"name":"sampleUpdated"} + """; + + TestRestClient.HttpResponse updateResponse = client.postJson( + SAMPLE_RESOURCE_UPDATE_ENDPOINT + "/" + resourceId, + sampleResourceUpdated + ); + updateResponse.assertStatusCode(HttpStatus.SC_OK); + } + + // resource should be visible to super-admin + try (TestRestClient client = cluster.getRestClient(cluster.getAdminCertificate())) { + Awaitility.await() + .alias("Wait until resource-sharing data is populated") + .until( + () -> client.get(OPENSEARCH_RESOURCE_SHARING_INDEX + "/_search").bodyAsJsonNode().get("hits").get("hits").size(), + equalTo(1) + ); + TestRestClient.HttpResponse response = client.get(SAMPLE_RESOURCE_GET_ENDPOINT + "/" + resourceId); + response.assertStatusCode(HttpStatus.SC_OK); + assertThat(response.getBody(), containsString("sampleUpdated")); + } + + // resource should not be visible to sharedUser + try (TestRestClient client = cluster.getRestClient(sharedUser)) { + + TestRestClient.HttpResponse response = client.get(SAMPLE_RESOURCE_GET_ENDPOINT + "/" + resourceId); + response.assertStatusCode(HttpStatus.SC_FORBIDDEN); + + response = client.get(SAMPLE_RESOURCE_GET_ENDPOINT); + response.assertStatusCode(HttpStatus.SC_OK); + assertThat(response.bodyAsJsonNode().get("resources").size(), equalTo(0)); + } + + // sharedUser should not be able to share admin's resource with itself + // Only admins and owners can share/revoke access at the moment + try (TestRestClient client = cluster.getRestClient(sharedUser)) { + TestRestClient.HttpResponse response = client.postJson( + SAMPLE_RESOURCE_SHARE_ENDPOINT + "/" + resourceId, + shareWithPayload(sharedUser.getName()) + ); + response.assertStatusCode(HttpStatus.SC_FORBIDDEN); + assertThat( + response.bodyAsJsonNode().get("error").get("root_cause").get(0).get("reason").asText(), + containsString("User " + sharedUser.getName() + " is not authorized") + ); + } + + try (TestRestClient client = cluster.getRestClient(cluster.getAdminCertificate())) { + Awaitility.await() + .alias("Wait until resource-sharing data is populated") + .until( + () -> client.get(OPENSEARCH_RESOURCE_SHARING_INDEX + "/_search").bodyAsJsonNode().get("hits").get("hits").size(), + equalTo(1) + ); + } + + // share resource with shared_with user + try (TestRestClient client = cluster.getRestClient(USER_ADMIN)) { + TestRestClient.HttpResponse response = client.postJson( + SAMPLE_RESOURCE_SHARE_ENDPOINT + "/" + resourceId, + shareWithPayload(sharedUser.getName()) + ); + response.assertStatusCode(HttpStatus.SC_OK); + assertThat( + response.bodyAsJsonNode().get("share_with").get(ResourceAccessActionGroups.PLACE_HOLDER).get("users").get(0).asText(), + containsString(sharedUser.getName()) + ); + } + + // resource should now be visible to sharedUser + try (TestRestClient client = cluster.getRestClient(sharedUser)) { + TestRestClient.HttpResponse response = client.get(SAMPLE_RESOURCE_GET_ENDPOINT + "/" + resourceId); + response.assertStatusCode(HttpStatus.SC_OK); + assertThat(response.getBody(), containsString("sampleUpdated")); + + response = client.get(SAMPLE_RESOURCE_GET_ENDPOINT); + response.assertStatusCode(HttpStatus.SC_OK); + assertThat(response.bodyAsJsonNode().get("resources").size(), equalTo(1)); + } + + // resource is still visible to super-admin + try (TestRestClient client = cluster.getRestClient(cluster.getAdminCertificate())) { + TestRestClient.HttpResponse response = client.get(SAMPLE_RESOURCE_GET_ENDPOINT + "/" + resourceId); + response.assertStatusCode(HttpStatus.SC_OK); + assertThat(response.getBody(), containsString("sampleUpdated")); + } + + // revoke share_with_user's access + try (TestRestClient client = cluster.getRestClient(USER_ADMIN)) { + TestRestClient.HttpResponse response = client.postJson( + SAMPLE_RESOURCE_REVOKE_ENDPOINT + "/" + resourceId, + revokeAccessPayload(sharedUser.getName()) + ); + response.assertStatusCode(HttpStatus.SC_OK); + assertThat(response.bodyAsJsonNode().get("share_with").size(), equalTo(0)); + } + + // get sample resource with sharedUser, user no longer has access to resource + try (TestRestClient client = cluster.getRestClient(sharedUser)) { + TestRestClient.HttpResponse response = client.get(SAMPLE_RESOURCE_GET_ENDPOINT + "/" + resourceId); + response.assertStatusCode(HttpStatus.SC_FORBIDDEN); + + response = client.get(SAMPLE_RESOURCE_GET_ENDPOINT); + response.assertStatusCode(HttpStatus.SC_OK); + assertThat(response.bodyAsJsonNode().get("resources").size(), equalTo(0)); + } + + // delete sample resource with sharedUser + try (TestRestClient client = cluster.getRestClient(sharedUser)) { + TestRestClient.HttpResponse response = client.delete(SAMPLE_RESOURCE_DELETE_ENDPOINT + "/" + resourceId); + response.assertStatusCode(HttpStatus.SC_FORBIDDEN); + } + + // delete sample resource + try (TestRestClient client = cluster.getRestClient(USER_ADMIN)) { + TestRestClient.HttpResponse response = client.delete(SAMPLE_RESOURCE_DELETE_ENDPOINT + "/" + resourceId); + response.assertStatusCode(HttpStatus.SC_OK); + } + + // corresponding entry should be removed from resource-sharing index + try (TestRestClient client = cluster.getRestClient(cluster.getAdminCertificate())) { + // Since test framework doesn't yet allow loading ex tensions we need to delete the resource sharing entry manually + TestRestClient.HttpResponse response = client.delete(OPENSEARCH_RESOURCE_SHARING_INDEX + "/_doc/" + resourceSharingDocId); + response.assertStatusCode(HttpStatus.SC_OK); + + Awaitility.await() + .alias("Wait until resource-sharing data is updated") + .until( + () -> client.get(OPENSEARCH_RESOURCE_SHARING_INDEX + "/_search").bodyAsJsonNode().get("hits").get("hits").size(), + equalTo(0) + ); + } + + // get sample resource with sharedUser + try (TestRestClient client = cluster.getRestClient(sharedUser)) { + TestRestClient.HttpResponse response = client.get(SAMPLE_RESOURCE_GET_ENDPOINT + "/" + resourceId); + response.assertStatusCode(HttpStatus.SC_NOT_FOUND); + } + + // get sample resource with admin + try (TestRestClient client = cluster.getRestClient(USER_ADMIN)) { + TestRestClient.HttpResponse response = client.get(SAMPLE_RESOURCE_GET_ENDPOINT + "/" + resourceId); + response.assertStatusCode(HttpStatus.SC_NOT_FOUND); + } + } +} diff --git a/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/AbstractSampleResourcePluginTests.java b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/AbstractSampleResourcePluginTests.java new file mode 100644 index 0000000000..f406b8bf7f --- /dev/null +++ b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/AbstractSampleResourcePluginTests.java @@ -0,0 +1,76 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.sample; + +import org.opensearch.common.settings.Settings; +import org.opensearch.security.resources.ResourceAccessControlClient; +import org.opensearch.security.resources.ResourceAccessHandler; +import org.opensearch.security.spi.resources.client.ResourceSharingClient; +import org.opensearch.test.framework.TestSecurityConfig; +import org.opensearch.test.framework.cluster.LocalCluster; + +import static org.opensearch.sample.utils.Constants.SAMPLE_RESOURCE_PLUGIN_PREFIX; + +/** + * Abstract class for sample resource plugin tests. Provides common constants and utility methods for testing. This class is not intended to be + * instantiated directly. It is extended by {@link AbstractSampleResourcePluginFeatureEnabledTests}, {@link SampleResourcePluginFeatureDisabledTests} + */ +public abstract class AbstractSampleResourcePluginTests { + + protected final static TestSecurityConfig.User SHARED_WITH_USER = new TestSecurityConfig.User("resource_sharing_test_user").roles( + new TestSecurityConfig.Role("shared_role").indexPermissions("*").on("*").clusterPermissions("*") + ); + + // No update permission + protected final static TestSecurityConfig.User SHARED_WITH_USER_LIMITED_PERMISSIONS = new TestSecurityConfig.User( + "resource_sharing_test_user_limited_perms" + ).roles( + new TestSecurityConfig.Role("shared_role_limited_perms").clusterPermissions( + "cluster:admin/security/resource_access/*", + "cluster:admin/sample-resource-plugin/get", + "cluster:admin/sample-resource-plugin/create", + "cluster:admin/sample-resource-plugin/share", + "cluster:admin/sample-resource-plugin/revoke" + ) + ); + + protected static final String SAMPLE_RESOURCE_CREATE_ENDPOINT = SAMPLE_RESOURCE_PLUGIN_PREFIX + "/create"; + protected static final String SAMPLE_RESOURCE_GET_ENDPOINT = SAMPLE_RESOURCE_PLUGIN_PREFIX + "/get"; + protected static final String SAMPLE_RESOURCE_UPDATE_ENDPOINT = SAMPLE_RESOURCE_PLUGIN_PREFIX + "/update"; + protected static final String SAMPLE_RESOURCE_DELETE_ENDPOINT = SAMPLE_RESOURCE_PLUGIN_PREFIX + "/delete"; + protected static final String SAMPLE_RESOURCE_SHARE_ENDPOINT = SAMPLE_RESOURCE_PLUGIN_PREFIX + "/share"; + protected static final String SAMPLE_RESOURCE_REVOKE_ENDPOINT = SAMPLE_RESOURCE_PLUGIN_PREFIX + "/revoke"; + + protected static ResourceSharingClient createResourceAccessControlClient(LocalCluster cluster) { + ResourceAccessHandler rAH = cluster.nodes().getFirst().getInjectable(ResourceAccessHandler.class); + Settings settings = cluster.node().settings(); + return new ResourceAccessControlClient(rAH, settings); + } + + protected static String shareWithPayload(String user) { + return """ + { + "share_with": { + "users": ["%s"] + } + } + """.formatted(user); + } + + protected static String revokeAccessPayload(String user) { + return """ + { + "entities_to_revoke": { + "users": ["%s"] + } + } + """.formatted(user); + + } +} diff --git a/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/SampleResourcePluginFeatureDisabledTests.java b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/SampleResourcePluginFeatureDisabledTests.java new file mode 100644 index 0000000000..f413f89c36 --- /dev/null +++ b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/SampleResourcePluginFeatureDisabledTests.java @@ -0,0 +1,151 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.sample; + +import java.util.Map; + +import com.carrotsearch.randomizedtesting.annotations.ThreadLeakScope; +import org.apache.http.HttpStatus; +import org.junit.After; +import org.junit.ClassRule; +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.opensearch.painless.PainlessModulePlugin; +import org.opensearch.test.framework.cluster.ClusterManager; +import org.opensearch.test.framework.cluster.LocalCluster; +import org.opensearch.test.framework.cluster.TestRestClient; +import org.opensearch.test.framework.cluster.TestRestClient.HttpResponse; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.opensearch.sample.utils.Constants.RESOURCE_INDEX_NAME; +import static org.opensearch.security.resources.ResourceSharingConstants.OPENSEARCH_RESOURCE_SHARING_INDEX; +import static org.opensearch.security.spi.resources.FeatureConfigConstants.OPENSEARCH_RESOURCE_SHARING_ENABLED; +import static org.opensearch.test.framework.TestSecurityConfig.AuthcDomain.AUTHC_HTTPBASIC_INTERNAL; +import static org.opensearch.test.framework.TestSecurityConfig.User.USER_ADMIN; + +/** + * These tests run with resource sharing feature disabled. + */ +@RunWith(com.carrotsearch.randomizedtesting.RandomizedRunner.class) +@ThreadLeakScope(ThreadLeakScope.Scope.NONE) +public class SampleResourcePluginFeatureDisabledTests extends AbstractSampleResourcePluginTests { + + @ClassRule + public static LocalCluster cluster = new LocalCluster.Builder().clusterManager(ClusterManager.SINGLENODE) + .plugin(SampleResourcePlugin.class, PainlessModulePlugin.class) + .anonymousAuth(true) + .authc(AUTHC_HTTPBASIC_INTERNAL) + .users(USER_ADMIN, SHARED_WITH_USER) + .nodeSettings(Map.of(OPENSEARCH_RESOURCE_SHARING_ENABLED, false)) + .build(); + + @After + public void clearIndices() { + try (TestRestClient client = cluster.getRestClient(cluster.getAdminCertificate())) { + client.delete(RESOURCE_INDEX_NAME); + } + } + + @Test + public void testPluginInstalledCorrectly() { + try (TestRestClient client = cluster.getRestClient(USER_ADMIN)) { + HttpResponse pluginsResponse = client.get("_cat/plugins"); + assertThat(pluginsResponse.getBody(), containsString("org.opensearch.security.OpenSearchSecurityPlugin")); + assertThat(pluginsResponse.getBody(), containsString("org.opensearch.sample.SampleResourcePlugin")); + } + } + + @Test + public void testNoResourceRestrictions() throws Exception { + String resourceId; + // create sample resource + try (TestRestClient client = cluster.getRestClient(USER_ADMIN)) { + String sampleResource = """ + {"name":"sample"} + """; + + HttpResponse response = client.putJson(SAMPLE_RESOURCE_CREATE_ENDPOINT, sampleResource); + response.assertStatusCode(HttpStatus.SC_OK); + + resourceId = response.getTextFromJsonBody("/message").split(":")[1].trim(); + } + + // assert that resource-sharing index doesn't exist + try (TestRestClient client = cluster.getRestClient(cluster.getAdminCertificate())) { + HttpResponse response = client.get(OPENSEARCH_RESOURCE_SHARING_INDEX + "/_search"); + response.assertStatusCode(HttpStatus.SC_NOT_FOUND); + } + + // resource should be visible to super-admin + try (TestRestClient client = cluster.getRestClient(cluster.getAdminCertificate())) { + HttpResponse response = client.get(SAMPLE_RESOURCE_GET_ENDPOINT + "/" + resourceId); + response.assertStatusCode(HttpStatus.SC_OK); + assertThat(response.getBody(), containsString("sample")); + } + + // resource should be visible to shared_with_user since there is no restriction and this user has * permission + try (TestRestClient client = cluster.getRestClient(SHARED_WITH_USER)) { + HttpResponse response = client.get(SAMPLE_RESOURCE_GET_ENDPOINT + "/" + resourceId); + response.assertStatusCode(HttpStatus.SC_OK); + assertThat(response.getBody(), containsString("sample")); + + response = client.get(SAMPLE_RESOURCE_GET_ENDPOINT); + response.assertStatusCode(HttpStatus.SC_OK); + assertThat(response.bodyAsJsonNode().get("resources").size(), equalTo(1)); + assertThat(response.getBody(), containsString("sample")); + } + + // shared_with_user is able to update admin's resource + try (TestRestClient client = cluster.getRestClient(SHARED_WITH_USER)) { + String updatePayload = "{" + "\"name\": \"sampleUpdated\"" + "}"; + HttpResponse updateResponse = client.postJson(SAMPLE_RESOURCE_UPDATE_ENDPOINT + "/" + resourceId, updatePayload); + updateResponse.assertStatusCode(HttpStatus.SC_OK); + } + + // admin can see updated value + try (TestRestClient client = cluster.getRestClient(USER_ADMIN)) { + HttpResponse response = client.get(SAMPLE_RESOURCE_GET_ENDPOINT + "/" + resourceId); + response.assertStatusCode(HttpStatus.SC_OK); + assertThat(response.getBody(), containsString("sampleUpdated")); + } + + // shared_with_user is able to call sample share api + try (TestRestClient client = cluster.getRestClient(SHARED_WITH_USER)) { + HttpResponse updateResponse = client.postJson( + SAMPLE_RESOURCE_SHARE_ENDPOINT + "/" + resourceId, + shareWithPayload(SHARED_WITH_USER.getName()) + ); + updateResponse.assertStatusCode(HttpStatus.SC_NOT_IMPLEMENTED); + } + + // shared_with_user is able to call sample revoke api + try (TestRestClient client = cluster.getRestClient(SHARED_WITH_USER)) { + HttpResponse updateResponse = client.postJson( + SAMPLE_RESOURCE_REVOKE_ENDPOINT + "/" + resourceId, + revokeAccessPayload(SHARED_WITH_USER.getName()) + ); + updateResponse.assertStatusCode(HttpStatus.SC_NOT_IMPLEMENTED); + } + + // delete sample resource - share_with user delete admin user's resource + try (TestRestClient client = cluster.getRestClient(SHARED_WITH_USER)) { + HttpResponse response = client.delete(SAMPLE_RESOURCE_DELETE_ENDPOINT + "/" + resourceId); + response.assertStatusCode(HttpStatus.SC_OK); + } + + // admin can no longer see the resource + try (TestRestClient client = cluster.getRestClient(USER_ADMIN)) { + HttpResponse response = client.get(SAMPLE_RESOURCE_GET_ENDPOINT + "/" + resourceId); + response.assertStatusCode(HttpStatus.SC_NOT_FOUND); + } + } +} diff --git a/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/SampleResourcePluginLimitedPermissionsTests.java b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/SampleResourcePluginLimitedPermissionsTests.java new file mode 100644 index 0000000000..cf465c7aca --- /dev/null +++ b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/SampleResourcePluginLimitedPermissionsTests.java @@ -0,0 +1,223 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.sample; + +import java.util.Map; + +import com.carrotsearch.randomizedtesting.annotations.ThreadLeakScope; +import org.apache.http.HttpStatus; +import org.awaitility.Awaitility; +import org.junit.ClassRule; +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.opensearch.painless.PainlessModulePlugin; +import org.opensearch.sample.resource.client.ResourceSharingClientAccessor; +import org.opensearch.security.spi.resources.ResourceProvider; +import org.opensearch.test.framework.TestSecurityConfig; +import org.opensearch.test.framework.cluster.ClusterManager; +import org.opensearch.test.framework.cluster.LocalCluster; +import org.opensearch.test.framework.cluster.TestRestClient; +import org.opensearch.test.framework.cluster.TestRestClient.HttpResponse; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.opensearch.sample.utils.Constants.RESOURCE_INDEX_NAME; +import static org.opensearch.security.resources.ResourceSharingConstants.OPENSEARCH_RESOURCE_SHARING_INDEX; +import static org.opensearch.security.spi.resources.FeatureConfigConstants.OPENSEARCH_RESOURCE_SHARING_ENABLED; +import static org.opensearch.security.support.ConfigConstants.SECURITY_SYSTEM_INDICES_ENABLED_KEY; +import static org.opensearch.test.framework.TestSecurityConfig.AuthcDomain.AUTHC_HTTPBASIC_INTERNAL; +import static org.opensearch.test.framework.TestSecurityConfig.User.USER_ADMIN; + +/** + * These tests run with resource sharing enabled and system index protection enabled + */ +@RunWith(com.carrotsearch.randomizedtesting.RandomizedRunner.class) +@ThreadLeakScope(ThreadLeakScope.Scope.NONE) +public class SampleResourcePluginLimitedPermissionsTests extends AbstractSampleResourcePluginFeatureEnabledTests { + + @ClassRule + public static LocalCluster cluster = new LocalCluster.Builder().clusterManager(ClusterManager.SINGLENODE) + .plugin(SampleResourcePlugin.class, PainlessModulePlugin.class) + .anonymousAuth(true) + .authc(AUTHC_HTTPBASIC_INTERNAL) + .users(USER_ADMIN, SHARED_WITH_USER_LIMITED_PERMISSIONS) + .nodeSettings(Map.of(SECURITY_SYSTEM_INDICES_ENABLED_KEY, true, OPENSEARCH_RESOURCE_SHARING_ENABLED, true)) + .build(); + + @Override + protected LocalCluster getLocalCluster() { + return cluster; + } + + @Override + protected TestSecurityConfig.User getSharedUser() { + return SHARED_WITH_USER_LIMITED_PERMISSIONS; + } + + @Test + public void testAccessWithLimitedIP() throws Exception { + String resourceId; + // create sample resource + try (TestRestClient client = cluster.getRestClient(SHARED_WITH_USER_LIMITED_PERMISSIONS)) { + String sampleResource = """ + {"name":"sample"} + """; + + HttpResponse response = client.putJson(SAMPLE_RESOURCE_CREATE_ENDPOINT, sampleResource); + response.assertStatusCode(HttpStatus.SC_OK); + + resourceId = response.getTextFromJsonBody("/message").split(":")[1].trim(); + } + + // Create an entry in resource-sharing index + try (TestRestClient client = cluster.getRestClient(cluster.getAdminCertificate())) { + // Since test framework doesn't yet allow loading ex tensions we need to create a resource sharing entry manually + + String json = """ + { + "source_idx": "%s", + "resource_id": "%s", + "created_by": { + "user": "%s" + } + } + """.formatted(RESOURCE_INDEX_NAME, resourceId, SHARED_WITH_USER_LIMITED_PERMISSIONS.getName()); + + HttpResponse response = client.postJson(OPENSEARCH_RESOURCE_SHARING_INDEX + "/_doc", json); + assertThat(response.getStatusReason(), containsString("Created")); + + // Also update the in-memory map and get + resourcePluginInfo.getResourceIndicesMutable().add(RESOURCE_INDEX_NAME); + ResourceProvider provider = new ResourceProvider(SampleResource.class.getCanonicalName(), RESOURCE_INDEX_NAME); + resourcePluginInfo.getResourceProvidersMutable().put(RESOURCE_INDEX_NAME, provider); + + ResourceSharingClientAccessor.setResourceSharingClient(createResourceAccessControlClient(cluster)); + + Awaitility.await() + .alias("Wait until resource-sharing data is populated") + .until( + () -> client.get(OPENSEARCH_RESOURCE_SHARING_INDEX + "/_search").bodyAsJsonNode().get("hits").get("hits").size(), + equalTo(1) + ); + response = client.get(SAMPLE_RESOURCE_GET_ENDPOINT); + response.assertStatusCode(HttpStatus.SC_OK); + assertThat(response.bodyAsJsonNode().get("resources").size(), equalTo(1)); + assertThat(response.getBody(), containsString("sample")); + } + + // user should be able to get its own resource as it has get API access + try (TestRestClient client = cluster.getRestClient(SHARED_WITH_USER_LIMITED_PERMISSIONS)) { + HttpResponse response = client.get(SAMPLE_RESOURCE_GET_ENDPOINT + "/" + resourceId); + response.assertStatusCode(HttpStatus.SC_OK); + } + + // Update user's sample resource + try (TestRestClient client = cluster.getRestClient(SHARED_WITH_USER_LIMITED_PERMISSIONS)) { + String sampleResourceUpdated = """ + {"name":"sampleUpdated"} + """; + + HttpResponse updateResponse = client.postJson(SAMPLE_RESOURCE_UPDATE_ENDPOINT + "/" + resourceId, sampleResourceUpdated); + // cannot update because this user doesnt have access to update API + updateResponse.assertStatusCode(HttpStatus.SC_FORBIDDEN); + assertThat( + updateResponse.bodyAsJsonNode().get("error").get("root_cause").get(0).get("reason").asText(), + containsString( + "no permissions for [cluster:admin/sample-resource-plugin/update] and User [name=resource_sharing_test_user_limited_perms, backend_roles=[], requestedTenant=null]" + ) + ); + } + + // User admin should not be able to update, since resource is not shared with it + try (TestRestClient client = cluster.getRestClient(USER_ADMIN)) { + String sampleResourceUpdated = """ + {"name":"sampleUpdated"} + """; + + HttpResponse updateResponse = client.postJson(SAMPLE_RESOURCE_UPDATE_ENDPOINT + "/" + resourceId, sampleResourceUpdated); + // cannot update because this user doesnt have access to the resource + updateResponse.assertStatusCode(HttpStatus.SC_FORBIDDEN); + } + + // Super admin can update the resource + try (TestRestClient client = cluster.getRestClient(cluster.getAdminCertificate())) { + String sampleResourceUpdated = """ + {"name":"sampleUpdated"} + """; + + HttpResponse updateResponse = client.postJson(SAMPLE_RESOURCE_UPDATE_ENDPOINT + "/" + resourceId, sampleResourceUpdated); + // cannot update because this user doesnt have access to update API + updateResponse.assertStatusCode(HttpStatus.SC_OK); + assertThat(updateResponse.getBody(), containsString("sample")); + } + + // share resource with admin + try (TestRestClient client = cluster.getRestClient(SHARED_WITH_USER_LIMITED_PERMISSIONS)) { + HttpResponse response = client.postJson( + SAMPLE_RESOURCE_SHARE_ENDPOINT + "/" + resourceId, + shareWithPayload(USER_ADMIN.getName()) + ); + + response.assertStatusCode(HttpStatus.SC_OK); + } + + // admin is able to access resource now + try (TestRestClient client = cluster.getRestClient(USER_ADMIN)) { + HttpResponse response = client.get(SAMPLE_RESOURCE_GET_ENDPOINT + "/" + resourceId); + response.assertStatusCode(HttpStatus.SC_OK); + } + + // revoke admin's access + try (TestRestClient client = cluster.getRestClient(SHARED_WITH_USER_LIMITED_PERMISSIONS)) { + HttpResponse response = client.postJson( + SAMPLE_RESOURCE_REVOKE_ENDPOINT + "/" + resourceId, + revokeAccessPayload(USER_ADMIN.getName()) + ); + + response.assertStatusCode(HttpStatus.SC_OK); + } + + // admin can no longer access the resource + try (TestRestClient client = cluster.getRestClient(USER_ADMIN)) { + HttpResponse response = client.get(SAMPLE_RESOURCE_GET_ENDPOINT + "/" + resourceId); + response.assertStatusCode(HttpStatus.SC_FORBIDDEN); + } + + // delete sample resource + try (TestRestClient client = cluster.getRestClient(SHARED_WITH_USER_LIMITED_PERMISSIONS)) { + HttpResponse response = client.delete(SAMPLE_RESOURCE_DELETE_ENDPOINT + "/" + resourceId); + + // cannot delete because this user doesnt have access to delete API + response.assertStatusCode(HttpStatus.SC_FORBIDDEN); + assertThat( + response.bodyAsJsonNode().get("error").get("root_cause").get(0).get("reason").asText(), + containsString( + "no permissions for [cluster:admin/sample-resource-plugin/delete] and User [name=resource_sharing_test_user_limited_perms, backend_roles=[], requestedTenant=null]" + ) + ); + } + + // User admin should not be able to delete share_with_user's resource + try (TestRestClient client = cluster.getRestClient(USER_ADMIN)) { + HttpResponse response = client.delete(SAMPLE_RESOURCE_DELETE_ENDPOINT + "/" + resourceId); + + // cannot delete because user admin doesn't have access to resource + response.assertStatusCode(HttpStatus.SC_FORBIDDEN); + } + + // Super admin can delete the resource + try (TestRestClient client = cluster.getRestClient(cluster.getAdminCertificate())) { + HttpResponse response = client.delete(SAMPLE_RESOURCE_DELETE_ENDPOINT + "/" + resourceId); + + response.assertStatusCode(HttpStatus.SC_OK); + } + } +} diff --git a/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/SampleResourcePluginSystemIndexDisabledTests.java b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/SampleResourcePluginSystemIndexDisabledTests.java new file mode 100644 index 0000000000..dea67052b2 --- /dev/null +++ b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/SampleResourcePluginSystemIndexDisabledTests.java @@ -0,0 +1,207 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.sample; + +import java.util.Map; + +import com.carrotsearch.randomizedtesting.annotations.ThreadLeakScope; +import org.apache.http.HttpStatus; +import org.awaitility.Awaitility; +import org.junit.ClassRule; +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.opensearch.painless.PainlessModulePlugin; +import org.opensearch.sample.resource.client.ResourceSharingClientAccessor; +import org.opensearch.security.spi.resources.ResourceAccessActionGroups; +import org.opensearch.security.spi.resources.ResourceProvider; +import org.opensearch.test.framework.TestSecurityConfig; +import org.opensearch.test.framework.cluster.ClusterManager; +import org.opensearch.test.framework.cluster.LocalCluster; +import org.opensearch.test.framework.cluster.TestRestClient; +import org.opensearch.test.framework.cluster.TestRestClient.HttpResponse; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.opensearch.sample.utils.Constants.RESOURCE_INDEX_NAME; +import static org.opensearch.security.resources.ResourceSharingConstants.OPENSEARCH_RESOURCE_SHARING_INDEX; +import static org.opensearch.security.spi.resources.FeatureConfigConstants.OPENSEARCH_RESOURCE_SHARING_ENABLED; +import static org.opensearch.test.framework.TestSecurityConfig.AuthcDomain.AUTHC_HTTPBASIC_INTERNAL; +import static org.opensearch.test.framework.TestSecurityConfig.User.USER_ADMIN; + +/** + * These tests run with resource sharing enabled but system index protection disabled + */ +@RunWith(com.carrotsearch.randomizedtesting.RandomizedRunner.class) +@ThreadLeakScope(ThreadLeakScope.Scope.NONE) +public class SampleResourcePluginSystemIndexDisabledTests extends AbstractSampleResourcePluginFeatureEnabledTests { + + @ClassRule + public static LocalCluster cluster = new LocalCluster.Builder().clusterManager(ClusterManager.SINGLENODE) + .plugin(SampleResourcePlugin.class, PainlessModulePlugin.class) + .anonymousAuth(true) + .authc(AUTHC_HTTPBASIC_INTERNAL) + .users(USER_ADMIN, SHARED_WITH_USER) + .nodeSettings(Map.of(OPENSEARCH_RESOURCE_SHARING_ENABLED, true)) + .build(); + + @Override + protected LocalCluster getLocalCluster() { + return cluster; + } + + @Override + protected TestSecurityConfig.User getSharedUser() { + return SHARED_WITH_USER; + } + + @Test + public void testDirectAccess() throws Exception { + String resourceId; + // create sample resource + try (TestRestClient client = cluster.getRestClient(USER_ADMIN)) { + String sampleResource = """ + {"name":"sample"} + """; + + HttpResponse response = client.putJson(SAMPLE_RESOURCE_CREATE_ENDPOINT, sampleResource); + response.assertStatusCode(HttpStatus.SC_OK); + + resourceId = response.getTextFromJsonBody("/message").split(":")[1].trim(); + } + + // Create an entry in resource-sharing index + try (TestRestClient client = cluster.getRestClient(cluster.getAdminCertificate())) { + // Since test framework doesn't yet allow loading ex tensions we need to create a resource sharing entry manually + String json = """ + { + "source_idx": "%s", + "resource_id": "%s", + "created_by": { + "user": "admin" + } + } + """.formatted(RESOURCE_INDEX_NAME, resourceId); + + HttpResponse response = client.postJson(OPENSEARCH_RESOURCE_SHARING_INDEX + "/_doc", json); + assertThat(response.getStatusReason(), containsString("Created")); + // Also update the in-memory map and get + resourcePluginInfo.getResourceIndicesMutable().add(RESOURCE_INDEX_NAME); + ResourceProvider provider = new ResourceProvider(SampleResource.class.getCanonicalName(), RESOURCE_INDEX_NAME); + resourcePluginInfo.getResourceProvidersMutable().put(RESOURCE_INDEX_NAME, provider); + + ResourceSharingClientAccessor.setResourceSharingClient(createResourceAccessControlClient(cluster)); + + Awaitility.await() + .alias("Wait until resource-sharing data is populated") + .until( + () -> client.get(OPENSEARCH_RESOURCE_SHARING_INDEX + "/_search").bodyAsJsonNode().get("hits").get("hits").size(), + equalTo(1) + ); + response = client.get(SAMPLE_RESOURCE_GET_ENDPOINT); + response.assertStatusCode(HttpStatus.SC_OK); + assertThat(response.bodyAsJsonNode().get("resources").size(), equalTo(1)); + assertThat(response.getBody(), containsString("sample")); + } + + // admin will be able to access resource directly since system index protection is disabled, and also via sample plugin + try (TestRestClient client = cluster.getRestClient(USER_ADMIN)) { + HttpResponse response = client.get(RESOURCE_INDEX_NAME + "/_doc/" + resourceId); + response.assertStatusCode(HttpStatus.SC_OK); + + response = client.get(SAMPLE_RESOURCE_GET_ENDPOINT + "/" + resourceId); + response.assertStatusCode(HttpStatus.SC_OK); + } + + // shared_with_user will be able to access resource directly since system index protection is disabled even-though resource is not + // shared with this user, but cannot access via sample plugin APIs + try (TestRestClient client = cluster.getRestClient(SHARED_WITH_USER)) { + HttpResponse response = client.get(RESOURCE_INDEX_NAME + "/_doc/" + resourceId); + response.assertStatusCode(HttpStatus.SC_OK); + + response = client.get(SAMPLE_RESOURCE_GET_ENDPOINT + "/" + resourceId); + response.assertStatusCode(HttpStatus.SC_FORBIDDEN); + } + + // Update sample resource shared_with_user will be able to update admin's resource because system index protection is disabled + try (TestRestClient client = cluster.getRestClient(SHARED_WITH_USER)) { + String sampleResourceUpdated = """ + {"name":"sampleUpdated"} + """; + + TestRestClient.HttpResponse updateResponse = client.postJson( + RESOURCE_INDEX_NAME + "/_doc/" + resourceId, + sampleResourceUpdated + ); + updateResponse.assertStatusCode(HttpStatus.SC_OK); + } + + // share resource with shared_with user + try (TestRestClient client = cluster.getRestClient(USER_ADMIN)) { + Awaitility.await() + .alias("Wait until resource-sharing data is populated") + .until( + () -> client.get(OPENSEARCH_RESOURCE_SHARING_INDEX + "/_search").bodyAsJsonNode().get("hits").get("hits").size(), + equalTo(1) + ); + + HttpResponse response = client.postJson( + SAMPLE_RESOURCE_SHARE_ENDPOINT + "/" + resourceId, + shareWithPayload(SHARED_WITH_USER.getName()) + ); + response.assertStatusCode(HttpStatus.SC_OK); + assertThat( + response.bodyAsJsonNode().get("share_with").get(ResourceAccessActionGroups.PLACE_HOLDER).get("users").get(0).asText(), + containsString(SHARED_WITH_USER.getName()) + ); + } + + // shared_with_user will still be able to access resource directly since system index protection is enabled, but can also access via + // sample plugin + try (TestRestClient client = cluster.getRestClient(SHARED_WITH_USER)) { + HttpResponse response = client.get(RESOURCE_INDEX_NAME + "/_doc/" + resourceId); + response.assertStatusCode(HttpStatus.SC_OK); + + response = client.get(SAMPLE_RESOURCE_GET_ENDPOINT + "/" + resourceId); + response.assertStatusCode(HttpStatus.SC_OK); + } + + // revoke share_with_user's access + try (TestRestClient client = cluster.getRestClient(USER_ADMIN)) { + Awaitility.await() + .alias("Wait until resource-sharing data is populated") + .until( + () -> client.get(OPENSEARCH_RESOURCE_SHARING_INDEX + "/_search").bodyAsJsonNode().get("hits").get("hits").size(), + equalTo(1) + ); + HttpResponse response = client.postJson( + SAMPLE_RESOURCE_REVOKE_ENDPOINT + "/" + resourceId, + revokeAccessPayload(SHARED_WITH_USER.getName()) + ); + response.assertStatusCode(HttpStatus.SC_OK); + assertThat(response.bodyAsJsonNode().get("share_with").size(), equalTo(0)); + } + + // shared_with_user will still be able to access the resource directly but not via sample plugin since access is revoked + try (TestRestClient client = cluster.getRestClient(SHARED_WITH_USER)) { + HttpResponse response = client.get(RESOURCE_INDEX_NAME + "/_doc/" + resourceId); + response.assertStatusCode(HttpStatus.SC_OK); + + response = client.get(SAMPLE_RESOURCE_GET_ENDPOINT + "/" + resourceId); + response.assertStatusCode(HttpStatus.SC_FORBIDDEN); + } + + // shared_with_user should be able to delete the resource since system index protection is disabled + try (TestRestClient client = cluster.getRestClient(SHARED_WITH_USER)) { + HttpResponse response = client.delete(RESOURCE_INDEX_NAME + "/_doc/" + resourceId); + response.assertStatusCode(HttpStatus.SC_OK); + } + } +} diff --git a/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/SampleResourcePluginTests.java b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/SampleResourcePluginTests.java new file mode 100644 index 0000000000..1400aed852 --- /dev/null +++ b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/SampleResourcePluginTests.java @@ -0,0 +1,216 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.sample; + +import java.util.Map; + +import com.carrotsearch.randomizedtesting.annotations.ThreadLeakScope; +import org.apache.http.HttpStatus; +import org.awaitility.Awaitility; +import org.junit.ClassRule; +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.opensearch.painless.PainlessModulePlugin; +import org.opensearch.sample.resource.client.ResourceSharingClientAccessor; +import org.opensearch.security.resources.ResourcePluginInfo; +import org.opensearch.security.spi.resources.ResourceAccessActionGroups; +import org.opensearch.security.spi.resources.ResourceProvider; +import org.opensearch.test.framework.TestSecurityConfig; +import org.opensearch.test.framework.cluster.ClusterManager; +import org.opensearch.test.framework.cluster.LocalCluster; +import org.opensearch.test.framework.cluster.TestRestClient; +import org.opensearch.test.framework.cluster.TestRestClient.HttpResponse; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.opensearch.sample.utils.Constants.RESOURCE_INDEX_NAME; +import static org.opensearch.security.resources.ResourceSharingConstants.OPENSEARCH_RESOURCE_SHARING_INDEX; +import static org.opensearch.security.spi.resources.FeatureConfigConstants.OPENSEARCH_RESOURCE_SHARING_ENABLED; +import static org.opensearch.security.support.ConfigConstants.SECURITY_SYSTEM_INDICES_ENABLED_KEY; +import static org.opensearch.test.framework.TestSecurityConfig.AuthcDomain.AUTHC_HTTPBASIC_INTERNAL; +import static org.opensearch.test.framework.TestSecurityConfig.User.USER_ADMIN; + +/** + * These tests run with resource sharing enabled and system index protection enabled + */ +@RunWith(com.carrotsearch.randomizedtesting.RandomizedRunner.class) +@ThreadLeakScope(ThreadLeakScope.Scope.NONE) +public class SampleResourcePluginTests extends AbstractSampleResourcePluginFeatureEnabledTests { + + @ClassRule + public static LocalCluster cluster = new LocalCluster.Builder().clusterManager(ClusterManager.SINGLENODE) + .plugin(SampleResourcePlugin.class, PainlessModulePlugin.class) + .anonymousAuth(true) + .authc(AUTHC_HTTPBASIC_INTERNAL) + .users(USER_ADMIN, SHARED_WITH_USER) + .nodeSettings(Map.of(SECURITY_SYSTEM_INDICES_ENABLED_KEY, true, OPENSEARCH_RESOURCE_SHARING_ENABLED, true)) + .build(); + + @Override + protected LocalCluster getLocalCluster() { + return cluster; + } + + @Override + protected TestSecurityConfig.User getSharedUser() { + return SHARED_WITH_USER; + } + + @Test + public void testDirectAccess() throws Exception { + ResourcePluginInfo resourcePluginInfo = cluster.nodes().getFirst().getInjectable(ResourcePluginInfo.class); + + String resourceId; + // create sample resource + try (TestRestClient client = cluster.getRestClient(USER_ADMIN)) { + String sampleResource = """ + {"name":"sample"} + """; + + HttpResponse response = client.putJson(SAMPLE_RESOURCE_CREATE_ENDPOINT, sampleResource); + response.assertStatusCode(HttpStatus.SC_OK); + + resourceId = response.getTextFromJsonBody("/message").split(":")[1].trim(); + } + + // Create an entry in resource-sharing index + try (TestRestClient client = cluster.getRestClient(cluster.getAdminCertificate())) { + // Since test framework doesn't yet allow loading ex tensions we need to create a resource sharing entry manually + String json = """ + { + "source_idx": "%s", + "resource_id": "%s", + "created_by": { + "user": "admin" + } + } + """.formatted(RESOURCE_INDEX_NAME, resourceId); + HttpResponse response = client.postJson(OPENSEARCH_RESOURCE_SHARING_INDEX + "/_doc", json); + assertThat(response.getStatusReason(), containsString("Created")); + // Also update the in-memory map and get + resourcePluginInfo.getResourceIndicesMutable().add(RESOURCE_INDEX_NAME); + ResourceProvider provider = new ResourceProvider(SampleResource.class.getCanonicalName(), RESOURCE_INDEX_NAME); + resourcePluginInfo.getResourceProvidersMutable().put(RESOURCE_INDEX_NAME, provider); + + ResourceSharingClientAccessor.setResourceSharingClient(createResourceAccessControlClient(cluster)); + + Awaitility.await() + .alias("Wait until resource-sharing data is populated") + .until( + () -> client.get(OPENSEARCH_RESOURCE_SHARING_INDEX + "/_search").bodyAsJsonNode().get("hits").get("hits").size(), + equalTo(1) + ); + response = client.get(SAMPLE_RESOURCE_GET_ENDPOINT); + response.assertStatusCode(HttpStatus.SC_OK); + assertThat(response.bodyAsJsonNode().get("resources").size(), equalTo(1)); + assertThat(response.getBody(), containsString("sample")); + } + + // admin should not be able to access resource directly since system index protection is enabled, but can access via sample plugin + try (TestRestClient client = cluster.getRestClient(USER_ADMIN)) { + HttpResponse response = client.get(RESOURCE_INDEX_NAME + "/_doc/" + resourceId); + response.assertStatusCode(HttpStatus.SC_NOT_FOUND); + + response = client.get(SAMPLE_RESOURCE_GET_ENDPOINT + "/" + resourceId); + response.assertStatusCode(HttpStatus.SC_OK); + } + + // shared_with_user should not be able to delete the resource since system index protection is enabled + try (TestRestClient client = cluster.getRestClient(SHARED_WITH_USER)) { + HttpResponse response = client.delete(RESOURCE_INDEX_NAME + "/_doc/" + resourceId); + response.assertStatusCode(HttpStatus.SC_FORBIDDEN); + } + + // shared_with_user should not be able to access resource directly since system index protection is enabled, and resource is not + // shared with user + try (TestRestClient client = cluster.getRestClient(SHARED_WITH_USER)) { + HttpResponse response = client.get(RESOURCE_INDEX_NAME + "/_doc/" + resourceId); + response.assertStatusCode(HttpStatus.SC_NOT_FOUND); + + response = client.get(SAMPLE_RESOURCE_GET_ENDPOINT + "/" + resourceId); + response.assertStatusCode(HttpStatus.SC_FORBIDDEN); + } + + // Update sample resource (shared_with_user cannot update admin's resource) because system index protection is enabled + try (TestRestClient client = cluster.getRestClient(SHARED_WITH_USER)) { + String sampleResourceUpdated = """ + {"name":"sampleUpdated"} + """; + + TestRestClient.HttpResponse updateResponse = client.postJson( + RESOURCE_INDEX_NAME + "/_doc/" + resourceId, + sampleResourceUpdated + ); + updateResponse.assertStatusCode(HttpStatus.SC_FORBIDDEN); + } + + try (TestRestClient client = cluster.getRestClient(cluster.getAdminCertificate())) { + Awaitility.await() + .alias("Wait until resource-sharing data is populated") + .until( + () -> client.get(OPENSEARCH_RESOURCE_SHARING_INDEX + "/_search").bodyAsJsonNode().get("hits").get("hits").size(), + equalTo(1) + ); + } + + // share resource with shared_with user + try (TestRestClient client = cluster.getRestClient(USER_ADMIN)) { + HttpResponse response = client.postJson( + SAMPLE_RESOURCE_SHARE_ENDPOINT + "/" + resourceId, + shareWithPayload(SHARED_WITH_USER.getName()) + ); + response.assertStatusCode(HttpStatus.SC_OK); + assertThat( + response.bodyAsJsonNode().get("share_with").get(ResourceAccessActionGroups.PLACE_HOLDER).get("users").get(0).asText(), + containsString(SHARED_WITH_USER.getName()) + ); + } + + // shared_with_user should not be able to access resource directly since system index protection is enabled, but can access via + // sample plugin + try (TestRestClient client = cluster.getRestClient(SHARED_WITH_USER)) { + HttpResponse response = client.get(RESOURCE_INDEX_NAME + "/_doc/" + resourceId); + response.assertStatusCode(HttpStatus.SC_NOT_FOUND); + + response = client.get(SAMPLE_RESOURCE_GET_ENDPOINT + "/" + resourceId); + response.assertStatusCode(HttpStatus.SC_OK); + } + + try (TestRestClient client = cluster.getRestClient(cluster.getAdminCertificate())) { + Awaitility.await() + .alias("Wait until resource-sharing data is populated") + .until( + () -> client.get(OPENSEARCH_RESOURCE_SHARING_INDEX + "/_search").bodyAsJsonNode().get("hits").get("hits").size(), + equalTo(1) + ); + } + // revoke share_with_user's access + try (TestRestClient client = cluster.getRestClient(USER_ADMIN)) { + HttpResponse response = client.postJson( + SAMPLE_RESOURCE_REVOKE_ENDPOINT + "/" + resourceId, + revokeAccessPayload(SHARED_WITH_USER.getName()) + ); + response.assertStatusCode(HttpStatus.SC_OK); + assertThat(response.bodyAsJsonNode().get("share_with").size(), equalTo(0)); + } + + // shared_with_user should not be able to access the resource directly nor via sample plugin since access is revoked + try (TestRestClient client = cluster.getRestClient(SHARED_WITH_USER)) { + HttpResponse response = client.get(RESOURCE_INDEX_NAME + "/_doc/" + resourceId); + response.assertStatusCode(HttpStatus.SC_NOT_FOUND); + + response = client.postJson(RESOURCE_INDEX_NAME + "/_search", "{\"query\" : {\"match_all\" : {}}}"); + response.assertStatusCode(HttpStatus.SC_OK); + assertThat(response.bodyAsJsonNode().get("hits").get("hits").size(), equalTo(0)); + } + + } +} diff --git a/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/security_disabled/ResourcePluginSecurityDisabledTests.java b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/security_disabled/ResourcePluginSecurityDisabledTests.java new file mode 100644 index 0000000000..3f03cae294 --- /dev/null +++ b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/security_disabled/ResourcePluginSecurityDisabledTests.java @@ -0,0 +1,105 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.sample.security_disabled; + +import java.util.Map; + +import com.carrotsearch.randomizedtesting.annotations.ThreadLeakScope; +import org.apache.http.HttpStatus; +import org.junit.After; +import org.junit.ClassRule; +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.opensearch.painless.PainlessModulePlugin; +import org.opensearch.sample.AbstractSampleResourcePluginTests; +import org.opensearch.sample.SampleResourcePlugin; +import org.opensearch.test.framework.cluster.ClusterManager; +import org.opensearch.test.framework.cluster.LocalCluster; +import org.opensearch.test.framework.cluster.TestRestClient; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.opensearch.sample.utils.Constants.RESOURCE_INDEX_NAME; +import static org.opensearch.test.framework.TestSecurityConfig.User.USER_ADMIN; + +/** + * This class defines a test scenario where security plugin is disabled + * It checks access through sample plugin as well as through direct security API calls + */ +@RunWith(com.carrotsearch.randomizedtesting.RandomizedRunner.class) +@ThreadLeakScope(ThreadLeakScope.Scope.NONE) +public class ResourcePluginSecurityDisabledTests extends AbstractSampleResourcePluginTests { + + @ClassRule + public static LocalCluster cluster = new LocalCluster.Builder().clusterManager(ClusterManager.SINGLENODE) + .plugin(SampleResourcePlugin.class, PainlessModulePlugin.class) + .loadConfigurationIntoIndex(false) + .nodeSettings(Map.of("plugins.security.disabled", true, "plugins.security.ssl.http.enabled", false)) + .build(); + + @After + public void clearIndices() { + try (TestRestClient client = cluster.getSecurityDisabledRestClient()) { + client.delete(RESOURCE_INDEX_NAME); + } + } + + @Test + public void testPluginInstalledCorrectly() { + try (TestRestClient client = cluster.getSecurityDisabledRestClient()) { + TestRestClient.HttpResponse pluginsResponse = client.get("_cat/plugins"); + // security plugin is simply disabled but it will still be present in + assertThat(pluginsResponse.getBody(), containsString("org.opensearch.security.OpenSearchSecurityPlugin")); + assertThat(pluginsResponse.getBody(), containsString("org.opensearch.sample.SampleResourcePlugin")); + } + } + + @Test + public void testSamplePluginAPIs() { + try (TestRestClient client = cluster.getSecurityDisabledRestClient()) { + String sampleResource = """ + {"name":"sample"} + """; + + TestRestClient.HttpResponse response = client.putJson(SAMPLE_RESOURCE_CREATE_ENDPOINT, sampleResource); + response.assertStatusCode(HttpStatus.SC_OK); + String resourceId = response.getTextFromJsonBody("/message").split(":")[1].trim(); + + // in sample plugin implementation, get all API is checked against + response = client.get(SAMPLE_RESOURCE_GET_ENDPOINT); + response.assertStatusCode(HttpStatus.SC_OK); + + response = client.get(SAMPLE_RESOURCE_GET_ENDPOINT + "/" + resourceId); + response.assertStatusCode(HttpStatus.SC_OK); + + String sampleResourceUpdated = """ + {"name":"sampleUpdated"} + """; + + response = client.postJson(SAMPLE_RESOURCE_UPDATE_ENDPOINT + "/" + resourceId, sampleResourceUpdated); + response.assertStatusCode(HttpStatus.SC_OK); + + response = client.postJson(SAMPLE_RESOURCE_SHARE_ENDPOINT + "/" + resourceId, shareWithPayload(USER_ADMIN.getName())); + assertNotImplementedResponse(response); + + response = client.postJson(SAMPLE_RESOURCE_REVOKE_ENDPOINT + "/" + resourceId, revokeAccessPayload(USER_ADMIN.getName())); + assertNotImplementedResponse(response); + + response = client.delete(SAMPLE_RESOURCE_DELETE_ENDPOINT + "/" + resourceId); + response.assertStatusCode(HttpStatus.SC_OK); + + } + } + + private void assertNotImplementedResponse(TestRestClient.HttpResponse response) { + response.assertStatusCode(HttpStatus.SC_NOT_IMPLEMENTED); + assertThat(response.getTextFromJsonBody("/error/reason"), containsString("Security plugin is disabled")); + } +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/SampleResource.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/SampleResource.java new file mode 100644 index 0000000000..e61227e96a --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/SampleResource.java @@ -0,0 +1,106 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.sample; + +import java.io.IOException; +import java.util.Map; + +import org.opensearch.core.ParseField; +import org.opensearch.core.common.io.stream.NamedWriteable; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; +import org.opensearch.core.xcontent.ConstructingObjectParser; +import org.opensearch.core.xcontent.ToXContent; +import org.opensearch.core.xcontent.ToXContentObject; +import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.core.xcontent.XContentParser; + +import static org.opensearch.core.xcontent.ConstructingObjectParser.constructorArg; +import static org.opensearch.core.xcontent.ConstructingObjectParser.optionalConstructorArg; + +/** + * Sample resource declared by this plugin. + */ +public class SampleResource implements NamedWriteable, ToXContentObject { + + private String name; + private String description; + private Map attributes; + + public SampleResource() throws IOException { + super(); + } + + public SampleResource(StreamInput in) throws IOException { + this.name = in.readString(); + this.description = in.readString(); + this.attributes = in.readMap(StreamInput::readString, StreamInput::readString); + } + + private static final ConstructingObjectParser PARSER = new ConstructingObjectParser<>( + "sample_resource", + true, + a -> { + SampleResource s; + try { + s = new SampleResource(); + } catch (IOException e) { + throw new RuntimeException(e); + } + s.setName((String) a[0]); + s.setDescription((String) a[1]); + s.setAttributes((Map) a[2]); + return s; + } + ); + + static { + PARSER.declareString(constructorArg(), new ParseField("name")); + PARSER.declareStringOrNull(optionalConstructorArg(), new ParseField("description")); + PARSER.declareObjectOrNull(optionalConstructorArg(), (p, c) -> p.mapStrings(), null, new ParseField("attributes")); + } + + public static SampleResource fromXContent(XContentParser parser) throws IOException { + return PARSER.parse(parser, null); + } + + public XContentBuilder toXContent(XContentBuilder builder, ToXContent.Params params) throws IOException { + return builder.startObject().field("name", name).field("description", description).field("attributes", attributes).endObject(); + } + + public void writeTo(StreamOutput out) throws IOException { + out.writeString(name); + out.writeString(description); + out.writeMap(attributes, StreamOutput::writeString, StreamOutput::writeString); + } + + public void setName(String name) { + this.name = name; + } + + public void setDescription(String description) { + this.description = description; + } + + public void setAttributes(Map attributes) { + this.attributes = attributes; + } + + public String getName() { + return name; + } + + @Override + public String getWriteableName() { + return "sample_resource"; + } +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/SampleResourceExtension.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/SampleResourceExtension.java new file mode 100644 index 0000000000..1b9caf7a3a --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/SampleResourceExtension.java @@ -0,0 +1,36 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.sample; + +import java.util.Set; + +import org.opensearch.sample.resource.client.ResourceSharingClientAccessor; +import org.opensearch.security.spi.resources.ResourceProvider; +import org.opensearch.security.spi.resources.ResourceSharingExtension; +import org.opensearch.security.spi.resources.client.ResourceSharingClient; + +import static org.opensearch.sample.utils.Constants.RESOURCE_INDEX_NAME; + +/** + * Responsible for parsing the XContent into a SampleResource object. + */ +public class SampleResourceExtension implements ResourceSharingExtension { + @Override + public Set getResourceProviders() { + return Set.of(new ResourceProvider(SampleResource.class.getCanonicalName(), RESOURCE_INDEX_NAME)); + } + + @Override + public void assignResourceSharingClient(ResourceSharingClient resourceSharingClient) { + ResourceSharingClientAccessor.setResourceSharingClient(resourceSharingClient); + } +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/SampleResourcePlugin.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/SampleResourcePlugin.java new file mode 100644 index 0000000000..f046cebbf0 --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/SampleResourcePlugin.java @@ -0,0 +1,124 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ +package org.opensearch.sample; + +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.function.Supplier; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import org.opensearch.action.ActionRequest; +import org.opensearch.cluster.metadata.IndexNameExpressionResolver; +import org.opensearch.cluster.node.DiscoveryNodes; +import org.opensearch.cluster.service.ClusterService; +import org.opensearch.common.settings.ClusterSettings; +import org.opensearch.common.settings.IndexScopedSettings; +import org.opensearch.common.settings.Settings; +import org.opensearch.common.settings.SettingsFilter; +import org.opensearch.core.action.ActionResponse; +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.indices.SystemIndexDescriptor; +import org.opensearch.plugins.ActionPlugin; +import org.opensearch.plugins.Plugin; +import org.opensearch.plugins.SystemIndexPlugin; +import org.opensearch.repositories.RepositoriesService; +import org.opensearch.rest.RestController; +import org.opensearch.rest.RestHandler; +import org.opensearch.sample.resource.actions.rest.create.CreateResourceAction; +import org.opensearch.sample.resource.actions.rest.create.CreateResourceRestAction; +import org.opensearch.sample.resource.actions.rest.create.UpdateResourceAction; +import org.opensearch.sample.resource.actions.rest.delete.DeleteResourceAction; +import org.opensearch.sample.resource.actions.rest.delete.DeleteResourceRestAction; +import org.opensearch.sample.resource.actions.rest.get.GetResourceAction; +import org.opensearch.sample.resource.actions.rest.get.GetResourceRestAction; +import org.opensearch.sample.resource.actions.rest.revoke.RevokeResourceAccessAction; +import org.opensearch.sample.resource.actions.rest.revoke.RevokeResourceAccessRestAction; +import org.opensearch.sample.resource.actions.rest.share.ShareResourceAction; +import org.opensearch.sample.resource.actions.rest.share.ShareResourceRestAction; +import org.opensearch.sample.resource.actions.transport.CreateResourceTransportAction; +import org.opensearch.sample.resource.actions.transport.DeleteResourceTransportAction; +import org.opensearch.sample.resource.actions.transport.GetResourceTransportAction; +import org.opensearch.sample.resource.actions.transport.RevokeResourceAccessTransportAction; +import org.opensearch.sample.resource.actions.transport.ShareResourceTransportAction; +import org.opensearch.sample.resource.actions.transport.UpdateResourceTransportAction; +import org.opensearch.script.ScriptService; +import org.opensearch.threadpool.ThreadPool; +import org.opensearch.transport.client.Client; +import org.opensearch.watcher.ResourceWatcherService; + +import static org.opensearch.sample.utils.Constants.RESOURCE_INDEX_NAME; + +/** + * Sample Resource plugin. + * It uses ".sample_resource_sharing_plugin" index to manage its resources, and exposes few REST APIs that manage CRUD operations on sample resources. + * + */ +public class SampleResourcePlugin extends Plugin implements ActionPlugin, SystemIndexPlugin { + private static final Logger log = LogManager.getLogger(SampleResourcePlugin.class); + + @Override + public Collection createComponents( + Client client, + ClusterService clusterService, + ThreadPool threadPool, + ResourceWatcherService resourceWatcherService, + ScriptService scriptService, + NamedXContentRegistry xContentRegistry, + Environment environment, + NodeEnvironment nodeEnvironment, + NamedWriteableRegistry namedWriteableRegistry, + IndexNameExpressionResolver indexNameExpressionResolver, + Supplier repositoriesServiceSupplier + ) { + return Collections.emptyList(); + } + + @Override + public List getRestHandlers( + Settings settings, + RestController restController, + ClusterSettings clusterSettings, + IndexScopedSettings indexScopedSettings, + SettingsFilter settingsFilter, + IndexNameExpressionResolver indexNameExpressionResolver, + Supplier nodesInCluster + ) { + return List.of( + new CreateResourceRestAction(), + new GetResourceRestAction(), + new DeleteResourceRestAction(), + new ShareResourceRestAction(), + new RevokeResourceAccessRestAction() + ); + } + + @Override + public List> getActions() { + return List.of( + new ActionHandler<>(CreateResourceAction.INSTANCE, CreateResourceTransportAction.class), + new ActionHandler<>(GetResourceAction.INSTANCE, GetResourceTransportAction.class), + new ActionHandler<>(UpdateResourceAction.INSTANCE, UpdateResourceTransportAction.class), + new ActionHandler<>(DeleteResourceAction.INSTANCE, DeleteResourceTransportAction.class), + new ActionHandler<>(ShareResourceAction.INSTANCE, ShareResourceTransportAction.class), + new ActionHandler<>(RevokeResourceAccessAction.INSTANCE, RevokeResourceAccessTransportAction.class) + ); + } + + @Override + public Collection getSystemIndexDescriptors(Settings settings) { + final SystemIndexDescriptor systemIndexDescriptor = new SystemIndexDescriptor(RESOURCE_INDEX_NAME, "Sample index with resources"); + return Collections.singletonList(systemIndexDescriptor); + } +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/create/CreateResourceAction.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/create/CreateResourceAction.java new file mode 100644 index 0000000000..3e73b95f79 --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/create/CreateResourceAction.java @@ -0,0 +1,29 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.sample.resource.actions.rest.create; + +import org.opensearch.action.ActionType; + +/** + * Action to create a sample resource + */ +public class CreateResourceAction extends ActionType { + /** + * Create sample resource action instance + */ + public static final CreateResourceAction INSTANCE = new CreateResourceAction(); + /** + * Create sample resource action name + */ + public static final String NAME = "cluster:admin/sample-resource-plugin/create"; + + private CreateResourceAction() { + super(NAME, CreateResourceResponse::new); + } +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/create/CreateResourceRequest.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/create/CreateResourceRequest.java new file mode 100644 index 0000000000..ca1caa21dd --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/create/CreateResourceRequest.java @@ -0,0 +1,50 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.sample.resource.actions.rest.create; + +import java.io.IOException; + +import org.opensearch.action.ActionRequest; +import org.opensearch.action.ActionRequestValidationException; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; +import org.opensearch.sample.SampleResource; + +/** + * Request object for CreateSampleResource transport action + */ +public class CreateResourceRequest extends ActionRequest { + + private final SampleResource resource; + + /** + * Default constructor + */ + public CreateResourceRequest(SampleResource resource) { + this.resource = resource; + } + + public CreateResourceRequest(StreamInput in) throws IOException { + this.resource = in.readNamedWriteable(SampleResource.class); + } + + @Override + public void writeTo(final StreamOutput out) throws IOException { + resource.writeTo(out); + } + + @Override + public ActionRequestValidationException validate() { + return null; + } + + public SampleResource getResource() { + return this.resource; + } +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/create/CreateResourceResponse.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/create/CreateResourceResponse.java new file mode 100644 index 0000000000..33c8b0b1e6 --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/create/CreateResourceResponse.java @@ -0,0 +1,55 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.sample.resource.actions.rest.create; + +import java.io.IOException; + +import org.opensearch.core.action.ActionResponse; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; +import org.opensearch.core.xcontent.ToXContentObject; +import org.opensearch.core.xcontent.XContentBuilder; + +/** + * Response to a CreateSampleResourceRequest + */ +public class CreateResourceResponse extends ActionResponse implements ToXContentObject { + private final String message; + + /** + * Default constructor + * + * @param message The message + */ + public CreateResourceResponse(String message) { + this.message = message; + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + out.writeString(message); + } + + /** + * Constructor with StreamInput + * + * @param in the stream input + */ + public CreateResourceResponse(final StreamInput in) throws IOException { + message = in.readString(); + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + builder.startObject(); + builder.field("message", message); + builder.endObject(); + return builder; + } +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/create/CreateResourceRestAction.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/create/CreateResourceRestAction.java new file mode 100644 index 0000000000..aa664ce248 --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/create/CreateResourceRestAction.java @@ -0,0 +1,100 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.sample.resource.actions.rest.create; + +import java.io.IOException; +import java.util.List; +import java.util.Map; + +import org.opensearch.core.xcontent.XContentParser; +import org.opensearch.rest.BaseRestHandler; +import org.opensearch.rest.RestRequest; +import org.opensearch.rest.action.RestToXContentListener; +import org.opensearch.sample.SampleResource; +import org.opensearch.transport.client.node.NodeClient; + +import static org.opensearch.rest.RestRequest.Method.POST; +import static org.opensearch.rest.RestRequest.Method.PUT; +import static org.opensearch.sample.utils.Constants.SAMPLE_RESOURCE_PLUGIN_API_PREFIX; + +/** + * Rest Action to create a Sample Resource. Registers Create and Update REST APIs. + */ +public class CreateResourceRestAction extends BaseRestHandler { + + public CreateResourceRestAction() {} + + @Override + public List routes() { + return List.of( + new Route(PUT, SAMPLE_RESOURCE_PLUGIN_API_PREFIX + "/create"), + new Route(POST, SAMPLE_RESOURCE_PLUGIN_API_PREFIX + "/update/{resource_id}") + ); + } + + @Override + public String getName() { + return "create_update_sample_resource"; + } + + @Override + protected RestChannelConsumer prepareRequest(RestRequest request, NodeClient client) throws IOException { + Map source; + try (XContentParser parser = request.contentParser()) { + source = parser.map(); + } + + switch (request.method()) { + case PUT: + return createResource(source, client); + case POST: + return updateResource(source, request.param("resource_id"), client); + default: + throw new IllegalArgumentException("Illegal method: " + request.method()); + } + } + + private RestChannelConsumer updateResource(Map source, String resourceId, NodeClient client) throws IOException { + String name = (String) source.get("name"); + String description = source.containsKey("description") ? (String) source.get("description") : null; + Map attributes = getAttributes(source); + SampleResource resource = new SampleResource(); + resource.setName(name); + resource.setDescription(description); + resource.setAttributes(attributes); + final UpdateResourceRequest updateResourceRequest = new UpdateResourceRequest(resourceId, resource); + return channel -> client.executeLocally( + UpdateResourceAction.INSTANCE, + updateResourceRequest, + new RestToXContentListener<>(channel) + ); + } + + private RestChannelConsumer createResource(Map source, NodeClient client) throws IOException { + String name = (String) source.get("name"); + String description = source.containsKey("description") ? (String) source.get("description") : null; + Map attributes = getAttributes(source); + SampleResource resource = new SampleResource(); + resource.setName(name); + resource.setDescription(description); + resource.setAttributes(attributes); + final CreateResourceRequest createSampleResourceRequest = new CreateResourceRequest(resource); + return channel -> client.executeLocally( + CreateResourceAction.INSTANCE, + createSampleResourceRequest, + new RestToXContentListener<>(channel) + ); + } + + // NOTE: Suppressing warnings should be avoided as it may lead to loosing important information for while root-causing an issue + @SuppressWarnings("unchecked") + private Map getAttributes(Map source) { + return source.containsKey("attributes") ? (Map) source.get("attributes") : null; + } +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/create/UpdateResourceAction.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/create/UpdateResourceAction.java new file mode 100644 index 0000000000..ec5f84adfb --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/create/UpdateResourceAction.java @@ -0,0 +1,29 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.sample.resource.actions.rest.create; + +import org.opensearch.action.ActionType; + +/** + * Action to update a sample resource + */ +public class UpdateResourceAction extends ActionType { + /** + * Update sample resource action instance + */ + public static final UpdateResourceAction INSTANCE = new UpdateResourceAction(); + /** + * Update sample resource action name + */ + public static final String NAME = "cluster:admin/sample-resource-plugin/update"; + + private UpdateResourceAction() { + super(NAME, CreateResourceResponse::new); + } +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/create/UpdateResourceRequest.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/create/UpdateResourceRequest.java new file mode 100644 index 0000000000..7e327bc175 --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/create/UpdateResourceRequest.java @@ -0,0 +1,58 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.sample.resource.actions.rest.create; + +import java.io.IOException; + +import org.opensearch.action.ActionRequest; +import org.opensearch.action.ActionRequestValidationException; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; +import org.opensearch.sample.SampleResource; + +/** + * Request object for UpdateResource transport action + */ +public class UpdateResourceRequest extends ActionRequest { + + private final String resourceId; + private final SampleResource resource; + + /** + * Default constructor + */ + public UpdateResourceRequest(String resourceId, SampleResource resource) { + this.resourceId = resourceId; + this.resource = resource; + } + + public UpdateResourceRequest(StreamInput in) throws IOException { + this.resourceId = in.readString(); + this.resource = in.readNamedWriteable(SampleResource.class); + } + + @Override + public void writeTo(final StreamOutput out) throws IOException { + out.writeString(resourceId); + resource.writeTo(out); + } + + @Override + public ActionRequestValidationException validate() { + return null; + } + + public SampleResource getResource() { + return this.resource; + } + + public String getResourceId() { + return this.resourceId; + } +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/delete/DeleteResourceAction.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/delete/DeleteResourceAction.java new file mode 100644 index 0000000000..d7410e6388 --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/delete/DeleteResourceAction.java @@ -0,0 +1,29 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.sample.resource.actions.rest.delete; + +import org.opensearch.action.ActionType; + +/** + * Action to delete a sample resource + */ +public class DeleteResourceAction extends ActionType { + /** + * Delete sample resource action instance + */ + public static final DeleteResourceAction INSTANCE = new DeleteResourceAction(); + /** + * Delete sample resource action name + */ + public static final String NAME = "cluster:admin/sample-resource-plugin/delete"; + + private DeleteResourceAction() { + super(NAME, DeleteResourceResponse::new); + } +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/delete/DeleteResourceRequest.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/delete/DeleteResourceRequest.java new file mode 100644 index 0000000000..9aa4332fe8 --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/delete/DeleteResourceRequest.java @@ -0,0 +1,49 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.sample.resource.actions.rest.delete; + +import java.io.IOException; + +import org.opensearch.action.ActionRequest; +import org.opensearch.action.ActionRequestValidationException; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; + +/** + * Request object for DeleteSampleResource transport action + */ +public class DeleteResourceRequest extends ActionRequest { + + private final String resourceId; + + /** + * Default constructor + */ + public DeleteResourceRequest(String resourceId) { + this.resourceId = resourceId; + } + + public DeleteResourceRequest(StreamInput in) throws IOException { + this.resourceId = in.readString(); + } + + @Override + public void writeTo(final StreamOutput out) throws IOException { + out.writeString(this.resourceId); + } + + @Override + public ActionRequestValidationException validate() { + return null; + } + + public String getResourceId() { + return this.resourceId; + } +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/delete/DeleteResourceResponse.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/delete/DeleteResourceResponse.java new file mode 100644 index 0000000000..7940b664db --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/delete/DeleteResourceResponse.java @@ -0,0 +1,55 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.sample.resource.actions.rest.delete; + +import java.io.IOException; + +import org.opensearch.core.action.ActionResponse; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; +import org.opensearch.core.xcontent.ToXContentObject; +import org.opensearch.core.xcontent.XContentBuilder; + +/** + * Response to a DeleteSampleResourceRequest + */ +public class DeleteResourceResponse extends ActionResponse implements ToXContentObject { + private final String message; + + /** + * Default constructor + * + * @param message The message + */ + public DeleteResourceResponse(String message) { + this.message = message; + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + out.writeString(message); + } + + /** + * Constructor with StreamInput + * + * @param in the stream input + */ + public DeleteResourceResponse(final StreamInput in) throws IOException { + message = in.readString(); + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + builder.startObject(); + builder.field("message", message); + builder.endObject(); + return builder; + } +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/delete/DeleteResourceRestAction.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/delete/DeleteResourceRestAction.java new file mode 100644 index 0000000000..32dec08084 --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/delete/DeleteResourceRestAction.java @@ -0,0 +1,53 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.sample.resource.actions.rest.delete; + +import java.util.List; + +import org.opensearch.core.common.Strings; +import org.opensearch.rest.BaseRestHandler; +import org.opensearch.rest.RestRequest; +import org.opensearch.rest.action.RestToXContentListener; +import org.opensearch.transport.client.node.NodeClient; + +import static java.util.Collections.singletonList; +import static org.opensearch.rest.RestRequest.Method.DELETE; +import static org.opensearch.sample.utils.Constants.SAMPLE_RESOURCE_PLUGIN_API_PREFIX; + +/** + * Rest Action to delete a Sample Resource. + */ +public class DeleteResourceRestAction extends BaseRestHandler { + + public DeleteResourceRestAction() {} + + @Override + public List routes() { + return singletonList(new Route(DELETE, SAMPLE_RESOURCE_PLUGIN_API_PREFIX + "/delete/{resource_id}")); + } + + @Override + public String getName() { + return "delete_sample_resource"; + } + + @Override + protected RestChannelConsumer prepareRequest(RestRequest request, NodeClient client) { + String resourceId = request.param("resource_id"); + if (Strings.isNullOrEmpty(resourceId)) { + throw new IllegalArgumentException("resource_id parameter is required"); + } + final DeleteResourceRequest createSampleResourceRequest = new DeleteResourceRequest(resourceId); + return channel -> client.executeLocally( + DeleteResourceAction.INSTANCE, + createSampleResourceRequest, + new RestToXContentListener<>(channel) + ); + } +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/get/GetResourceAction.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/get/GetResourceAction.java new file mode 100644 index 0000000000..0249a06501 --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/get/GetResourceAction.java @@ -0,0 +1,29 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.sample.resource.actions.rest.get; + +import org.opensearch.action.ActionType; + +/** + * Action to get a sample resource + */ +public class GetResourceAction extends ActionType { + /** + * Get sample resource action instance + */ + public static final GetResourceAction INSTANCE = new GetResourceAction(); + /** + * Get sample resource action name + */ + public static final String NAME = "cluster:admin/sample-resource-plugin/get"; + + private GetResourceAction() { + super(NAME, GetResourceResponse::new); + } +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/get/GetResourceRequest.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/get/GetResourceRequest.java new file mode 100644 index 0000000000..eb8d8abb1f --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/get/GetResourceRequest.java @@ -0,0 +1,49 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.sample.resource.actions.rest.get; + +import java.io.IOException; + +import org.opensearch.action.ActionRequest; +import org.opensearch.action.ActionRequestValidationException; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; + +/** + * Request object for GetSampleResource transport action + */ +public class GetResourceRequest extends ActionRequest { + + private final String resourceId; + + /** + * Default constructor + */ + public GetResourceRequest(String resourceId) { + this.resourceId = resourceId; + } + + public GetResourceRequest(StreamInput in) throws IOException { + this.resourceId = in.readString(); + } + + @Override + public void writeTo(final StreamOutput out) throws IOException { + out.writeString(this.resourceId); + } + + @Override + public ActionRequestValidationException validate() { + return null; + } + + public String getResourceId() { + return this.resourceId; + } +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/get/GetResourceResponse.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/get/GetResourceResponse.java new file mode 100644 index 0000000000..78cc06fe24 --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/get/GetResourceResponse.java @@ -0,0 +1,54 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.sample.resource.actions.rest.get; + +import java.io.IOException; +import java.util.Set; + +import org.opensearch.core.action.ActionResponse; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; +import org.opensearch.core.xcontent.ToXContentObject; +import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.sample.SampleResource; + +public class GetResourceResponse extends ActionResponse implements ToXContentObject { + private final Set resources; + + /** + * Default constructor + * + * @param resources The resources + */ + public GetResourceResponse(Set resources) { + this.resources = resources; + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + out.writeCollection(resources, (o, r) -> r.writeTo(o)); + } + + /** + * Constructor with StreamInput + * + * @param in the stream input + */ + public GetResourceResponse(final StreamInput in) throws IOException { + resources = in.readSet(SampleResource::new); + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + builder.startObject(); + builder.field("resources", resources); + builder.endObject(); + return builder; + } +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/get/GetResourceRestAction.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/get/GetResourceRestAction.java new file mode 100644 index 0000000000..f534543fde --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/get/GetResourceRestAction.java @@ -0,0 +1,48 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.sample.resource.actions.rest.get; + +import java.util.List; + +import org.opensearch.rest.BaseRestHandler; +import org.opensearch.rest.RestRequest; +import org.opensearch.rest.action.RestToXContentListener; +import org.opensearch.transport.client.node.NodeClient; + +import static org.opensearch.rest.RestRequest.Method.GET; +import static org.opensearch.sample.utils.Constants.SAMPLE_RESOURCE_PLUGIN_API_PREFIX; + +/** + * Rest action to get a sample resource + */ +public class GetResourceRestAction extends BaseRestHandler { + + public GetResourceRestAction() {} + + @Override + public List routes() { + return List.of( + new Route(GET, SAMPLE_RESOURCE_PLUGIN_API_PREFIX + "/get/{resource_id}"), + new Route(GET, SAMPLE_RESOURCE_PLUGIN_API_PREFIX + "/get") + ); + } + + @Override + public String getName() { + return "get_sample_resource"; + } + + @Override + protected RestChannelConsumer prepareRequest(RestRequest request, NodeClient client) { + String resourceId = request.param("resource_id"); + + final GetResourceRequest getResourceRequest = new GetResourceRequest(resourceId); + return channel -> client.executeLocally(GetResourceAction.INSTANCE, getResourceRequest, new RestToXContentListener<>(channel)); + } +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/revoke/RevokeResourceAccessAction.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/revoke/RevokeResourceAccessAction.java new file mode 100644 index 0000000000..9231683499 --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/revoke/RevokeResourceAccessAction.java @@ -0,0 +1,29 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.sample.resource.actions.rest.revoke; + +import org.opensearch.action.ActionType; + +/** + * Action to revoke a sample resource + */ +public class RevokeResourceAccessAction extends ActionType { + /** + * Revoke sample resource action instance + */ + public static final RevokeResourceAccessAction INSTANCE = new RevokeResourceAccessAction(); + /** + * Revoke sample resource action name + */ + public static final String NAME = "cluster:admin/sample-resource-plugin/revoke"; + + private RevokeResourceAccessAction() { + super(NAME, RevokeResourceAccessResponse::new); + } +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/revoke/RevokeResourceAccessRequest.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/revoke/RevokeResourceAccessRequest.java new file mode 100644 index 0000000000..a066aefd6e --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/revoke/RevokeResourceAccessRequest.java @@ -0,0 +1,55 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.sample.resource.actions.rest.revoke; + +import java.io.IOException; + +import org.opensearch.action.ActionRequest; +import org.opensearch.action.ActionRequestValidationException; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; +import org.opensearch.security.spi.resources.sharing.SharedWithActionGroup; + +/** + * Request object for revoking access to a sample resource + */ +public class RevokeResourceAccessRequest extends ActionRequest { + + String resourceId; + SharedWithActionGroup.ActionGroupRecipients entitiesToRevoke; + + public RevokeResourceAccessRequest(String resourceId, SharedWithActionGroup.ActionGroupRecipients entitiesToRevoke) { + this.resourceId = resourceId; + this.entitiesToRevoke = entitiesToRevoke; + } + + public RevokeResourceAccessRequest(StreamInput in) throws IOException { + resourceId = in.readString(); + entitiesToRevoke = in.readNamedWriteable(SharedWithActionGroup.ActionGroupRecipients.class); + } + + @Override + public void writeTo(final StreamOutput out) throws IOException { + out.writeString(resourceId); + out.writeNamedWriteable(entitiesToRevoke); + } + + @Override + public ActionRequestValidationException validate() { + return null; + } + + public String getResourceId() { + return resourceId; + } + + public SharedWithActionGroup.ActionGroupRecipients getEntitiesToRevoke() { + return entitiesToRevoke; + } +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/revoke/RevokeResourceAccessResponse.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/revoke/RevokeResourceAccessResponse.java new file mode 100644 index 0000000000..2a1bf47e6f --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/revoke/RevokeResourceAccessResponse.java @@ -0,0 +1,46 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.sample.resource.actions.rest.revoke; + +import java.io.IOException; + +import org.opensearch.core.action.ActionResponse; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; +import org.opensearch.core.xcontent.ToXContentObject; +import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.security.spi.resources.sharing.ShareWith; + +/** + * Response for the RevokeResourceAccessAction + */ +public class RevokeResourceAccessResponse extends ActionResponse implements ToXContentObject { + private final ShareWith shareWith; + + public RevokeResourceAccessResponse(ShareWith shareWith) { + this.shareWith = shareWith; + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + out.writeNamedWriteable(shareWith); + } + + public RevokeResourceAccessResponse(final StreamInput in) throws IOException { + shareWith = new ShareWith(in); + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + builder.startObject(); + builder.field("share_with", shareWith); + builder.endObject(); + return builder; + } +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/revoke/RevokeResourceAccessRestAction.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/revoke/RevokeResourceAccessRestAction.java new file mode 100644 index 0000000000..3f98ce9fa4 --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/revoke/RevokeResourceAccessRestAction.java @@ -0,0 +1,90 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.sample.resource.actions.rest.revoke; + +import java.io.IOException; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import org.opensearch.core.common.Strings; +import org.opensearch.core.xcontent.XContentParser; +import org.opensearch.rest.BaseRestHandler; +import org.opensearch.rest.RestRequest; +import org.opensearch.rest.action.RestToXContentListener; +import org.opensearch.security.spi.resources.sharing.Recipient; +import org.opensearch.security.spi.resources.sharing.SharedWithActionGroup; +import org.opensearch.transport.client.node.NodeClient; + +import static java.util.Collections.singletonList; +import static org.opensearch.rest.RestRequest.Method.POST; +import static org.opensearch.sample.utils.Constants.SAMPLE_RESOURCE_PLUGIN_API_PREFIX; + +/** + * Rest Action to revoke sample resource access + */ +public class RevokeResourceAccessRestAction extends BaseRestHandler { + + public RevokeResourceAccessRestAction() {} + + @Override + public List routes() { + return singletonList(new Route(POST, SAMPLE_RESOURCE_PLUGIN_API_PREFIX + "/revoke/{resource_id}")); + } + + @Override + public String getName() { + return "revoke_sample_resource"; + } + + @Override + protected RestChannelConsumer prepareRequest(RestRequest request, NodeClient client) throws IOException { + String resourceId = request.param("resource_id"); + if (Strings.isNullOrEmpty(resourceId)) { + throw new IllegalArgumentException("resource_id parameter is required"); + } + Map source; + try (XContentParser parser = request.contentParser()) { + source = parser.map(); + } + + final RevokeResourceAccessRequest revokeResourceAccessRequest = new RevokeResourceAccessRequest( + resourceId, + parseRevokedEntities((Map) source.get("entities_to_revoke")) + ); + return channel -> client.executeLocally( + RevokeResourceAccessAction.INSTANCE, + revokeResourceAccessRequest, + new RestToXContentListener<>(channel) + ); + } + + private SharedWithActionGroup.ActionGroupRecipients parseRevokedEntities(Map source) { + if (source == null || source.isEmpty()) { + throw new IllegalArgumentException("entities_to_revoke is required and cannot be empty"); + } + + Map> entitiesToRevoke = source.entrySet() + .stream() + .filter(entry -> entry.getValue() instanceof Collection) + .collect( + Collectors.toMap( + entry -> Recipient.fromValue(entry.getKey()), + entry -> ((Collection) entry.getValue()).stream() + .filter(String.class::isInstance) + .map(String.class::cast) + .collect(Collectors.toSet()) + ) + ); + + return new SharedWithActionGroup.ActionGroupRecipients(entitiesToRevoke); + } +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/share/ShareResourceAction.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/share/ShareResourceAction.java new file mode 100644 index 0000000000..52de757b1b --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/share/ShareResourceAction.java @@ -0,0 +1,29 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.sample.resource.actions.rest.share; + +import org.opensearch.action.ActionType; + +/** + * Action to share a sample resource + */ +public class ShareResourceAction extends ActionType { + /** + * Share sample resource action instance + */ + public static final ShareResourceAction INSTANCE = new ShareResourceAction(); + /** + * Share sample resource action name + */ + public static final String NAME = "cluster:admin/sample-resource-plugin/share"; + + private ShareResourceAction() { + super(NAME, ShareResourceResponse::new); + } +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/share/ShareResourceRequest.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/share/ShareResourceRequest.java new file mode 100644 index 0000000000..3d028b181b --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/share/ShareResourceRequest.java @@ -0,0 +1,56 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.sample.resource.actions.rest.share; + +import java.io.IOException; + +import org.opensearch.action.ActionRequest; +import org.opensearch.action.ActionRequestValidationException; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; +import org.opensearch.security.spi.resources.sharing.SharedWithActionGroup; + +/** + * Request object for sharing sample resource transport action + */ +public class ShareResourceRequest extends ActionRequest { + + private final String resourceId; + + private final SharedWithActionGroup.ActionGroupRecipients shareWith; + + public ShareResourceRequest(String resourceId, SharedWithActionGroup.ActionGroupRecipients shareWith) { + this.resourceId = resourceId; + this.shareWith = shareWith; + } + + public ShareResourceRequest(StreamInput in) throws IOException { + this.resourceId = in.readString(); + this.shareWith = in.readNamedWriteable(SharedWithActionGroup.ActionGroupRecipients.class); + } + + @Override + public void writeTo(final StreamOutput out) throws IOException { + out.writeString(this.resourceId); + out.writeNamedWriteable(shareWith); + } + + @Override + public ActionRequestValidationException validate() { + return null; + } + + public String getResourceId() { + return this.resourceId; + } + + public SharedWithActionGroup.ActionGroupRecipients getShareWith() { + return shareWith; + } +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/share/ShareResourceResponse.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/share/ShareResourceResponse.java new file mode 100644 index 0000000000..e8df82b841 --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/share/ShareResourceResponse.java @@ -0,0 +1,46 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.sample.resource.actions.rest.share; + +import java.io.IOException; + +import org.opensearch.core.action.ActionResponse; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; +import org.opensearch.core.xcontent.ToXContentObject; +import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.security.spi.resources.sharing.ShareWith; + +/** + * Response object for ShareResourceAction + */ +public class ShareResourceResponse extends ActionResponse implements ToXContentObject { + private final ShareWith shareWith; + + public ShareResourceResponse(ShareWith shareWith) { + this.shareWith = shareWith; + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + out.writeNamedWriteable(shareWith); + } + + public ShareResourceResponse(final StreamInput in) throws IOException { + shareWith = new ShareWith(in); + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + builder.startObject(); + builder.field("share_with", shareWith); + builder.endObject(); + return builder; + } +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/share/ShareResourceRestAction.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/share/ShareResourceRestAction.java new file mode 100644 index 0000000000..1b3bfc0493 --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/share/ShareResourceRestAction.java @@ -0,0 +1,80 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.sample.resource.actions.rest.share; + +import java.io.IOException; +import java.util.List; +import java.util.Map; + +import org.opensearch.common.xcontent.LoggingDeprecationHandler; +import org.opensearch.common.xcontent.XContentFactory; +import org.opensearch.common.xcontent.XContentType; +import org.opensearch.core.common.Strings; +import org.opensearch.core.xcontent.NamedXContentRegistry; +import org.opensearch.core.xcontent.XContentParser; +import org.opensearch.rest.BaseRestHandler; +import org.opensearch.rest.RestRequest; +import org.opensearch.rest.action.RestToXContentListener; +import org.opensearch.security.spi.resources.sharing.SharedWithActionGroup; +import org.opensearch.transport.client.node.NodeClient; + +import static java.util.Collections.singletonList; +import static org.opensearch.rest.RestRequest.Method.POST; +import static org.opensearch.sample.utils.Constants.SAMPLE_RESOURCE_PLUGIN_API_PREFIX; + +/** + * Rest Action to share a resource + */ +public class ShareResourceRestAction extends BaseRestHandler { + + public ShareResourceRestAction() {} + + @Override + public List routes() { + return singletonList(new Route(POST, SAMPLE_RESOURCE_PLUGIN_API_PREFIX + "/share/{resource_id}")); + } + + @Override + public String getName() { + return "share_sample_resource"; + } + + // NOTE: Suppressing warnings should be avoided as it may lead to loosing important information for while root-causing an issue + @SuppressWarnings("unchecked") + @Override + protected RestChannelConsumer prepareRequest(RestRequest request, NodeClient client) throws IOException { + String resourceId = request.param("resource_id"); + if (Strings.isNullOrEmpty(resourceId)) { + throw new IllegalArgumentException("resource_id parameter is required"); + } + + Map source; + try (XContentParser parser = request.contentParser()) { + source = parser.map(); + } + + Map shareWith = (Map) source.get("share_with"); + + final ShareResourceRequest shareResourceRequest = new ShareResourceRequest(resourceId, parseShareWith(shareWith)); + return channel -> client.executeLocally(ShareResourceAction.INSTANCE, shareResourceRequest, new RestToXContentListener<>(channel)); + } + + private SharedWithActionGroup.ActionGroupRecipients parseShareWith(Map source) throws IOException { + String jsonString = XContentFactory.jsonBuilder().map(source).toString(); + + try ( + XContentParser parser = XContentType.JSON.xContent() + .createParser(NamedXContentRegistry.EMPTY, LoggingDeprecationHandler.INSTANCE, jsonString) + ) { + return SharedWithActionGroup.ActionGroupRecipients.fromXContent(parser); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Invalid share_with structure: " + e.getMessage(), e); + } + } +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/transport/CreateResourceTransportAction.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/transport/CreateResourceTransportAction.java new file mode 100644 index 0000000000..8b52b26512 --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/transport/CreateResourceTransportAction.java @@ -0,0 +1,82 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.sample.resource.actions.transport; + +import java.io.IOException; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import org.opensearch.action.index.IndexRequest; +import org.opensearch.action.support.ActionFilters; +import org.opensearch.action.support.HandledTransportAction; +import org.opensearch.action.support.WriteRequest; +import org.opensearch.common.inject.Inject; +import org.opensearch.common.util.concurrent.ThreadContext; +import org.opensearch.core.action.ActionListener; +import org.opensearch.core.xcontent.ToXContent; +import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.sample.SampleResource; +import org.opensearch.sample.resource.actions.rest.create.CreateResourceAction; +import org.opensearch.sample.resource.actions.rest.create.CreateResourceRequest; +import org.opensearch.sample.resource.actions.rest.create.CreateResourceResponse; +import org.opensearch.tasks.Task; +import org.opensearch.transport.TransportService; +import org.opensearch.transport.client.Client; + +import static org.opensearch.common.xcontent.XContentFactory.jsonBuilder; +import static org.opensearch.sample.utils.Constants.RESOURCE_INDEX_NAME; + +/** + * Transport action for creating a new resource. + */ +public class CreateResourceTransportAction extends HandledTransportAction { + private static final Logger log = LogManager.getLogger(CreateResourceTransportAction.class); + + private final TransportService transportService; + private final Client nodeClient; + + @Inject + public CreateResourceTransportAction(TransportService transportService, ActionFilters actionFilters, Client nodeClient) { + super(CreateResourceAction.NAME, transportService, actionFilters, CreateResourceRequest::new); + this.transportService = transportService; + this.nodeClient = nodeClient; + } + + @Override + protected void doExecute(Task task, CreateResourceRequest request, ActionListener listener) { + ThreadContext threadContext = transportService.getThreadPool().getThreadContext(); + try (ThreadContext.StoredContext ignore = threadContext.stashContext()) { + createResource(request, listener); + } catch (Exception e) { + log.error("Failed to create resource", e); + listener.onFailure(e); + } + } + + private void createResource(CreateResourceRequest request, ActionListener listener) { + SampleResource sample = request.getResource(); + try (XContentBuilder builder = jsonBuilder()) { + IndexRequest ir = nodeClient.prepareIndex(RESOURCE_INDEX_NAME) + .setWaitForActiveShards(1) + .setRefreshPolicy(WriteRequest.RefreshPolicy.IMMEDIATE) + .setSource(sample.toXContent(builder, ToXContent.EMPTY_PARAMS)) + .request(); + + log.debug("Index Request: {}", ir.toString()); + + nodeClient.index(ir, ActionListener.wrap(idxResponse -> { + log.debug("Created resource: {}", idxResponse.getId()); + listener.onResponse(new CreateResourceResponse("Created resource: " + idxResponse.getId())); + }, listener::onFailure)); + } catch (IOException e) { + listener.onFailure(new RuntimeException(e)); + } + } +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/transport/DeleteResourceTransportAction.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/transport/DeleteResourceTransportAction.java new file mode 100644 index 0000000000..98131d7aa7 --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/transport/DeleteResourceTransportAction.java @@ -0,0 +1,99 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.sample.resource.actions.transport; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import org.opensearch.OpenSearchStatusException; +import org.opensearch.ResourceNotFoundException; +import org.opensearch.action.DocWriteResponse; +import org.opensearch.action.delete.DeleteRequest; +import org.opensearch.action.delete.DeleteResponse; +import org.opensearch.action.support.ActionFilters; +import org.opensearch.action.support.HandledTransportAction; +import org.opensearch.action.support.WriteRequest; +import org.opensearch.common.inject.Inject; +import org.opensearch.common.util.concurrent.ThreadContext; +import org.opensearch.core.action.ActionListener; +import org.opensearch.core.rest.RestStatus; +import org.opensearch.sample.resource.actions.rest.delete.DeleteResourceAction; +import org.opensearch.sample.resource.actions.rest.delete.DeleteResourceRequest; +import org.opensearch.sample.resource.actions.rest.delete.DeleteResourceResponse; +import org.opensearch.sample.resource.client.ResourceSharingClientAccessor; +import org.opensearch.security.spi.resources.client.ResourceSharingClient; +import org.opensearch.tasks.Task; +import org.opensearch.transport.TransportService; +import org.opensearch.transport.client.node.NodeClient; + +import static org.opensearch.sample.utils.Constants.RESOURCE_INDEX_NAME; + +/** + * Transport action for deleting a resource + */ +public class DeleteResourceTransportAction extends HandledTransportAction { + private static final Logger log = LogManager.getLogger(DeleteResourceTransportAction.class); + + private final TransportService transportService; + private final NodeClient nodeClient; + + @Inject + public DeleteResourceTransportAction(TransportService transportService, ActionFilters actionFilters, NodeClient nodeClient) { + super(DeleteResourceAction.NAME, transportService, actionFilters, DeleteResourceRequest::new); + this.transportService = transportService; + this.nodeClient = nodeClient; + } + + @Override + protected void doExecute(Task task, DeleteResourceRequest request, ActionListener listener) { + String resourceId = request.getResourceId(); + if (resourceId == null || resourceId.isEmpty()) { + listener.onFailure(new IllegalArgumentException("Resource ID cannot be null or empty")); + return; + } + + // Check permission to resource + ResourceSharingClient resourceSharingClient = ResourceSharingClientAccessor.getResourceSharingClient(); + resourceSharingClient.verifyResourceAccess(resourceId, RESOURCE_INDEX_NAME, ActionListener.wrap(isAuthorized -> { + if (!isAuthorized) { + listener.onFailure( + new OpenSearchStatusException("Current user is not authorized to delete resource: " + resourceId, RestStatus.FORBIDDEN) + ); + return; + } + + // Authorization successful, proceed with deletion + ThreadContext threadContext = transportService.getThreadPool().getThreadContext(); + try (ThreadContext.StoredContext ignored = threadContext.stashContext()) { + deleteResource(resourceId, ActionListener.wrap(deleteResponse -> { + if (deleteResponse.getResult() == DocWriteResponse.Result.NOT_FOUND) { + listener.onFailure(new ResourceNotFoundException("Resource " + resourceId + " not found.")); + } else { + listener.onResponse(new DeleteResourceResponse("Resource " + resourceId + " deleted successfully.")); + } + }, exception -> { + log.error("Failed to delete resource: " + resourceId, exception); + listener.onFailure(exception); + })); + } + }, exception -> { + log.error("Failed to verify resource access: " + resourceId, exception); + listener.onFailure(exception); + })); + } + + private void deleteResource(String resourceId, ActionListener listener) { + DeleteRequest deleteRequest = new DeleteRequest(RESOURCE_INDEX_NAME, resourceId).setRefreshPolicy( + WriteRequest.RefreshPolicy.IMMEDIATE + ); + + nodeClient.delete(deleteRequest, listener); + } + +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/transport/GetResourceTransportAction.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/transport/GetResourceTransportAction.java new file mode 100644 index 0000000000..d3fe0f2f59 --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/transport/GetResourceTransportAction.java @@ -0,0 +1,173 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.sample.resource.actions.transport; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Collections; +import java.util.Set; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import org.opensearch.OpenSearchStatusException; +import org.opensearch.ResourceNotFoundException; +import org.opensearch.action.get.GetRequest; +import org.opensearch.action.search.SearchRequest; +import org.opensearch.action.support.ActionFilters; +import org.opensearch.action.support.HandledTransportAction; +import org.opensearch.common.Nullable; +import org.opensearch.common.inject.Inject; +import org.opensearch.common.settings.Settings; +import org.opensearch.common.util.concurrent.ThreadContext; +import org.opensearch.common.xcontent.LoggingDeprecationHandler; +import org.opensearch.common.xcontent.XContentType; +import org.opensearch.core.action.ActionListener; +import org.opensearch.core.common.Strings; +import org.opensearch.core.rest.RestStatus; +import org.opensearch.core.xcontent.NamedXContentRegistry; +import org.opensearch.core.xcontent.XContentParser; +import org.opensearch.index.query.QueryBuilders; +import org.opensearch.sample.SampleResource; +import org.opensearch.sample.resource.actions.rest.get.GetResourceAction; +import org.opensearch.sample.resource.actions.rest.get.GetResourceRequest; +import org.opensearch.sample.resource.actions.rest.get.GetResourceResponse; +import org.opensearch.sample.resource.client.ResourceSharingClientAccessor; +import org.opensearch.search.SearchHit; +import org.opensearch.search.builder.SearchSourceBuilder; +import org.opensearch.security.spi.resources.client.ResourceSharingClient; +import org.opensearch.tasks.Task; +import org.opensearch.transport.TransportService; +import org.opensearch.transport.client.node.NodeClient; + +import static org.opensearch.sample.utils.Constants.RESOURCE_INDEX_NAME; +import static org.opensearch.security.spi.resources.FeatureConfigConstants.OPENSEARCH_RESOURCE_SHARING_ENABLED; +import static org.opensearch.security.spi.resources.FeatureConfigConstants.OPENSEARCH_RESOURCE_SHARING_ENABLED_DEFAULT; + +/** + * Transport action for getting a resource + */ +public class GetResourceTransportAction extends HandledTransportAction { + private static final Logger log = LogManager.getLogger(GetResourceTransportAction.class); + + private final TransportService transportService; + private final NodeClient nodeClient; + private final Settings settings; + + @Inject + public GetResourceTransportAction( + Settings settings, + TransportService transportService, + ActionFilters actionFilters, + NodeClient nodeClient + ) { + super(GetResourceAction.NAME, transportService, actionFilters, GetResourceRequest::new); + this.transportService = transportService; + this.nodeClient = nodeClient; + this.settings = settings; + } + + @Override + protected void doExecute(Task task, GetResourceRequest request, ActionListener listener) { + ResourceSharingClient client = ResourceSharingClientAccessor.getResourceSharingClient(); + String resourceId = request.getResourceId(); + + if (Strings.isNullOrEmpty(resourceId)) { + fetchAllResources(listener, client); + } else { + verifyAndFetchSingle(resourceId, listener, client); + } + } + + private void fetchAllResources(ActionListener listener, ResourceSharingClient client) { + boolean sharingEnabled = settings.getAsBoolean(OPENSEARCH_RESOURCE_SHARING_ENABLED, OPENSEARCH_RESOURCE_SHARING_ENABLED_DEFAULT); + + if (sharingEnabled) { + client.getAccessibleResourceIds(RESOURCE_INDEX_NAME, ActionListener.wrap(ids -> { + if (ids.isEmpty()) { + listener.onResponse(new GetResourceResponse(Collections.emptySet())); + } else { + fetchResourcesByIds(ids, listener); + } + }, listener::onFailure)); + } else { + // feature disabled → return everything + fetchResourcesByIds(null, listener); + } + } + + private void verifyAndFetchSingle(String resourceId, ActionListener listener, ResourceSharingClient client) { + client.verifyResourceAccess(resourceId, RESOURCE_INDEX_NAME, ActionListener.wrap(authorized -> { + if (!authorized) { + listener.onFailure(new OpenSearchStatusException("Not authorized to access resource: " + resourceId, RestStatus.FORBIDDEN)); + } else { + fetchResourceById(resourceId, listener); + } + }, listener::onFailure)); + } + + private void fetchResourceById(String resourceId, ActionListener listener) { + withThreadContext(stashed -> { + GetRequest req = new GetRequest(RESOURCE_INDEX_NAME, resourceId); + nodeClient.get(req, ActionListener.wrap(resp -> { + if (resp.isSourceEmpty()) { + listener.onFailure(new ResourceNotFoundException("Resource " + resourceId + " not found.")); + } else { + SampleResource resource = parseResource(resp.getSourceAsString()); + listener.onResponse(new GetResourceResponse(Set.of(resource))); + } + }, listener::onFailure)); + }); + } + + private void fetchResourcesByIds(@Nullable Set ids, ActionListener listener) { + withThreadContext(stashed -> { + SearchSourceBuilder ssb = new SearchSourceBuilder().size(1000) + .query( + ids == null ? QueryBuilders.matchAllQuery() : QueryBuilders.idsQuery().addIds(ids.toArray(ids.toArray(String[]::new))) + ); + + SearchRequest req = new SearchRequest(RESOURCE_INDEX_NAME).source(ssb); + nodeClient.search(req, ActionListener.wrap(searchResponse -> { + SearchHit[] hits = searchResponse.getHits().getHits(); + if (hits.length == 0) { + listener.onFailure(new ResourceNotFoundException("No resources found in index: " + RESOURCE_INDEX_NAME)); + } else { + Set resources = Arrays.stream(hits).map(hit -> { + try { + return parseResource(hit.getSourceAsString()); + } catch (IOException e) { + throw new RuntimeException(e); + } + }).collect(Collectors.toSet()); + listener.onResponse(new GetResourceResponse(resources)); + } + }, listener::onFailure)); + }); + } + + private SampleResource parseResource(String json) throws IOException { + try ( + XContentParser parser = XContentType.JSON.xContent() + .createParser(NamedXContentRegistry.EMPTY, LoggingDeprecationHandler.INSTANCE, json) + ) { + return SampleResource.fromXContent(parser); + } + } + + private void withThreadContext(Consumer action) { + ThreadContext tc = transportService.getThreadPool().getThreadContext(); + try (ThreadContext.StoredContext st = tc.stashContext()) { + action.accept(st); + } + } + +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/transport/RevokeResourceAccessTransportAction.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/transport/RevokeResourceAccessTransportAction.java new file mode 100644 index 0000000000..e90bfaacda --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/transport/RevokeResourceAccessTransportAction.java @@ -0,0 +1,54 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.sample.resource.actions.transport; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import org.opensearch.action.support.ActionFilters; +import org.opensearch.action.support.HandledTransportAction; +import org.opensearch.common.inject.Inject; +import org.opensearch.core.action.ActionListener; +import org.opensearch.sample.resource.actions.rest.revoke.RevokeResourceAccessAction; +import org.opensearch.sample.resource.actions.rest.revoke.RevokeResourceAccessRequest; +import org.opensearch.sample.resource.actions.rest.revoke.RevokeResourceAccessResponse; +import org.opensearch.sample.resource.client.ResourceSharingClientAccessor; +import org.opensearch.security.spi.resources.client.ResourceSharingClient; +import org.opensearch.tasks.Task; +import org.opensearch.transport.TransportService; + +import static org.opensearch.sample.utils.Constants.RESOURCE_INDEX_NAME; + +/** + * Transport action for revoking resource access. + */ +public class RevokeResourceAccessTransportAction extends HandledTransportAction { + private static final Logger log = LogManager.getLogger(RevokeResourceAccessTransportAction.class); + + @Inject + public RevokeResourceAccessTransportAction(TransportService transportService, ActionFilters actionFilters) { + super(RevokeResourceAccessAction.NAME, transportService, actionFilters, RevokeResourceAccessRequest::new); + } + + @Override + protected void doExecute(Task task, RevokeResourceAccessRequest request, ActionListener listener) { + ResourceSharingClient resourceSharingClient = ResourceSharingClientAccessor.getResourceSharingClient(); + resourceSharingClient.revoke( + request.getResourceId(), + RESOURCE_INDEX_NAME, + request.getEntitiesToRevoke(), + ActionListener.wrap(success -> { + RevokeResourceAccessResponse response = new RevokeResourceAccessResponse(success.getShareWith()); + log.debug("Revoked resource access: {}", response.toString()); + listener.onResponse(response); + }, listener::onFailure) + ); + } + +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/transport/ShareResourceTransportAction.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/transport/ShareResourceTransportAction.java new file mode 100644 index 0000000000..30f2fb1fb1 --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/transport/ShareResourceTransportAction.java @@ -0,0 +1,54 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.sample.resource.actions.transport; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import org.opensearch.action.support.ActionFilters; +import org.opensearch.action.support.HandledTransportAction; +import org.opensearch.common.inject.Inject; +import org.opensearch.core.action.ActionListener; +import org.opensearch.sample.resource.actions.rest.share.ShareResourceAction; +import org.opensearch.sample.resource.actions.rest.share.ShareResourceRequest; +import org.opensearch.sample.resource.actions.rest.share.ShareResourceResponse; +import org.opensearch.sample.resource.client.ResourceSharingClientAccessor; +import org.opensearch.security.spi.resources.client.ResourceSharingClient; +import org.opensearch.tasks.Task; +import org.opensearch.transport.TransportService; + +import static org.opensearch.sample.utils.Constants.RESOURCE_INDEX_NAME; + +/** + * Transport action implementation for sharing a resource. + */ +public class ShareResourceTransportAction extends HandledTransportAction { + private static final Logger log = LogManager.getLogger(ShareResourceTransportAction.class); + + @Inject + public ShareResourceTransportAction(TransportService transportService, ActionFilters actionFilters) { + super(ShareResourceAction.NAME, transportService, actionFilters, ShareResourceRequest::new); + } + + @Override + protected void doExecute(Task task, ShareResourceRequest request, ActionListener listener) { + if (request.getResourceId() == null || request.getResourceId().isEmpty()) { + listener.onFailure(new IllegalArgumentException("Resource ID cannot be null or empty")); + return; + } + + ResourceSharingClient resourceSharingClient = ResourceSharingClientAccessor.getResourceSharingClient(); + resourceSharingClient.share(request.getResourceId(), RESOURCE_INDEX_NAME, request.getShareWith(), ActionListener.wrap(sharing -> { + ShareResourceResponse response = new ShareResourceResponse(sharing.getShareWith()); + log.debug("Shared resource: {}", response.toString()); + listener.onResponse(response); + }, listener::onFailure)); + } + +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/transport/UpdateResourceTransportAction.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/transport/UpdateResourceTransportAction.java new file mode 100644 index 0000000000..3a634184e6 --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/transport/UpdateResourceTransportAction.java @@ -0,0 +1,107 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.sample.resource.actions.transport; + +import java.io.IOException; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import org.opensearch.OpenSearchStatusException; +import org.opensearch.action.support.ActionFilters; +import org.opensearch.action.support.HandledTransportAction; +import org.opensearch.action.support.WriteRequest; +import org.opensearch.action.update.UpdateRequest; +import org.opensearch.common.inject.Inject; +import org.opensearch.common.util.concurrent.ThreadContext; +import org.opensearch.core.action.ActionListener; +import org.opensearch.core.rest.RestStatus; +import org.opensearch.core.xcontent.ToXContent; +import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.sample.SampleResource; +import org.opensearch.sample.resource.actions.rest.create.CreateResourceResponse; +import org.opensearch.sample.resource.actions.rest.create.UpdateResourceAction; +import org.opensearch.sample.resource.actions.rest.create.UpdateResourceRequest; +import org.opensearch.sample.resource.client.ResourceSharingClientAccessor; +import org.opensearch.security.spi.resources.client.ResourceSharingClient; +import org.opensearch.tasks.Task; +import org.opensearch.transport.TransportService; +import org.opensearch.transport.client.node.NodeClient; + +import static org.opensearch.common.xcontent.XContentFactory.jsonBuilder; +import static org.opensearch.sample.utils.Constants.RESOURCE_INDEX_NAME; + +/** + * Transport action for updating a resource. + */ +public class UpdateResourceTransportAction extends HandledTransportAction { + private static final Logger log = LogManager.getLogger(UpdateResourceTransportAction.class); + + private final TransportService transportService; + private final NodeClient nodeClient; + + @Inject + public UpdateResourceTransportAction(TransportService transportService, ActionFilters actionFilters, NodeClient nodeClient) { + super(UpdateResourceAction.NAME, transportService, actionFilters, UpdateResourceRequest::new); + this.transportService = transportService; + this.nodeClient = nodeClient; + } + + @Override + protected void doExecute(Task task, UpdateResourceRequest request, ActionListener listener) { + if (request.getResourceId() == null || request.getResourceId().isEmpty()) { + listener.onFailure(new IllegalArgumentException("Resource ID cannot be null or empty")); + return; + } + // Check permission to resource + ResourceSharingClient resourceSharingClient = ResourceSharingClientAccessor.getResourceSharingClient(); + resourceSharingClient.verifyResourceAccess(request.getResourceId(), RESOURCE_INDEX_NAME, ActionListener.wrap(isAuthorized -> { + if (!isAuthorized) { + listener.onFailure( + new OpenSearchStatusException( + "Current user is not authorized to access resource: " + request.getResourceId(), + RestStatus.FORBIDDEN + ) + ); + return; + } + + updateResource(request, listener); + }, listener::onFailure)); + } + + private void updateResource(UpdateResourceRequest request, ActionListener listener) { + ThreadContext threadContext = this.transportService.getThreadPool().getThreadContext(); + try (ThreadContext.StoredContext ignore = threadContext.stashContext()) { + String resourceId = request.getResourceId(); + SampleResource sample = request.getResource(); + try (XContentBuilder builder = jsonBuilder()) { + UpdateRequest ur = new UpdateRequest(RESOURCE_INDEX_NAME, resourceId).setRefreshPolicy(WriteRequest.RefreshPolicy.IMMEDIATE) + .doc(sample.toXContent(builder, ToXContent.EMPTY_PARAMS)); + + log.debug("Update Request: {}", ur.toString()); + + nodeClient.update( + ur, + ActionListener.wrap( + updateResponse -> { log.debug("Updated resource: {}", updateResponse.toString()); }, + listener::onFailure + ) + ); + } catch (IOException e) { + listener.onFailure(new RuntimeException(e)); + } + listener.onResponse(new CreateResourceResponse("Resource " + request.getResource().getName() + " updated successfully.")); + } catch (Exception e) { + log.error("Failed to update resource: {}", request.getResourceId(), e); + listener.onFailure(e); + } + + } +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/client/ResourceSharingClientAccessor.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/client/ResourceSharingClientAccessor.java new file mode 100644 index 0000000000..0c5b7bca4e --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/client/ResourceSharingClientAccessor.java @@ -0,0 +1,35 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.sample.resource.client; + +import org.opensearch.security.spi.resources.client.NoopResourceSharingClient; +import org.opensearch.security.spi.resources.client.ResourceSharingClient; + +/** + * Accessor for resource sharing client supplied by the SPI. + */ +public class ResourceSharingClientAccessor { + private static ResourceSharingClient CLIENT; + + private ResourceSharingClientAccessor() {} + + /** + * Set the resource sharing client + */ + public static void setResourceSharingClient(ResourceSharingClient client) { + CLIENT = client; + } + + /** + * Get the resource sharing client + */ + public static ResourceSharingClient getResourceSharingClient() { + return CLIENT == null ? new NoopResourceSharingClient() : CLIENT; + } +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/utils/Constants.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/utils/Constants.java new file mode 100644 index 0000000000..8cccb7e178 --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/utils/Constants.java @@ -0,0 +1,19 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.sample.utils; + +/** + * Constants for Sample Resource Sharing Plugin + */ +public class Constants { + public static final String RESOURCE_INDEX_NAME = ".sample_resource_sharing_plugin"; + + public static final String SAMPLE_RESOURCE_PLUGIN_PREFIX = "_plugins/sample_resource_sharing"; + public static final String SAMPLE_RESOURCE_PLUGIN_API_PREFIX = "/" + SAMPLE_RESOURCE_PLUGIN_PREFIX; +} diff --git a/sample-resource-plugin/src/main/plugin-metadata/plugin-security.policy b/sample-resource-plugin/src/main/plugin-metadata/plugin-security.policy new file mode 100644 index 0000000000..a5dfc33a87 --- /dev/null +++ b/sample-resource-plugin/src/main/plugin-metadata/plugin-security.policy @@ -0,0 +1,3 @@ +grant { + permission java.lang.RuntimePermission "getClassLoader"; +}; \ No newline at end of file diff --git a/sample-resource-plugin/src/main/resources/META-INF/services/org.opensearch.security.spi.resources.ResourceSharingExtension b/sample-resource-plugin/src/main/resources/META-INF/services/org.opensearch.security.spi.resources.ResourceSharingExtension new file mode 100644 index 0000000000..d8a7415020 --- /dev/null +++ b/sample-resource-plugin/src/main/resources/META-INF/services/org.opensearch.security.spi.resources.ResourceSharingExtension @@ -0,0 +1 @@ +org.opensearch.sample.SampleResourceExtension \ No newline at end of file diff --git a/settings.gradle b/settings.gradle index 23bd76ad8b..19b259b700 100644 --- a/settings.gradle +++ b/settings.gradle @@ -8,3 +8,6 @@ rootProject.name = 'opensearch-security' include "spi" project(":spi").name = "opensearch-security-spi" + +include "sample-resource-plugin" +project(":sample-resource-plugin").name = "opensearch-sample-resource-plugin" diff --git a/src/integrationTest/java/org/opensearch/test/framework/cluster/OpenSearchClientProvider.java b/src/integrationTest/java/org/opensearch/test/framework/cluster/OpenSearchClientProvider.java index 9b6b1dea15..8716e5ae6c 100644 --- a/src/integrationTest/java/org/opensearch/test/framework/cluster/OpenSearchClientProvider.java +++ b/src/integrationTest/java/org/opensearch/test/framework/cluster/OpenSearchClientProvider.java @@ -1,30 +1,30 @@ /* -* Copyright 2020 floragunn GmbH -* -* Licensed under the Apache License, Version 2.0 (the "License"); -* you may not use this file except in compliance with the License. -* You may obtain a copy of the License at -* -* http://www.apache.org/licenses/LICENSE-2.0 -* -* Unless required by applicable law or agreed to in writing, software -* distributed under the License is distributed on an "AS IS" BASIS, -* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -* See the License for the specific language governing permissions and -* limitations under the License. -* -*/ + * Copyright 2020 floragunn GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ /* -* SPDX-License-Identifier: Apache-2.0 -* -* The OpenSearch Contributors require contributions made to -* this file be licensed under the Apache-2.0 license or a -* compatible open source license. -* -* Modifications Copyright OpenSearch Contributors. See -* GitHub history for details. -*/ + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ package org.opensearch.test.framework.cluster; @@ -70,12 +70,12 @@ import static org.opensearch.test.framework.cluster.TestRestClientConfiguration.getBasicAuthHeader; /** -* OpenSearchClientProvider provides methods to get a REST client for an underlying cluster or node. -* -* This interface is implemented by both LocalCluster and LocalOpenSearchCluster.Node. Thus, it is possible to get a -* REST client for a whole cluster (without choosing the node it is operating on) or to get a REST client for a specific -* node. -*/ + * OpenSearchClientProvider provides methods to get a REST client for an underlying cluster or node. + * + * This interface is implemented by both LocalCluster and LocalOpenSearchCluster.Node. Thus, it is possible to get a + * REST client for a whole cluster (without choosing the node it is operating on) or to get a REST client for a specific + * node. + */ public interface OpenSearchClientProvider { String getClusterName(); @@ -92,12 +92,12 @@ default URI getHttpAddressAsURI() { } /** - * Returns a REST client that sends requests with basic authentication for the specified User object. Optionally, - * additional HTTP headers can be specified which will be sent with each request. - * - * This method should be usually preferred. The other getRestClient() methods shall be only used for specific - * situations. - */ + * Returns a REST client that sends requests with basic authentication for the specified User object. Optionally, + * additional HTTP headers can be specified which will be sent with each request. + * + * This method should be usually preferred. The other getRestClient() methods shall be only used for specific + * situations. + */ default TestRestClient getRestClient(UserCredentialsHolder user, CertificateData useCertificateData, Header... headers) { return getRestClient(user.getName(), user.getPassword(), useCertificateData, headers); } @@ -180,12 +180,12 @@ default CloseableHttpClient getClosableHttpClient(String[] supportedCipherSuit) } /** - * Returns a REST client that sends requests with basic authentication for the specified user name and password. Optionally, - * additional HTTP headers can be specified which will be sent with each request. - * - * Normally, you should use the method with the User object argument instead. Use this only if you need more - * control over username and password - for example, when you want to send a wrong password. - */ + * Returns a REST client that sends requests with basic authentication for the specified user name and password. Optionally, + * additional HTTP headers can be specified which will be sent with each request. + * + * Normally, you should use the method with the User object argument instead. Use this only if you need more + * control over username and password - for example, when you want to send a wrong password. + */ default TestRestClient getRestClient(String user, String password, Header... headers) { return createGenericClientRestClient(new TestRestClientConfiguration().username(user).password(password).headers(headers)); } @@ -200,9 +200,9 @@ default TestRestClient getRestClient(String user, String password, CertificateDa } /** - * Returns a REST client. You can specify additional HTTP headers that will be sent with each request. Use this - * method to test non-basic authentication, such as JWT bearer authentication. - */ + * Returns a REST client. You can specify additional HTTP headers that will be sent with each request. Use this + * method to test non-basic authentication, such as JWT bearer authentication. + */ default TestRestClient getRestClient(CertificateData useCertificateData, Header... headers) { return getRestClient(Arrays.asList(headers), useCertificateData); } @@ -220,6 +220,10 @@ default TestRestClient getRestClient(List
headers, CertificateData useCe return createGenericClientRestClient(headers, useCertificateData, null); } + default TestRestClient getSecurityDisabledRestClient() { + return new TestRestClient(getHttpAddress(), List.of(), getSSLContext(null), null, false, false); + } + default TestRestClient createGenericClientRestClient( List
headers, CertificateData useCertificateData, diff --git a/src/main/java/org/opensearch/security/dlic/rest/support/Utils.java b/src/main/java/org/opensearch/security/dlic/rest/support/Utils.java index a845f86333..1451f413dc 100644 --- a/src/main/java/org/opensearch/security/dlic/rest/support/Utils.java +++ b/src/main/java/org/opensearch/security/dlic/rest/support/Utils.java @@ -59,7 +59,6 @@ public class Utils { public static final String PLUGINS_PREFIX = "_plugins/_security"; public final static String PLUGIN_ROUTE_PREFIX = "/" + PLUGINS_PREFIX; - public final static String PLUGIN_RESOURCE_ROUTE_PREFIX = PLUGIN_ROUTE_PREFIX + "/resources"; @Deprecated public final static String LEGACY_PLUGIN_ROUTE_PREFIX = "/" + LEGACY_OPENDISTRO_PREFIX; diff --git a/src/main/java/org/opensearch/security/resources/ResourceAccessHandler.java b/src/main/java/org/opensearch/security/resources/ResourceAccessHandler.java index 98e2ae9a2d..974ba3ff8f 100644 --- a/src/main/java/org/opensearch/security/resources/ResourceAccessHandler.java +++ b/src/main/java/org/opensearch/security/resources/ResourceAccessHandler.java @@ -15,6 +15,8 @@ import java.util.HashSet; import java.util.Map; import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -82,37 +84,34 @@ public void getAccessibleResourceIdsForCurrentUser(String resourceIndex, ActionL // 1) collect all entities we’ll match against share_with arrays // for users: - Set userQueryEntities = new HashSet<>(); - userQueryEntities.add(user.getName()); - userQueryEntities.add("*"); // for matching against publicly shared resource + Set users = new HashSet<>(); + users.add(user.getName()); + users.add("*"); // for matching against publicly shared resource // for roles: - Set roleQueryEntities = new HashSet<>(user.getSecurityRoles()); - roleQueryEntities.add("*"); // for matching against publicly shared resource + Set roles = new HashSet<>(user.getSecurityRoles()); + roles.add("*"); // for matching against publicly shared resource // for backend_roles: - Set backendQueryEntities = new HashSet<>(user.getRoles()); - backendQueryEntities.add("*"); // for matching against publicly shared resource + Set backendRoles = new HashSet<>(user.getRoles()); + backendRoles.add("*"); // for matching against publicly shared resource + + // 2) build a flattened query (allows us to compute large number of entries in less than a second compared to multi-match query with + // BEST_FIELDS) + Set flatPrincipals = Stream.concat( + // users + users.stream().map(u -> "user:" + u), + // then roles and backend_roles + Stream.concat(roles.stream().map(r -> "role:" + r), backendRoles.stream().map(b -> "backend:" + b)) + ).collect(Collectors.toSet()); - // 2) build one BoolQuery: BoolQueryBuilder query = QueryBuilders.boolQuery() - // match owner .should(QueryBuilders.termQuery("created_by.user.keyword", user.getName())) - // match any share_with.*.users - .should(QueryBuilders.termsQuery("share_with.*.users.keyword", userQueryEntities)) - // match any share_with.*.roles - .should(QueryBuilders.termsQuery("share_with.*.roles.keyword", roleQueryEntities)) - // match any share_with.*.backend_roles - .should(QueryBuilders.termsQuery("share_with.*.backend_roles.keyword", backendQueryEntities)) + .should(QueryBuilders.termsQuery("all_shared_principals", flatPrincipals)) .minimumShouldMatch(1); - Set entitiesForLogging = new HashSet<>(); - entitiesForLogging.addAll(userQueryEntities); - entitiesForLogging.addAll(roleQueryEntities); - entitiesForLogging.addAll(backendQueryEntities); - // 3) Fetch all accessible resource IDs - resourceSharingIndexHandler.fetchSharedDocuments(resourceIndex, entitiesForLogging, query, listener); + resourceSharingIndexHandler.fetchSharedDocuments(resourceIndex, flatPrincipals, query, listener); } /** diff --git a/src/main/java/org/opensearch/security/resources/ResourceSharingIndexHandler.java b/src/main/java/org/opensearch/security/resources/ResourceSharingIndexHandler.java index d3d401f750..a47a1acef8 100644 --- a/src/main/java/org/opensearch/security/resources/ResourceSharingIndexHandler.java +++ b/src/main/java/org/opensearch/security/resources/ResourceSharingIndexHandler.java @@ -282,7 +282,7 @@ public void fetchSharedDocuments( boolQuery.must(QueryBuilders.existsQuery("share_with")).must(actionGroupQuery); - executeSearchRequest(scroll, searchRequest, boolQuery, ActionListener.wrap(resourceIds -> { + executeFlattenedSearchRequest(scroll, searchRequest, boolQuery, ActionListener.wrap(resourceIds -> { LOGGER.debug("Found {} documents matching the criteria in {}", resourceIds.size(), resourceSharingIndex); listener.onResponse(resourceIds); @@ -1043,6 +1043,68 @@ private void executeSearchRequest( }, listener::onFailure); } + /** + * Executes a multi-clause query in a flattened fashion to boost performance by almost 20x for large queries. + * This is specifically to replace multi-match queries for wild-card expansions. + * @param scroll Search scroll context + * @param searchRequest Initial search request + * @param query Query builder for the request + * @param listener Listener to receive the collected resource IDs + */ + private void executeFlattenedSearchRequest( + Scroll scroll, + SearchRequest searchRequest, + AbstractQueryBuilder> query, + ActionListener> listener + ) { + // Painless script to pull out every user/role/backend_role from share_with.* into one array + String scriptSource = """ + if (params._source.share_with instanceof Map) { + for (def grp : params._source.share_with.values()) { + if (grp.users instanceof List) { + for (u in grp.users) { + emit("user:" + u); + } + } + if (grp.roles instanceof List) { + for (r in grp.roles) { + emit("role:" + r); + } + } + if (grp.backend_roles instanceof List) { + for (b in grp.backend_roles) { + emit("backend:" + b); + } + } + } + } + """; + + Script script = new Script( + ScriptType.INLINE, + "painless", + scriptSource, + Map.of() // no params + ); + + SearchSourceBuilder ssb = new SearchSourceBuilder().derivedField( + "all_shared_principals", // synthetic flattened field + "keyword", // type + script // inline script + ).query(query).size(1000).fetchSource(new String[] { "resource_id" }, null); + + searchRequest.source(ssb); + + // … the rest stays exactly the same … + StepListener searchStep = new StepListener<>(); + client.search(searchRequest, searchStep); + searchStep.whenComplete(initialResponse -> { + Set collectedResourceIds = new HashSet<>(); + String scrollId = initialResponse.getScrollId(); + processScrollResults(collectedResourceIds, scroll, scrollId, initialResponse.getHits().getHits(), listener); + }, listener::onFailure); + } + /** * Recursively processes scroll results and collects resource IDs. *