diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5a41062883..c8e5202c29 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -111,12 +111,25 @@ jobs: - name: Checkout security uses: actions/checkout@v4 - - name: Build and Test + - name: Publish SPI to Local Maven + uses: gradle/gradle-build-action@v3 + with: + cache-disabled: true + arguments: :opensearch-resource-sharing-spi:publishToMavenLocal -Dbuild.snapshot=false + + - name: Run Integration Tests + uses: gradle/gradle-build-action@v3 + with: + cache-disabled: true + arguments: | + :integrationTest -Dbuild.snapshot=false + + - name: Run SampleResourcePlugin Integration Tests uses: gradle/gradle-build-action@v3 with: cache-disabled: true arguments: | - integrationTest -Dbuild.snapshot=false + :opensearch-sample-resource-plugin:integrationTest -Dbuild.snapshot=false - uses: actions/upload-artifact@v4 if: always() @@ -125,7 +138,6 @@ jobs: path: | ./build/reports/ - resource-tests: env: CI_ENVIRONMENT: resource-test @@ -146,12 +158,18 @@ jobs: - name: Checkout security uses: actions/checkout@v4 - - name: Build and Test + - name: Publish SPI to Local Maven + uses: gradle/gradle-build-action@v3 + with: + cache-disabled: true + arguments: :opensearch-resource-sharing-spi:publishToMavenLocal -Dbuild.snapshot=false + + - name: Run Resource Tests uses: gradle/gradle-build-action@v3 with: cache-disabled: true arguments: | - integrationTest -Dbuild.snapshot=false --tests org.opensearch.security.ResourceFocusedTests + :integrationTest -Dbuild.snapshot=false --tests org.opensearch.security.ResourceFocusedTests backward-compatibility-build: runs-on: ubuntu-latest @@ -214,40 +232,57 @@ jobs: build-artifact-names: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-java@v4 - with: - distribution: temurin # Temurin is a distribution of adoptium - java-version: 21 - - - run: | - security_plugin_version=$(./gradlew properties -q | grep -E '^version:' | awk '{print $2}') - security_plugin_version_no_snapshot=$(echo $security_plugin_version | sed 's/-SNAPSHOT//g') - security_plugin_version_only_number=$(echo $security_plugin_version_no_snapshot | cut -d- -f1) - test_qualifier=alpha2 - - echo "SECURITY_PLUGIN_VERSION=$security_plugin_version" >> $GITHUB_ENV - echo "SECURITY_PLUGIN_VERSION_NO_SNAPSHOT=$security_plugin_version_no_snapshot" >> $GITHUB_ENV - echo "SECURITY_PLUGIN_VERSION_ONLY_NUMBER=$security_plugin_version_only_number" >> $GITHUB_ENV - echo "TEST_QUALIFIER=$test_qualifier" >> $GITHUB_ENV - - - run: | - echo ${{ env.SECURITY_PLUGIN_VERSION }} - echo ${{ env.SECURITY_PLUGIN_VERSION_NO_SNAPSHOT }} - echo ${{ env.SECURITY_PLUGIN_VERSION_ONLY_NUMBER }} - echo ${{ env.TEST_QUALIFIER }} - - - run: ./gradlew clean assemble && test -s ./build/distributions/opensearch-security-${{ env.SECURITY_PLUGIN_VERSION }}.zip - - - run: ./gradlew clean assemble -Dbuild.snapshot=false && test -s ./build/distributions/opensearch-security-${{ env.SECURITY_PLUGIN_VERSION_NO_SNAPSHOT }}.zip - - - run: ./gradlew clean assemble -Dbuild.snapshot=false -Dbuild.version_qualifier=${{ env.TEST_QUALIFIER }} && test -s ./build/distributions/opensearch-security-${{ env.SECURITY_PLUGIN_VERSION_ONLY_NUMBER }}-${{ env.TEST_QUALIFIER }}.zip - - - run: ./gradlew clean assemble -Dbuild.version_qualifier=${{ env.TEST_QUALIFIER }} && test -s ./build/distributions/opensearch-security-${{ env.SECURITY_PLUGIN_VERSION_ONLY_NUMBER }}-${{ env.TEST_QUALIFIER }}-SNAPSHOT.zip + - name: Setup Environment + uses: actions/checkout@v4 - - run: ./gradlew clean publishPluginZipPublicationToZipStagingRepository && test -s ./build/distributions/opensearch-security-${{ env.SECURITY_PLUGIN_VERSION }}.zip && test -s ./build/distributions/opensearch-security-${{ env.SECURITY_PLUGIN_VERSION }}.pom + - name: Configure Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 21 - - name: List files in the build directory if there was an error - run: ls -al ./build/distributions/ - if: failure() + - name: Build and Test Artifacts + run: | + # Set version variables + security_plugin_version=$(./gradlew properties -q | grep -E '^version:' | awk '{print $2}') + security_plugin_version_no_snapshot=$(echo $security_plugin_version | sed 's/-SNAPSHOT//g') + security_plugin_version_only_number=$(echo $security_plugin_version_no_snapshot | cut -d- -f1) + test_qualifier=alpha2 + + # Debug print versions + echo "Versions:" + echo $security_plugin_version + echo $security_plugin_version_no_snapshot + echo $security_plugin_version_only_number + echo $test_qualifier + + # Publish SPI + ./gradlew :opensearch-resource-sharing-spi:publishToMavenLocal && test -s ./spi/build/libs/opensearch-resource-sharing-spi-$security_plugin_version.jar + ./gradlew :opensearch-resource-sharing-spi:publishToMavenLocal -Dbuild.snapshot=false && test -s ./spi/build/libs/opensearch-resource-sharing-spi-$security_plugin_version_no_snapshot.jar + ./gradlew :opensearch-resource-sharing-spi:publishToMavenLocal -Dbuild.snapshot=false -Dbuild.version_qualifier=$test_qualifier && test -s ./spi/build/libs/opensearch-resource-sharing-spi-$security_plugin_version_only_number-$test_qualifier.jar + ./gradlew :opensearch-resource-sharing-spi:publishToMavenLocal -Dbuild.version_qualifier=$test_qualifier && test -s ./spi/build/libs/opensearch-resource-sharing-spi-$security_plugin_version_only_number-$test_qualifier-SNAPSHOT.jar + + # Build artifacts + ./gradlew clean assemble && \ + test -s ./build/distributions/opensearch-security-$security_plugin_version.zip && \ + test -s ./sample-resource-plugin/build/distributions/opensearch-sample-resource-plugin-$security_plugin_version.zip + + ./gradlew clean assemble -Dbuild.snapshot=false && \ + test -s ./build/distributions/opensearch-security-$security_plugin_version_no_snapshot.zip && \ + test -s ./sample-resource-plugin/build/distributions/opensearch-sample-resource-plugin-$security_plugin_version_no_snapshot.zip + + ./gradlew clean assemble -Dbuild.snapshot=false -Dbuild.version_qualifier=$test_qualifier && \ + test -s ./build/distributions/opensearch-security-$security_plugin_version_only_number-$test_qualifier.zip && \ + test -s ./sample-resource-plugin/build/distributions/opensearch-sample-resource-plugin-$security_plugin_version_only_number-$test_qualifier.zip + + ./gradlew clean assemble -Dbuild.version_qualifier=$test_qualifier && \ + test -s ./build/distributions/opensearch-security-$security_plugin_version_only_number-$test_qualifier-SNAPSHOT.zip && \ + test -s ./sample-resource-plugin/build/distributions/opensearch-sample-resource-plugin-$security_plugin_version_only_number-$test_qualifier-SNAPSHOT.zip + + ./gradlew clean publishPluginZipPublicationToZipStagingRepository && \ + test -s ./build/distributions/opensearch-security-$security_plugin_version.zip && \ + test -s ./build/distributions/opensearch-security-$security_plugin_version.pom + + - name: List files in build directory on failure + if: failure() + run: ls -al ./build/distributions/ diff --git a/.github/workflows/maven-publish.yml b/.github/workflows/maven-publish.yml index d10fd67beb..2d4e7e1df0 100644 --- a/.github/workflows/maven-publish.yml +++ b/.github/workflows/maven-publish.yml @@ -32,4 +32,5 @@ jobs: export SONATYPE_PASSWORD=$(aws secretsmanager get-secret-value --secret-id maven-snapshots-password --query SecretString --output text) echo "::add-mask::$SONATYPE_USERNAME" echo "::add-mask::$SONATYPE_PASSWORD" - ./gradlew publishPluginZipPublicationToSnapshotsRepository + ./gradlew --no-daemon publishPluginZipPublicationToSnapshotsRepository + ./gradlew --no-daemon :opensearch-resource-sharing-spi:publishMavenJavaPublicationToSnapshotsRepository diff --git a/.gitignore b/.gitignore index 6fbfafabac..5eb2da999f 100644 --- a/.gitignore +++ b/.gitignore @@ -43,7 +43,3 @@ out/ build/ gradle-build/ .gradle/ - -# nodejs -node_modules/ -package-lock.json diff --git a/build.gradle b/build.gradle index 2785514692..8319dd0f0b 100644 --- a/build.gradle +++ b/build.gradle @@ -402,6 +402,7 @@ opensearchplugin { name 'opensearch-security' description 'Provide access control related features for OpenSearch' classname 'org.opensearch.security.OpenSearchSecurityPlugin' + extendedPlugins = ['lang-painless'] } // This requires an additional Jar not published as part of build-tools @@ -499,6 +500,12 @@ configurations { force "org.checkerframework:checker-qual:3.48.4" force "ch.qos.logback:logback-classic:1.5.16" force "commons-io:commons-io:2.18.0" + force "com.carrotsearch.randomizedtesting:randomizedtesting-runner:2.8.2" + force "org.hamcrest:hamcrest:2.2" + force "org.mockito:mockito-core:5.15.2" + 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" } } @@ -506,6 +513,56 @@ configurations { integrationTestRuntimeOnly.extendsFrom runtimeOnly } +allprojects { + configurations { + integrationTestImplementation.extendsFrom implementation + } + dependencies { + //integration test framework: + integrationTestImplementation('com.carrotsearch.randomizedtesting:randomizedtesting-runner:2.8.2') { + exclude(group: 'junit', module: 'junit') + } + integrationTestImplementation 'junit:junit:4.13.2' + integrationTestImplementation("org.opensearch.plugin:reindex-client:${opensearch_version}"){ + exclude(group: 'org.slf4j', module: 'slf4j-api') + } + integrationTestImplementation "org.opensearch.plugin:percolator-client:${opensearch_version}" + integrationTestImplementation 'commons-io:commons-io:2.18.0' + integrationTestImplementation "org.apache.logging.log4j:log4j-core:${versions.log4j}" + integrationTestImplementation "org.apache.logging.log4j:log4j-jul:${versions.log4j}" + integrationTestImplementation 'org.hamcrest:hamcrest:2.2' + integrationTestImplementation "org.bouncycastle:bcpkix-jdk18on:${versions.bouncycastle}" + integrationTestImplementation "org.bouncycastle:bcutil-jdk18on:${versions.bouncycastle}" + integrationTestImplementation('org.awaitility:awaitility:4.2.2') { + exclude(group: 'org.hamcrest', module: 'hamcrest') + } + integrationTestImplementation 'com.unboundid:unboundid-ldapsdk:4.0.14' + integrationTestImplementation "org.opensearch.plugin:mapper-size:${opensearch_version}" + integrationTestImplementation "org.apache.httpcomponents:httpclient-cache:4.5.14" + integrationTestImplementation "org.apache.httpcomponents:httpclient:4.5.14" + integrationTestImplementation "org.apache.httpcomponents:fluent-hc:4.5.14" + integrationTestImplementation "org.apache.httpcomponents:httpcore:4.4.16" + integrationTestImplementation "org.apache.httpcomponents:httpasyncclient:4.1.5" + integrationTestImplementation "org.mockito:mockito-core:5.15.2" + integrationTestImplementation "org.passay:passay:1.6.6" + integrationTestImplementation "org.opensearch:opensearch:${opensearch_version}" + integrationTestImplementation "org.opensearch.plugin:transport-netty4-client:${opensearch_version}" + integrationTestImplementation "org.opensearch.plugin:aggs-matrix-stats-client:${opensearch_version}" + integrationTestImplementation "org.opensearch.plugin:parent-join-client:${opensearch_version}" + integrationTestImplementation 'com.password4j:password4j:1.8.2' + integrationTestImplementation "com.google.guava:guava:${guava_version}" + integrationTestImplementation "org.apache.commons:commons-lang3:${versions.commonslang}" + integrationTestImplementation "com.fasterxml.jackson.core:jackson-databind:${versions.jackson_databind}" + integrationTestImplementation 'org.greenrobot:eventbus-java:3.3.1' + integrationTestImplementation('com.flipkart.zjsonpatch:zjsonpatch:0.4.16'){ + exclude(group:'com.fasterxml.jackson.core') + } + integrationTestImplementation 'org.slf4j:slf4j-api:2.0.12' + integrationTestImplementation 'com.selectivem.collections:special-collections-complete:1.4.0' + integrationTestImplementation "org.opensearch.plugin:lang-painless:${opensearch_version}" + } +} + //create source set 'integrationTest' //add classes from the main source set to the compilation and runtime classpaths of the integrationTest sourceSets { @@ -526,6 +583,9 @@ sourceSets { //add new task that runs integration tests task integrationTest(type: Test) { + filter { + excludeTestsMatching 'org.opensearch.sample.*ResourcePlugin*' + } doFirst { // Only run resources tests on resource-test CI environments or locally if (System.getenv('CI_ENVIRONMENT') != 'resource-test' && System.getenv('CI_ENVIRONMENT') != null) { @@ -574,6 +634,8 @@ tasks.integrationTest.finalizedBy(jacocoTestReport) // report is always generate check.dependsOn integrationTest dependencies { + implementation project(path: ":opensearch-resource-sharing-spi") + compileOnly "org.opensearch.plugin:lang-painless:${opensearch_version}" implementation "org.opensearch.plugin:transport-netty4-client:${opensearch_version}" implementation "org.opensearch.client:opensearch-rest-high-level-client:${opensearch_version}" implementation "org.apache.httpcomponents.client5:httpclient5-cache:${versions.httpclient5}" @@ -723,31 +785,6 @@ dependencies { compileOnly "org.opensearch:opensearch:${opensearch_version}" - //integration test framework: - integrationTestImplementation('com.carrotsearch.randomizedtesting:randomizedtesting-runner:2.8.2') { - exclude(group: 'junit', module: 'junit') - } - integrationTestImplementation 'junit:junit:4.13.2' - integrationTestImplementation "org.opensearch.plugin:reindex-client:${opensearch_version}" - integrationTestImplementation "org.opensearch.plugin:percolator-client:${opensearch_version}" - integrationTestImplementation 'commons-io:commons-io:2.18.0' - integrationTestImplementation "org.apache.logging.log4j:log4j-core:${versions.log4j}" - integrationTestImplementation "org.apache.logging.log4j:log4j-jul:${versions.log4j}" - integrationTestImplementation 'org.hamcrest:hamcrest:2.2' - integrationTestImplementation "org.bouncycastle:bcpkix-jdk18on:${versions.bouncycastle}" - integrationTestImplementation "org.bouncycastle:bcutil-jdk18on:${versions.bouncycastle}" - integrationTestImplementation('org.awaitility:awaitility:4.2.2') { - exclude(group: 'org.hamcrest', module: 'hamcrest') - } - integrationTestImplementation 'com.unboundid:unboundid-ldapsdk:4.0.14' - integrationTestImplementation "org.opensearch.plugin:mapper-size:${opensearch_version}" - integrationTestImplementation "org.apache.httpcomponents:httpclient-cache:4.5.14" - integrationTestImplementation "org.apache.httpcomponents:httpclient:4.5.14" - integrationTestImplementation "org.apache.httpcomponents:fluent-hc:4.5.14" - integrationTestImplementation "org.apache.httpcomponents:httpcore:4.4.16" - integrationTestImplementation "org.apache.httpcomponents:httpasyncclient:4.1.5" - integrationTestImplementation "org.mockito:mockito-core:5.15.2" - //spotless implementation('com.google.googlejavaformat:google-java-format:1.25.2') { exclude group: 'com.google.guava' diff --git a/sample-resource-plugin/README.md b/sample-resource-plugin/README.md new file mode 100644 index 0000000000..f568544df2 --- /dev/null +++ b/sample-resource-plugin/README.md @@ -0,0 +1,78 @@ +# 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. + +## Features + +- Create, update and delete resources. + +## 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": "Resource created successfully." + } + ``` +### 2. Update Resource +- **Endpoint:** `POST /_plugins/sample_resource_sharing/update/{resourceId}` +- **Description:** Updates a resource. +- **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." + } + ``` + +## 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 + ``` + +## 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..e3c057cf3f --- /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" version "1.6.0" +} +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-SNAPSHOT") + isSnapshot = "true" == System.getProperty("build.snapshot", "true") + buildVersionQualifier = System.getProperty("build.version_qualifier", "") + + 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', + 'com.fasterxml.jackson.core:jackson-databind:2.18.2' + } +} + +dependencies { + // Main implementation dependencies + compileOnly "org.opensearch:opensearch-resource-sharing-spi:${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 + integrationTestImplementation "org.opensearch:opensearch-resource-sharing-spi:${opensearch_build}" +} + +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 { + if (System.getenv('DISABLE_RETRY') != 'true') { + retry { + failOnPassedAfterRetry = false + maxRetries = 2 + 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") +} 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..6435c371ab --- /dev/null +++ b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/AbstractSampleResourcePluginTests.java @@ -0,0 +1,71 @@ +package org.opensearch.sample; + +import com.carrotsearch.randomizedtesting.annotations.ThreadLeakScope; +import org.junit.runner.RunWith; + +import org.opensearch.security.spi.resources.ResourceAccessScope; +import org.opensearch.test.framework.TestSecurityConfig; + +import static org.opensearch.sample.utils.Constants.RESOURCE_INDEX_NAME; +import static org.opensearch.sample.utils.Constants.SAMPLE_RESOURCE_PLUGIN_PREFIX; +import static org.opensearch.security.dlic.rest.support.Utils.PLUGIN_RESOURCE_ROUTE_PREFIX; + +/** + * These tests run with security enabled + */ +@RunWith(com.carrotsearch.randomizedtesting.RandomizedRunner.class) +@ThreadLeakScope(ThreadLeakScope.Scope.NONE) +public class AbstractSampleResourcePluginTests { + + final static TestSecurityConfig.User SHARED_WITH_USER = new TestSecurityConfig.User("resource_sharing_test_user").roles( + new TestSecurityConfig.Role("shared_role").indexPermissions("*").on("*").clusterPermissions("*") + ); + + static final String SAMPLE_RESOURCE_CREATE_ENDPOINT = SAMPLE_RESOURCE_PLUGIN_PREFIX + "/create"; + static final String SAMPLE_RESOURCE_UPDATE_ENDPOINT = SAMPLE_RESOURCE_PLUGIN_PREFIX + "/update"; + static final String SAMPLE_RESOURCE_DELETE_ENDPOINT = SAMPLE_RESOURCE_PLUGIN_PREFIX + "/delete"; + private static final String PLUGIN_RESOURCE_ROUTE_PREFIX_NO_LEADING_SLASH = PLUGIN_RESOURCE_ROUTE_PREFIX.replaceFirst("/", ""); + static final String SECURITY_RESOURCE_LIST_ENDPOINT = PLUGIN_RESOURCE_ROUTE_PREFIX_NO_LEADING_SLASH + "/list"; + static final String SECURITY_RESOURCE_SHARE_ENDPOINT = PLUGIN_RESOURCE_ROUTE_PREFIX_NO_LEADING_SLASH + "/share"; + static final String SECURITY_RESOURCE_VERIFY_ENDPOINT = PLUGIN_RESOURCE_ROUTE_PREFIX_NO_LEADING_SLASH + "/verify_access"; + static final String SECURITY_RESOURCE_REVOKE_ENDPOINT = PLUGIN_RESOURCE_ROUTE_PREFIX_NO_LEADING_SLASH + "/revoke"; + + static String shareWithPayload(String resourceId) { + return "{" + + "\"resource_id\":\"" + + resourceId + + "\"," + + "\"resource_index\":\"" + + RESOURCE_INDEX_NAME + + "\"," + + "\"share_with\":{" + + "\"" + + SampleResourceScope.PUBLIC.value() + + "\":{" + + "\"users\": [\"" + + SHARED_WITH_USER.getName() + + "\"]" + + "}" + + "}" + + "}"; + } + + static String revokeAccessPayload(String resourceId) { + return "{" + + "\"resource_id\": \"" + + resourceId + + "\"," + + "\"resource_index\": \"" + + RESOURCE_INDEX_NAME + + "\"," + + "\"entities\": {" + + "\"users\": [\"" + + SHARED_WITH_USER.getName() + + "\"]" + + "}," + + "\"scopes\": [\"" + + ResourceAccessScope.PUBLIC + + "\"]" + + "}"; + } +} diff --git a/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/SampleResourcePluginResourceSharingDisabledTests.java b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/SampleResourcePluginResourceSharingDisabledTests.java new file mode 100644 index 0000000000..62ba2e343c --- /dev/null +++ b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/SampleResourcePluginResourceSharingDisabledTests.java @@ -0,0 +1,136 @@ +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.support.ConfigConstants.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 security disabled + */ +@RunWith(com.carrotsearch.randomizedtesting.RandomizedRunner.class) +@ThreadLeakScope(ThreadLeakScope.Scope.NONE) +public class SampleResourcePluginResourceSharingDisabledTests 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 and neither do resource-sharing APIs + try (TestRestClient client = cluster.getRestClient(cluster.getAdminCertificate())) { + HttpResponse response = client.get(OPENSEARCH_RESOURCE_SHARING_INDEX + "/_search"); + response.assertStatusCode(HttpStatus.SC_NOT_FOUND); + + response = client.get(SECURITY_RESOURCE_LIST_ENDPOINT + "/" + RESOURCE_INDEX_NAME); + response.assertStatusCode(HttpStatus.SC_BAD_REQUEST); + assertThat(response.getBody(), containsString("no handler found for uri")); + + response = client.postJson(SECURITY_RESOURCE_SHARE_ENDPOINT, "{}"); + response.assertStatusCode(HttpStatus.SC_BAD_REQUEST); + assertThat(response.getBody(), containsString("no handler found for uri")); + + response = client.postJson(SECURITY_RESOURCE_REVOKE_ENDPOINT, "{}"); + response.assertStatusCode(HttpStatus.SC_BAD_REQUEST); + assertThat(response.getBody(), containsString("no handler found for uri")); + + response = client.postJson(SECURITY_RESOURCE_VERIFY_ENDPOINT, "{}"); + response.assertStatusCode(HttpStatus.SC_BAD_REQUEST); + assertThat(response.getBody(), containsString("no handler found for uri")); + } + + // resource should be visible to super-admin + try (TestRestClient client = cluster.getRestClient(cluster.getAdminCertificate())) { + + HttpResponse response = client.postJson(RESOURCE_INDEX_NAME + "/_search", "{\"query\" : {\"match_all\" : {}}}"); + response.assertStatusCode(HttpStatus.SC_OK); + assertThat(response.bodyAsJsonNode().get("hits").get("hits").size(), equalTo(1)); + 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.postJson(RESOURCE_INDEX_NAME + "/_search", "{\"query\" : {\"match_all\" : {}}}"); + response.assertStatusCode(HttpStatus.SC_OK); + assertThat(response.bodyAsJsonNode().get("hits").get("hits").size(), equalTo(1)); + } + + // 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 getResponse = client.get(RESOURCE_INDEX_NAME + "/_doc/" + resourceId); + getResponse.assertStatusCode(HttpStatus.SC_OK); + assertThat(getResponse.getBody(), containsString("sampleUpdated")); + } + + // 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 getResponse = client.get(RESOURCE_INDEX_NAME + "/_doc/" + resourceId); + getResponse.assertStatusCode(HttpStatus.SC_NOT_FOUND); + } + + } +} 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..9922f5a9c0 --- /dev/null +++ b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/SampleResourcePluginTests.java @@ -0,0 +1,322 @@ +package org.opensearch.sample; + +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.security.OpenSearchSecurityPlugin; +import org.opensearch.security.spi.resources.ResourceAccessScope; +import org.opensearch.security.spi.resources.ResourceProvider; +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.hamcrest.Matchers.nullValue; +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.AuthcDomain.AUTHC_HTTPBASIC_INTERNAL; +import static org.opensearch.test.framework.TestSecurityConfig.User.USER_ADMIN; + +/** + * These tests run with resource sharing enabled + */ +@RunWith(com.carrotsearch.randomizedtesting.RandomizedRunner.class) +@ThreadLeakScope(ThreadLeakScope.Scope.NONE) +public class SampleResourcePluginTests 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) + .build(); + + @After + public void clearIndices() { + try (TestRestClient client = cluster.getRestClient(cluster.getAdminCertificate())) { + client.delete(RESOURCE_INDEX_NAME); + client.delete(OPENSEARCH_RESOURCE_SHARING_INDEX); + OpenSearchSecurityPlugin.getResourceIndicesMutable().remove(RESOURCE_INDEX_NAME); + OpenSearchSecurityPlugin.getResourceProvidersMutable().remove(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 testCreateUpdateDeleteSampleResource() throws Exception { + String resourceId; + String resourceSharingDocId; + // 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 = String.format( + "{" + + " \"source_idx\": \".sample_resource_sharing_plugin\"," + + " \"resource_id\": \"%s\"," + + " \"created_by\": {" + + " \"user\": \"admin\"" + + " }" + + "}", + resourceId + ); + 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 list + OpenSearchSecurityPlugin.getResourceIndicesMutable().add(RESOURCE_INDEX_NAME); + ResourceProvider provider = new ResourceProvider( + SampleResource.class.getCanonicalName(), + RESOURCE_INDEX_NAME, + new SampleResourceParser() + ); + OpenSearchSecurityPlugin.getResourceProvidersMutable().put(RESOURCE_INDEX_NAME, provider); + + Thread.sleep(1000); + response = client.get(SECURITY_RESOURCE_LIST_ENDPOINT + "/" + RESOURCE_INDEX_NAME); + response.assertStatusCode(HttpStatus.SC_OK); + assertThat(response.bodyAsJsonNode().get("resources").size(), equalTo(1)); + assertThat(response.getBody(), containsString("sample")); + } + + // Update sample resource (admin should be able to update resource) + try (TestRestClient client = cluster.getRestClient(USER_ADMIN)) { + String sampleResourceUpdated = "{\"name\":\"sampleUpdated\"}"; + 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())) { + + HttpResponse response = client.get(SECURITY_RESOURCE_LIST_ENDPOINT + "/" + RESOURCE_INDEX_NAME); + response.assertStatusCode(HttpStatus.SC_OK); + assertThat(response.bodyAsJsonNode().get("resources").size(), equalTo(1)); + assertThat(response.getBody(), containsString("sampleUpdated")); + } + + // resource should no longer be visible to shared_with_user + try (TestRestClient client = cluster.getRestClient(SHARED_WITH_USER)) { + + HttpResponse response = client.get(SECURITY_RESOURCE_LIST_ENDPOINT + "/" + RESOURCE_INDEX_NAME); + response.assertStatusCode(HttpStatus.SC_OK); + assertThat(response.bodyAsJsonNode().get("resources").size(), equalTo(0)); + } + + // shared_with_user should not be able to share admin's resource with itself + try (TestRestClient client = cluster.getRestClient(SHARED_WITH_USER)) { + + HttpResponse response = client.postJson(SECURITY_RESOURCE_SHARE_ENDPOINT, shareWithPayload(resourceId)); + response.assertStatusCode(HttpStatus.SC_FORBIDDEN); + assertThat( + response.bodyAsJsonNode().get("message").asText(), + containsString("User " + SHARED_WITH_USER.getName() + " is not authorized") + ); + } + + // share resource with shared_with user + try (TestRestClient client = cluster.getRestClient(USER_ADMIN)) { + Thread.sleep(1000); + + HttpResponse response = client.postJson(SECURITY_RESOURCE_SHARE_ENDPOINT, shareWithPayload(resourceId)); + response.assertStatusCode(HttpStatus.SC_OK); + assertThat( + response.bodyAsJsonNode().get("share_with").get(SampleResourceScope.PUBLIC.value()).get("users").get(0).asText(), + containsString(SHARED_WITH_USER.getName()) + ); + } + + // resource should now be visible to shared_with_user + try (TestRestClient client = cluster.getRestClient(SHARED_WITH_USER)) { + HttpResponse response = client.get(SECURITY_RESOURCE_LIST_ENDPOINT + "/" + RESOURCE_INDEX_NAME); + response.assertStatusCode(HttpStatus.SC_OK); + assertThat(response.bodyAsJsonNode().get("resources").size(), equalTo(1)); + assertThat(response.getBody(), containsString("sampleUpdated")); + } + + // resource is still visible to super-admin + try (TestRestClient client = cluster.getRestClient(cluster.getAdminCertificate())) { + HttpResponse response = client.get(SECURITY_RESOURCE_LIST_ENDPOINT + "/" + RESOURCE_INDEX_NAME); + response.assertStatusCode(HttpStatus.SC_OK); + assertThat(response.bodyAsJsonNode().get("resources").size(), equalTo(1)); + assertThat(response.getBody(), containsString("sampleUpdated")); + } + + // verify access + try (TestRestClient client = cluster.getRestClient(SHARED_WITH_USER)) { + String verifyAccessPayload = "{\"resource_id\":\"" + + resourceId + + "\",\"resource_index\":\"" + + RESOURCE_INDEX_NAME + + "\",\"scope\":\"" + + ResourceAccessScope.PUBLIC + + "\"}"; + HttpResponse response = client.postJson(SECURITY_RESOURCE_VERIFY_ENDPOINT, verifyAccessPayload); + response.assertStatusCode(HttpStatus.SC_OK); + assertThat(response.bodyAsJsonNode().get("has_permission").asBoolean(), equalTo(true)); + } + + // shared_with user should not be able to revoke access to admin's resource + try (TestRestClient client = cluster.getRestClient(SHARED_WITH_USER)) { + HttpResponse response = client.postJson(SECURITY_RESOURCE_REVOKE_ENDPOINT, revokeAccessPayload(resourceId)); + response.assertStatusCode(HttpStatus.SC_FORBIDDEN); + assertThat( + response.bodyAsJsonNode().get("message").asText(), + containsString("User " + SHARED_WITH_USER.getName() + " is not authorized") + ); + } + + // revoke share_with_user's access + try (TestRestClient client = cluster.getRestClient(USER_ADMIN)) { + Thread.sleep(1000); + HttpResponse response = client.postJson(SECURITY_RESOURCE_REVOKE_ENDPOINT, revokeAccessPayload(resourceId)); + response.assertStatusCode(HttpStatus.SC_OK); + assertThat(response.bodyAsJsonNode().get("share_with"), nullValue()); + } + + // verify access - share_with_user should no longer have access to admin's resource + try (TestRestClient client = cluster.getRestClient(SHARED_WITH_USER)) { + String verifyAccessPayload = "{\"resource_id\":\"" + + resourceId + + "\",\"resource_index\":\"" + + RESOURCE_INDEX_NAME + + "\",\"scope\":\"" + + ResourceAccessScope.PUBLIC + + "\"}"; + HttpResponse response = client.postJson(SECURITY_RESOURCE_VERIFY_ENDPOINT, verifyAccessPayload); + response.assertStatusCode(HttpStatus.SC_OK); + assertThat(response.bodyAsJsonNode().get("has_permission").asBoolean(), equalTo(false)); + } + + // delete sample resource + try (TestRestClient client = cluster.getRestClient(USER_ADMIN)) { + 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 + HttpResponse response = client.delete(OPENSEARCH_RESOURCE_SHARING_INDEX + "/_doc/" + resourceSharingDocId); + assertThat(response.getStatusReason(), containsString("OK")); + + Thread.sleep(1000); + response = client.get(OPENSEARCH_RESOURCE_SHARING_INDEX + "/_search"); + response.assertStatusCode(HttpStatus.SC_OK); + assertThat(response.getBody(), containsString("hits\":[]")); + } + } + + @Test + public void testDLSRestrictionForResourceByDirectlyUpdatingTheResourceIndex() throws Exception { + String resourceId; + // create sample resource + try (TestRestClient client = cluster.getRestClient(USER_ADMIN)) { + String sampleResource = "{\"name\":\"sample\"}"; + HttpResponse response = client.postJson(RESOURCE_INDEX_NAME + "/_doc", sampleResource); + response.assertStatusCode(HttpStatus.SC_CREATED); + + resourceId = response.bodyAsJsonNode().get("_id").asText(); + } + + // 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 = String.format( + "{" + + " \"source_idx\": \".sample_resource_sharing_plugin\"," + + " \"resource_id\": \"%s\"," + + " \"created_by\": {" + + " \"user\": \"admin\"" + + " }" + + "}", + resourceId + ); + HttpResponse response = client.postJson(OPENSEARCH_RESOURCE_SHARING_INDEX + "/_doc", json); + assertThat(response.getStatusReason(), containsString("Created")); + // Also update the in-memory map and list + OpenSearchSecurityPlugin.getResourceIndicesMutable().add(RESOURCE_INDEX_NAME); + ResourceProvider provider = new ResourceProvider( + SampleResource.class.getCanonicalName(), + RESOURCE_INDEX_NAME, + new SampleResourceParser() + ); + OpenSearchSecurityPlugin.getResourceProvidersMutable().put(RESOURCE_INDEX_NAME, provider); + + Thread.sleep(1000); + } + + // resource is still visible to super-admin + try (TestRestClient client = cluster.getRestClient(cluster.getAdminCertificate())) { + + HttpResponse response = client.postJson(RESOURCE_INDEX_NAME + "/_search", "{\"query\" : {\"match_all\" : {}}}"); + response.assertStatusCode(HttpStatus.SC_OK); + assertThat(response.bodyAsJsonNode().get("hits").get("hits").size(), equalTo(1)); + assertThat(response.getBody(), containsString("sample")); + } + + String updatePayload = "{" + "\"doc\": {" + "\"name\": \"sampleUpdated\"" + "}" + "}"; + + // Update sample resource with shared_with user. This will fail since the resource has not been shared + try (TestRestClient client = cluster.getRestClient(SHARED_WITH_USER)) { + HttpResponse updateResponse = client.postJson(RESOURCE_INDEX_NAME + "/_update/" + resourceId, updatePayload); + // it will show not found since the resource is not visible to shared_with_user + updateResponse.assertStatusCode(HttpStatus.SC_NOT_FOUND); + } + + // Admin is still allowed to update its own resource + try (TestRestClient client = cluster.getRestClient(USER_ADMIN)) { + HttpResponse updateResponse = client.postJson(RESOURCE_INDEX_NAME + "/_update/" + resourceId, updatePayload); + // it will show not found since the resource is not visible to shared_with_user + updateResponse.assertStatusCode(HttpStatus.SC_OK); + assertThat(updateResponse.bodyAsJsonNode().get("_shards").get("successful").asInt(), equalTo(1)); + } + + // Verify that share_with user does not have access to the resource + try (TestRestClient client = cluster.getRestClient(SHARED_WITH_USER)) { + HttpResponse getResponse = client.get(RESOURCE_INDEX_NAME + "/_doc/" + resourceId); + // it will show not found since the resource is not visible to shared_with_user + getResponse.assertStatusCode(HttpStatus.SC_NOT_FOUND); + } + + // share the resource with shared_with_user + try (TestRestClient client = cluster.getRestClient(USER_ADMIN)) { + HttpResponse response = client.postJson(SECURITY_RESOURCE_SHARE_ENDPOINT, shareWithPayload(resourceId)); + response.assertStatusCode(HttpStatus.SC_OK); + } + + // Verify that share_with user now has access to the resource + try (TestRestClient client = cluster.getRestClient(SHARED_WITH_USER)) { + HttpResponse getResponse = client.get(RESOURCE_INDEX_NAME + "/_doc/" + resourceId); + // it will show not found since the resource is not visible to shared_with_user + getResponse.assertStatusCode(HttpStatus.SC_OK); + assertThat(getResponse.getBody(), containsString("sampleUpdated")); + } + } + +} 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..23aae25d42 --- /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.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; +import org.opensearch.core.xcontent.ConstructingObjectParser; +import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.core.xcontent.XContentParser; +import org.opensearch.security.spi.resources.Resource; + +import static org.opensearch.core.xcontent.ConstructingObjectParser.constructorArg; +import static org.opensearch.core.xcontent.ConstructingObjectParser.optionalConstructorArg; + +public class SampleResource extends Resource { + + private String name; + private String description; + private Map attributes; + + public SampleResource() throws IOException { + super(null); + } + + public SampleResource(StreamInput in) throws IOException { + super(in); + this.name = in.readString(); + this.description = in.readString(); + this.attributes = in.readMap(StreamInput::readString, StreamInput::readString); + } + + @SuppressWarnings("unchecked") + 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); + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + return builder.startObject().field("name", name).field("description", description).field("attributes", attributes).endObject(); + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + out.writeString(name); + out.writeString(description); + out.writeMap(attributes, StreamOutput::writeString, StreamOutput::writeString); + } + + @Override + public String getWriteableName() { + return "sample_resource"; + } + + public void setName(String name) { + this.name = name; + } + + public void setDescription(String description) { + this.description = description; + } + + public void setAttributes(Map attributes) { + this.attributes = attributes; + } + + @Override + public String getResourceName() { + return name; + } +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/SampleResourceParser.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/SampleResourceParser.java new file mode 100644 index 0000000000..42fb2582e2 --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/SampleResourceParser.java @@ -0,0 +1,24 @@ +/* + * 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 org.opensearch.core.xcontent.XContentParser; +import org.opensearch.security.spi.resources.ResourceParser; + +public class SampleResourceParser implements ResourceParser { + @Override + public SampleResource parseXContent(XContentParser parser) throws IOException { + return SampleResource.fromXContent(parser); + } +} 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..6d386b85cb --- /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.client.Client; +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.transport.CreateResourceTransportAction; +import org.opensearch.sample.resource.actions.transport.DeleteResourceTransportAction; +import org.opensearch.sample.resource.actions.transport.UpdateResourceTransportAction; +import org.opensearch.script.ScriptService; +import org.opensearch.security.spi.resources.ResourceParser; +import org.opensearch.security.spi.resources.ResourceSharingExtension; +import org.opensearch.threadpool.ThreadPool; +import org.opensearch.watcher.ResourceWatcherService; + +import static org.opensearch.sample.utils.Constants.RESOURCE_INDEX_NAME; + +/** + * Sample Resource plugin. + * It uses ".sample_resources" index to manage its resources, and exposes a REST API + * + */ +public class SampleResourcePlugin extends Plugin implements ActionPlugin, SystemIndexPlugin, ResourceSharingExtension { + 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 + ) { + log.info("Loaded SampleResourcePlugin components."); + 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 DeleteResourceRestAction()); + } + + @Override + public List> getActions() { + return List.of( + new ActionHandler<>(CreateResourceAction.INSTANCE, CreateResourceTransportAction.class), + new ActionHandler<>(UpdateResourceAction.INSTANCE, UpdateResourceTransportAction.class), + new ActionHandler<>(DeleteResourceAction.INSTANCE, DeleteResourceTransportAction.class) + ); + } + + @Override + public Collection getSystemIndexDescriptors(Settings settings) { + final SystemIndexDescriptor systemIndexDescriptor = new SystemIndexDescriptor(RESOURCE_INDEX_NAME, "Sample index with resources"); + return Collections.singletonList(systemIndexDescriptor); + } + + @Override + public String getResourceType() { + return SampleResource.class.getCanonicalName(); + } + + @Override + public String getResourceIndex() { + return RESOURCE_INDEX_NAME; + } + + @Override + public ResourceParser getResourceParser() { + return new SampleResourceParser(); + } +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/SampleResourceScope.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/SampleResourceScope.java new file mode 100644 index 0000000000..cfec368aa7 --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/SampleResourceScope.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 org.opensearch.security.spi.resources.ResourceAccessScope; + +/** + * This class demonstrates a sample implementation of Basic Access Scopes to fit each plugin's use-case. + * The plugin then uses this scope when seeking access evaluation for a user on a particular resource. + */ +public enum SampleResourceScope implements ResourceAccessScope { + + SAMPLE_FULL_ACCESS("sample_full_access"), + + PUBLIC("public"); + + private final String name; + + SampleResourceScope(String scopeName) { + this.name = scopeName; + } + + @Override + public String value() { + return name; + } +} 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..d3e9a7a468 --- /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.security.spi.resources.Resource; + +/** + * Request object for CreateSampleResource transport action + */ +public class CreateResourceRequest extends ActionRequest { + + private final Resource resource; + + /** + * Default constructor + */ + public CreateResourceRequest(Resource resource) { + this.resource = resource; + } + + public CreateResourceRequest(StreamInput in) throws IOException { + this.resource = in.readNamedWriteable(Resource.class); + } + + @Override + public void writeTo(final StreamOutput out) throws IOException { + resource.writeTo(out); + } + + @Override + public ActionRequestValidationException validate() { + return null; + } + + public Resource 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..1975298f3f --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/create/CreateResourceRestAction.java @@ -0,0 +1,92 @@ +/* + * 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.client.node.NodeClient; +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 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; + +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/{resourceId}") + ); + } + + @Override + public String getName() { + return "create_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("resourceId"), client); + default: + throw new IllegalArgumentException("Illegal method: " + request.method()); + } + } + + @SuppressWarnings("unchecked") + 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 = source.containsKey("attributes") ? (Map) source.get("attributes") : null; + 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 = source.containsKey("attributes") ? (Map) source.get("attributes") : null; + 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) + ); + } +} 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..129c2d1546 --- /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 { + /** + * Create sample resource action instance + */ + public static final UpdateResourceAction INSTANCE = new UpdateResourceAction(); + /** + * Create 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..db74525a3a --- /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.security.spi.resources.Resource; + +/** + * Request object for UpdateResource transport action + */ +public class UpdateResourceRequest extends ActionRequest { + + private final String resourceId; + private final Resource resource; + + /** + * Default constructor + */ + public UpdateResourceRequest(String resourceId, Resource resource) { + this.resourceId = resourceId; + this.resource = resource; + } + + public UpdateResourceRequest(StreamInput in) throws IOException { + this.resourceId = in.readString(); + this.resource = in.readNamedWriteable(Resource.class); + } + + @Override + public void writeTo(final StreamOutput out) throws IOException { + out.writeString(resourceId); + resource.writeTo(out); + } + + @Override + public ActionRequestValidationException validate() { + return null; + } + + public Resource 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..bfb672dfec --- /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 create a sample resource + */ +public class DeleteResourceAction extends ActionType { + /** + * Create sample resource action instance + */ + public static final DeleteResourceAction INSTANCE = new DeleteResourceAction(); + /** + * Create 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..d7c4637f31 --- /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 CreateSampleResource 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..31bf86ca79 --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/delete/DeleteResourceResponse.java @@ -0,0 +1,52 @@ +/* + * 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; + +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..699b5e0303 --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/delete/DeleteResourceRestAction.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.delete; + +import java.util.List; + +import org.opensearch.client.node.NodeClient; +import org.opensearch.core.common.Strings; +import org.opensearch.rest.BaseRestHandler; +import org.opensearch.rest.RestRequest; +import org.opensearch.rest.action.RestToXContentListener; + +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; + +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/transport/CreateResourceTransportAction.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/transport/CreateResourceTransportAction.java new file mode 100644 index 0000000000..21c994f7fa --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/transport/CreateResourceTransportAction.java @@ -0,0 +1,79 @@ +/* + * 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.client.Client; +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.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.security.spi.resources.Resource; +import org.opensearch.tasks.Task; +import org.opensearch.transport.TransportService; + +import static org.opensearch.common.xcontent.XContentFactory.jsonBuilder; +import static org.opensearch.sample.utils.Constants.RESOURCE_INDEX_NAME; + +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.info("Failed to create resource", e); + listener.onFailure(e); + } + } + + private void createResource(CreateResourceRequest request, ActionListener listener) { + Resource 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.info("Index Request: {}", ir.toString()); + + nodeClient.index(ir, ActionListener.wrap(idxResponse -> { + log.info("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..4ce8954bfe --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/transport/DeleteResourceTransportAction.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.resource.actions.transport; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +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.client.Client; +import org.opensearch.common.inject.Inject; +import org.opensearch.common.util.concurrent.ThreadContext; +import org.opensearch.core.action.ActionListener; +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.tasks.Task; +import org.opensearch.transport.TransportService; + +import static org.opensearch.sample.utils.Constants.RESOURCE_INDEX_NAME; + +public class DeleteResourceTransportAction extends HandledTransportAction { + private static final Logger log = LogManager.getLogger(DeleteResourceTransportAction.class); + + private final TransportService transportService; + private final Client nodeClient; + + @Inject + public DeleteResourceTransportAction(TransportService transportService, ActionFilters actionFilters, Client nodeClient) { + super(DeleteResourceAction.NAME, transportService, actionFilters, DeleteResourceRequest::new); + this.transportService = transportService; + this.nodeClient = nodeClient; + } + + @Override + protected void doExecute(Task task, DeleteResourceRequest request, ActionListener listener) { + if (request.getResourceId() == null || request.getResourceId().isEmpty()) { + listener.onFailure(new IllegalArgumentException("Resource ID cannot be null or empty")); + return; + } + + ThreadContext threadContext = transportService.getThreadPool().getThreadContext(); + try (ThreadContext.StoredContext ignore = threadContext.stashContext()) { + deleteResource(request, ActionListener.wrap(deleteResponse -> { + if (deleteResponse.getResult() == DocWriteResponse.Result.NOT_FOUND) { + listener.onFailure(new ResourceNotFoundException("Resource " + request.getResourceId() + " not found.")); + } else { + listener.onResponse(new DeleteResourceResponse("Resource " + request.getResourceId() + " deleted successfully.")); + } + }, exception -> { + log.error("Failed to delete resource: " + request.getResourceId(), exception); + listener.onFailure(exception); + })); + } + } + + private void deleteResource(DeleteResourceRequest request, ActionListener listener) { + DeleteRequest deleteRequest = new DeleteRequest(RESOURCE_INDEX_NAME, request.getResourceId()).setRefreshPolicy( + WriteRequest.RefreshPolicy.IMMEDIATE + ); + + nodeClient.delete(deleteRequest, listener); + } + +} 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..9dda0f4e4b --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/transport/UpdateResourceTransportAction.java @@ -0,0 +1,81 @@ +/* + * 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.support.ActionFilters; +import org.opensearch.action.support.HandledTransportAction; +import org.opensearch.action.support.WriteRequest; +import org.opensearch.action.update.UpdateRequest; +import org.opensearch.client.Client; +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.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.security.spi.resources.Resource; +import org.opensearch.tasks.Task; +import org.opensearch.transport.TransportService; + +import static org.opensearch.common.xcontent.XContentFactory.jsonBuilder; +import static org.opensearch.sample.utils.Constants.RESOURCE_INDEX_NAME; + +public class UpdateResourceTransportAction extends HandledTransportAction { + private static final Logger log = LogManager.getLogger(UpdateResourceTransportAction.class); + + private final TransportService transportService; + private final Client nodeClient; + + @Inject + public UpdateResourceTransportAction(TransportService transportService, ActionFilters actionFilters, Client nodeClient) { + super(UpdateResourceAction.NAME, transportService, actionFilters, UpdateResourceRequest::new); + this.transportService = transportService; + this.nodeClient = nodeClient; + } + + @Override + protected void doExecute(Task task, UpdateResourceRequest request, ActionListener listener) { + ThreadContext threadContext = transportService.getThreadPool().getThreadContext(); + try (ThreadContext.StoredContext ignore = threadContext.stashContext()) { + updateResource(request, listener); + listener.onResponse( + new CreateResourceResponse("Resource " + request.getResource().getResourceName() + " updated successfully.") + ); + } catch (Exception e) { + log.info("Failed to update resource: {}", request.getResourceId(), e); + listener.onFailure(e); + } + } + + private void updateResource(UpdateResourceRequest request, ActionListener listener) { + String resourceId = request.getResourceId(); + Resource 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.info("Update Request: {}", ur.toString()); + + nodeClient.update( + ur, + ActionListener.wrap(updateResponse -> { log.info("Updated resource: {}", updateResponse.toString()); }, listener::onFailure) + ); + } catch (IOException e) { + listener.onFailure(new RuntimeException(e)); + } + + } +} 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..3be49d033e --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/utils/Constants.java @@ -0,0 +1,16 @@ +/* + * 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; + +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/java/org/opensearch/sample/utils/SampleResourcePluginException.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/utils/SampleResourcePluginException.java new file mode 100644 index 0000000000..1ac2baaaae --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/utils/SampleResourcePluginException.java @@ -0,0 +1,17 @@ +/* + * 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; + +import org.opensearch.OpenSearchException; + +public class SampleResourcePluginException extends OpenSearchException { + public SampleResourcePluginException(String msg, Object... args) { + super(msg, args); + } +} 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..1ca89eaf74 --- /dev/null +++ b/sample-resource-plugin/src/main/resources/META-INF/services/org.opensearch.security.spi.resources.ResourceSharingExtension @@ -0,0 +1 @@ +org.opensearch.sample.SampleResourcePlugin \ No newline at end of file diff --git a/settings.gradle b/settings.gradle index 1c3e7ff5aa..647daa1a47 100644 --- a/settings.gradle +++ b/settings.gradle @@ -5,3 +5,9 @@ */ rootProject.name = 'opensearch-security' + +include "spi" +project(":spi").name = "opensearch-resource-sharing-spi" + +include "sample-resource-plugin" +project(":sample-resource-plugin").name = "opensearch-sample-resource-plugin" diff --git a/spi/README.md b/spi/README.md new file mode 100644 index 0000000000..38efb1cf85 --- /dev/null +++ b/spi/README.md @@ -0,0 +1,11 @@ +# Resource Sharing and Access Control SPI + +This SPI provides interfaces to implement Resource Sharing and Access Control. + +## License + +This code is licensed under the Apache 2.0 License. + +## Copyright + +Copyright OpenSearch Contributors. diff --git a/spi/build.gradle b/spi/build.gradle new file mode 100644 index 0000000000..f23861c448 --- /dev/null +++ b/spi/build.gradle @@ -0,0 +1,80 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +plugins { + id 'java' + id 'maven-publish' +} + +ext { + opensearch_version = System.getProperty("opensearch.version", "3.0.0-SNAPSHOT") +} + +repositories { + mavenLocal() + mavenCentral() + maven { url "https://aws.oss.sonatype.org/content/repositories/snapshots" } +} + +dependencies { + compileOnly "org.opensearch:opensearch:${opensearch_version}" +} + +java { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 +} + +task sourcesJar(type: Jar) { + archiveClassifier.set 'sources' + from sourceSets.main.allJava +} + +task javadocJar(type: Jar) { + archiveClassifier.set 'javadoc' + from tasks.javadoc +} + +publishing { + publications { + mavenJava(MavenPublication) { + from components.java + artifact sourcesJar + artifact javadocJar + pom { + name.set("OpenSearch Resource Sharing SPI") + description.set("OpenSearch Security Resource Sharing") + url.set("https://github.com/opensearch-project/security") + licenses { + license { + name.set("The Apache License, Version 2.0") + url.set("http://www.apache.org/licenses/LICENSE-2.0.txt") + } + } + scm { + connection.set("scm:git@github.com:opensearch-project/security.git") + developerConnection.set("scm:git@github.com:opensearch-project/security.git") + url.set("https://github.com/opensearch-project/security.git") + } + developers { + developer { + name.set("OpenSearch Contributors") + url.set("https://github.com/opensearch-project") + } + } + } + } + } + repositories { + maven { + name = "Snapshots" + url = "https://aws.oss.sonatype.org/content/repositories/snapshots" + credentials { + username "$System.env.SONATYPE_USERNAME" + password "$System.env.SONATYPE_PASSWORD" + } + } + } +} diff --git a/spi/src/main/java/org/opensearch/security/spi/resources/Resource.java b/spi/src/main/java/org/opensearch/security/spi/resources/Resource.java new file mode 100644 index 0000000000..18de796c8e --- /dev/null +++ b/spi/src/main/java/org/opensearch/security/spi/resources/Resource.java @@ -0,0 +1,33 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.security.spi.resources; + +import java.io.IOException; + +import org.opensearch.core.common.io.stream.NamedWriteable; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.xcontent.ToXContentFragment; + +/** + * Marker interface for all resources + */ +public abstract class Resource implements NamedWriteable, ToXContentFragment { + /** + * Abstract method to get the resource name. + * Must be implemented by subclasses. + * + * @return resource name + */ + public abstract String getResourceName(); + + /** + * Enforces that all subclasses have a constructor accepting StreamInput. + */ + protected Resource(StreamInput in) throws IOException {} +} diff --git a/spi/src/main/java/org/opensearch/security/spi/resources/ResourceAccessScope.java b/spi/src/main/java/org/opensearch/security/spi/resources/ResourceAccessScope.java new file mode 100644 index 0000000000..e6fd2a76f6 --- /dev/null +++ b/spi/src/main/java/org/opensearch/security/spi/resources/ResourceAccessScope.java @@ -0,0 +1,38 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.security.spi.resources; + +import java.util.Arrays; + +/** + * This interface defines the two basic access scopes for resource-access. + * Each plugin must implement their own scopes and manage them. + * These access scopes will then be used to verify the type of access being requested. + * + * @opensearch.experimental + */ +public interface ResourceAccessScope> { + String RESTRICTED = "restricted"; + String PUBLIC = "public"; + + static & ResourceAccessScope> E fromValue(Class enumClass, String value) { + for (E enumConstant : enumClass.getEnumConstants()) { + if (enumConstant.value().equalsIgnoreCase(value)) { + return enumConstant; + } + } + throw new IllegalArgumentException("Unknown value: " + value); + } + + String value(); + + static & ResourceAccessScope> String[] values(Class enumClass) { + return Arrays.stream(enumClass.getEnumConstants()).map(ResourceAccessScope::value).toArray(String[]::new); + } +} diff --git a/spi/src/main/java/org/opensearch/security/spi/resources/ResourceParser.java b/spi/src/main/java/org/opensearch/security/spi/resources/ResourceParser.java new file mode 100644 index 0000000000..be57200da4 --- /dev/null +++ b/spi/src/main/java/org/opensearch/security/spi/resources/ResourceParser.java @@ -0,0 +1,23 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.security.spi.resources; + +import java.io.IOException; + +import org.opensearch.core.xcontent.XContentParser; + +public interface ResourceParser { + /** + * Parse source bytes supplied by the parser to a desired Resource type + * @param parser to parser bytes-ref json input + * @return the parsed object of Resource type + * @throws IOException if something went wrong while parsing + */ + T parseXContent(XContentParser parser) throws IOException; +} diff --git a/spi/src/main/java/org/opensearch/security/spi/resources/ResourceProvider.java b/spi/src/main/java/org/opensearch/security/spi/resources/ResourceProvider.java new file mode 100644 index 0000000000..d6bde36a75 --- /dev/null +++ b/spi/src/main/java/org/opensearch/security/spi/resources/ResourceProvider.java @@ -0,0 +1,33 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.security.spi.resources; + +public class ResourceProvider { + private final String resourceType; + private final String resourceIndexName; + private final ResourceParser resourceParser; + + public ResourceParser getResourceParser() { + return resourceParser; + } + + public String getResourceIndexName() { + return resourceIndexName; + } + + public String getResourceType() { + return resourceType; + } + + public ResourceProvider(String resourceType, String resourceIndexName, ResourceParser resourceParser) { + this.resourceType = resourceType; + this.resourceIndexName = resourceIndexName; + this.resourceParser = resourceParser; + } +} diff --git a/spi/src/main/java/org/opensearch/security/spi/resources/ResourceSharingException.java b/spi/src/main/java/org/opensearch/security/spi/resources/ResourceSharingException.java new file mode 100644 index 0000000000..e669341726 --- /dev/null +++ b/spi/src/main/java/org/opensearch/security/spi/resources/ResourceSharingException.java @@ -0,0 +1,28 @@ +package org.opensearch.security.spi.resources; + +import java.io.IOException; + +import org.opensearch.OpenSearchException; +import org.opensearch.core.common.io.stream.StreamInput; + +/** + * This class represents an exception that occurs during resource sharing operations. + * It extends the OpenSearchException class. + */ +public class ResourceSharingException extends OpenSearchException { + public ResourceSharingException(Throwable cause) { + super(cause); + } + + public ResourceSharingException(String msg, Object... args) { + super(msg, args); + } + + public ResourceSharingException(String msg, Throwable cause, Object... args) { + super(msg, cause, args); + } + + public ResourceSharingException(StreamInput in) throws IOException { + super(in); + } +} diff --git a/spi/src/main/java/org/opensearch/security/spi/resources/ResourceSharingExtension.java b/spi/src/main/java/org/opensearch/security/spi/resources/ResourceSharingExtension.java new file mode 100644 index 0000000000..f6eb1d35e8 --- /dev/null +++ b/spi/src/main/java/org/opensearch/security/spi/resources/ResourceSharingExtension.java @@ -0,0 +1,33 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.security.spi.resources; + +/** + * This interface should be implemented by all the plugins that define one or more resources. + * + * @opensearch.experimental + */ +public interface ResourceSharingExtension { + + /** + * Type of the resource + * @return a string containing the type of the resource + */ + String getResourceType(); + + /** + * The index where resource meta-data is stored + * @return the name of the parent index where resource meta-data is stored + */ + String getResourceIndex(); + + default ResourceParser getResourceParser() { + return null; + }; +} diff --git a/spi/src/main/java/org/opensearch/security/spi/resources/package-info.java b/spi/src/main/java/org/opensearch/security/spi/resources/package-info.java new file mode 100644 index 0000000000..8990889429 --- /dev/null +++ b/spi/src/main/java/org/opensearch/security/spi/resources/package-info.java @@ -0,0 +1,14 @@ +/* + * 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. + */ + +/** + * This package defines class required to implement resource access control in OpenSearch. + * + * @opensearch.experimental + */ +package main.java.org.opensearch.security.spi.resources; diff --git a/src/integrationTest/java/org/opensearch/security/SearchOperationTest.java b/src/integrationTest/java/org/opensearch/security/SearchOperationTest.java index cbb5ec11f0..adcd32f224 100644 --- a/src/integrationTest/java/org/opensearch/security/SearchOperationTest.java +++ b/src/integrationTest/java/org/opensearch/security/SearchOperationTest.java @@ -143,6 +143,7 @@ import static org.opensearch.security.Song.TITLE_POISON; import static org.opensearch.security.Song.TITLE_SONG_1_PLUS_1; import static org.opensearch.security.auditlog.impl.AuditCategory.INDEX_EVENT; +import static org.opensearch.security.support.ConfigConstants.OPENSEARCH_RESOURCE_SHARING_ENABLED; import static org.opensearch.test.framework.TestSecurityConfig.AuthcDomain.AUTHC_HTTPBASIC_INTERNAL; import static org.opensearch.test.framework.TestSecurityConfig.Role.ALL_ACCESS; import static org.opensearch.test.framework.audit.AuditMessagePredicate.auditPredicate; @@ -383,6 +384,7 @@ public class SearchOperationTest { new AuditConfiguration(true).compliance(new AuditCompliance().enabled(true)) .filters(new AuditFilters().enabledRest(true).enabledTransport(true)) ) + .nodeSettings(Map.of(OPENSEARCH_RESOURCE_SHARING_ENABLED, false)) .build(); @Rule diff --git a/src/main/java/org/opensearch/security/DefaultObjectMapper.java b/src/main/java/org/opensearch/security/DefaultObjectMapper.java index 68a537c669..05ceabb86c 100644 --- a/src/main/java/org/opensearch/security/DefaultObjectMapper.java +++ b/src/main/java/org/opensearch/security/DefaultObjectMapper.java @@ -287,12 +287,26 @@ public static TypeFactory getTypeFactory() { return objectMapper.getTypeFactory(); } + @SuppressWarnings("removal") public static Set getFields(Class cls) { - return objectMapper.getSerializationConfig() - .introspect(getTypeFactory().constructType(cls)) - .findProperties() - .stream() - .map(BeanPropertyDefinition::getName) - .collect(ImmutableSet.toImmutableSet()); + final SecurityManager sm = System.getSecurityManager(); + + if (sm != null) { + sm.checkPermission(new SpecialPermission()); + } + + try { + return AccessController.doPrivileged( + (PrivilegedExceptionAction>) () -> objectMapper.getSerializationConfig() + .introspect(getTypeFactory().constructType(cls)) + .findProperties() + .stream() + .map(BeanPropertyDefinition::getName) + .collect(ImmutableSet.toImmutableSet()) + ); + } catch (final PrivilegedActionException e) { + throw (RuntimeException) e.getCause(); + } + } } diff --git a/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java b/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java index 13d0f79330..30691d47fd 100644 --- a/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java +++ b/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java @@ -49,6 +49,8 @@ import java.util.Objects; import java.util.Optional; import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; import java.util.function.BiFunction; import java.util.function.Function; @@ -58,6 +60,8 @@ import java.util.stream.Collectors; import java.util.stream.Stream; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; import com.google.common.collect.Lists; import org.apache.commons.lang3.StringUtils; import org.apache.logging.log4j.LogManager; @@ -117,6 +121,7 @@ import org.opensearch.indices.IndicesService; import org.opensearch.indices.SystemIndexDescriptor; import org.opensearch.plugins.ClusterPlugin; +import org.opensearch.plugins.ExtensiblePlugin; import org.opensearch.plugins.ExtensionAwarePlugin; import org.opensearch.plugins.IdentityPlugin; import org.opensearch.plugins.MapperPlugin; @@ -174,16 +179,26 @@ import org.opensearch.security.privileges.RestLayerPrivilegesEvaluator; import org.opensearch.security.privileges.dlsfls.DlsFlsBaseContext; import org.opensearch.security.resolver.IndexResolverReplacer; +import org.opensearch.security.resources.ResourceAccessHandler; +import org.opensearch.security.resources.ResourceSharingConstants; +import org.opensearch.security.resources.ResourceSharingIndexHandler; +import org.opensearch.security.resources.ResourceSharingIndexListener; +import org.opensearch.security.resources.ResourceSharingIndexManagementRepository; import org.opensearch.security.rest.DashboardsInfoAction; import org.opensearch.security.rest.SecurityConfigUpdateAction; import org.opensearch.security.rest.SecurityHealthAction; import org.opensearch.security.rest.SecurityInfoAction; import org.opensearch.security.rest.SecurityWhoAmIAction; import org.opensearch.security.rest.TenantInfoAction; +import org.opensearch.security.rest.resources.access.ResourceAccessRestAction; import org.opensearch.security.securityconf.DynamicConfigFactory; import org.opensearch.security.securityconf.impl.CType; import org.opensearch.security.setting.OpensearchDynamicSetting; import org.opensearch.security.setting.TransportPassiveAuthSetting; +import org.opensearch.security.spi.resources.Resource; +import org.opensearch.security.spi.resources.ResourceParser; +import org.opensearch.security.spi.resources.ResourceProvider; +import org.opensearch.security.spi.resources.ResourceSharingExtension; import org.opensearch.security.ssl.ExternalSecurityKeyStore; import org.opensearch.security.ssl.OpenSearchSecureSettingsFactory; import org.opensearch.security.ssl.OpenSearchSecuritySSLPlugin; @@ -235,9 +250,10 @@ public final class OpenSearchSecurityPlugin extends OpenSearchSecuritySSLPlugin implements ClusterPlugin, MapperPlugin, + IdentityPlugin, // CS-SUPPRESS-SINGLE: RegexpSingleline get Extensions Settings ExtensionAwarePlugin, - IdentityPlugin + ExtensiblePlugin // CS-ENFORCE-SINGLE { @@ -273,6 +289,10 @@ public final class OpenSearchSecurityPlugin extends OpenSearchSecuritySSLPlugin private volatile OpensearchDynamicSetting transportPassiveAuthSetting; private volatile PasswordHasher passwordHasher; private volatile DlsFlsBaseContext dlsFlsBaseContext; + private ResourceSharingIndexManagementRepository rmr; + private ResourceAccessHandler resourceAccessHandler; + private static final Map RESOURCE_PROVIDERS = new HashMap<>(); + private static final Set RESOURCE_INDICES = new HashSet<>(); public static boolean isActionTraceEnabled() { @@ -663,6 +683,14 @@ public List getRestHandlers( passwordHasher ) ); + + // Adds rest handlers for resource-access-control actions + if (settings.getAsBoolean( + ConfigConstants.OPENSEARCH_RESOURCE_SHARING_ENABLED, + ConfigConstants.OPENSEARCH_RESOURCE_SHARING_ENABLED_DEFAULT + )) { + handlers.add(new ResourceAccessRestAction(resourceAccessHandler)); + } log.debug("Added {} rest handler(s)", handlers.size()); } } @@ -714,9 +742,22 @@ public void onIndexModule(IndexModule indexModule) { ciol, evaluator, dlsFlsValve::getCurrentConfig, - dlsFlsBaseContext + dlsFlsBaseContext, + resourceAccessHandler ) ); + + // Listening on POST and DELETE operations in resource indices + ResourceSharingIndexListener resourceSharingIndexListener = ResourceSharingIndexListener.getInstance(); + resourceSharingIndexListener.initialize(threadPool, localClient, auditLog); + if (settings.getAsBoolean( + ConfigConstants.OPENSEARCH_RESOURCE_SHARING_ENABLED, + ConfigConstants.OPENSEARCH_RESOURCE_SHARING_ENABLED_DEFAULT + ) && RESOURCE_INDICES.contains(indexModule.getIndex().getName())) { + indexModule.addIndexOperationListener(resourceSharingIndexListener); + log.info("Security plugin started listening to operations on resource-index {}", indexModule.getIndex().getName()); + } + indexModule.forceQueryCacheProvider((indexSettings, nodeCache) -> new QueryCache() { @Override @@ -755,7 +796,10 @@ public Weight doCache(Weight weight, QueryCachingPolicy policy) { @Override public void onPreQueryPhase(SearchContext context) { - dlsFlsValve.handleSearchContext(context, threadPool, namedXContentRegistry.get()); + CompletableFuture.runAsync( + () -> { dlsFlsValve.handleSearchContext(context, threadPool, namedXContentRegistry.get()); }, + threadPool.generic() + ).orTimeout(5, TimeUnit.SECONDS).join(); } @Override @@ -1121,6 +1165,22 @@ public Collection createComponents( namedXContentRegistry.get() ); + final var resourceSharingIndex = ResourceSharingConstants.OPENSEARCH_RESOURCE_SHARING_INDEX; + ResourceSharingIndexHandler rsIndexHandler = new ResourceSharingIndexHandler( + resourceSharingIndex, + localClient, + threadPool, + auditLog + ); + resourceAccessHandler = new ResourceAccessHandler(threadPool, rsIndexHandler, adminDns); + resourceAccessHandler.initializeRecipientTypes(); + // Resource Sharing index is enabled by default + boolean isResourceSharingEnabled = settings.getAsBoolean( + ConfigConstants.OPENSEARCH_RESOURCE_SHARING_ENABLED, + ConfigConstants.OPENSEARCH_RESOURCE_SHARING_ENABLED_DEFAULT + ); + rmr = ResourceSharingIndexManagementRepository.create(rsIndexHandler, isResourceSharingEnabled); + dlsFlsBaseContext = new DlsFlsBaseContext(evaluator, threadPool.getThreadContext(), adminDns); if (SSLConfig.isSslOnlyMode()) { @@ -1133,7 +1193,8 @@ public Collection createComponents( resolver, xContentRegistry, threadPool, - dlsFlsBaseContext + dlsFlsBaseContext, + resourceAccessHandler ); cr.subscribeOnChange(configMap -> { ((DlsFlsValveImpl) dlsFlsValve).updateConfiguration(cr.getConfiguration(CType.ROLES)); }); } @@ -1192,7 +1253,7 @@ public Collection createComponents( // NOTE: We need to create DefaultInterClusterRequestEvaluator before creating ConfigurationRepository since the latter requires // security index to be accessible which means - // communciation with other nodes is already up. However for the communication to be up, there needs to be trusted nodes_dn. Hence + // communication with other nodes is already up. However for the communication to be up, there needs to be trusted nodes_dn. Hence // the base values from opensearch.yml // is used to first establish trust between same cluster nodes and there after dynamic config is loaded if enabled. if (DEFAULT_INTERCLUSTER_REQUEST_EVALUATOR_CLASS.equals(className)) { @@ -1210,6 +1271,7 @@ public Collection createComponents( components.add(dcf); components.add(userService); components.add(passwordHasher); + components.add(resourceAccessHandler); components.add(sslSettingsManager); if (isSslCertReloadEnabled(settings) && sslCertificatesHotReloadEnabled(settings)) { @@ -1389,7 +1451,7 @@ public List> getSettings() { settings.add(Setting.simpleString(ConfigConstants.SECURITY_CONFIG_INDEX_NAME, Property.NodeScope, Property.Filtered)); settings.add(Setting.groupSetting(ConfigConstants.SECURITY_AUTHCZ_IMPERSONATION_DN + ".", Property.NodeScope)); // not filtered - // here + // here settings.add(Setting.simpleString(ConfigConstants.SECURITY_CERT_OID, Property.NodeScope, Property.Filtered)); @@ -1405,8 +1467,8 @@ public List> getSettings() { );// not filtered here settings.add(Setting.boolSetting(ConfigConstants.SECURITY_NODES_DN_DYNAMIC_CONFIG_ENABLED, false, Property.NodeScope));// not - // filtered - // here + // filtered + // here settings.add( Setting.boolSetting( @@ -1450,8 +1512,8 @@ public List> getSettings() { Setting.boolSetting(ConfigConstants.SECURITY_DFM_EMPTY_OVERRIDES_ALL, false, Property.NodeScope, Property.Filtered) ); settings.add(Setting.groupSetting(ConfigConstants.SECURITY_AUTHCZ_REST_IMPERSONATION_USERS + ".", Property.NodeScope)); // not - // filtered - // here + // filtered + // here settings.add(Setting.simpleString(ConfigConstants.SECURITY_ROLES_MAPPING_RESOLUTION, Property.NodeScope, Property.Filtered)); settings.add( @@ -2064,6 +2126,16 @@ public List> getSettings() { // Privileges evaluation settings.add(ActionPrivileges.PRECOMPUTED_PRIVILEGES_MAX_HEAP_SIZE); + + // Resource Sharing + settings.add( + Setting.boolSetting( + ConfigConstants.OPENSEARCH_RESOURCE_SHARING_ENABLED, + ConfigConstants.OPENSEARCH_RESOURCE_SHARING_ENABLED_DEFAULT, + Property.NodeScope, + Property.Filtered + ) + ); } return settings; @@ -2087,6 +2159,18 @@ public void onNodeStarted(DiscoveryNode localNode) { if (!SSLConfig.isSslOnlyMode() && !client && !disabled && !useClusterStateToInitSecurityConfig(settings)) { cr.initOnNodeStart(); } + + // rmr will be null when sec plugin is disabled or is in SSLOnly mode, hence rmr will not be instantiated + if (settings != null + && settings.getAsBoolean( + ConfigConstants.OPENSEARCH_RESOURCE_SHARING_ENABLED, + ConfigConstants.OPENSEARCH_RESOURCE_SHARING_ENABLED_DEFAULT + ) + && rmr != null) { + // create resource sharing index if absent + rmr.createResourceSharingIndexIfAbsent(); + } + final Set securityModules = ReflectionHelper.getModulesLoaded(); log.info("{} OpenSearch Security modules loaded so far: {}", securityModules.size(), securityModules); } @@ -2131,8 +2215,12 @@ public Collection getSystemIndexDescriptors(Settings sett ConfigConstants.SECURITY_CONFIG_INDEX_NAME, ConfigConstants.OPENDISTRO_SECURITY_DEFAULT_CONFIG_INDEX ); - final SystemIndexDescriptor systemIndexDescriptor = new SystemIndexDescriptor(indexPattern, "Security index"); - return Collections.singletonList(systemIndexDescriptor); + final SystemIndexDescriptor securityIndexDescriptor = new SystemIndexDescriptor(indexPattern, "Security index"); + final SystemIndexDescriptor resourceSharingIndexDescriptor = new SystemIndexDescriptor( + ResourceSharingConstants.OPENSEARCH_RESOURCE_SHARING_INDEX, + "Resource Sharing index" + ); + return List.of(securityIndexDescriptor, resourceSharingIndexDescriptor); } @Override @@ -2191,6 +2279,46 @@ private void tryAddSecurityProvider() { }); } + public static Map getResourceProviders() { + return ImmutableMap.copyOf(RESOURCE_PROVIDERS); + } + + public static Set getResourceIndices() { + return ImmutableSet.copyOf(RESOURCE_INDICES); + } + + // TODO following should be removed once core test framework allows loading extended classes + public static Map getResourceProvidersMutable() { + return RESOURCE_PROVIDERS; + } + + public static Set getResourceIndicesMutable() { + return RESOURCE_INDICES; + } + + // CS-SUPPRESS-SINGLE: RegexpSingleline get Extensions Settings + @Override + public void loadExtensions(ExtensiblePlugin.ExtensionLoader loader) { + + if (settings.getAsBoolean( + ConfigConstants.OPENSEARCH_RESOURCE_SHARING_ENABLED, + ConfigConstants.OPENSEARCH_RESOURCE_SHARING_ENABLED_DEFAULT + )) { + for (ResourceSharingExtension extension : loader.loadExtensions(ResourceSharingExtension.class)) { + String resourceType = extension.getResourceType(); + String resourceIndexName = extension.getResourceIndex(); + ResourceParser resourceParser = extension.getResourceParser(); + + RESOURCE_INDICES.add(resourceIndexName); + + ResourceProvider resourceProvider = new ResourceProvider(resourceType, resourceIndexName, resourceParser); + RESOURCE_PROVIDERS.put(resourceIndexName, resourceProvider); + log.info("Loaded resource sharing extension: {}, index: {}", resourceType, resourceIndexName); + } + } + } + // CS-ENFORCE-SINGLE + public static class GuiceHolder implements LifecycleComponent { private static RepositoriesService repositoriesService; diff --git a/src/main/java/org/opensearch/security/auth/UserSubjectImpl.java b/src/main/java/org/opensearch/security/auth/UserSubjectImpl.java index 63adc559e3..a28ed8dd63 100644 --- a/src/main/java/org/opensearch/security/auth/UserSubjectImpl.java +++ b/src/main/java/org/opensearch/security/auth/UserSubjectImpl.java @@ -48,4 +48,8 @@ public T runAs(Callable callable) throws Exception { return callable.call(); } } + + public User getUser() { + return user; + } } diff --git a/src/main/java/org/opensearch/security/configuration/DlsFlsValveImpl.java b/src/main/java/org/opensearch/security/configuration/DlsFlsValveImpl.java index 498b908e5d..18e02fab30 100644 --- a/src/main/java/org/opensearch/security/configuration/DlsFlsValveImpl.java +++ b/src/main/java/org/opensearch/security/configuration/DlsFlsValveImpl.java @@ -17,6 +17,7 @@ import java.util.Comparator; import java.util.List; import java.util.Objects; +import java.util.concurrent.CountDownLatch; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; import java.util.stream.StreamSupport; @@ -77,6 +78,7 @@ import org.opensearch.security.privileges.dlsfls.FieldMasking; import org.opensearch.security.privileges.dlsfls.IndexToRuleMap; import org.opensearch.security.resolver.IndexResolverReplacer; +import org.opensearch.security.resources.ResourceAccessHandler; import org.opensearch.security.securityconf.DynamicConfigFactory; import org.opensearch.security.securityconf.impl.SecurityDynamicConfiguration; import org.opensearch.security.securityconf.impl.v7.RoleV7; @@ -98,6 +100,8 @@ public class DlsFlsValveImpl implements DlsFlsRequestValve { private final AtomicReference dlsFlsProcessedConfig = new AtomicReference<>(); private final FieldMasking.Config fieldMaskingConfig; private final Settings settings; + private final ResourceAccessHandler resourceAccessHandler; + private final boolean isResourceSharingEnabled; public DlsFlsValveImpl( Settings settings, @@ -106,7 +110,8 @@ public DlsFlsValveImpl( IndexNameExpressionResolver resolver, NamedXContentRegistry namedXContentRegistry, ThreadPool threadPool, - DlsFlsBaseContext dlsFlsBaseContext + DlsFlsBaseContext dlsFlsBaseContext, + ResourceAccessHandler resourceAccessHandler ) { super(); this.nodeClient = nodeClient; @@ -118,6 +123,11 @@ public DlsFlsValveImpl( this.fieldMaskingConfig = FieldMasking.Config.fromSettings(settings); this.dlsFlsBaseContext = dlsFlsBaseContext; this.settings = settings; + this.resourceAccessHandler = resourceAccessHandler; + this.isResourceSharingEnabled = settings.getAsBoolean( + ConfigConstants.OPENSEARCH_RESOURCE_SHARING_ENABLED, + ConfigConstants.OPENSEARCH_RESOURCE_SHARING_ENABLED_DEFAULT + ); clusterService.addListener(event -> { DlsFlsProcessedConfig config = dlsFlsProcessedConfig.get(); @@ -349,6 +359,7 @@ public void handleSearchContext(SearchContext searchContext, ThreadPool threadPo try { String index = searchContext.indexShard().indexSettings().getIndex().getName(); + assert !Strings.isNullOrEmpty(index); if (log.isTraceEnabled()) { log.trace("handleSearchContext(); index: {}", index); } @@ -374,57 +385,93 @@ public void handleSearchContext(SearchContext searchContext, ThreadPool threadPo } PrivilegesEvaluationContext privilegesEvaluationContext = this.dlsFlsBaseContext.getPrivilegesEvaluationContext(); + if (privilegesEvaluationContext == null) { return; } DlsFlsProcessedConfig config = this.dlsFlsProcessedConfig.get(); - DlsRestriction dlsRestriction = config.getDocumentPrivileges().getRestriction(privilegesEvaluationContext, index); + if (this.isResourceSharingEnabled && OpenSearchSecurityPlugin.getResourceIndices().contains(index)) { + CountDownLatch latch = new CountDownLatch(1); + + threadPool.generic() + .submit( + () -> this.resourceAccessHandler.getAccessibleResourceIdsForCurrentUser(index, ActionListener.wrap(resourceIds -> { + try { + log.info("Creating a DLS restriction for resource IDs: {}", resourceIds); + // Create a DLS restriction and apply it + DlsRestriction dlsRestriction = this.resourceAccessHandler.createResourceDLSRestriction( + resourceIds, + namedXContentRegistry + ); + applyDlsRestrictionToSearchContext(dlsRestriction, index, searchContext, mode); + } catch (Exception e) { + log.error("Error while creating or applying DLS restriction for index '{}': {}", index, e.getMessage()); + applyDlsRestrictionToSearchContext(DlsRestriction.FULL, index, searchContext, mode); + } finally { + latch.countDown(); // Release the latch + } + }, exception -> { + log.error("Failed to fetch resource IDs for index '{}': {}", index, exception.getMessage()); + // Apply a default restriction on failure + applyDlsRestrictionToSearchContext(DlsRestriction.FULL, index, searchContext, mode); + latch.countDown(); + })) + ); - if (log.isTraceEnabled()) { - log.trace("handleSearchContext(); index: {}; dlsRestriction: {}", index, dlsRestriction); + } else { + // Synchronous path for non-resource-sharing-enabled cases + DlsRestriction dlsRestriction = config.getDocumentPrivileges().getRestriction(privilegesEvaluationContext, index); + applyDlsRestrictionToSearchContext(dlsRestriction, index, searchContext, mode); } - DocumentAllowList documentAllowList = DocumentAllowList.get(threadContext); + } catch (Exception e) { + log.error("Error in handleSearchContext()", e); + throw new RuntimeException("Error evaluating dls for a search query: " + e, e); + } + } - if (documentAllowList.isEntryForIndexPresent(index)) { - // The documentAllowList is needed for two cases: - // - DLS rules which use "term lookup queries" and thus need to access indices for which no privileges are present - // - Dashboards multi tenancy which can redirect index accesses to indices for which no normal index privileges are present + private void applyDlsRestrictionToSearchContext(DlsRestriction dlsRestriction, String index, SearchContext searchContext, Mode mode) { + if (log.isTraceEnabled()) { + log.trace("handleSearchContext(); index: {}; dlsRestriction: {}", index, dlsRestriction); + } - if (!dlsRestriction.isUnrestricted() && documentAllowList.isAllowed(index, "*")) { - dlsRestriction = DlsRestriction.NONE; - log.debug("Lifting DLS for {} due to present document allowlist", index); - } + DocumentAllowList documentAllowList = DocumentAllowList.get(threadContext); + + if (documentAllowList.isEntryForIndexPresent(index)) { + // The documentAllowList is needed for two cases: + // - DLS rules which use "term lookup queries" and thus need to access indices for which no privileges are present + // - Dashboards multi tenancy which can redirect index accesses to indices for which no normal index privileges are present + + if (!dlsRestriction.isUnrestricted() && documentAllowList.isAllowed(index, "*")) { + dlsRestriction = DlsRestriction.NONE; + log.debug("Lifting DLS for {} due to present document allowlist", index); } + } - if (!dlsRestriction.isUnrestricted()) { - if (mode == Mode.ADAPTIVE && dlsRestriction.containsTermLookupQuery()) { - // Special case for scroll operations: - // Normally, the check dlsFlsBaseContext.isDlsDoneOnFilterLevel() already aborts early if DLS filter level mode - // has been activated. However, this is not the case for scroll operations, as these lose the thread context value - // on which dlsFlsBaseContext.isDlsDoneOnFilterLevel() is based on. Thus, we need to check here again the deeper - // conditions. - log.trace("DlsRestriction: contains TLQ."); - return; - } + if (!dlsRestriction.isUnrestricted()) { + if (mode == Mode.ADAPTIVE && dlsRestriction.containsTermLookupQuery()) { + // Special case for scroll operations: + // Normally, the check dlsFlsBaseContext.isDlsDoneOnFilterLevel() already aborts early if DLS filter level mode + // has been activated. However, this is not the case for scroll operations, as these lose the thread context value + // on which dlsFlsBaseContext.isDlsDoneOnFilterLevel() is based on. Thus, we need to check here again the deeper + // conditions. + log.trace("DlsRestriction: contains TLQ."); + return; + } - assert searchContext.parsedQuery() != null; + assert searchContext.parsedQuery() != null; - BooleanQuery.Builder queryBuilder = dlsRestriction.toBooleanQueryBuilder( - searchContext.getQueryShardContext(), - (q) -> new ConstantScoreQuery(q) - ); + BooleanQuery.Builder queryBuilder = dlsRestriction.toBooleanQueryBuilder( + searchContext.getQueryShardContext(), + (q) -> new ConstantScoreQuery(q) + ); - queryBuilder.add(searchContext.parsedQuery().query(), Occur.MUST); + queryBuilder.add(searchContext.parsedQuery().query(), Occur.MUST); - searchContext.parsedQuery(new ParsedQuery(queryBuilder.build())); - searchContext.preProcess(true); - } - } catch (Exception e) { - log.error("Error in handleSearchContext()", e); - throw new RuntimeException("Error evaluating dls for a search query: " + e, e); + searchContext.parsedQuery(new ParsedQuery(queryBuilder.build())); + searchContext.preProcess(true); } } @@ -493,10 +540,7 @@ private static InternalAggregation aggregateBuckets(InternalAggregation aggregat return aggregation; } - private static List mergeBuckets( - List buckets, - Comparator comparator - ) { + private static List mergeBuckets(List buckets, Comparator comparator) { if (log.isDebugEnabled()) { log.debug("Merging buckets: {}", buckets.stream().map(b -> b.getKeyAsString()).collect(ImmutableList.toImmutableList())); } @@ -540,12 +584,12 @@ private Mode getDlsModeHeader() { private static class BucketMerger implements Consumer { private Comparator comparator; - private StringTerms.Bucket bucket = null; + private Bucket bucket = null; private int mergeCount; private long mergedDocCount; private long mergedDocCountError; private boolean showDocCountError = true; - private final ImmutableList.Builder builder; + private final ImmutableList.Builder builder; BucketMerger(Comparator comparator, int size) { this.comparator = Objects.requireNonNull(comparator); @@ -557,7 +601,7 @@ private void finalizeBucket() { builder.add(this.bucket); } else { builder.add( - new StringTerms.Bucket( + new Bucket( StringTermsGetter.getTerm(bucket), mergedDocCount, (InternalAggregations) bucket.getAggregations(), @@ -569,7 +613,7 @@ private void finalizeBucket() { } } - private void merge(StringTerms.Bucket bucket) { + private void merge(Bucket bucket) { if (this.bucket != null && (bucket == null || comparator.compare(this.bucket, bucket) != 0)) { finalizeBucket(); this.bucket = null; @@ -580,13 +624,13 @@ private void merge(StringTerms.Bucket bucket) { } } - public List getBuckets() { + public List getBuckets() { merge(null); return builder.build(); } @Override - public void accept(StringTerms.Bucket bucket) { + public void accept(Bucket bucket) { merge(bucket); mergeCount++; mergedDocCount += bucket.getDocCount(); @@ -603,7 +647,7 @@ public void accept(StringTerms.Bucket bucket) { private static class StringTermsGetter { private static final Field REDUCE_ORDER = getField(InternalTerms.class, "reduceOrder"); - private static final Field TERM_BYTES = getField(StringTerms.Bucket.class, "termBytes"); + private static final Field TERM_BYTES = getField(Bucket.class, "termBytes"); private static final Field FORMAT = getField(InternalTerms.Bucket.class, "format"); private StringTermsGetter() {} @@ -647,11 +691,11 @@ public static BucketOrder getReduceOrder(StringTerms stringTerms) { return getFieldValue(REDUCE_ORDER, stringTerms); } - public static BytesRef getTerm(StringTerms.Bucket bucket) { + public static BytesRef getTerm(Bucket bucket) { return getFieldValue(TERM_BYTES, bucket); } - public static DocValueFormat getDocValueFormat(StringTerms.Bucket bucket) { + public static DocValueFormat getDocValueFormat(Bucket bucket) { return getFieldValue(FORMAT, bucket); } } diff --git a/src/main/java/org/opensearch/security/configuration/SecurityFlsDlsIndexSearcherWrapper.java b/src/main/java/org/opensearch/security/configuration/SecurityFlsDlsIndexSearcherWrapper.java index 4f7a412097..9a05f4f50b 100644 --- a/src/main/java/org/opensearch/security/configuration/SecurityFlsDlsIndexSearcherWrapper.java +++ b/src/main/java/org/opensearch/security/configuration/SecurityFlsDlsIndexSearcherWrapper.java @@ -16,6 +16,8 @@ import java.util.Collections; import java.util.HashSet; import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicReference; import java.util.function.LongSupplier; import java.util.function.Supplier; @@ -29,11 +31,14 @@ import org.opensearch.cluster.metadata.IndexMetadata; import org.opensearch.cluster.service.ClusterService; import org.opensearch.common.settings.Settings; +import org.opensearch.core.action.ActionListener; +import org.opensearch.core.common.Strings; import org.opensearch.core.index.shard.ShardId; import org.opensearch.index.IndexService; import org.opensearch.index.mapper.SeqNoFieldMapper; import org.opensearch.index.query.QueryShardContext; import org.opensearch.index.shard.ShardUtils; +import org.opensearch.security.OpenSearchSecurityPlugin; import org.opensearch.security.auditlog.AuditLog; import org.opensearch.security.compliance.ComplianceIndexingOperationListener; import org.opensearch.security.privileges.DocumentAllowList; @@ -45,6 +50,8 @@ import org.opensearch.security.privileges.dlsfls.DlsRestriction; import org.opensearch.security.privileges.dlsfls.FieldMasking; import org.opensearch.security.privileges.dlsfls.FieldPrivileges; +import org.opensearch.security.resources.ResourceAccessHandler; +import org.opensearch.security.spi.resources.ResourceSharingException; import org.opensearch.security.support.ConfigConstants; public class SecurityFlsDlsIndexSearcherWrapper extends SystemIndexSearcherWrapper { @@ -61,6 +68,8 @@ public class SecurityFlsDlsIndexSearcherWrapper extends SystemIndexSearcherWrapp private final LongSupplier nowInMillis; private final Supplier dlsFlsProcessedConfigSupplier; private final DlsFlsBaseContext dlsFlsBaseContext; + private final ResourceAccessHandler resourceAccessHandler; + private final boolean isResourceSharingEnabled; public SecurityFlsDlsIndexSearcherWrapper( final IndexService indexService, @@ -71,7 +80,8 @@ public SecurityFlsDlsIndexSearcherWrapper( final ComplianceIndexingOperationListener ciol, final PrivilegesEvaluator evaluator, final Supplier dlsFlsProcessedConfigSupplier, - final DlsFlsBaseContext dlsFlsBaseContext + final DlsFlsBaseContext dlsFlsBaseContext, + final ResourceAccessHandler resourceAccessHandler ) { super(indexService, settings, adminDNs, evaluator); Set metadataFieldsCopy; @@ -103,36 +113,125 @@ public SecurityFlsDlsIndexSearcherWrapper( log.debug("FLS/DLS {} enabled for index {}", this, indexService.index().getName()); this.dlsFlsProcessedConfigSupplier = dlsFlsProcessedConfigSupplier; this.dlsFlsBaseContext = dlsFlsBaseContext; + this.resourceAccessHandler = resourceAccessHandler; + this.isResourceSharingEnabled = settings.getAsBoolean( + ConfigConstants.OPENSEARCH_RESOURCE_SHARING_ENABLED, + ConfigConstants.OPENSEARCH_RESOURCE_SHARING_ENABLED_DEFAULT + ); } @SuppressWarnings("unchecked") @Override protected DirectoryReader dlsFlsWrap(final DirectoryReader reader, boolean isAdmin) throws IOException { - final ShardId shardId = ShardUtils.extractShardId(reader); PrivilegesEvaluationContext privilegesEvaluationContext = this.dlsFlsBaseContext.getPrivilegesEvaluationContext(); + final String indexName = (shardId != null) ? shardId.getIndexName() : null; if (log.isTraceEnabled()) { - log.trace("dlsFlsWrap(); index: {}; privilegeEvaluationContext: {}", index.getName(), privilegesEvaluationContext); + log.trace("dlsFlsWrap(); index: {}; isAdmin: {}", indexName, isAdmin); } + // 1. If user is admin, or we have no shard/index info, just wrap with default logic (no doc-level restriction). if (isAdmin || privilegesEvaluationContext == null) { - return new DlsFlsFilterLeafReader.DlsFlsDirectoryReader( - reader, - FieldPrivileges.FlsRule.ALLOW_ALL, - null, - indexService, - threadContext, - clusterService, - auditlog, - FieldMasking.FieldMaskingRule.ALLOW_ALL, - shardId, - metaFields - ); + return wrapWithDefaultDlsFls(reader, shardId); } + assert !Strings.isNullOrEmpty(indexName); + // 2. If resource sharing is disabled or this is not a resource index, fallback to standard DLS/FLS logic. + if (!this.isResourceSharingEnabled || !OpenSearchSecurityPlugin.getResourceIndices().contains(indexName)) { + return wrapStandardDlsFls(privilegesEvaluationContext, reader, shardId, indexName, isAdmin); + } + + // TODO see if steps 3,4,5 can be changed to be completely asynchronous + // 3.Since we need DirectoryReader *now*, we'll block the thread using a CountDownLatch until the async call completes. + final AtomicReference> resourceIdsRef = new AtomicReference<>(Collections.emptySet()); + final AtomicReference exceptionRef = new AtomicReference<>(null); + final CountDownLatch latch = new CountDownLatch(1); + + // 4. Perform the async call to fetch resource IDs + this.resourceAccessHandler.getAccessibleResourceIdsForCurrentUser(indexName, ActionListener.wrap(resourceIds -> { + log.debug("Fetched resource IDs for index '{}': {}", indexName, resourceIds); + resourceIdsRef.set(resourceIds); + latch.countDown(); + }, ex -> { + log.error("Failed to fetch resource IDs for index '{}': {}", indexName, ex.getMessage(), ex); + exceptionRef.set(ex); + latch.countDown(); + })); + + // 5. Block until the async call completes try { + latch.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while waiting for resource IDs", e); + } + + // 6. Throw any errors + if (exceptionRef.get() != null) { + throw new ResourceSharingException("Failed to get resource IDs for index: " + indexName, exceptionRef.get()); + } + + // 7. If the user has no accessible resources, produce a reader that yields zero documents + final Set resourceIds = resourceIdsRef.get(); + if (resourceIds.isEmpty()) { + log.debug("User has no accessible resources in index '{}'; returning EmptyDirectoryReader.", indexName); + return new EmptyFilterLeafReader.EmptyDirectoryReader(reader); + } + + // 8. Build the resource-based query to restrict docs + final QueryShardContext queryShardContext = this.indexService.newQueryShardContext(shardId.getId(), null, nowInMillis, null); + final Query resourceQuery = this.resourceAccessHandler.createResourceDLSQuery(resourceIds, queryShardContext); + + log.debug("Applying resource-based DLS query for index '{}'", indexName); + // 9. Wrap with a DLS/FLS DirectoryReader that includes doc-level restriction (resourceQuery), + // with FLS (ALLOW_ALL) since we don't need field-level restrictions here. + return new DlsFlsFilterLeafReader.DlsFlsDirectoryReader( + reader, + FieldPrivileges.FlsRule.ALLOW_ALL, + resourceQuery, + indexService, + threadContext, + clusterService, + auditlog, + FieldMasking.FieldMaskingRule.ALLOW_ALL, + shardId, + metaFields + ); + } + + /** + * Wrap the reader with an "ALLOW_ALL" doc-level filter and field privileges, + * i.e., no doc-level or field-level restrictions. + */ + private DirectoryReader wrapWithDefaultDlsFls(DirectoryReader reader, ShardId shardId) throws IOException { + return new DlsFlsFilterLeafReader.DlsFlsDirectoryReader( + reader, + FieldPrivileges.FlsRule.ALLOW_ALL, + null, // no doc-level restriction + indexService, + threadContext, + clusterService, + auditlog, + FieldMasking.FieldMaskingRule.ALLOW_ALL, + shardId, + metaFields + ); + } + + /** + * Fallback to your existing logic to handle DLS/FLS if the index is not a resource index, + * or if other conditions apply (like dlsFlsBaseContext usage, etc.). + */ + private DirectoryReader wrapStandardDlsFls( + PrivilegesEvaluationContext privilegesEvaluationContext, + DirectoryReader reader, + ShardId shardId, + String indexName, + boolean isAdmin + ) throws IOException { + try { DlsFlsProcessedConfig config = this.dlsFlsProcessedConfigSupplier.get(); DlsRestriction dlsRestriction; @@ -203,4 +302,5 @@ protected DirectoryReader dlsFlsWrap(final DirectoryReader reader, boolean isAdm throw new OpenSearchException("Error while evaluating DLS/FLS", e); } } + } 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 2e900169db..ba8a5cda5b 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 @@ -64,6 +64,8 @@ public class Utils { public final static String LEGACY_PLUGIN_API_ROUTE_PREFIX = LEGACY_PLUGIN_ROUTE_PREFIX + "/api"; + public final static String PLUGIN_RESOURCE_ROUTE_PREFIX = PLUGIN_ROUTE_PREFIX + "/resources"; + private static final ObjectMapper internalMapper = new ObjectMapper(); public static Map convertJsonToxToStructuredMap(ToXContent jsonContent) { diff --git a/src/main/java/org/opensearch/security/privileges/dlsfls/DlsRestriction.java b/src/main/java/org/opensearch/security/privileges/dlsfls/DlsRestriction.java index 242e0000a4..01fccb78e6 100644 --- a/src/main/java/org/opensearch/security/privileges/dlsfls/DlsRestriction.java +++ b/src/main/java/org/opensearch/security/privileges/dlsfls/DlsRestriction.java @@ -53,7 +53,7 @@ public class DlsRestriction extends AbstractRuleBasedPrivileges.Rule { private final ImmutableList queries; - DlsRestriction(List queries) { + public DlsRestriction(List queries) { this.queries = ImmutableList.copyOf(queries); } diff --git a/src/main/java/org/opensearch/security/privileges/dlsfls/DocumentPrivileges.java b/src/main/java/org/opensearch/security/privileges/dlsfls/DocumentPrivileges.java index 2afcdd4b82..40ebfd7282 100644 --- a/src/main/java/org/opensearch/security/privileges/dlsfls/DocumentPrivileges.java +++ b/src/main/java/org/opensearch/security/privileges/dlsfls/DocumentPrivileges.java @@ -92,7 +92,7 @@ protected DlsRestriction compile(PrivilegesEvaluationContext context, Collection /** * The basic rules of DLS are queries. This class encapsulates single queries. */ - static abstract class DlsQuery { + public static abstract class DlsQuery { final String queryString; DlsQuery(String queryString) { @@ -118,7 +118,7 @@ public boolean equals(Object obj) { return Objects.equals(this.queryString, other.queryString); } - protected QueryBuilder parseQuery(String queryString, NamedXContentRegistry xContentRegistry) + public static QueryBuilder parseQuery(String queryString, NamedXContentRegistry xContentRegistry) throws PrivilegesConfigurationValidationException { try { XContentParser parser = JsonXContent.jsonXContent.createParser( @@ -193,7 +193,7 @@ public static class RenderedDlsQuery { private final QueryBuilder queryBuilder; private final String renderedSource; - RenderedDlsQuery(QueryBuilder queryBuilder, String renderedSource) { + public RenderedDlsQuery(QueryBuilder queryBuilder, String renderedSource) { this.queryBuilder = queryBuilder; this.renderedSource = renderedSource; } diff --git a/src/main/java/org/opensearch/security/resources/CreatedBy.java b/src/main/java/org/opensearch/security/resources/CreatedBy.java new file mode 100644 index 0000000000..af27001663 --- /dev/null +++ b/src/main/java/org/opensearch/security/resources/CreatedBy.java @@ -0,0 +1,89 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.security.resources; + +import java.io.IOException; + +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.ToXContentFragment; +import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.core.xcontent.XContentParser; + +/** + * This class is used to store information about the creator of a resource. + * Concrete implementation will be provided by security plugin + * + * @opensearch.experimental + */ +public class CreatedBy implements ToXContentFragment, NamedWriteable { + + private final Enum creatorType; + private final String creator; + + public CreatedBy(Enum creatorType, String creator) { + this.creatorType = creatorType; + this.creator = creator; + } + + public CreatedBy(StreamInput in) throws IOException { + this.creatorType = in.readEnum(Creator.class); + this.creator = in.readString(); + } + + public String getCreator() { + return creator; + } + + public Enum getCreatorType() { + return creatorType; + } + + @Override + public String toString() { + return "CreatedBy {" + this.creatorType + "='" + this.creator + '\'' + '}'; + } + + @Override + public String getWriteableName() { + return "created_by"; + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + out.writeEnum(Creator.valueOf(creatorType.name())); + out.writeString(creator); + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + return builder.startObject().field(String.valueOf(creatorType), creator).endObject(); + } + + public static CreatedBy fromXContent(XContentParser parser) throws IOException { + String creator = null; + Enum creatorType = null; + XContentParser.Token token; + + while ((token = parser.nextToken()) != XContentParser.Token.END_OBJECT) { + if (token == XContentParser.Token.FIELD_NAME) { + creatorType = Creator.fromName(parser.currentName()); + } else if (token == XContentParser.Token.VALUE_STRING) { + creator = parser.text(); + } + } + + if (creator == null) { + throw new IllegalArgumentException(creatorType + " is required"); + } + + return new CreatedBy(creatorType, creator); + } +} diff --git a/src/main/java/org/opensearch/security/resources/Creator.java b/src/main/java/org/opensearch/security/resources/Creator.java new file mode 100644 index 0000000000..ee2e9de7ab --- /dev/null +++ b/src/main/java/org/opensearch/security/resources/Creator.java @@ -0,0 +1,32 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.security.resources; + +public enum Creator { + USER("user"); + + private final String name; + + Creator(String name) { + this.name = name; + } + + public String getName() { + return name; + } + + public static Creator fromName(String name) { + for (Creator creator : values()) { + if (creator.name.equalsIgnoreCase(name)) { // Case-insensitive comparison + return creator; + } + } + throw new IllegalArgumentException("No enum constant for name: " + name); + } +} diff --git a/src/main/java/org/opensearch/security/resources/Recipient.java b/src/main/java/org/opensearch/security/resources/Recipient.java new file mode 100644 index 0000000000..354f75fc0f --- /dev/null +++ b/src/main/java/org/opensearch/security/resources/Recipient.java @@ -0,0 +1,25 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.security.resources; + +public enum Recipient { + USERS("users"), + ROLES("roles"), + BACKEND_ROLES("backend_roles"); + + private final String name; + + Recipient(String name) { + this.name = name; + } + + public String getName() { + return name; + } +} diff --git a/src/main/java/org/opensearch/security/resources/RecipientType.java b/src/main/java/org/opensearch/security/resources/RecipientType.java new file mode 100644 index 0000000000..bfe2bfec12 --- /dev/null +++ b/src/main/java/org/opensearch/security/resources/RecipientType.java @@ -0,0 +1,24 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.security.resources; + +/** + * This class determines a type of recipient a resource can be shared with. + * An example type would be a user or a role. + * This class is used to determine the type of recipient a resource can be shared with. + * + * @opensearch.experimental + */ +public record RecipientType(String type) { + + @Override + public String toString() { + return type; + } +} diff --git a/src/main/java/org/opensearch/security/resources/RecipientTypeRegistry.java b/src/main/java/org/opensearch/security/resources/RecipientTypeRegistry.java new file mode 100644 index 0000000000..95da5debef --- /dev/null +++ b/src/main/java/org/opensearch/security/resources/RecipientTypeRegistry.java @@ -0,0 +1,33 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.security.resources; + +import java.util.HashMap; +import java.util.Map; + +/** + * This class determines a collection of recipient types a resource can be shared with. + * + * @opensearch.experimental + */ +public class RecipientTypeRegistry { + private static final Map REGISTRY = new HashMap<>(); + + public static void registerRecipientType(String key, RecipientType recipientType) { + REGISTRY.put(key, recipientType); + } + + public static RecipientType fromValue(String value) { + RecipientType type = REGISTRY.get(value); + if (type == null) { + throw new IllegalArgumentException("Unknown RecipientType: " + value + ". Must be 1 of these: " + REGISTRY.values()); + } + return type; + } +} diff --git a/src/main/java/org/opensearch/security/resources/ResourceAccessHandler.java b/src/main/java/org/opensearch/security/resources/ResourceAccessHandler.java new file mode 100644 index 0000000000..01deb71d66 --- /dev/null +++ b/src/main/java/org/opensearch/security/resources/ResourceAccessHandler.java @@ -0,0 +1,635 @@ +/* + * 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.security.resources; + +import java.io.IOException; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.Future; + +import com.fasterxml.jackson.core.JsonProcessingException; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.apache.lucene.search.Query; + +import org.opensearch.action.StepListener; +import org.opensearch.common.util.concurrent.ThreadContext; +import org.opensearch.core.action.ActionListener; +import org.opensearch.core.xcontent.NamedXContentRegistry; +import org.opensearch.index.query.BoolQueryBuilder; +import org.opensearch.index.query.ConstantScoreQueryBuilder; +import org.opensearch.index.query.QueryBuilder; +import org.opensearch.index.query.QueryBuilders; +import org.opensearch.index.query.QueryShardContext; +import org.opensearch.security.DefaultObjectMapper; +import org.opensearch.security.OpenSearchSecurityPlugin; +import org.opensearch.security.auth.UserSubjectImpl; +import org.opensearch.security.configuration.AdminDNs; +import org.opensearch.security.privileges.PrivilegesConfigurationValidationException; +import org.opensearch.security.privileges.dlsfls.DlsRestriction; +import org.opensearch.security.privileges.dlsfls.DocumentPrivileges; +import org.opensearch.security.spi.resources.Resource; +import org.opensearch.security.spi.resources.ResourceParser; +import org.opensearch.security.spi.resources.ResourceSharingException; +import org.opensearch.security.support.ConfigConstants; +import org.opensearch.security.user.User; +import org.opensearch.threadpool.ThreadPool; + +/** + * This class handles resource access permissions for users and roles. + * It provides methods to check if a user has permission to access a resource + * based on the resource sharing configuration. + */ +public class ResourceAccessHandler { + private static final Logger LOGGER = LogManager.getLogger(ResourceAccessHandler.class); + + private final ThreadContext threadContext; + private final ResourceSharingIndexHandler resourceSharingIndexHandler; + private final AdminDNs adminDNs; + + public ResourceAccessHandler( + final ThreadPool threadPool, + final ResourceSharingIndexHandler resourceSharingIndexHandler, + AdminDNs adminDns + ) { + this.threadContext = threadPool.getThreadContext(); + this.resourceSharingIndexHandler = resourceSharingIndexHandler; + this.adminDNs = adminDns; + } + + /** + * Initializes the recipient types for users, roles, and backend roles. + * These recipient types are used to identify the types of recipients for resource sharing. + */ + public void initializeRecipientTypes() { + RecipientTypeRegistry.registerRecipientType(Recipient.USERS.getName(), new RecipientType(Recipient.USERS.getName())); + RecipientTypeRegistry.registerRecipientType(Recipient.ROLES.getName(), new RecipientType(Recipient.ROLES.getName())); + RecipientTypeRegistry.registerRecipientType( + Recipient.BACKEND_ROLES.getName(), + new RecipientType(Recipient.BACKEND_ROLES.getName()) + ); + } + + /** + * Returns a set of accessible resource IDs for the current user within the specified resource index. + * @param resourceIndex The resource index to check for accessible resources. + * @param listener The listener to be notified with the set of accessible resource IDs. + * + */ + public Future getAccessibleResourceIdsForCurrentUser(String resourceIndex, ActionListener> listener) { + final UserSubjectImpl userSubject = (UserSubjectImpl) threadContext.getPersistent( + ConfigConstants.OPENDISTRO_SECURITY_AUTHENTICATED_USER + ); + final User user = (userSubject == null) ? null : userSubject.getUser(); + + // If no user is authenticated, return an empty set + if (user == null) { + LOGGER.info("Unable to fetch user details."); + listener.onResponse(Collections.emptySet()); + return null; + } + + LOGGER.info("Listing accessible resources within the resource index {} for user: {}", resourceIndex, user.getName()); + + // 2. If the user is admin, simply fetch all resources + if (adminDNs.isAdmin(user)) { + loadAllResources(resourceIndex, ActionListener.wrap(listener::onResponse, listener::onFailure)); + return null; + } + + // StepListener for the user’s "own" resources + StepListener> ownResourcesListener = new StepListener<>(); + + // StepListener for resources shared with the user’s name + StepListener> userNameResourcesListener = new StepListener<>(); + + // StepListener for resources shared with the user’s roles + StepListener> rolesResourcesListener = new StepListener<>(); + + // StepListener for resources shared with the user’s backend roles + StepListener> backendRolesResourcesListener = new StepListener<>(); + + // Load own resources for the user. + loadOwnResources(resourceIndex, user.getName(), ownResourcesListener); + + // Load resources shared with the user by its name. + ownResourcesListener.whenComplete( + ownResources -> loadSharedWithResources( + resourceIndex, + Set.of(user.getName()), + Recipient.USERS.getName(), + userNameResourcesListener + ), + listener::onFailure + ); + + // Load resources shared with the user’s roles. + userNameResourcesListener.whenComplete( + userNameResources -> loadSharedWithResources( + resourceIndex, + user.getSecurityRoles(), + Recipient.ROLES.getName(), + rolesResourcesListener + ), + listener::onFailure + ); + + // Load resources shared with the user’s backend roles. + rolesResourcesListener.whenComplete( + rolesResources -> loadSharedWithResources( + resourceIndex, + user.getRoles(), + Recipient.BACKEND_ROLES.getName(), + backendRolesResourcesListener + ), + listener::onFailure + ); + + // Combine all results and pass them back to the original listener. + backendRolesResourcesListener.whenComplete(backendRolesResources -> { + Set allResources = new HashSet<>(); + + // Retrieve results from each StepListener + allResources.addAll(ownResourcesListener.result()); + allResources.addAll(userNameResourcesListener.result()); + allResources.addAll(rolesResourcesListener.result()); + allResources.addAll(backendRolesResourcesListener.result()); + + LOGGER.debug("Found {} accessible resources for user {}", allResources.size(), user.getName()); + listener.onResponse(allResources); + }, listener::onFailure); + return null; + } + + /** + * Returns a set of accessible resources for the current user within the specified resource index. + * + * @param resourceIndex The resource index to check for accessible resources. + * @param listener The listener to be notified with the set of accessible resources. + */ + @SuppressWarnings("unchecked") + public void getAccessibleResourcesForCurrentUser(String resourceIndex, ActionListener> listener) { + try { + validateArguments(resourceIndex); + + ResourceParser parser = OpenSearchSecurityPlugin.getResourceProviders().get(resourceIndex).getResourceParser(); + + StepListener> resourceIdsListener = new StepListener<>(); + StepListener> resourcesListener = new StepListener<>(); + + // Fetch resource IDs + getAccessibleResourceIdsForCurrentUser(resourceIndex, resourceIdsListener); + + // Fetch docs + resourceIdsListener.whenComplete(resourceIds -> { + if (resourceIds.isEmpty()) { + // No accessible resources => immediately respond with empty set + listener.onResponse(Collections.emptySet()); + } else { + // Fetch the resource documents asynchronously + this.resourceSharingIndexHandler.getResourceDocumentsFromIds(resourceIds, resourceIndex, parser, resourcesListener); + } + }, listener::onFailure); + + // Send final response + resourcesListener.whenComplete( + listener::onResponse, + ex -> listener.onFailure(new ResourceSharingException("Failed to get accessible resources: " + ex.getMessage(), ex)) + ); + } catch (Exception e) { + listener.onFailure(new ResourceSharingException("Failed to process accessible resources request: " + e.getMessage(), e)); + } + } + + /** + * Checks whether current user has given permission (scope) to access given resource. + * + * @param resourceId The resource ID to check access for. + * @param resourceIndex The resource index containing the resource. + * @param scope The permission scope to check. + * @param listener The listener to be notified with the permission check result. + */ + public void hasPermission(String resourceId, String resourceIndex, String scope, ActionListener listener) { + validateArguments(resourceId, resourceIndex, scope); + + final UserSubjectImpl userSubject = (UserSubjectImpl) threadContext.getPersistent( + ConfigConstants.OPENDISTRO_SECURITY_AUTHENTICATED_USER + ); + final User user = (userSubject == null) ? null : userSubject.getUser(); + + if (user == null) { + LOGGER.warn("No authenticated user found in ThreadContext"); + listener.onResponse(false); + return; + } + + LOGGER.info("Checking if user '{}' has '{}' permission to resource '{}'", user.getName(), scope, resourceId); + + if (adminDNs.isAdmin(user)) { + LOGGER.info("User '{}' is admin, automatically granted '{}' permission on '{}'", user.getName(), scope, resourceId); + listener.onResponse(true); + return; + } + + Set userRoles = user.getSecurityRoles(); + Set userBackendRoles = user.getRoles(); + + this.resourceSharingIndexHandler.fetchDocumentById(resourceIndex, resourceId, ActionListener.wrap(document -> { + if (document == null) { + LOGGER.warn("Resource '{}' not found in index '{}'", resourceId, resourceIndex); + listener.onResponse(false); + return; + } + + if (isSharedWithEveryone(document) + || isOwnerOfResource(document, user.getName()) + || isSharedWithEntity(document, Recipient.USERS, Set.of(user.getName()), scope) + || isSharedWithEntity(document, Recipient.ROLES, userRoles, scope) + || isSharedWithEntity(document, Recipient.BACKEND_ROLES, userBackendRoles, scope)) { + + LOGGER.info("User '{}' has '{}' permission to resource '{}'", user.getName(), scope, resourceId); + listener.onResponse(true); + } else { + LOGGER.info("User '{}' does not have '{}' permission to resource '{}'", user.getName(), scope, resourceId); + listener.onResponse(false); + } + }, exception -> { + LOGGER.error( + "Failed to fetch resource sharing document for resource '{}' in index '{}': {}", + resourceId, + resourceIndex, + exception.getMessage() + ); + listener.onFailure(exception); + })); + } + + /** + * Shares a resource with the specified users, roles, and backend roles. + * @param resourceId The resource ID to share. + * @param resourceIndex The index where resource is store + * @param shareWith The users, roles, and backend roles as well as scope to share the resource with. + * @param listener The listener to be notified with the updated ResourceSharing document. + */ + public void shareWith(String resourceId, String resourceIndex, ShareWith shareWith, ActionListener listener) { + validateArguments(resourceId, resourceIndex, shareWith); + + final UserSubjectImpl userSubject = (UserSubjectImpl) threadContext.getPersistent( + ConfigConstants.OPENDISTRO_SECURITY_AUTHENTICATED_USER + ); + final User user = (userSubject == null) ? null : userSubject.getUser(); + + if (user == null) { + LOGGER.warn("No authenticated user found in the ThreadContext."); + listener.onFailure(new ResourceSharingException("No authenticated user found.")); + return; + } + + LOGGER.info("Sharing resource {} created by {} with {}", resourceId, user.getName(), shareWith.toString()); + + boolean isAdmin = adminDNs.isAdmin(user); + + this.resourceSharingIndexHandler.updateResourceSharingInfo( + resourceId, + resourceIndex, + user.getName(), + shareWith, + isAdmin, + ActionListener.wrap( + // On success, return the updated ResourceSharing + updatedResourceSharing -> { + LOGGER.info("Successfully shared resource {} with {}", resourceId, shareWith.toString()); + listener.onResponse(updatedResourceSharing); + }, + // On failure, log and pass the exception along + e -> { + LOGGER.error("Failed to share resource {} with {}: {}", resourceId, shareWith.toString(), e.getMessage()); + listener.onFailure(e); + } + ) + ); + } + + /** + * Revokes access to a resource for the specified users, roles, and backend roles. + * @param resourceId The resource ID to revoke access from. + * @param resourceIndex The index where resource is store + * @param revokeAccess The users, roles, and backend roles to revoke access for. + * @param scopes The permission scopes to revoke access for. + * @param listener The listener to be notified with the updated ResourceSharing document. + */ + public void revokeAccess( + String resourceId, + String resourceIndex, + Map> revokeAccess, + Set scopes, + ActionListener listener + ) { + // Validate input + validateArguments(resourceId, resourceIndex, revokeAccess, scopes); + + // Retrieve user + final UserSubjectImpl userSubject = (UserSubjectImpl) threadContext.getPersistent( + ConfigConstants.OPENDISTRO_SECURITY_AUTHENTICATED_USER + ); + final User user = (userSubject == null) ? null : userSubject.getUser(); + + if (user != null) { + LOGGER.info("User {} revoking access to resource {} for {} for scopes {} ", user.getName(), resourceId, revokeAccess, scopes); + } else { + listener.onFailure( + new ResourceSharingException( + "Failed to revoke access to resource {} for {} for scopes {} with no authenticated user", + resourceId, + revokeAccess, + scopes + ) + ); + } + + boolean isAdmin = (user != null) && adminDNs.isAdmin(user); + + this.resourceSharingIndexHandler.revokeAccess( + resourceId, + resourceIndex, + revokeAccess, + scopes, + (user != null ? user.getName() : null), + isAdmin, + ActionListener.wrap(listener::onResponse, exception -> { + LOGGER.error("Failed to revoke access to resource {} in index {}: {}", resourceId, resourceIndex, exception.getMessage()); + listener.onFailure(exception); + }) + ); + } + + /** + * Deletes a resource sharing record by its ID and the resource index it belongs to. + * @param resourceId The resource ID to delete. + * @param resourceIndex The resource index containing the resource. + * @param listener The listener to be notified with the deletion result. + */ + public void deleteResourceSharingRecord(String resourceId, String resourceIndex, ActionListener listener) { + try { + validateArguments(resourceId, resourceIndex); + + final UserSubjectImpl userSubject = (UserSubjectImpl) threadContext.getPersistent( + ConfigConstants.OPENDISTRO_SECURITY_AUTHENTICATED_USER + ); + final User user = (userSubject == null) ? null : userSubject.getUser(); + + if (user != null) { + LOGGER.info( + "Deleting resource sharing record for resource {} in {} created by {}", + resourceId, + resourceIndex, + user.getName() + ); + } else { + listener.onFailure(new ResourceSharingException("No authenticated user available.")); + } + + StepListener fetchDocListener = new StepListener<>(); + StepListener deleteDocListener = new StepListener<>(); + + resourceSharingIndexHandler.fetchDocumentById(resourceIndex, resourceId, fetchDocListener); + + fetchDocListener.whenComplete(document -> { + if (document == null) { + LOGGER.info("Document {} does not exist in index {}", resourceId, resourceIndex); + listener.onResponse(false); + return; + } + + boolean isAdmin = (user != null && adminDNs.isAdmin(user)); + boolean isOwner = (user != null && isOwnerOfResource(document, user.getName())); + + if (!isAdmin && !isOwner) { + LOGGER.info( + "User {} does not have access to delete the record {}", + (user == null ? "UNKNOWN" : user.getName()), + resourceId + ); + // Not allowed => no deletion + listener.onResponse(false); + } else { + resourceSharingIndexHandler.deleteResourceSharingRecord(resourceId, resourceIndex, deleteDocListener); + } + }, listener::onFailure); + + deleteDocListener.whenComplete(listener::onResponse, listener::onFailure); + } catch (Exception e) { + LOGGER.error("Failed to delete resource sharing record for resource {}", resourceId, e); + listener.onFailure(e); + } + } + + /** + * Deletes all resource sharing records for the current user. + * @param listener The listener to be notified with the deletion result. + */ + public void deleteAllResourceSharingRecordsForCurrentUser(ActionListener listener) { + final UserSubjectImpl userSubject = (UserSubjectImpl) threadContext.getPersistent( + ConfigConstants.OPENDISTRO_SECURITY_AUTHENTICATED_USER + ); + final User user = (userSubject == null) ? null : userSubject.getUser(); + + if (user == null) { + listener.onFailure(new ResourceSharingException("No authenticated user available.")); + return; + } + + LOGGER.info("Deleting all resource sharing records for user {}", user.getName()); + + resourceSharingIndexHandler.deleteAllRecordsForUser(user.getName(), ActionListener.wrap(listener::onResponse, exception -> { + LOGGER.error( + "Failed to delete all resource sharing records for user {}: {}", + user.getName(), + exception.getMessage(), + exception + ); + listener.onFailure(exception); + })); + } + + /** + * Loads all resources within the specified resource index. + * + * @param resourceIndex The resource index to load resources from. + * @param listener The listener to be notified with the set of resource IDs. + */ + private void loadAllResources(String resourceIndex, ActionListener> listener) { + this.resourceSharingIndexHandler.fetchAllDocuments(resourceIndex, listener); + } + + /** + * Loads resources owned by the specified user within the given resource index. + * + * @param resourceIndex The resource index to load resources from. + * @param userName The username of the owner. + * @param listener The listener to be notified with the set of resource IDs. + */ + private void loadOwnResources(String resourceIndex, String userName, ActionListener> listener) { + this.resourceSharingIndexHandler.fetchDocumentsByField(resourceIndex, "created_by.user", userName, listener); + } + + /** + * Loads resources shared with the specified entities within the given resource index, including public resources. + * + * @param resourceIndex The resource index to load resources from. + * @param entities The set of entities to check for shared resources. + * @param recipientType The type of entity (e.g., users, roles, backend_roles). + * @param listener The listener to be notified with the set of resource IDs. + */ + private void loadSharedWithResources( + String resourceIndex, + Set entities, + String recipientType, + ActionListener> listener + ) { + Set entitiesCopy = new HashSet<>(entities); + // To allow "public" resources to be matched for any user, role, backend_role + entitiesCopy.add("*"); + this.resourceSharingIndexHandler.fetchDocumentsForAllScopes(resourceIndex, entitiesCopy, recipientType, listener); + } + + /** + * Checks if the given resource is owned by the specified user. + * + * @param document The ResourceSharing document to check. + * @param userName The username to check ownership against. + * @return True if the resource is owned by the user, false otherwise. + */ + private boolean isOwnerOfResource(ResourceSharing document, String userName) { + return document.getCreatedBy() != null && document.getCreatedBy().getCreator().equals(userName); + } + + /** + * Checks if the given resource is shared with the specified entities and scope. + * + * @param document The ResourceSharing document to check. + * @param recipient The recipient entity + * @param entities The set of entities to check for sharing. + * @param scope The permission scope to check. + * @return True if the resource is shared with the entities and scope, false otherwise. + */ + private boolean isSharedWithEntity(ResourceSharing document, Recipient recipient, Set entities, String scope) { + for (String entity : entities) { + if (checkSharing(document, recipient, entity, scope)) { + return true; + } + } + return false; + } + + /** + * Checks if the given resource is shared with everyone. + * + * @param document The ResourceSharing document to check. + * @return True if the resource is shared with everyone, false otherwise. + */ + private boolean isSharedWithEveryone(ResourceSharing document) { + return document.getShareWith() != null + && document.getShareWith().getSharedWithScopes().stream().anyMatch(sharedWithScope -> sharedWithScope.getScope().equals("*")); + } + + /** + * Checks if the given resource is shared with the specified entity and scope. + * + * @param document The ResourceSharing document to check. + * @param recipient The recipient entity + * @param identifier The identifier of the entity to check for sharing. + * @param scope The permission scope to check. + * @return True if the resource is shared with the entity and scope, false otherwise. + */ + private boolean checkSharing(ResourceSharing document, Recipient recipient, String identifier, String scope) { + if (document.getShareWith() == null) { + return false; + } + + return document.getShareWith() + .getSharedWithScopes() + .stream() + .filter(sharedWithScope -> sharedWithScope.getScope().equals(scope)) + .findFirst() + .map(sharedWithScope -> { + SharedWithScope.ScopeRecipients scopePermissions = sharedWithScope.getSharedWithPerScope(); + Map> recipients = scopePermissions.getRecipients(); + + return switch (recipient) { + case Recipient.USERS, Recipient.ROLES, Recipient.BACKEND_ROLES -> recipients.get( + RecipientTypeRegistry.fromValue(recipient.getName()) + ).contains(identifier); + }; + }) + .orElse(false); // Return false if no matching scope is found + } + + private void validateArguments(Object... args) { + if (args == null) { + throw new IllegalArgumentException("Arguments cannot be null"); + } + for (Object arg : args) { + if (arg == null) { + throw new IllegalArgumentException("Argument cannot be null"); + } + // Additional check for String type arguments + if (arg instanceof String && ((String) arg).trim().isEmpty()) { + throw new IllegalArgumentException("Arguments cannot be empty"); + } + } + } + + /** + * Creates a DLS query for the given resource IDs. + * @param resourceIds The resource IDs to create the query for. + * @param queryShardContext The query shard context. + * @return The DLS query. + * @throws IOException If an I/O error occurs. + */ + public Query createResourceDLSQuery(Set resourceIds, QueryShardContext queryShardContext) throws IOException { + BoolQueryBuilder boolQueryBuilder = QueryBuilders.boolQuery(); + boolQueryBuilder.filter(QueryBuilders.termsQuery("_id", resourceIds)); + ConstantScoreQueryBuilder builder = new ConstantScoreQueryBuilder(boolQueryBuilder); + return builder.toQuery(queryShardContext); + } + + /** + * Creates a DLS restriction for the given resource IDs. + * @param resourceIds The resource IDs to create the restriction for. + * @param xContentRegistry The named XContent registry. + * @return The DLS restriction. + * @throws JsonProcessingException If an error occurs while processing JSON. + * @throws PrivilegesConfigurationValidationException If the privileges configuration is invalid. + */ + public DlsRestriction createResourceDLSRestriction(Set resourceIds, NamedXContentRegistry xContentRegistry) + throws JsonProcessingException, PrivilegesConfigurationValidationException { + + // resourceIds.isEmpty() is true when user doesn't have access to any resources + if (resourceIds.isEmpty()) { + LOGGER.info("No resources found for user. Enforcing full restriction."); + return DlsRestriction.FULL; + } + + String jsonQuery = String.format( + "{ \"bool\": { \"filter\": [ { \"terms\": { \"_id\": %s } } ] } }", + DefaultObjectMapper.writeValueAsString(resourceIds, true) + ); + QueryBuilder queryBuilder = DocumentPrivileges.DlsQuery.parseQuery(jsonQuery, xContentRegistry); + DocumentPrivileges.RenderedDlsQuery renderedDlsQuery = new DocumentPrivileges.RenderedDlsQuery(queryBuilder, jsonQuery); + List documentPrivileges = List.of(renderedDlsQuery); + return new DlsRestriction(documentPrivileges); + } +} diff --git a/src/main/java/org/opensearch/security/resources/ResourceSharing.java b/src/main/java/org/opensearch/security/resources/ResourceSharing.java new file mode 100644 index 0000000000..6dd6734a87 --- /dev/null +++ b/src/main/java/org/opensearch/security/resources/ResourceSharing.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.security.resources; + +import java.io.IOException; +import java.util.Objects; + +import org.opensearch.core.common.io.stream.NamedWriteable; +import org.opensearch.core.common.io.stream.StreamOutput; +import org.opensearch.core.xcontent.ToXContentFragment; +import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.core.xcontent.XContentParser; + +/** + * Represents a resource sharing configuration that manages access control for OpenSearch resources. + * This class holds information about shared resources including their source, creator, and sharing permissions. + * + *

This class implements {@link ToXContentFragment} for JSON serialization and {@link NamedWriteable} + * for stream-based serialization.

+ * + * The class maintains information about: + *
    + *
  • The source index where the resource is defined
  • + *
  • The unique identifier of the resource
  • + *
  • The creator's information
  • + *
  • The sharing permissions and recipients
  • + *
+ * + * + * @see org.opensearch.security.resources.CreatedBy + * @see org.opensearch.security.resources.ShareWith + * @opensearch.experimental + */ +public class ResourceSharing implements ToXContentFragment, NamedWriteable { + + /** + * The index where the resource is defined + */ + private String sourceIdx; + + /** + * The unique identifier of the resource + */ + private String resourceId; + + /** + * Information about who created the resource + */ + private CreatedBy createdBy; + + /** + * Information about with whom the resource is shared with + */ + private ShareWith shareWith; + + public ResourceSharing(String sourceIdx, String resourceId, CreatedBy createdBy, ShareWith shareWith) { + this.sourceIdx = sourceIdx; + this.resourceId = resourceId; + this.createdBy = createdBy; + this.shareWith = shareWith; + } + + public String getSourceIdx() { + return sourceIdx; + } + + public void setSourceIdx(String sourceIdx) { + this.sourceIdx = sourceIdx; + } + + public String getResourceId() { + return resourceId; + } + + public void setResourceId(String resourceId) { + this.resourceId = resourceId; + } + + public CreatedBy getCreatedBy() { + return createdBy; + } + + public void setCreatedBy(CreatedBy createdBy) { + this.createdBy = createdBy; + } + + public ShareWith getShareWith() { + return shareWith; + } + + public void setShareWith(ShareWith shareWith) { + this.shareWith = shareWith; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + ResourceSharing resourceSharing = (ResourceSharing) o; + return Objects.equals(getSourceIdx(), resourceSharing.getSourceIdx()) + && Objects.equals(getResourceId(), resourceSharing.getResourceId()) + && Objects.equals(getCreatedBy(), resourceSharing.getCreatedBy()) + && Objects.equals(getShareWith(), resourceSharing.getShareWith()); + } + + @Override + public int hashCode() { + return Objects.hash(getSourceIdx(), getResourceId(), getCreatedBy(), getShareWith()); + } + + @Override + public String toString() { + return "Resource {" + + "sourceIdx='" + + sourceIdx + + '\'' + + ", resourceId='" + + resourceId + + '\'' + + ", createdBy=" + + createdBy + + ", sharedWith=" + + shareWith + + '}'; + } + + @Override + public String getWriteableName() { + return "resource_sharing"; + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + out.writeString(sourceIdx); + out.writeString(resourceId); + createdBy.writeTo(out); + if (shareWith != null) { + out.writeBoolean(true); + shareWith.writeTo(out); + } else { + out.writeBoolean(false); + } + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + builder.startObject().field("source_idx", sourceIdx).field("resource_id", resourceId).field("created_by"); + createdBy.toXContent(builder, params); + if (shareWith != null && !shareWith.getSharedWithScopes().isEmpty()) { + builder.field("share_with"); + shareWith.toXContent(builder, params); + } + return builder.endObject(); + } + + public static ResourceSharing fromXContent(XContentParser parser) throws IOException { + String sourceIdx = null; + String resourceId = null; + CreatedBy createdBy = null; + ShareWith shareWith = null; + + String currentFieldName = null; + XContentParser.Token token; + + while ((token = parser.nextToken()) != XContentParser.Token.END_OBJECT) { + if (token == XContentParser.Token.FIELD_NAME) { + currentFieldName = parser.currentName(); + } else { + switch (Objects.requireNonNull(currentFieldName)) { + case "source_idx": + sourceIdx = parser.text(); + break; + case "resource_id": + resourceId = parser.text(); + break; + case "created_by": + createdBy = CreatedBy.fromXContent(parser); + break; + case "share_with": + shareWith = ShareWith.fromXContent(parser); + break; + default: + parser.skipChildren(); + break; + } + } + } + + validateRequiredField("source_idx", sourceIdx); + validateRequiredField("resource_id", resourceId); + validateRequiredField("created_by", createdBy); + + return new ResourceSharing(sourceIdx, resourceId, createdBy, shareWith); + } + + private static void validateRequiredField(String field, T value) { + if (value == null) { + throw new IllegalArgumentException(field + " is required"); + } + } +} diff --git a/src/main/java/org/opensearch/security/resources/ResourceSharingConstants.java b/src/main/java/org/opensearch/security/resources/ResourceSharingConstants.java new file mode 100644 index 0000000000..a6ed3f2b03 --- /dev/null +++ b/src/main/java/org/opensearch/security/resources/ResourceSharingConstants.java @@ -0,0 +1,16 @@ +/* + * 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.security.resources; + +public class ResourceSharingConstants { + // Resource sharing index + public static final String OPENSEARCH_RESOURCE_SHARING_INDEX = ".opensearch_resource_sharing"; +} diff --git a/src/main/java/org/opensearch/security/resources/ResourceSharingIndexHandler.java b/src/main/java/org/opensearch/security/resources/ResourceSharingIndexHandler.java new file mode 100644 index 0000000000..4ba251370a --- /dev/null +++ b/src/main/java/org/opensearch/security/resources/ResourceSharingIndexHandler.java @@ -0,0 +1,1404 @@ +/* + * 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.security.resources; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.Callable; + +import com.fasterxml.jackson.core.type.TypeReference; +import org.apache.commons.lang3.StringUtils; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import org.opensearch.action.DocWriteRequest; +import org.opensearch.action.StepListener; +import org.opensearch.action.admin.indices.create.CreateIndexRequest; +import org.opensearch.action.admin.indices.create.CreateIndexResponse; +import org.opensearch.action.get.MultiGetItemResponse; +import org.opensearch.action.get.MultiGetRequest; +import org.opensearch.action.index.IndexRequest; +import org.opensearch.action.index.IndexResponse; +import org.opensearch.action.search.ClearScrollRequest; +import org.opensearch.action.search.SearchRequest; +import org.opensearch.action.search.SearchResponse; +import org.opensearch.action.search.SearchScrollRequest; +import org.opensearch.action.support.WriteRequest; +import org.opensearch.client.Client; +import org.opensearch.common.unit.TimeValue; +import org.opensearch.common.util.concurrent.ThreadContext; +import org.opensearch.common.xcontent.LoggingDeprecationHandler; +import org.opensearch.common.xcontent.XContentFactory; +import org.opensearch.common.xcontent.XContentHelper; +import org.opensearch.common.xcontent.XContentType; +import org.opensearch.core.action.ActionListener; +import org.opensearch.core.common.bytes.BytesReference; +import org.opensearch.core.xcontent.NamedXContentRegistry; +import org.opensearch.core.xcontent.ToXContent; +import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.core.xcontent.XContentParser; +import org.opensearch.index.IndexNotFoundException; +import org.opensearch.index.query.BoolQueryBuilder; +import org.opensearch.index.query.MultiMatchQueryBuilder; +import org.opensearch.index.query.QueryBuilders; +import org.opensearch.index.reindex.BulkByScrollResponse; +import org.opensearch.index.reindex.DeleteByQueryAction; +import org.opensearch.index.reindex.DeleteByQueryRequest; +import org.opensearch.index.reindex.UpdateByQueryAction; +import org.opensearch.index.reindex.UpdateByQueryRequest; +import org.opensearch.script.Script; +import org.opensearch.script.ScriptType; +import org.opensearch.search.Scroll; +import org.opensearch.search.SearchHit; +import org.opensearch.search.builder.SearchSourceBuilder; +import org.opensearch.security.DefaultObjectMapper; +import org.opensearch.security.auditlog.AuditLog; +import org.opensearch.security.spi.resources.Resource; +import org.opensearch.security.spi.resources.ResourceAccessScope; +import org.opensearch.security.spi.resources.ResourceParser; +import org.opensearch.security.spi.resources.ResourceSharingException; +import org.opensearch.threadpool.ThreadPool; + +import static org.opensearch.common.xcontent.XContentFactory.jsonBuilder; + +/** + * This class handles the creation and management of the resource sharing index. + * It provides methods to create the index, index resource sharing entries along with updates and deletion, retrieve shared resources. + */ +public class ResourceSharingIndexHandler { + + private static final Logger LOGGER = LogManager.getLogger(ResourceSharingIndexHandler.class); + + private final Client client; + + private final String resourceSharingIndex; + + private final ThreadPool threadPool; + + private final AuditLog auditLog; + + public ResourceSharingIndexHandler(final String indexName, final Client client, final ThreadPool threadPool, final AuditLog auditLog) { + this.resourceSharingIndex = indexName; + this.client = client; + this.threadPool = threadPool; + this.auditLog = auditLog; + } + + public final static Map INDEX_SETTINGS = Map.of("index.number_of_shards", 1, "index.auto_expand_replicas", "0-all"); + + /** + * Creates the resource sharing index if it doesn't already exist. + * This method initializes the index with predefined mappings and settings + * for storing resource sharing information. + * The index will be created with the following structure: + * - source_idx (keyword): The source index containing the original document + * - resource_id (keyword): The ID of the shared resource + * - created_by (object): Information about the user who created the sharing + * - user (keyword): Username of the creator + * - share_with (object): Access control configuration for shared resources + * - [group_name] (object): Name of the access group + * - users (array): List of users with access + * - roles (array): List of roles with access + * - backend_roles (array): List of backend roles with access + * + * @throws RuntimeException if there are issues reading/writing index settings + * or communicating with the cluster + */ + + public void createResourceSharingIndexIfAbsent(Callable callable) { + // TODO: Once stashContext is replaced with switchContext this call will have to be modified + try (ThreadContext.StoredContext ctx = this.threadPool.getThreadContext().stashContext()) { + + CreateIndexRequest cir = new CreateIndexRequest(resourceSharingIndex).settings(INDEX_SETTINGS).waitForActiveShards(1); + ActionListener cirListener = ActionListener.wrap(response -> { + LOGGER.info("Resource sharing index {} created.", resourceSharingIndex); + if (callable != null) { + callable.call(); + } + }, (failResponse) -> { + /* Index already exists, ignore and continue */ + LOGGER.info("Index {} already exists.", resourceSharingIndex); + try { + if (callable != null) { + callable.call(); + } + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + this.client.admin().indices().create(cir, cirListener); + } + } + + /** + * Creates or updates a resource sharing record in the dedicated resource sharing index. + * This method handles the persistence of sharing metadata for resources, including + * the creator information and sharing permissions. + * + * @param resourceId The unique identifier of the resource being shared + * @param resourceIndex The source index where the original resource is stored + * @param createdBy Object containing information about the user creating/updating the sharing + * @param shareWith Object containing the sharing permissions' configuration. Can be null for initial creation. + * When provided, it should contain the access control settings for different groups: + * { + * "group_name": { + * "users": ["user1", "user2"], + * "roles": ["role1", "role2"], + * "backend_roles": ["backend_role1"] + * } + * } + * + * @return ResourceSharing Returns resourceSharing object if the operation was successful, null otherwise + * @throws IOException if there are issues with index operations or JSON processing + */ + public ResourceSharing indexResourceSharing(String resourceId, String resourceIndex, CreatedBy createdBy, ShareWith shareWith) + throws IOException { + // TODO: Once stashContext is replaced with switchContext this call will have to be modified + try (ThreadContext.StoredContext ctx = this.threadPool.getThreadContext().stashContext()) { + ResourceSharing entry = new ResourceSharing(resourceIndex, resourceId, createdBy, shareWith); + + IndexRequest ir = client.prepareIndex(resourceSharingIndex) + .setRefreshPolicy(WriteRequest.RefreshPolicy.IMMEDIATE) + .setSource(entry.toXContent(jsonBuilder(), ToXContent.EMPTY_PARAMS)) + .setOpType(DocWriteRequest.OpType.CREATE) // only create if an entry doesn't exist + .request(); + + ActionListener irListener = ActionListener.wrap( + idxResponse -> LOGGER.info("Successfully created {} entry.", resourceSharingIndex), + (failResponse) -> { + LOGGER.error(failResponse.getMessage()); + LOGGER.info("Failed to create {} entry.", resourceSharingIndex); + } + ); + client.index(ir, irListener); + return entry; + } catch (Exception e) { + LOGGER.info("Failed to create {} entry.", resourceSharingIndex, e); + throw new ResourceSharingException("Failed to create " + resourceSharingIndex + " entry.", e); + } + } + + /** + * Fetches all resource sharing records that match the specified system index. This method retrieves + * a list of resource IDs associated with the given system index from the resource sharing index. + * + *

The method executes the following steps: + *

    + *
  1. Creates a search request with term query matching the system index
  2. + *
  3. Applies source filtering to only fetch resource_id field
  4. + *
  5. Executes the search with a limit of 10000 documents
  6. + *
  7. Processes the results to extract resource IDs
  8. + *
+ * + *

Example query structure: + *

+    * {
+    *   "query": {
+    *     "term": {
+    *       "source_idx": "resource_index_name"
+    *     }
+    *   },
+    *   "_source": ["resource_id"],
+    *   "size": 10000
+    * }
+    * 
+ * + * @param pluginIndex The source index to match against the source_idx field + * @param listener The listener to be notified when the operation completes. + * The listener receives a set of resource IDs as a result. + * @apiNote This method: + *
    + *
  • Uses source filtering for optimal performance
  • + *
  • Performs exact matching on the source_idx field
  • + *
  • Returns an empty list instead of throwing exceptions
  • + *
+ */ + public void fetchAllDocuments(String pluginIndex, ActionListener> listener) { + LOGGER.debug("Fetching all documents asynchronously from {} where source_idx = {}", resourceSharingIndex, pluginIndex); + + try (final ThreadContext.StoredContext ctx = this.threadPool.getThreadContext().stashContext()) { + SearchRequest searchRequest = new SearchRequest(resourceSharingIndex); + SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder().query( + QueryBuilders.termQuery("source_idx.keyword", pluginIndex) + ).size(10000).fetchSource(new String[] { "resource_id" }, null); + + searchRequest.source(searchSourceBuilder); + + client.search(searchRequest, new ActionListener<>() { + @Override + public void onResponse(SearchResponse searchResponse) { + try { + Set resourceIds = new HashSet<>(); + + SearchHit[] hits = searchResponse.getHits().getHits(); + for (SearchHit hit : hits) { + Map sourceAsMap = hit.getSourceAsMap(); + if (sourceAsMap != null && sourceAsMap.containsKey("resource_id")) { + resourceIds.add(sourceAsMap.get("resource_id").toString()); + } + } + + LOGGER.debug("Found {} documents in {} for source_idx: {}", resourceIds.size(), resourceSharingIndex, pluginIndex); + + listener.onResponse(resourceIds); + } catch (Exception e) { + LOGGER.error( + "Error while processing search response from {} for source_idx: {}", + resourceSharingIndex, + pluginIndex, + e + ); + listener.onFailure(e); + } + } + + @Override + public void onFailure(Exception e) { + LOGGER.error("Failed to fetch documents from {} for source_idx: {}", resourceSharingIndex, pluginIndex, e); + listener.onFailure(e); + } + }); + } catch (Exception e) { + LOGGER.error("Failed to initiate fetch documents from {} for source_idx: {}", resourceSharingIndex, pluginIndex, e); + listener.onFailure(e); + } + } + + /** + * Fetches documents that match the specified system index and have specific access type values. + * This method uses scroll API to handle large result sets efficiently. + * + *

The method executes the following steps: + *

    + *
  1. Validates the RecipientType parameter
  2. + *
  3. Creates a scrolling search request with a compound query
  4. + *
  5. Processes results in batches using scroll API
  6. + *
  7. Collects all matching resource IDs
  8. + *
  9. Cleans up scroll context
  10. + *
+ * + *

Example query structure: + *

+    * {
+    *   "query": {
+    *     "bool": {
+    *       "must": [
+    *         { "term": { "source_idx": "resource_index_name" } },
+    *         {
+    *           "bool": {
+    *             "should": [
+    *               {
+    *                 "nested": {
+    *                   "path": "share_with.*.RecipientType",
+    *                   "query": {
+    *                     "term": { "share_with.*.RecipientType": "entity_value" }
+    *                   }
+    *                 }
+    *               }
+    *             ],
+    *             "minimum_should_match": 1
+    *           }
+    *         }
+    *       ]
+    *     }
+    *   },
+    *   "_source": ["resource_id"],
+    *   "size": 1000
+    * }
+    * 
+ * + * @param pluginIndex The source index to match against the source_idx field + * @param entities Set of values to match in the specified RecipientType field + * @param recipientType The type of association with the resource. Must be one of: + *
    + *
  • "users" - for user-based access
  • + *
  • "roles" - for role-based access
  • + *
  • "backend_roles" - for backend role-based access
  • + *
+ * @param listener The listener to be notified when the operation completes. + * The listener receives a set of resource IDs as a result. + * @throws RuntimeException if the search operation fails + * + * @apiNote This method: + *
    + *
  • Uses scroll API with 1-minute timeout
  • + *
  • Processes results in batches of 1000 documents
  • + *
  • Performs source filtering for optimization
  • + *
  • Uses nested queries for accessing array elements
  • + *
  • Properly cleans up scroll context after use
  • + *
+ */ + + public void fetchDocumentsForAllScopes( + String pluginIndex, + Set entities, + String recipientType, + ActionListener> listener + ) { + // "*" must match all scopes + fetchDocumentsForAGivenScope(pluginIndex, entities, recipientType, "*", listener); + } + + /** + * Fetches documents that match the specified system index and have specific access type values for a given scope. + * This method uses scroll API to handle large result sets efficiently. + * + *

The method executes the following steps: + *

    + *
  1. Validates the RecipientType parameter
  2. + *
  3. Creates a scrolling search request with a compound query
  4. + *
  5. Processes results in batches using scroll API
  6. + *
  7. Collects all matching resource IDs
  8. + *
  9. Cleans up scroll context
  10. + *
+ * + *

Example query structure: + *

+     * {
+     *   "query": {
+     *     "bool": {
+     *       "must": [
+     *         { "term": { "source_idx": "resource_index_name" } },
+     *         {
+     *           "bool": {
+     *             "should": [
+     *               {
+     *                 "nested": {
+     *                   "path": "share_with.scope.RecipientType",
+     *                   "query": {
+     *                     "term": { "share_with.scope.RecipientType": "entity_value" }
+     *                   }
+     *                 }
+     *               }
+     *             ],
+     *             "minimum_should_match": 1
+     *           }
+     *         }
+     *       ]
+     *     }
+     *   },
+     *   "_source": ["resource_id"],
+     *   "size": 1000
+     * }
+     * 
+ * + * @param pluginIndex The source index to match against the source_idx field + * @param entities Set of values to match in the specified RecipientType field + * @param recipientType The type of association with the resource. Must be one of: + *
    + *
  • "users" - for user-based access
  • + *
  • "roles" - for role-based access
  • + *
  • "backend_roles" - for backend role-based access
  • + *
+ * @param scope The scope of the access. Should be implementation of {@link ResourceAccessScope} + * @param listener The listener to be notified when the operation completes. + * The listener receives a set of resource IDs as a result. + * @throws RuntimeException if the search operation fails + * + * @apiNote This method: + *
    + *
  • Uses scroll API with 1-minute timeout
  • + *
  • Processes results in batches of 1000 documents
  • + *
  • Performs source filtering for optimization
  • + *
  • Uses nested queries for accessing array elements
  • + *
  • Properly cleans up scroll context after use
  • + *
+ */ + public void fetchDocumentsForAGivenScope( + String pluginIndex, + Set entities, + String recipientType, + String scope, + ActionListener> listener + ) { + LOGGER.debug( + "Fetching documents asynchronously from index: {}, where share_with.{}.{} contains any of {}", + pluginIndex, + scope, + recipientType, + entities + ); + + final Set resourceIds = new HashSet<>(); + final Scroll scroll = new Scroll(TimeValue.timeValueMinutes(1L)); + + try (ThreadContext.StoredContext ctx = this.threadPool.getThreadContext().stashContext()) { + SearchRequest searchRequest = new SearchRequest(resourceSharingIndex); + searchRequest.scroll(scroll); + + BoolQueryBuilder boolQuery = QueryBuilders.boolQuery().must(QueryBuilders.termQuery("source_idx.keyword", pluginIndex)); + + BoolQueryBuilder shouldQuery = QueryBuilders.boolQuery(); + if ("*".equals(scope)) { + for (String entity : entities) { + shouldQuery.should( + QueryBuilders.multiMatchQuery(entity, "share_with.*." + recipientType + ".keyword") + .type(MultiMatchQueryBuilder.Type.BEST_FIELDS) + ); + } + } else { + for (String entity : entities) { + shouldQuery.should(QueryBuilders.termQuery("share_with." + scope + "." + recipientType + ".keyword", entity)); + } + } + shouldQuery.minimumShouldMatch(1); + + boolQuery.must(QueryBuilders.existsQuery("share_with")).must(shouldQuery); + + executeSearchRequest(resourceIds, scroll, searchRequest, boolQuery, ActionListener.wrap(success -> { + LOGGER.debug("Found {} documents matching the criteria in {}", resourceIds.size(), resourceSharingIndex); + listener.onResponse(resourceIds); + + }, exception -> { + LOGGER.error( + "Search failed for pluginIndex={}, scope={}, recipientType={}, entities={}", + pluginIndex, + scope, + recipientType, + entities, + exception + ); + listener.onFailure(exception); + + })); + } catch (Exception e) { + LOGGER.error( + "Failed to initiate fetch from {} for criteria - pluginIndex: {}, scope: {}, RecipientType: {}, entities: {}", + resourceSharingIndex, + pluginIndex, + scope, + recipientType, + entities, + e + ); + listener.onFailure(new RuntimeException("Failed to fetch documents: " + e.getMessage(), e)); + } + } + + /** + * Fetches documents from the resource sharing index that match a specific field value. + * This method uses scroll API to efficiently handle large result sets and performs exact + * matching on both system index and the specified field. + * + *

The method executes the following steps: + *

    + *
  1. Validates input parameters for null/empty values
  2. + *
  3. Creates a scrolling search request with a bool query
  4. + *
  5. Processes results in batches using scroll API
  6. + *
  7. Extracts resource IDs from matching documents
  8. + *
  9. Cleans up scroll context after completion
  10. + *
+ * + *

Example query structure: + *

+     * {
+     *   "query": {
+     *     "bool": {
+     *       "must": [
+     *         { "term": { "source_idx": "system_index_value" } },
+     *         { "term": { "field_name": "field_value" } }
+     *       ]
+     *     }
+     *   },
+     *   "_source": ["resource_id"],
+     *   "size": 1000
+     * }
+     * 
+ * + * @param pluginIndex The source index to match against the source_idx field + * @param field The field name to search in. Must be a valid field in the index mapping + * @param value The value to match for the specified field. Performs exact term matching + * @param listener The listener to be notified when the operation completes. + * The listener receives a set of resource IDs as a result. + * + * @throws IllegalArgumentException if any parameter is null or empty + * @throws RuntimeException if the search operation fails, wrapping the underlying exception + * + * @apiNote This method: + *
    + *
  • Uses scroll API with 1-minute timeout for handling large result sets
  • + *
  • Performs exact term matching (not analyzed) on field values
  • + *
  • Processes results in batches of 1000 documents
  • + *
  • Uses source filtering to only fetch resource_id field
  • + *
  • Automatically cleans up scroll context after use
  • + *
+ * + * Example usage: + *
+     * Set resources = fetchDocumentsByField("myIndex", "status", "active");
+     * 
+ */ + public void fetchDocumentsByField(String pluginIndex, String field, String value, ActionListener> listener) { + if (StringUtils.isBlank(pluginIndex) || StringUtils.isBlank(field) || StringUtils.isBlank(value)) { + listener.onFailure(new IllegalArgumentException("pluginIndex, field, and value must not be null or empty")); + return; + } + + LOGGER.debug("Fetching documents from index: {}, where {} = {}", pluginIndex, field, value); + + Set resourceIds = new HashSet<>(); + final Scroll scroll = new Scroll(TimeValue.timeValueMinutes(1L)); + + // TODO: Once stashContext is replaced with switchContext this call will have to be modified + try (ThreadContext.StoredContext ctx = this.threadPool.getThreadContext().stashContext()) { + SearchRequest searchRequest = new SearchRequest(resourceSharingIndex); + searchRequest.scroll(scroll); + + BoolQueryBuilder boolQuery = QueryBuilders.boolQuery() + .must(QueryBuilders.termQuery("source_idx.keyword", pluginIndex)) + .must(QueryBuilders.termQuery(field + ".keyword", value)); + + executeSearchRequest(resourceIds, scroll, searchRequest, boolQuery, ActionListener.wrap(success -> { + LOGGER.info("Found {} documents in {} where {} = {}", resourceIds.size(), resourceSharingIndex, field, value); + listener.onResponse(resourceIds); + }, exception -> { + LOGGER.error("Failed to fetch documents from {} where {} = {}", resourceSharingIndex, field, value, exception); + listener.onFailure(new RuntimeException("Failed to fetch documents: " + exception.getMessage(), exception)); + })); + } catch (Exception e) { + LOGGER.error("Failed to initiate fetch from {} where {} = {}", resourceSharingIndex, field, value, e); + listener.onFailure(new RuntimeException("Failed to initiate fetch: " + e.getMessage(), e)); + } + + } + + /** + * Fetches a specific resource sharing document by its resource ID and system index. + * This method performs an exact match search and parses the result into a ResourceSharing object. + * + *

The method executes the following steps: + *

    + *
  1. Validates input parameters for null/empty values
  2. + *
  3. Creates a search request with a bool query for exact matching
  4. + *
  5. Executes the search with a limit of 1 document
  6. + *
  7. Parses the result using XContent parser if found
  8. + *
  9. Returns null if no matching document exists
  10. + *
+ * + *

Example query structure: + *

+    * {
+    *   "query": {
+    *     "bool": {
+    *       "must": [
+    *         { "term": { "source_idx": "resource_index_name" } },
+    *         { "term": { "resource_id": "resource_id_value" } }
+    *       ]
+    *     }
+    *   },
+    *   "size": 1
+    * }
+    * 
+ * + * @param pluginIndex The source index to match against the source_idx field + * @param resourceId The resource ID to fetch. Must exactly match the resource_id field + * @param listener The listener to be notified when the operation completes. + * The listener receives the parsed ResourceSharing object or null if not found + * + * @throws IllegalArgumentException if pluginIndexName or resourceId is null or empty + * @throws RuntimeException if the search operation fails or parsing errors occur, + * wrapping the underlying exception + * + * @apiNote This method: + *
    + *
  • Uses term queries for exact matching
  • + *
  • Expects only one matching document per resource ID
  • + *
  • Uses XContent parsing for consistent object creation
  • + *
  • Returns null instead of throwing exceptions for non-existent documents
  • + *
  • Provides detailed logging for troubleshooting
  • + *
+ * + * Example usage: + *
+    * ResourceSharing sharing = fetchDocumentById("myIndex", "resource123");
+    * if (sharing != null) {
+    *     // Process the resource sharing object
+    * }
+    * 
+ */ + public void fetchDocumentById(String pluginIndex, String resourceId, ActionListener listener) { + if (StringUtils.isBlank(pluginIndex) || StringUtils.isBlank(resourceId)) { + listener.onFailure(new IllegalArgumentException("pluginIndex and resourceId must not be null or empty")); + return; + } + LOGGER.debug("Fetching document from index: {}, resourceId: {}", pluginIndex, resourceId); + + try (ThreadContext.StoredContext ctx = this.threadPool.getThreadContext().stashContext()) { + BoolQueryBuilder boolQuery = QueryBuilders.boolQuery() + .must(QueryBuilders.termQuery("source_idx.keyword", pluginIndex)) + .must(QueryBuilders.termQuery("resource_id.keyword", resourceId)); + + SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder().query(boolQuery).size(1); // There is only one document for + // a single resource + + SearchRequest searchRequest = new SearchRequest(resourceSharingIndex).source(searchSourceBuilder); + + client.search(searchRequest, new ActionListener<>() { + @Override + public void onResponse(SearchResponse searchResponse) { + try { + SearchHit[] hits = searchResponse.getHits().getHits(); + if (hits.length == 0) { + LOGGER.debug("No document found for resourceId: {} in index: {}", resourceId, pluginIndex); + listener.onResponse(null); + return; + } + + SearchHit hit = hits[0]; + try ( + XContentParser parser = XContentType.JSON.xContent() + .createParser(NamedXContentRegistry.EMPTY, LoggingDeprecationHandler.INSTANCE, hit.getSourceAsString()) + ) { + parser.nextToken(); + ResourceSharing resourceSharing = ResourceSharing.fromXContent(parser); + + LOGGER.debug("Successfully fetched document for resourceId: {} from index: {}", resourceId, pluginIndex); + + listener.onResponse(resourceSharing); + } + } catch (Exception e) { + LOGGER.error("Failed to parse document for resourceId: {} from index: {}", resourceId, pluginIndex, e); + listener.onFailure( + new ResourceSharingException( + "Failed to parse document for resourceId: " + resourceId + " from index: " + pluginIndex, + e + ) + ); + } + } + + @Override + public void onFailure(Exception e) { + + LOGGER.error("Failed to fetch document for resourceId: {} from index: {}", resourceId, pluginIndex, e); + listener.onFailure( + new ResourceSharingException( + "Failed to fetch document for resourceId: " + resourceId + " from index: " + pluginIndex, + e + ) + ); + + } + }); + } catch (Exception e) { + LOGGER.error("Failed to fetch document for resourceId: {} from index: {}", resourceId, pluginIndex, e); + listener.onFailure( + new ResourceSharingException("Failed to fetch document for resourceId: " + resourceId + " from index: " + pluginIndex, e) + ); + } + } + + /** + * Helper method to execute a search request and collect resource IDs from the results. + * @param resourceIds List to collect resource IDs + * @param scroll Search Scroll + * @param searchRequest Request to execute + * @param boolQuery Query to execute with the request + * @param listener Listener to be notified when the operation completes + */ + private void executeSearchRequest( + Set resourceIds, + Scroll scroll, + SearchRequest searchRequest, + BoolQueryBuilder boolQuery, + ActionListener listener + ) { + SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder().query(boolQuery) + .size(1000) + .fetchSource(new String[] { "resource_id" }, null); + + searchRequest.source(searchSourceBuilder); + + StepListener searchStep = new StepListener<>(); + + client.search(searchRequest, searchStep); + + searchStep.whenComplete(initialResponse -> { + String scrollId = initialResponse.getScrollId(); + processScrollResults(resourceIds, scroll, scrollId, initialResponse.getHits().getHits(), listener); + }, listener::onFailure); + } + + /** + * Helper method to process scroll results recursively. + * @param resourceIds List to collect resource IDs + * @param scroll Search Scroll + * @param scrollId Scroll ID + * @param hits Search hits + * @param listener Listener to be notified when the operation completes + */ + private void processScrollResults( + Set resourceIds, + Scroll scroll, + String scrollId, + SearchHit[] hits, + ActionListener listener + ) { + // If no hits, clean up and complete + if (hits == null || hits.length == 0) { + clearScroll(scrollId, listener); + return; + } + + // Process current batch of hits + for (SearchHit hit : hits) { + Map sourceAsMap = hit.getSourceAsMap(); + if (sourceAsMap != null && sourceAsMap.containsKey("resource_id")) { + resourceIds.add(sourceAsMap.get("resource_id").toString()); + } + } + + // Prepare next scroll request + SearchScrollRequest scrollRequest = new SearchScrollRequest(scrollId); + scrollRequest.scroll(scroll); + + // Execute next scroll + client.searchScroll(scrollRequest, ActionListener.wrap(scrollResponse -> { + // Process next batch recursively + processScrollResults(resourceIds, scroll, scrollResponse.getScrollId(), scrollResponse.getHits().getHits(), listener); + }, e -> { + // Clean up scroll context on failure + clearScroll(scrollId, ActionListener.wrap(r -> listener.onFailure(e), ex -> { + e.addSuppressed(ex); + listener.onFailure(e); + })); + })); + } + + /** + * Helper method to clear scroll context. + * @param scrollId Scroll ID + * @param listener Listener to be notified when the operation completes + */ + private void clearScroll(String scrollId, ActionListener listener) { + if (scrollId == null) { + listener.onResponse(null); + return; + } + + ClearScrollRequest clearScrollRequest = new ClearScrollRequest(); + clearScrollRequest.addScrollId(scrollId); + + client.clearScroll(clearScrollRequest, ActionListener.wrap(r -> listener.onResponse(null), e -> { + LOGGER.warn("Failed to clear scroll context", e); + listener.onResponse(null); + })); + } + + /** + * Updates the sharing configuration for an existing resource in the resource sharing index. + * NOTE: This method only grants new access. To remove access use {@link #revokeAccess(String, String, Map, Set, String, boolean, ActionListener)} + * This method modifies the sharing permissions for a specific resource identified by its + * resource ID and source index. + * + * @param resourceId The unique identifier of the resource whose sharing configuration needs to be updated + * @param sourceIdx The source index where the original resource is stored + * @param requestUserName The user requesting to share the resource + * @param shareWith Updated sharing configuration object containing access control settings: + * { + * "scope": { + * "users": ["user1", "user2"], + * "roles": ["role1", "role2"], + * "backend_roles": ["backend_role1"] + * } + * } + * @param isAdmin Boolean indicating whether the user requesting to share is an admin or not + * @param listener Listener to be notified when the operation completes + * + * @throws RuntimeException if there's an error during the update operation + */ + public void updateResourceSharingInfo( + String resourceId, + String sourceIdx, + String requestUserName, + ShareWith shareWith, + boolean isAdmin, + ActionListener listener + ) { + XContentBuilder builder; + Map shareWithMap; + try { + builder = XContentFactory.jsonBuilder(); + shareWith.toXContent(builder, ToXContent.EMPTY_PARAMS); + String json = builder.toString(); + shareWithMap = DefaultObjectMapper.readValue(json, new TypeReference<>() { + }); + } catch (IOException e) { + LOGGER.error("Failed to build json content", e); + listener.onFailure(new ResourceSharingException("Failed to build json content", e)); + return; + } + + StepListener fetchDocListener = new StepListener<>(); + StepListener updateScriptListener = new StepListener<>(); + StepListener updatedSharingListener = new StepListener<>(); + + // Fetch resource sharing doc + fetchDocumentById(sourceIdx, resourceId, fetchDocListener); + + // build update script + fetchDocListener.whenComplete(currentSharingInfo -> { + // Check if user can share. At present only the resource creator and admin is allowed to share the resource + if (!isAdmin && currentSharingInfo != null && !currentSharingInfo.getCreatedBy().getCreator().equals(requestUserName)) { + + LOGGER.error("User {} is not authorized to share resource {}", requestUserName, resourceId); + listener.onFailure( + new ResourceSharingException("User " + requestUserName + " is not authorized to share resource " + resourceId) + ); + } + + Script updateScript = new Script(ScriptType.INLINE, "painless", """ + if (ctx._source.share_with == null) { + ctx._source.share_with = [:]; + } + + for (def entry : params.shareWith.entrySet()) { + def scopeName = entry.getKey(); + def newScope = entry.getValue(); + + if (!ctx._source.share_with.containsKey(scopeName)) { + def newScopeEntry = [:]; + for (def field : newScope.entrySet()) { + if (field.getValue() != null && !field.getValue().isEmpty()) { + newScopeEntry[field.getKey()] = new HashSet(field.getValue()); + } + } + ctx._source.share_with[scopeName] = newScopeEntry; + } else { + def existingScope = ctx._source.share_with[scopeName]; + + for (def field : newScope.entrySet()) { + def fieldName = field.getKey(); + def newValues = field.getValue(); + + if (newValues != null && !newValues.isEmpty()) { + if (!existingScope.containsKey(fieldName)) { + existingScope[fieldName] = new HashSet(); + } + + for (def value : newValues) { + if (!existingScope[fieldName].contains(value)) { + existingScope[fieldName].add(value); + } + } + } + } + } + } + """, Collections.singletonMap("shareWith", shareWithMap)); + + updateByQueryResourceSharing(sourceIdx, resourceId, updateScript, updateScriptListener); + + }, listener::onFailure); + + // Build & return the updated ResourceSharing + updateScriptListener.whenComplete(success -> { + if (!success) { + LOGGER.error("Failed to update resource sharing info for resource {}", resourceId); + listener.onResponse(null); + return; + } + // TODO check if this should be replaced by Java in-memory computation (current intuition is that it will be more memory + // intensive to do it in java) + fetchDocumentById(sourceIdx, resourceId, updatedSharingListener); + }, listener::onFailure); + + updatedSharingListener.whenComplete(listener::onResponse, listener::onFailure); + } + + /** + * Updates resource sharing entries that match the specified source index and resource ID + * using the provided update script. This method performs an update-by-query operation + * in the resource sharing index. + * + *

The method executes the following steps: + *

    + *
  1. Creates a bool query to match exact source index and resource ID
  2. + *
  3. Constructs an update-by-query request with the query and update script
  4. + *
  5. Executes the update operation
  6. + *
  7. Returns success/failure status based on update results
  8. + *
+ * + *

Example document matching structure: + *

+     * {
+     *   "source_idx": "source_index_name",
+     *   "resource_id": "resource_id_value",
+     *   "share_with": {
+     *     // sharing configuration to be updated
+     *   }
+     * }
+     * 
+ * + * @param sourceIdx The source index to match in the query (exact match) + * @param resourceId The resource ID to match in the query (exact match) + * @param updateScript The script containing the update operations to be performed. + * This script defines how the matching documents should be modified + * @param listener Listener to be notified when the operation completes + * + * @apiNote This method: + *
    + *
  • Uses term queries for exact matching of source_idx and resource_id
  • + *
  • Returns false for both "no matching documents" and "operation failure" cases
  • + *
  • Logs the complete update request for debugging purposes
  • + *
  • Provides detailed logging for success and failure scenarios
  • + *
+ * + * @implNote The update operation uses a bool query with two must clauses: + *
+     * {
+     *   "query": {
+     *     "bool": {
+     *       "must": [
+     *         { "term": { "source_idx.keyword": sourceIdx } },
+     *         { "term": { "resource_id.keyword": resourceId } }
+     *       ]
+     *     }
+     *   }
+     * }
+     * 
+ */ + private void updateByQueryResourceSharing(String sourceIdx, String resourceId, Script updateScript, ActionListener listener) { + try (ThreadContext.StoredContext ctx = this.threadPool.getThreadContext().stashContext()) { + BoolQueryBuilder query = QueryBuilders.boolQuery() + .must(QueryBuilders.termQuery("source_idx.keyword", sourceIdx)) + .must(QueryBuilders.termQuery("resource_id.keyword", resourceId)); + + UpdateByQueryRequest ubq = new UpdateByQueryRequest(resourceSharingIndex).setQuery(query) + .setScript(updateScript) + .setRefresh(true); + + client.execute(UpdateByQueryAction.INSTANCE, ubq, new ActionListener<>() { + @Override + public void onResponse(BulkByScrollResponse response) { + long updated = response.getUpdated(); + if (updated > 0) { + LOGGER.info("Successfully updated {} documents in {}.", updated, resourceSharingIndex); + listener.onResponse(true); + } else { + LOGGER.info( + "No documents found to update in {} for source_idx: {} and resource_id: {}", + resourceSharingIndex, + sourceIdx, + resourceId + ); + listener.onResponse(false); + } + + } + + @Override + public void onFailure(Exception e) { + + LOGGER.error("Failed to update documents in {}.", resourceSharingIndex, e); + listener.onFailure(e); + + } + }); + } catch (Exception e) { + LOGGER.error("Failed to update documents in {} before request submission.", resourceSharingIndex, e); + listener.onFailure(e); + } + } + + /** + * Revokes access for specified entities from a resource sharing document. This method removes the specified + * entities (users, roles, or backend roles) from the existing sharing configuration while preserving other + * sharing settings. + * + *

The method performs the following steps: + *

    + *
  1. Fetches the existing document
  2. + *
  3. Removes specified entities from their respective lists in all sharing groups
  4. + *
  5. Updates the document if modifications were made
  6. + *
  7. Returns the updated resource sharing configuration
  8. + *
+ * + *

Example document structure: + *

+     * {
+     *   "source_idx": "resource_index_name",
+     *   "resource_id": "resource_id",
+     *   "share_with": {
+     *     "scope": {
+     *       "users": ["user1", "user2"],
+     *       "roles": ["role1", "role2"],
+     *       "backend_roles": ["backend_role1"]
+     *     }
+     *   }
+     * }
+     * 
+ * + * @param resourceId The ID of the resource from which to revoke access + * @param sourceIdx The name of the system index where the resource exists + * @param revokeAccess A map containing entity types (USER, ROLE, BACKEND_ROLE) and their corresponding + * values to be removed from the sharing configuration + * @param scopes A list of scopes to revoke access from. If null or empty, access is revoked from all scopes + * @param requestUserName The user trying to revoke the accesses + * @param isAdmin Boolean indicating whether the user is an admin or not + * @param listener Listener to be notified when the operation completes + * @throws IllegalArgumentException if resourceId, sourceIdx is null/empty, or if revokeAccess is null/empty + * @throws RuntimeException if the update operation fails or encounters an error + * + * @see RecipientType + * @see ResourceSharing + * + * @apiNote This method modifies the existing document. If no modifications are needed (i.e., specified + * entities don't exist in the current configuration), the original document is returned unchanged. + * @example + *
+     * Map> revokeAccess = new HashMap<>();
+     * revokeAccess.put(RecipientType.USER, Set.of("user1", "user2"));
+     * revokeAccess.put(RecipientType.ROLE, Set.of("role1"));
+     * ResourceSharing updated = revokeAccess("resourceId", "pluginIndex", revokeAccess);
+     * 
+ */ + public void revokeAccess( + String resourceId, + String sourceIdx, + Map> revokeAccess, + Set scopes, + String requestUserName, + boolean isAdmin, + ActionListener listener + ) { + if (StringUtils.isBlank(resourceId) || StringUtils.isBlank(sourceIdx) || revokeAccess == null || revokeAccess.isEmpty()) { + listener.onFailure(new IllegalArgumentException("resourceId, sourceIdx, and revokeAccess must not be null or empty")); + return; + } + + try (ThreadContext.StoredContext ctx = this.threadPool.getThreadContext().stashContext()) { + + LOGGER.debug( + "Revoking access for resource {} in {} for entities: {} and scopes: {}", + resourceId, + sourceIdx, + revokeAccess, + scopes + ); + + StepListener currentSharingListener = new StepListener<>(); + StepListener revokeUpdateListener = new StepListener<>(); + StepListener updatedSharingListener = new StepListener<>(); + + // Fetch the current ResourceSharing document + fetchDocumentById(sourceIdx, resourceId, currentSharingListener); + + // Check permissions & build revoke script + currentSharingListener.whenComplete(currentSharingInfo -> { + // Only admin or the creator of the resource is currently allowed to revoke access + if (!isAdmin && currentSharingInfo != null && !currentSharingInfo.getCreatedBy().getCreator().equals(requestUserName)) { + listener.onFailure( + new ResourceSharingException( + "User " + requestUserName + " is not authorized to revoke access to resource " + resourceId + ) + ); + } + + Map revoke = new HashMap<>(); + for (Map.Entry> entry : revokeAccess.entrySet()) { + revoke.put(entry.getKey().type().toLowerCase(), new ArrayList<>(entry.getValue())); + } + List scopesToUse = (scopes != null) ? new ArrayList<>(scopes) : new ArrayList<>(); + + Script revokeScript = new Script(ScriptType.INLINE, "painless", """ + if (ctx._source.share_with != null) { + Set scopesToProcess = new HashSet(params.scopes.isEmpty() ? ctx._source.share_with.keySet() : params.scopes); + + for (def scopeName : scopesToProcess) { + if (ctx._source.share_with.containsKey(scopeName)) { + def existingScope = ctx._source.share_with.get(scopeName); + + for (def entry : params.revokeAccess.entrySet()) { + def RecipientType = entry.getKey(); + def entitiesToRemove = entry.getValue(); + + if (existingScope.containsKey(RecipientType) && existingScope[RecipientType] != null) { + if (!(existingScope[RecipientType] instanceof HashSet)) { + existingScope[RecipientType] = new HashSet(existingScope[RecipientType]); + } + + existingScope[RecipientType].removeAll(entitiesToRemove); + + if (existingScope[RecipientType].isEmpty()) { + existingScope.remove(RecipientType); + } + } + } + + if (existingScope.isEmpty()) { + ctx._source.share_with.remove(scopeName); + } + } + } + } + """, Map.of("revokeAccess", revoke, "scopes", scopesToUse)); + updateByQueryResourceSharing(sourceIdx, resourceId, revokeScript, revokeUpdateListener); + + }, listener::onFailure); + + // Return doc or null based on successful result, fail otherwise + revokeUpdateListener.whenComplete(success -> { + if (!success) { + LOGGER.error("Failed to revoke access for resource {} in index {} (no docs updated).", resourceId, sourceIdx); + listener.onResponse(null); + return; + } + // TODO check if this should be replaced by Java in-memory computation (current intuition is that it will be more memory + // intensive to do it in java) + fetchDocumentById(sourceIdx, resourceId, updatedSharingListener); + }, listener::onFailure); + + updatedSharingListener.whenComplete(listener::onResponse, listener::onFailure); + } + } + + /** + * Deletes resource sharing records that match the specified source index and resource ID. + * This method performs a delete-by-query operation in the resource sharing index. + * + *

The method executes the following steps: + *

    + *
  1. Creates a delete-by-query request with a bool query
  2. + *
  3. Matches documents based on exact source index and resource ID
  4. + *
  5. Executes the delete operation with immediate refresh
  6. + *
  7. Returns the success/failure status based on deletion results
  8. + *
+ * + *

Example document structure that will be deleted: + *

+     * {
+     *   "source_idx": "source_index_name",
+     *   "resource_id": "resource_id_value",
+     *   "share_with": {
+     *     // sharing configuration
+     *   }
+     * }
+     * 
+ * + * @param sourceIdx The source index to match in the query (exact match) + * @param resourceId The resource ID to match in the query (exact match) + * @param listener The listener to be notified when the operation completes + * @throws IllegalArgumentException if sourceIdx or resourceId is null/empty + * @throws RuntimeException if the delete operation fails or encounters an error + * + * @implNote The delete operation uses a bool query with two must clauses to ensure exact matching: + *
+     * {
+     *   "query": {
+     *     "bool": {
+     *       "must": [
+     *         { "term": { "source_idx": sourceIdx } },
+     *         { "term": { "resource_id": resourceId } }
+     *       ]
+     *     }
+     *   }
+     * }
+     * 
+ */ + public void deleteResourceSharingRecord(String resourceId, String sourceIdx, ActionListener listener) { + LOGGER.debug( + "Deleting documents asynchronously from {} where source_idx = {} and resource_id = {}", + resourceSharingIndex, + sourceIdx, + resourceId + ); + + try (ThreadContext.StoredContext ctx = this.threadPool.getThreadContext().stashContext()) { + DeleteByQueryRequest dbq = new DeleteByQueryRequest(resourceSharingIndex).setQuery( + QueryBuilders.boolQuery() + .must(QueryBuilders.termQuery("source_idx.keyword", sourceIdx)) + .must(QueryBuilders.termQuery("resource_id.keyword", resourceId)) + ).setRefresh(true); + + client.execute(DeleteByQueryAction.INSTANCE, dbq, new ActionListener<>() { + @Override + public void onResponse(BulkByScrollResponse response) { + + long deleted = response.getDeleted(); + if (deleted > 0) { + LOGGER.info("Successfully deleted {} documents from {}", deleted, resourceSharingIndex); + listener.onResponse(true); + } else { + LOGGER.info( + "No documents found to delete in {} for source_idx: {} and resource_id: {}", + resourceSharingIndex, + sourceIdx, + resourceId + ); + // No documents were deleted + listener.onResponse(false); + } + } + + @Override + public void onFailure(Exception e) { + LOGGER.error("Failed to delete documents from {}", resourceSharingIndex, e); + listener.onFailure(e); + + } + }); + } catch (Exception e) { + LOGGER.error("Failed to delete documents from {} before request submission", resourceSharingIndex, e); + listener.onFailure(e); + } + } + + /** + * Deletes all resource sharing records that were created by a specific user. + * This method performs a delete-by-query operation to remove all documents where + * the created_by.user field matches the specified username. + * + *

The method executes the following steps: + *

    + *
  1. Validates the input username parameter
  2. + *
  3. Creates a delete-by-query request with term query matching
  4. + *
  5. Executes the delete operation with immediate refresh
  6. + *
  7. Returns the operation status based on number of deleted documents
  8. + *
+ * + *

Example query structure: + *

+    * {
+    *   "query": {
+    *     "term": {
+    *       "created_by.user": "username"
+    *     }
+    *   }
+    * }
+    * 
+ * + * @param name The username to match against the created_by.user field + * @param listener The listener to be notified when the operation completes + * @throws IllegalArgumentException if name is null or empty + * + * + * @implNote Implementation details: + *
    + *
  • Uses DeleteByQueryRequest for efficient bulk deletion
  • + *
  • Sets refresh=true for immediate consistency
  • + *
  • Uses term query for exact username matching
  • + *
  • Implements comprehensive error handling and logging
  • + *
+ * + * Example usage: + *
+    * boolean success = deleteAllRecordsForUser("john.doe");
+    * if (success) {
+    *     // Records were successfully deleted
+    * } else {
+    *     // No matching records found or operation failed
+    * }
+    * 
+ */ + public void deleteAllRecordsForUser(String name, ActionListener listener) { + if (StringUtils.isBlank(name)) { + listener.onFailure(new IllegalArgumentException("Username must not be null or empty")); + return; + } + + LOGGER.debug("Deleting all records for user {} asynchronously", name); + + try (ThreadContext.StoredContext ctx = this.threadPool.getThreadContext().stashContext()) { + DeleteByQueryRequest deleteRequest = new DeleteByQueryRequest(resourceSharingIndex).setQuery( + QueryBuilders.termQuery("created_by.user", name) + ).setRefresh(true); + + client.execute(DeleteByQueryAction.INSTANCE, deleteRequest, new ActionListener<>() { + @Override + public void onResponse(BulkByScrollResponse response) { + long deletedDocs = response.getDeleted(); + if (deletedDocs > 0) { + LOGGER.info("Successfully deleted {} documents created by user {}", deletedDocs, name); + listener.onResponse(true); + } else { + LOGGER.info("No documents found for user {}", name); + // No documents matched => success = false + listener.onResponse(false); + } + } + + @Override + public void onFailure(Exception e) { + LOGGER.error("Failed to delete documents for user {}", name, e); + listener.onFailure(e); + } + }); + } catch (Exception e) { + LOGGER.error("Failed to delete documents for user {} before request submission", name, e); + listener.onFailure(e); + } + } + + /** + * Fetches all documents from the specified resource index and deserializes them into the specified class. + * + * @param resourceIndex The resource index to fetch documents from. + * @param parser The class to deserialize the documents into a specified type defined by the parser. + * @param listener The listener to be notified with the set of deserialized documents. + * @param The type of the deserialized documents. + */ + public void getResourceDocumentsFromIds( + Set resourceIds, + String resourceIndex, + ResourceParser parser, + ActionListener> listener + ) { + if (resourceIds.isEmpty()) { + listener.onResponse(new HashSet<>()); + return; + } + + // stashing Context to avoid permission issues in-case resourceIndex is a system index + try (ThreadContext.StoredContext ctx = this.threadPool.getThreadContext().stashContext()) { + MultiGetRequest request = new MultiGetRequest(); + for (String id : resourceIds) { + request.add(new MultiGetRequest.Item(resourceIndex, id)); + } + + client.multiGet(request, ActionListener.wrap(response -> { + Set result = new HashSet<>(); + try { + for (MultiGetItemResponse itemResponse : response.getResponses()) { + if (!itemResponse.isFailed() && itemResponse.getResponse().isExists()) { + BytesReference sourceAsString = itemResponse.getResponse().getSourceAsBytesRef(); + XContentParser xContentParser = XContentHelper.createParser( + NamedXContentRegistry.EMPTY, + LoggingDeprecationHandler.INSTANCE, + sourceAsString, + XContentType.JSON + ); + T resource = parser.parseXContent(xContentParser); + result.add(resource); + } + } + listener.onResponse(result); + } catch (Exception e) { + listener.onFailure(new ResourceSharingException("Failed to parse resources: " + e.getMessage(), e)); + } + }, e -> { + if (e instanceof IndexNotFoundException) { + LOGGER.error("Index {} does not exist", resourceIndex, e); + listener.onFailure(e); + } else { + LOGGER.error("Failed to fetch resources with ids {} from index {}", resourceIds, resourceIndex, e); + listener.onFailure(new ResourceSharingException("Failed to fetch resources: " + e.getMessage(), e)); + } + })); + } + } + +} diff --git a/src/main/java/org/opensearch/security/resources/ResourceSharingIndexListener.java b/src/main/java/org/opensearch/security/resources/ResourceSharingIndexListener.java new file mode 100644 index 0000000000..d32a49c80e --- /dev/null +++ b/src/main/java/org/opensearch/security/resources/ResourceSharingIndexListener.java @@ -0,0 +1,130 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.security.resources; + +import java.io.IOException; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import org.opensearch.client.Client; +import org.opensearch.core.action.ActionListener; +import org.opensearch.core.index.shard.ShardId; +import org.opensearch.index.engine.Engine; +import org.opensearch.index.shard.IndexingOperationListener; +import org.opensearch.security.auditlog.AuditLog; +import org.opensearch.security.auth.UserSubjectImpl; +import org.opensearch.security.support.ConfigConstants; +import org.opensearch.security.user.User; +import org.opensearch.threadpool.ThreadPool; + +/** + * This class implements an index operation listener for operations performed on resources stored in plugin's indices + * These indices are defined on bootstrap and configured to listen in OpenSearchSecurityPlugin.java + */ +public class ResourceSharingIndexListener implements IndexingOperationListener { + + private final static Logger log = LogManager.getLogger(ResourceSharingIndexListener.class); + + private static final ResourceSharingIndexListener INSTANCE = new ResourceSharingIndexListener(); + private ResourceSharingIndexHandler resourceSharingIndexHandler; + + private boolean initialized; + + private ThreadPool threadPool; + + private ResourceSharingIndexListener() {} + + public static ResourceSharingIndexListener getInstance() { + return ResourceSharingIndexListener.INSTANCE; + } + + /** + * Initializes the ResourceSharingIndexListener with the provided ThreadPool and Client. + * This method is called during the plugin's initialization process. + * + * @param threadPool The ThreadPool instance to be used for executing operations. + * @param client The Client instance to be used for interacting with OpenSearch. + */ + public void initialize(ThreadPool threadPool, Client client, AuditLog auditLog) { + + if (initialized) { + return; + } + + initialized = true; + this.threadPool = threadPool; + this.resourceSharingIndexHandler = new ResourceSharingIndexHandler( + ResourceSharingConstants.OPENSEARCH_RESOURCE_SHARING_INDEX, + client, + threadPool, + auditLog + ); + + } + + public boolean isInitialized() { + return initialized; + } + + /** + * This method is called after an index operation is performed. + * It creates a resource sharing entry in the dedicated resource sharing index. + * @param shardId The shard ID of the index where the operation was performed. + * @param index The index where the operation was performed. + * @param result The result of the index operation. + */ + @Override + public void postIndex(ShardId shardId, Engine.Index index, Engine.IndexResult result) { + + String resourceIndex = shardId.getIndexName(); + log.info("postIndex called on {}", resourceIndex); + + String resourceId = index.id(); + + final UserSubjectImpl userSubject = (UserSubjectImpl) threadPool.getThreadContext() + .getPersistent(ConfigConstants.OPENDISTRO_SECURITY_AUTHENTICATED_USER); + final User user = userSubject.getUser(); + try { + ResourceSharing sharing = this.resourceSharingIndexHandler.indexResourceSharing( + resourceId, + resourceIndex, + new CreatedBy(Creator.USER, user.getName()), + null + ); + log.info("Successfully created a resource sharing entry {}", sharing); + } catch (IOException e) { + log.info("Failed to create a resource sharing entry for resource: {}", resourceId); + } + } + + /** + * This method is called after a delete operation is performed. + * It deletes the corresponding resource sharing entry from the dedicated resource sharing index. + * @param shardId The shard ID of the index where the delete operation was performed. + * @param delete The delete operation that was performed. + * @param result The result of the delete operation. + */ + @Override + public void postDelete(ShardId shardId, Engine.Delete delete, Engine.DeleteResult result) { + + String resourceIndex = shardId.getIndexName(); + log.info("postDelete called on {}", resourceIndex); + + String resourceId = delete.id(); + + this.resourceSharingIndexHandler.deleteResourceSharingRecord(resourceId, resourceIndex, ActionListener.wrap(deleted -> { + if (deleted) { + log.info("Successfully deleted resource sharing entry for resource {}", resourceId); + } else { + log.info("No resource sharing entry found for resource {}", resourceId); + } + }, exception -> log.error("Failed to delete resource sharing entry for resource {}", resourceId, exception))); + } +} diff --git a/src/main/java/org/opensearch/security/resources/ResourceSharingIndexManagementRepository.java b/src/main/java/org/opensearch/security/resources/ResourceSharingIndexManagementRepository.java new file mode 100644 index 0000000000..9ad7e18975 --- /dev/null +++ b/src/main/java/org/opensearch/security/resources/ResourceSharingIndexManagementRepository.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. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.resources; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +public class ResourceSharingIndexManagementRepository { + + private static final Logger log = LogManager.getLogger(ResourceSharingIndexManagementRepository.class); + + private final ResourceSharingIndexHandler resourceSharingIndexHandler; + private final boolean resourceSharingEnabled; + + protected ResourceSharingIndexManagementRepository( + final ResourceSharingIndexHandler resourceSharingIndexHandler, + boolean isResourceSharingEnabled + ) { + this.resourceSharingIndexHandler = resourceSharingIndexHandler; + this.resourceSharingEnabled = isResourceSharingEnabled; + } + + public static ResourceSharingIndexManagementRepository create( + ResourceSharingIndexHandler resourceSharingIndexHandler, + boolean isResourceSharingEnabled + ) { + return new ResourceSharingIndexManagementRepository(resourceSharingIndexHandler, isResourceSharingEnabled); + } + + /** + * Creates the resource sharing index if it doesn't already exist. + * This method is called during the initialization phase of the repository. + * It ensures that the index is set up with the necessary mappings and settings + * before any operations are performed on the index. + */ + public void createResourceSharingIndexIfAbsent() { + // TODO check if this should be wrapped in an atomic completable future + if (resourceSharingEnabled) { + log.info("Attempting to create Resource Sharing index"); + this.resourceSharingIndexHandler.createResourceSharingIndexIfAbsent(() -> null); + } + + } +} diff --git a/src/main/java/org/opensearch/security/resources/ShareWith.java b/src/main/java/org/opensearch/security/resources/ShareWith.java new file mode 100644 index 0000000000..2a8e047761 --- /dev/null +++ b/src/main/java/org/opensearch/security/resources/ShareWith.java @@ -0,0 +1,104 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.security.resources; + +import java.io.IOException; +import java.util.HashSet; +import java.util.Set; + +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.ToXContentFragment; +import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.core.xcontent.XContentParser; + +/** + * + * This class contains information about whom a resource is shared with and at what scope. + * Example: + * "share_with": { + * "read_only": { + * "users": [], + * "roles": [], + * "backend_roles": [] + * }, + * "read_write": { + * "users": [], + * "roles": [], + * "backend_roles": [] + * } + * } + * + * @opensearch.experimental + */ +public class ShareWith implements ToXContentFragment, NamedWriteable { + + /** + * A set of objects representing the scopes and their associated users, roles, and backend roles. + */ + private final Set sharedWithScopes; + + public ShareWith(Set sharedWithScopes) { + this.sharedWithScopes = sharedWithScopes; + } + + public ShareWith(StreamInput in) throws IOException { + this.sharedWithScopes = in.readSet(SharedWithScope::new); + } + + public Set getSharedWithScopes() { + return sharedWithScopes; + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + builder.startObject(); + + for (SharedWithScope scope : sharedWithScopes) { + scope.toXContent(builder, params); + } + + return builder.endObject(); + } + + public static ShareWith fromXContent(XContentParser parser) throws IOException { + Set sharedWithScopes = new HashSet<>(); + + if (parser.currentToken() != XContentParser.Token.START_OBJECT) { + parser.nextToken(); + } + + XContentParser.Token token; + while ((token = parser.nextToken()) != XContentParser.Token.END_OBJECT) { + // Each field in the object represents a SharedWithScope + if (token == XContentParser.Token.FIELD_NAME) { + SharedWithScope scope = SharedWithScope.fromXContent(parser); + sharedWithScopes.add(scope); + } + } + + return new ShareWith(sharedWithScopes); + } + + @Override + public String getWriteableName() { + return "share_with"; + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + out.writeCollection(sharedWithScopes); + } + + @Override + public String toString() { + return "ShareWith " + sharedWithScopes; + } +} diff --git a/src/main/java/org/opensearch/security/resources/SharedWithScope.java b/src/main/java/org/opensearch/security/resources/SharedWithScope.java new file mode 100644 index 0000000000..5a0bbc01b4 --- /dev/null +++ b/src/main/java/org/opensearch/security/resources/SharedWithScope.java @@ -0,0 +1,169 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.security.resources; + +import java.io.IOException; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +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.ToXContentFragment; +import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.core.xcontent.XContentParser; + +/** + * This class represents the scope at which a resource is shared with. + * Example: + * "read_only": { + * "users": [], + * "roles": [], + * "backend_roles": [] + * } + * where "users", "roles" and "backend_roles" are the recipient entities + * + * @opensearch.experimental + */ +public class SharedWithScope implements ToXContentFragment, NamedWriteable { + + private final String scope; + + private final ScopeRecipients scopeRecipients; + + public SharedWithScope(String scope, ScopeRecipients scopeRecipients) { + this.scope = scope; + this.scopeRecipients = scopeRecipients; + } + + public SharedWithScope(StreamInput in) throws IOException { + this.scope = in.readString(); + this.scopeRecipients = new ScopeRecipients(in); + } + + public String getScope() { + return scope; + } + + public ScopeRecipients getSharedWithPerScope() { + return scopeRecipients; + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + builder.field(scope); + builder.startObject(); + + scopeRecipients.toXContent(builder, params); + + return builder.endObject(); + } + + public static SharedWithScope fromXContent(XContentParser parser) throws IOException { + String scope = parser.currentName(); + + parser.nextToken(); + + ScopeRecipients scopeRecipients = ScopeRecipients.fromXContent(parser); + + return new SharedWithScope(scope, scopeRecipients); + } + + @Override + public String toString() { + return "{" + scope + ": " + scopeRecipients + '}'; + } + + @Override + public String getWriteableName() { + return "shared_with_scope"; + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + out.writeString(scope); + out.writeNamedWriteable(scopeRecipients); + } + + /** + * This class represents the entities with whom a resource is shared with for a given scope. + * + * @opensearch.experimental + */ + public static class ScopeRecipients implements ToXContentFragment, NamedWriteable { + + private final Map> recipients; + + public ScopeRecipients(Map> recipients) { + if (recipients == null) { + throw new IllegalArgumentException("Recipients map cannot be null"); + } + this.recipients = recipients; + } + + public ScopeRecipients(StreamInput in) throws IOException { + this.recipients = in.readMap( + key -> RecipientTypeRegistry.fromValue(key.readString()), + input -> input.readSet(StreamInput::readString) + ); + } + + public Map> getRecipients() { + return recipients; + } + + @Override + public String getWriteableName() { + return "scope_recipients"; + } + + public static ScopeRecipients fromXContent(XContentParser parser) throws IOException { + Map> recipients = new HashMap<>(); + + XContentParser.Token token; + while ((token = parser.nextToken()) != XContentParser.Token.END_OBJECT) { + if (token == XContentParser.Token.FIELD_NAME) { + String fieldName = parser.currentName(); + RecipientType recipientType = RecipientTypeRegistry.fromValue(fieldName); + + parser.nextToken(); + Set values = new HashSet<>(); + while (parser.nextToken() != XContentParser.Token.END_ARRAY) { + values.add(parser.text()); + } + recipients.put(recipientType, values); + } + } + + return new ScopeRecipients(recipients); + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + out.writeMap( + recipients, + (streamOutput, recipientType) -> streamOutput.writeString(recipientType.type()), + (streamOutput, strings) -> streamOutput.writeCollection(strings, StreamOutput::writeString) + ); + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + if (recipients.isEmpty()) { + return builder; + } + for (Map.Entry> entry : recipients.entrySet()) { + builder.array(entry.getKey().type(), entry.getValue().toArray()); + } + return builder; + } + } +} diff --git a/src/main/java/org/opensearch/security/resources/package-info.java b/src/main/java/org/opensearch/security/resources/package-info.java new file mode 100644 index 0000000000..855bdf81af --- /dev/null +++ b/src/main/java/org/opensearch/security/resources/package-info.java @@ -0,0 +1,12 @@ +/* + * 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.security.resources; diff --git a/src/main/java/org/opensearch/security/rest/resources/access/ResourceAccessRestAction.java b/src/main/java/org/opensearch/security/rest/resources/access/ResourceAccessRestAction.java new file mode 100644 index 0000000000..c4b26ba949 --- /dev/null +++ b/src/main/java/org/opensearch/security/rest/resources/access/ResourceAccessRestAction.java @@ -0,0 +1,249 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.security.rest.resources.access; + +import java.io.IOException; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import com.google.common.collect.ImmutableList; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import org.opensearch.client.node.NodeClient; +import org.opensearch.common.xcontent.LoggingDeprecationHandler; +import org.opensearch.common.xcontent.XContentFactory; +import org.opensearch.common.xcontent.XContentType; +import org.opensearch.core.action.ActionListener; +import org.opensearch.core.rest.RestStatus; +import org.opensearch.core.xcontent.NamedXContentRegistry; +import org.opensearch.core.xcontent.XContentParser; +import org.opensearch.rest.BaseRestHandler; +import org.opensearch.rest.BytesRestResponse; +import org.opensearch.rest.RestChannel; +import org.opensearch.rest.RestRequest; +import org.opensearch.security.resources.RecipientType; +import org.opensearch.security.resources.RecipientTypeRegistry; +import org.opensearch.security.resources.ResourceAccessHandler; +import org.opensearch.security.resources.ResourceSharing; +import org.opensearch.security.resources.ShareWith; +import org.opensearch.security.spi.resources.Resource; + +import static org.opensearch.rest.RestRequest.Method.GET; +import static org.opensearch.rest.RestRequest.Method.POST; +import static org.opensearch.security.dlic.rest.api.Responses.badRequest; +import static org.opensearch.security.dlic.rest.api.Responses.forbidden; +import static org.opensearch.security.dlic.rest.api.Responses.ok; +import static org.opensearch.security.dlic.rest.api.Responses.unauthorized; +import static org.opensearch.security.dlic.rest.support.Utils.PLUGIN_RESOURCE_ROUTE_PREFIX; +import static org.opensearch.security.dlic.rest.support.Utils.addRoutesPrefix; + +/** + * This class handles the REST API for resource access management. + */ +public class ResourceAccessRestAction extends BaseRestHandler { + private static final Logger LOGGER = LogManager.getLogger(ResourceAccessRestAction.class); + + private final ResourceAccessHandler resourceAccessHandler; + + public ResourceAccessRestAction(ResourceAccessHandler resourceAccessHandler) { + this.resourceAccessHandler = resourceAccessHandler; + } + + @Override + public List routes() { + return addRoutesPrefix( + ImmutableList.of( + new Route(GET, "/list/{resourceIndex}"), + new Route(POST, "/revoke"), + new Route(POST, "/share"), + new Route(POST, "/verify_access") + ), + PLUGIN_RESOURCE_ROUTE_PREFIX + ); + } + + @Override + public String getName() { + return "resource_api_action"; + } + + @Override + protected RestChannelConsumer prepareRequest(RestRequest request, NodeClient client) throws IOException { + consumeParams(request); // early consume params to avoid 400s + String path = request.path().split(PLUGIN_RESOURCE_ROUTE_PREFIX)[1].split("/")[1]; + return switch (path) { + case "list" -> channel -> handleListResources(request, channel); + case "revoke" -> channel -> handleRevokeResource(request, channel); + case "share" -> channel -> handleShareResource(request, channel); + case "verify_access" -> channel -> handleVerifyRequest(request, channel); + default -> channel -> badRequest(channel, "Unknown route: " + path); + }; + } + + /** + * Consume params early to avoid 400s. + * @param request from which the params must be consumed + */ + private void consumeParams(RestRequest request) { + request.param("resourceIndex", ""); + } + + /** + * Handle the list resources request. + * @param request the request to handle + * @param channel the channel to send the response to + */ + private void handleListResources(RestRequest request, RestChannel channel) { + String resourceIndex = request.param("resourceIndex", ""); + resourceAccessHandler.getAccessibleResourcesForCurrentUser( + resourceIndex, + ActionListener.wrap(resources -> sendResponse(channel, resources), e -> handleError(channel, e.getMessage(), e)) + ); + } + + /** + * Handle the share resource request. + * @param request the request to handle + * @param channel the channel to send the response to + * @throws IOException if an I/O error occurs + */ + private void handleShareResource(RestRequest request, RestChannel channel) throws IOException { + Map source; + try (XContentParser parser = request.contentParser()) { + source = parser.map(); + } + String resourceId = (String) source.get("resource_id"); + String resourceIndex = (String) source.get("resource_index"); + + ShareWith shareWith = parseShareWith(source); + resourceAccessHandler.shareWith( + resourceId, + resourceIndex, + shareWith, + ActionListener.wrap(response -> sendResponse(channel, response), e -> handleError(channel, e.getMessage(), e)) + ); + } + + /** + * Handle the revoke resource request. + * @param request the request to handle + * @param channel the channel to send the response to + * @throws IOException if an I/O error occurs + */ + @SuppressWarnings("unchecked") + private void handleRevokeResource(RestRequest request, RestChannel channel) throws IOException { + Map source; + try (XContentParser parser = request.contentParser()) { + source = parser.map(); + } + + String resourceId = (String) source.get("resource_id"); + String resourceIndex = (String) source.get("resource_index"); + + Map> revokeSource = (Map>) source.get("entities"); + Map> revoke = revokeSource.entrySet() + .stream() + .collect(Collectors.toMap(entry -> RecipientTypeRegistry.fromValue(entry.getKey()), Map.Entry::getValue)); + Set scopes = new HashSet<>(source.containsKey("scopes") ? (List) source.get("scopes") : List.of()); + resourceAccessHandler.revokeAccess( + resourceId, + resourceIndex, + revoke, + scopes, + ActionListener.wrap(response -> sendResponse(channel, response), e -> handleError(channel, e.getMessage(), e)) + ); + } + + /** + * Handle the verify request. + * @param request the request to handle + * @param channel the channel to send the response to + * @throws IOException if an I/O error occurs + */ + private void handleVerifyRequest(RestRequest request, RestChannel channel) throws IOException { + Map source; + try (XContentParser parser = request.contentParser()) { + source = parser.map(); + } + + String resourceId = (String) source.get("resource_id"); + String resourceIndex = (String) source.get("resource_index"); + String scope = (String) source.get("scope"); + + resourceAccessHandler.hasPermission( + resourceId, + resourceIndex, + scope, + ActionListener.wrap(response -> sendResponse(channel, response), e -> handleError(channel, e.getMessage(), e)) + ); + } + + /** + * Parse the share with structure from the request body. + * @param source the request body + * @return the parsed ShareWith object + * @throws IOException if an I/O error occurs + */ + @SuppressWarnings("unchecked") + private ShareWith parseShareWith(Map source) throws IOException { + Map shareWithMap = (Map) source.get("share_with"); + if (shareWithMap == null || shareWithMap.isEmpty()) { + throw new IllegalArgumentException("share_with is required and cannot be empty"); + } + + String jsonString = XContentFactory.jsonBuilder().map(shareWithMap).toString(); + + try ( + XContentParser parser = XContentType.JSON.xContent() + .createParser(NamedXContentRegistry.EMPTY, LoggingDeprecationHandler.INSTANCE, jsonString) + ) { + return ShareWith.fromXContent(parser); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Invalid share_with structure: " + e.getMessage(), e); + } + } + + /** + * Send the appropriate response to the channel. + * @param channel the channel to send the response to + * @param response the response to send + * @throws IOException if an I/O error occurs + */ + @SuppressWarnings("unchecked") + private void sendResponse(RestChannel channel, Object response) throws IOException { + if (response instanceof Set) { // list + Set resources = (Set) response; + ok(channel, (builder, params) -> builder.startObject().field("resources", resources).endObject()); + } else if (response instanceof ResourceSharing resourceSharing) { // share & revoke + ok(channel, (resourceSharing::toXContent)); + } else if (response instanceof Boolean) { // verify_access + ok(channel, (builder, params) -> builder.startObject().field("has_permission", String.valueOf(response)).endObject()); + } + } + + /** + * Handle errors that occur during request processing. + * @param channel the channel to send the error response to + * @param message the error message + * @param e the exception that caused the error + */ + private void handleError(RestChannel channel, String message, Exception e) { + LOGGER.error(message, e); + if (message.contains("not authorized")) { + forbidden(channel, message); + } else if (message.contains("no authenticated")) { + unauthorized(channel); + } + channel.sendResponse(new BytesRestResponse(RestStatus.INTERNAL_SERVER_ERROR, message)); + } +} diff --git a/src/main/java/org/opensearch/security/support/ConfigConstants.java b/src/main/java/org/opensearch/security/support/ConfigConstants.java index 106b7451c4..9585995919 100644 --- a/src/main/java/org/opensearch/security/support/ConfigConstants.java +++ b/src/main/java/org/opensearch/security/support/ConfigConstants.java @@ -43,6 +43,7 @@ public class ConfigConstants { public static final String OPENDISTRO_SECURITY_CONFIG_PREFIX = "_opendistro_security_"; + public static final String SECURITY_SETTINGS_PREFIX = "plugins.security."; public static final String OPENDISTRO_SECURITY_CHANNEL_TYPE = OPENDISTRO_SECURITY_CONFIG_PREFIX + "channel_type"; @@ -129,11 +130,11 @@ public class ConfigConstants { public static final String OPENDISTRO_SECURITY_DEFAULT_CONFIG_INDEX = ".opendistro_security"; - public static final String SECURITY_ENABLE_SNAPSHOT_RESTORE_PRIVILEGE = "plugins.security.enable_snapshot_restore_privilege"; + public static final String SECURITY_ENABLE_SNAPSHOT_RESTORE_PRIVILEGE = SECURITY_SETTINGS_PREFIX + "enable_snapshot_restore_privilege"; public static final boolean SECURITY_DEFAULT_ENABLE_SNAPSHOT_RESTORE_PRIVILEGE = true; - public static final String SECURITY_CHECK_SNAPSHOT_RESTORE_WRITE_PRIVILEGES = - "plugins.security.check_snapshot_restore_write_privileges"; + public static final String SECURITY_CHECK_SNAPSHOT_RESTORE_WRITE_PRIVILEGES = SECURITY_SETTINGS_PREFIX + + "check_snapshot_restore_write_privileges"; public static final boolean SECURITY_DEFAULT_CHECK_SNAPSHOT_RESTORE_WRITE_PRIVILEGES = true; public static final Set SECURITY_SNAPSHOT_RESTORE_NEEDED_WRITE_PRIVILEGES = Collections.unmodifiableSet( new HashSet(Arrays.asList("indices:admin/create", "indices:data/write/index" @@ -141,37 +142,39 @@ public class ConfigConstants { )) ); - public static final String SECURITY_INTERCLUSTER_REQUEST_EVALUATOR_CLASS = "plugins.security.cert.intercluster_request_evaluator_class"; + public static final String SECURITY_INTERCLUSTER_REQUEST_EVALUATOR_CLASS = SECURITY_SETTINGS_PREFIX + + "cert.intercluster_request_evaluator_class"; public static final String OPENDISTRO_SECURITY_ACTION_NAME = OPENDISTRO_SECURITY_CONFIG_PREFIX + "action_name"; - public static final String SECURITY_AUTHCZ_ADMIN_DN = "plugins.security.authcz.admin_dn"; - public static final String SECURITY_CONFIG_INDEX_NAME = "plugins.security.config_index_name"; - public static final String SECURITY_AUTHCZ_IMPERSONATION_DN = "plugins.security.authcz.impersonation_dn"; - public static final String SECURITY_AUTHCZ_REST_IMPERSONATION_USERS = "plugins.security.authcz.rest_impersonation_user"; + public static final String SECURITY_AUTHCZ_ADMIN_DN = SECURITY_SETTINGS_PREFIX + "authcz.admin_dn"; + public static final String SECURITY_CONFIG_INDEX_NAME = SECURITY_SETTINGS_PREFIX + "config_index_name"; + public static final String SECURITY_AUTHCZ_IMPERSONATION_DN = SECURITY_SETTINGS_PREFIX + "authcz.impersonation_dn"; + public static final String SECURITY_AUTHCZ_REST_IMPERSONATION_USERS = SECURITY_SETTINGS_PREFIX + "authcz.rest_impersonation_user"; public static final String BCRYPT = "bcrypt"; public static final String PBKDF2 = "pbkdf2"; - public static final String SECURITY_PASSWORD_HASHING_BCRYPT_ROUNDS = "plugins.security.password.hashing.bcrypt.rounds"; + public static final String SECURITY_PASSWORD_HASHING_BCRYPT_ROUNDS = SECURITY_SETTINGS_PREFIX + "password.hashing.bcrypt.rounds"; public static final int SECURITY_PASSWORD_HASHING_BCRYPT_ROUNDS_DEFAULT = 12; - public static final String SECURITY_PASSWORD_HASHING_BCRYPT_MINOR = "plugins.security.password.hashing.bcrypt.minor"; + public static final String SECURITY_PASSWORD_HASHING_BCRYPT_MINOR = SECURITY_SETTINGS_PREFIX + "password.hashing.bcrypt.minor"; public static final String SECURITY_PASSWORD_HASHING_BCRYPT_MINOR_DEFAULT = "Y"; - public static final String SECURITY_PASSWORD_HASHING_ALGORITHM = "plugins.security.password.hashing.algorithm"; + public static final String SECURITY_PASSWORD_HASHING_ALGORITHM = SECURITY_SETTINGS_PREFIX + "password.hashing.algorithm"; public static final String SECURITY_PASSWORD_HASHING_ALGORITHM_DEFAULT = BCRYPT; - public static final String SECURITY_PASSWORD_HASHING_PBKDF2_ITERATIONS = "plugins.security.password.hashing.pbkdf2.iterations"; + public static final String SECURITY_PASSWORD_HASHING_PBKDF2_ITERATIONS = SECURITY_SETTINGS_PREFIX + + "password.hashing.pbkdf2.iterations"; public static final int SECURITY_PASSWORD_HASHING_PBKDF2_ITERATIONS_DEFAULT = 600_000; - public static final String SECURITY_PASSWORD_HASHING_PBKDF2_LENGTH = "plugins.security.password.hashing.pbkdf2.length"; + public static final String SECURITY_PASSWORD_HASHING_PBKDF2_LENGTH = SECURITY_SETTINGS_PREFIX + "password.hashing.pbkdf2.length"; public static final int SECURITY_PASSWORD_HASHING_PBKDF2_LENGTH_DEFAULT = 256; - public static final String SECURITY_PASSWORD_HASHING_PBKDF2_FUNCTION = "plugins.security.password.hashing.pbkdf2.function"; + public static final String SECURITY_PASSWORD_HASHING_PBKDF2_FUNCTION = SECURITY_SETTINGS_PREFIX + "password.hashing.pbkdf2.function"; public static final String SECURITY_PASSWORD_HASHING_PBKDF2_FUNCTION_DEFAULT = Hmac.SHA256.name(); - public static final String SECURITY_AUDIT_TYPE_DEFAULT = "plugins.security.audit.type"; - public static final String SECURITY_AUDIT_CONFIG_DEFAULT = "plugins.security.audit.config"; - public static final String SECURITY_AUDIT_CONFIG_ROUTES = "plugins.security.audit.routes"; - public static final String SECURITY_AUDIT_CONFIG_ENDPOINTS = "plugins.security.audit.endpoints"; - public static final String SECURITY_AUDIT_THREADPOOL_SIZE = "plugins.security.audit.threadpool.size"; - public static final String SECURITY_AUDIT_THREADPOOL_MAX_QUEUE_LEN = "plugins.security.audit.threadpool.max_queue_len"; + public static final String SECURITY_AUDIT_TYPE_DEFAULT = SECURITY_SETTINGS_PREFIX + "audit.type"; + public static final String SECURITY_AUDIT_CONFIG_DEFAULT = SECURITY_SETTINGS_PREFIX + "audit.config"; + public static final String SECURITY_AUDIT_CONFIG_ROUTES = SECURITY_SETTINGS_PREFIX + "audit.routes"; + public static final String SECURITY_AUDIT_CONFIG_ENDPOINTS = SECURITY_SETTINGS_PREFIX + "audit.endpoints"; + public static final String SECURITY_AUDIT_THREADPOOL_SIZE = SECURITY_SETTINGS_PREFIX + "audit.threadpool.size"; + public static final String SECURITY_AUDIT_THREADPOOL_MAX_QUEUE_LEN = SECURITY_SETTINGS_PREFIX + "audit.threadpool.max_queue_len"; public static final String OPENDISTRO_SECURITY_AUDIT_LOG_REQUEST_BODY = "opendistro_security.audit.log_request_body"; public static final String OPENDISTRO_SECURITY_AUDIT_RESOLVE_INDICES = "opendistro_security.audit.resolve_indices"; public static final String OPENDISTRO_SECURITY_AUDIT_ENABLE_REST = "opendistro_security.audit.enable_rest"; @@ -186,13 +189,13 @@ public class ConfigConstants { ); public static final String OPENDISTRO_SECURITY_AUDIT_IGNORE_USERS = "opendistro_security.audit.ignore_users"; public static final String OPENDISTRO_SECURITY_AUDIT_IGNORE_REQUESTS = "opendistro_security.audit.ignore_requests"; - public static final String SECURITY_AUDIT_IGNORE_HEADERS = "plugins.security.audit.ignore_headers"; + public static final String SECURITY_AUDIT_IGNORE_HEADERS = SECURITY_SETTINGS_PREFIX + "audit.ignore_headers"; public static final String OPENDISTRO_SECURITY_AUDIT_RESOLVE_BULK_REQUESTS = "opendistro_security.audit.resolve_bulk_requests"; public static final boolean OPENDISTRO_SECURITY_AUDIT_SSL_VERIFY_HOSTNAMES_DEFAULT = true; public static final boolean OPENDISTRO_SECURITY_AUDIT_SSL_ENABLE_SSL_CLIENT_AUTH_DEFAULT = false; public static final String OPENDISTRO_SECURITY_AUDIT_EXCLUDE_SENSITIVE_HEADERS = "opendistro_security.audit.exclude_sensitive_headers"; - public static final String SECURITY_AUDIT_CONFIG_DEFAULT_PREFIX = "plugins.security.audit.config."; + public static final String SECURITY_AUDIT_CONFIG_DEFAULT_PREFIX = SECURITY_SETTINGS_PREFIX + "audit.config."; // Internal Opensearch data_stream public static final String SECURITY_AUDIT_OPENSEARCH_DATASTREAM_NAME = "data_stream.name"; @@ -235,31 +238,31 @@ public class ConfigConstants { public static final String SECURITY_AUDIT_LOG4J_LEVEL = "log4j.level"; // retry - public static final String SECURITY_AUDIT_RETRY_COUNT = "plugins.security.audit.config.retry_count"; - public static final String SECURITY_AUDIT_RETRY_DELAY_MS = "plugins.security.audit.config.retry_delay_ms"; + public static final String SECURITY_AUDIT_RETRY_COUNT = SECURITY_SETTINGS_PREFIX + "audit.config.retry_count"; + public static final String SECURITY_AUDIT_RETRY_DELAY_MS = SECURITY_SETTINGS_PREFIX + "audit.config.retry_delay_ms"; - public static final String SECURITY_KERBEROS_KRB5_FILEPATH = "plugins.security.kerberos.krb5_filepath"; - public static final String SECURITY_KERBEROS_ACCEPTOR_KEYTAB_FILEPATH = "plugins.security.kerberos.acceptor_keytab_filepath"; - public static final String SECURITY_KERBEROS_ACCEPTOR_PRINCIPAL = "plugins.security.kerberos.acceptor_principal"; - public static final String SECURITY_CERT_OID = "plugins.security.cert.oid"; - public static final String SECURITY_CERT_INTERCLUSTER_REQUEST_EVALUATOR_CLASS = - "plugins.security.cert.intercluster_request_evaluator_class"; - public static final String SECURITY_ADVANCED_MODULES_ENABLED = "plugins.security.advanced_modules_enabled"; - public static final String SECURITY_NODES_DN = "plugins.security.nodes_dn"; - public static final String SECURITY_NODES_DN_DYNAMIC_CONFIG_ENABLED = "plugins.security.nodes_dn_dynamic_config_enabled"; - public static final String SECURITY_DISABLED = "plugins.security.disabled"; + public static final String SECURITY_KERBEROS_KRB5_FILEPATH = SECURITY_SETTINGS_PREFIX + "kerberos.krb5_filepath"; + public static final String SECURITY_KERBEROS_ACCEPTOR_KEYTAB_FILEPATH = SECURITY_SETTINGS_PREFIX + "kerberos.acceptor_keytab_filepath"; + public static final String SECURITY_KERBEROS_ACCEPTOR_PRINCIPAL = SECURITY_SETTINGS_PREFIX + "kerberos.acceptor_principal"; + public static final String SECURITY_CERT_OID = SECURITY_SETTINGS_PREFIX + "cert.oid"; + public static final String SECURITY_CERT_INTERCLUSTER_REQUEST_EVALUATOR_CLASS = SECURITY_SETTINGS_PREFIX + + "cert.intercluster_request_evaluator_class"; + public static final String SECURITY_ADVANCED_MODULES_ENABLED = SECURITY_SETTINGS_PREFIX + "advanced_modules_enabled"; + public static final String SECURITY_NODES_DN = SECURITY_SETTINGS_PREFIX + "nodes_dn"; + public static final String SECURITY_NODES_DN_DYNAMIC_CONFIG_ENABLED = SECURITY_SETTINGS_PREFIX + "nodes_dn_dynamic_config_enabled"; + public static final String SECURITY_DISABLED = SECURITY_SETTINGS_PREFIX + "disabled"; - public static final String SECURITY_CACHE_TTL_MINUTES = "plugins.security.cache.ttl_minutes"; - public static final String SECURITY_ALLOW_UNSAFE_DEMOCERTIFICATES = "plugins.security.allow_unsafe_democertificates"; - public static final String SECURITY_ALLOW_DEFAULT_INIT_SECURITYINDEX = "plugins.security.allow_default_init_securityindex"; + public static final String SECURITY_CACHE_TTL_MINUTES = SECURITY_SETTINGS_PREFIX + "cache.ttl_minutes"; + public static final String SECURITY_ALLOW_UNSAFE_DEMOCERTIFICATES = SECURITY_SETTINGS_PREFIX + "allow_unsafe_democertificates"; + public static final String SECURITY_ALLOW_DEFAULT_INIT_SECURITYINDEX = SECURITY_SETTINGS_PREFIX + "allow_default_init_securityindex"; - public static final String SECURITY_ALLOW_DEFAULT_INIT_USE_CLUSTER_STATE = - "plugins.security.allow_default_init_securityindex.use_cluster_state"; + public static final String SECURITY_ALLOW_DEFAULT_INIT_USE_CLUSTER_STATE = SECURITY_SETTINGS_PREFIX + + "allow_default_init_securityindex.use_cluster_state"; - public static final String SECURITY_BACKGROUND_INIT_IF_SECURITYINDEX_NOT_EXIST = - "plugins.security.background_init_if_securityindex_not_exist"; + public static final String SECURITY_BACKGROUND_INIT_IF_SECURITYINDEX_NOT_EXIST = SECURITY_SETTINGS_PREFIX + + "background_init_if_securityindex_not_exist"; - public static final String SECURITY_ROLES_MAPPING_RESOLUTION = "plugins.security.roles_mapping_resolution"; + public static final String SECURITY_ROLES_MAPPING_RESOLUTION = SECURITY_SETTINGS_PREFIX + "roles_mapping_resolution"; public static final String OPENDISTRO_SECURITY_COMPLIANCE_HISTORY_WRITE_METADATA_ONLY = "opendistro_security.compliance.history.write.metadata_only"; @@ -278,21 +281,22 @@ public class ConfigConstants { public static final String OPENDISTRO_SECURITY_COMPLIANCE_HISTORY_EXTERNAL_CONFIG_ENABLED = "opendistro_security.compliance.history.external_config_enabled"; public static final String OPENDISTRO_SECURITY_SOURCE_FIELD_CONTEXT = OPENDISTRO_SECURITY_CONFIG_PREFIX + "source_field_context"; - public static final String SECURITY_COMPLIANCE_DISABLE_ANONYMOUS_AUTHENTICATION = - "plugins.security.compliance.disable_anonymous_authentication"; - public static final String SECURITY_COMPLIANCE_IMMUTABLE_INDICES = "plugins.security.compliance.immutable_indices"; - public static final String SECURITY_COMPLIANCE_SALT = "plugins.security.compliance.salt"; + public static final String SECURITY_COMPLIANCE_DISABLE_ANONYMOUS_AUTHENTICATION = SECURITY_SETTINGS_PREFIX + + "compliance.disable_anonymous_authentication"; + public static final String SECURITY_COMPLIANCE_IMMUTABLE_INDICES = SECURITY_SETTINGS_PREFIX + "compliance.immutable_indices"; + public static final String SECURITY_COMPLIANCE_SALT = SECURITY_SETTINGS_PREFIX + "compliance.salt"; public static final String SECURITY_COMPLIANCE_SALT_DEFAULT = "e1ukloTsQlOgPquJ";// 16 chars public static final String SECURITY_COMPLIANCE_HISTORY_INTERNAL_CONFIG_ENABLED = "opendistro_security.compliance.history.internal_config_enabled"; - public static final String SECURITY_SSL_ONLY = "plugins.security.ssl_only"; + public static final String SECURITY_SSL_ONLY = SECURITY_SETTINGS_PREFIX + "ssl_only"; public static final String SECURITY_CONFIG_SSL_DUAL_MODE_ENABLED = "plugins.security_config.ssl_dual_mode_enabled"; public static final String SECURITY_SSL_DUAL_MODE_SKIP_SECURITY = OPENDISTRO_SECURITY_CONFIG_PREFIX + "passive_security"; public static final String LEGACY_OPENDISTRO_SECURITY_CONFIG_SSL_DUAL_MODE_ENABLED = "opendistro_security_config.ssl_dual_mode_enabled"; - public static final String SECURITY_SSL_CERT_RELOAD_ENABLED = "plugins.security.ssl_cert_reload_enabled"; - public static final String SECURITY_SSL_CERTIFICATES_HOT_RELOAD_ENABLED = "plugins.security.ssl.certificates_hot_reload.enabled"; - public static final String SECURITY_DISABLE_ENVVAR_REPLACEMENT = "plugins.security.disable_envvar_replacement"; - public static final String SECURITY_DFM_EMPTY_OVERRIDES_ALL = "plugins.security.dfm_empty_overrides_all"; + public static final String SECURITY_SSL_CERT_RELOAD_ENABLED = SECURITY_SETTINGS_PREFIX + "ssl_cert_reload_enabled"; + public static final String SECURITY_SSL_CERTIFICATES_HOT_RELOAD_ENABLED = SECURITY_SETTINGS_PREFIX + + "ssl.certificates_hot_reload.enabled"; + public static final String SECURITY_DISABLE_ENVVAR_REPLACEMENT = SECURITY_SETTINGS_PREFIX + "disable_envvar_replacement"; + public static final String SECURITY_DFM_EMPTY_OVERRIDES_ALL = SECURITY_SETTINGS_PREFIX + "dfm_empty_overrides_all"; public enum RolesMappingResolution { MAPPING_ONLY, @@ -300,44 +304,46 @@ public enum RolesMappingResolution { BOTH } - public static final String SECURITY_FILTER_SECURITYINDEX_FROM_ALL_REQUESTS = "plugins.security.filter_securityindex_from_all_requests"; - public static final String SECURITY_DLS_MODE = "plugins.security.dls.mode"; + public static final String SECURITY_FILTER_SECURITYINDEX_FROM_ALL_REQUESTS = SECURITY_SETTINGS_PREFIX + + "filter_securityindex_from_all_requests"; + public static final String SECURITY_DLS_MODE = SECURITY_SETTINGS_PREFIX + "dls.mode"; // REST API - public static final String SECURITY_RESTAPI_ROLES_ENABLED = "plugins.security.restapi.roles_enabled"; - public static final String SECURITY_RESTAPI_ADMIN_ENABLED = "plugins.security.restapi.admin.enabled"; - public static final String SECURITY_RESTAPI_ENDPOINTS_DISABLED = "plugins.security.restapi.endpoints_disabled"; - public static final String SECURITY_RESTAPI_PASSWORD_VALIDATION_REGEX = "plugins.security.restapi.password_validation_regex"; - public static final String SECURITY_RESTAPI_PASSWORD_VALIDATION_ERROR_MESSAGE = - "plugins.security.restapi.password_validation_error_message"; - public static final String SECURITY_RESTAPI_PASSWORD_MIN_LENGTH = "plugins.security.restapi.password_min_length"; - public static final String SECURITY_RESTAPI_PASSWORD_SCORE_BASED_VALIDATION_STRENGTH = - "plugins.security.restapi.password_score_based_validation_strength"; + public static final String SECURITY_RESTAPI_ROLES_ENABLED = SECURITY_SETTINGS_PREFIX + "restapi.roles_enabled"; + public static final String SECURITY_RESTAPI_ADMIN_ENABLED = SECURITY_SETTINGS_PREFIX + "restapi.admin.enabled"; + public static final String SECURITY_RESTAPI_ENDPOINTS_DISABLED = SECURITY_SETTINGS_PREFIX + "restapi.endpoints_disabled"; + public static final String SECURITY_RESTAPI_PASSWORD_VALIDATION_REGEX = SECURITY_SETTINGS_PREFIX + "restapi.password_validation_regex"; + public static final String SECURITY_RESTAPI_PASSWORD_VALIDATION_ERROR_MESSAGE = SECURITY_SETTINGS_PREFIX + + "restapi.password_validation_error_message"; + public static final String SECURITY_RESTAPI_PASSWORD_MIN_LENGTH = SECURITY_SETTINGS_PREFIX + "restapi.password_min_length"; + public static final String SECURITY_RESTAPI_PASSWORD_SCORE_BASED_VALIDATION_STRENGTH = SECURITY_SETTINGS_PREFIX + + "restapi.password_score_based_validation_strength"; // Illegal Opcodes from here on - public static final String SECURITY_UNSUPPORTED_DISABLE_REST_AUTH_INITIALLY = - "plugins.security.unsupported.disable_rest_auth_initially"; - public static final String SECURITY_UNSUPPORTED_DELAY_INITIALIZATION_SECONDS = - "plugins.security.unsupported.delay_initialization_seconds"; - public static final String SECURITY_UNSUPPORTED_DISABLE_INTERTRANSPORT_AUTH_INITIALLY = - "plugins.security.unsupported.disable_intertransport_auth_initially"; - public static final String SECURITY_UNSUPPORTED_PASSIVE_INTERTRANSPORT_AUTH_INITIALLY = - "plugins.security.unsupported.passive_intertransport_auth_initially"; - public static final String SECURITY_UNSUPPORTED_RESTORE_SECURITYINDEX_ENABLED = - "plugins.security.unsupported.restore.securityindex.enabled"; - public static final String SECURITY_UNSUPPORTED_INJECT_USER_ENABLED = "plugins.security.unsupported.inject_user.enabled"; - public static final String SECURITY_UNSUPPORTED_INJECT_ADMIN_USER_ENABLED = "plugins.security.unsupported.inject_user.admin.enabled"; - public static final String SECURITY_UNSUPPORTED_ALLOW_NOW_IN_DLS = "plugins.security.unsupported.allow_now_in_dls"; - - public static final String SECURITY_UNSUPPORTED_RESTAPI_ALLOW_SECURITYCONFIG_MODIFICATION = - "plugins.security.unsupported.restapi.allow_securityconfig_modification"; - public static final String SECURITY_UNSUPPORTED_LOAD_STATIC_RESOURCES = "plugins.security.unsupported.load_static_resources"; - public static final String SECURITY_UNSUPPORTED_ACCEPT_INVALID_CONFIG = "plugins.security.unsupported.accept_invalid_config"; + public static final String SECURITY_UNSUPPORTED_DISABLE_REST_AUTH_INITIALLY = SECURITY_SETTINGS_PREFIX + + "unsupported.disable_rest_auth_initially"; + public static final String SECURITY_UNSUPPORTED_DELAY_INITIALIZATION_SECONDS = SECURITY_SETTINGS_PREFIX + + "unsupported.delay_initialization_seconds"; + public static final String SECURITY_UNSUPPORTED_DISABLE_INTERTRANSPORT_AUTH_INITIALLY = SECURITY_SETTINGS_PREFIX + + "unsupported.disable_intertransport_auth_initially"; + public static final String SECURITY_UNSUPPORTED_PASSIVE_INTERTRANSPORT_AUTH_INITIALLY = SECURITY_SETTINGS_PREFIX + + "unsupported.passive_intertransport_auth_initially"; + public static final String SECURITY_UNSUPPORTED_RESTORE_SECURITYINDEX_ENABLED = SECURITY_SETTINGS_PREFIX + + "unsupported.restore.securityindex.enabled"; + public static final String SECURITY_UNSUPPORTED_INJECT_USER_ENABLED = SECURITY_SETTINGS_PREFIX + "unsupported.inject_user.enabled"; + public static final String SECURITY_UNSUPPORTED_INJECT_ADMIN_USER_ENABLED = SECURITY_SETTINGS_PREFIX + + "unsupported.inject_user.admin.enabled"; + public static final String SECURITY_UNSUPPORTED_ALLOW_NOW_IN_DLS = SECURITY_SETTINGS_PREFIX + "unsupported.allow_now_in_dls"; + + public static final String SECURITY_UNSUPPORTED_RESTAPI_ALLOW_SECURITYCONFIG_MODIFICATION = SECURITY_SETTINGS_PREFIX + + "unsupported.restapi.allow_securityconfig_modification"; + public static final String SECURITY_UNSUPPORTED_LOAD_STATIC_RESOURCES = SECURITY_SETTINGS_PREFIX + "unsupported.load_static_resources"; + public static final String SECURITY_UNSUPPORTED_ACCEPT_INVALID_CONFIG = SECURITY_SETTINGS_PREFIX + "unsupported.accept_invalid_config"; // Protected indices settings. Marked for deprecation, after all config indices move to System indices. - public static final String SECURITY_PROTECTED_INDICES_ENABLED_KEY = "plugins.security.protected_indices.enabled"; + public static final String SECURITY_PROTECTED_INDICES_ENABLED_KEY = SECURITY_SETTINGS_PREFIX + "protected_indices.enabled"; public static final Boolean SECURITY_PROTECTED_INDICES_ENABLED_DEFAULT = false; - public static final String SECURITY_PROTECTED_INDICES_KEY = "plugins.security.protected_indices.indices"; + public static final String SECURITY_PROTECTED_INDICES_KEY = SECURITY_SETTINGS_PREFIX + "protected_indices.indices"; public static final List SECURITY_PROTECTED_INDICES_DEFAULT = Collections.emptyList(); - public static final String SECURITY_PROTECTED_INDICES_ROLES_KEY = "plugins.security.protected_indices.roles"; + public static final String SECURITY_PROTECTED_INDICES_ROLES_KEY = SECURITY_SETTINGS_PREFIX + "protected_indices.roles"; public static final List SECURITY_PROTECTED_INDICES_ROLES_DEFAULT = Collections.emptyList(); // Roles injection for plugins @@ -351,19 +357,20 @@ public enum RolesMappingResolution { // System indices settings public static final String SYSTEM_INDEX_PERMISSION = "system:admin/system_index"; - public static final String SECURITY_SYSTEM_INDICES_ENABLED_KEY = "plugins.security.system_indices.enabled"; + public static final String SECURITY_SYSTEM_INDICES_ENABLED_KEY = SECURITY_SETTINGS_PREFIX + "system_indices.enabled"; public static final Boolean SECURITY_SYSTEM_INDICES_ENABLED_DEFAULT = false; - public static final String SECURITY_SYSTEM_INDICES_PERMISSIONS_ENABLED_KEY = "plugins.security.system_indices.permission.enabled"; + public static final String SECURITY_SYSTEM_INDICES_PERMISSIONS_ENABLED_KEY = SECURITY_SETTINGS_PREFIX + + "system_indices.permission.enabled"; public static final Boolean SECURITY_SYSTEM_INDICES_PERMISSIONS_DEFAULT = false; - public static final String SECURITY_SYSTEM_INDICES_KEY = "plugins.security.system_indices.indices"; + public static final String SECURITY_SYSTEM_INDICES_KEY = SECURITY_SETTINGS_PREFIX + "system_indices.indices"; public static final List SECURITY_SYSTEM_INDICES_DEFAULT = Collections.emptyList(); - public static final String SECURITY_MASKED_FIELDS_ALGORITHM_DEFAULT = "plugins.security.masked_fields.algorithm.default"; + public static final String SECURITY_MASKED_FIELDS_ALGORITHM_DEFAULT = SECURITY_SETTINGS_PREFIX + "masked_fields.algorithm.default"; public static final String TENANCY_PRIVATE_TENANT_NAME = "private"; public static final String TENANCY_GLOBAL_TENANT_NAME = "global"; public static final String TENANCY_GLOBAL_TENANT_DEFAULT_NAME = ""; - public static final String USE_JDK_SERIALIZATION = "plugins.security.use_jdk_serialization"; + public static final String USE_JDK_SERIALIZATION = SECURITY_SETTINGS_PREFIX + "use_jdk_serialization"; // On-behalf-of endpoints settings // CS-SUPPRESS-SINGLE: RegexpSingleline get Extensions Settings @@ -374,6 +381,10 @@ public enum RolesMappingResolution { // Variable for initial admin password support public static final String OPENSEARCH_INITIAL_ADMIN_PASSWORD = "OPENSEARCH_INITIAL_ADMIN_PASSWORD"; + // Resource sharing feature-flag + public static final String OPENSEARCH_RESOURCE_SHARING_ENABLED = SECURITY_SETTINGS_PREFIX + "resource_sharing.enabled"; + public static final boolean OPENSEARCH_RESOURCE_SHARING_ENABLED_DEFAULT = true; + public static Set getSettingAsSet( final Settings settings, final String key, diff --git a/src/main/java/org/opensearch/security/util/ResourceValidation.java b/src/main/java/org/opensearch/security/util/ResourceValidation.java new file mode 100644 index 0000000000..428aae2cf2 --- /dev/null +++ b/src/main/java/org/opensearch/security/util/ResourceValidation.java @@ -0,0 +1,34 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.security.util; + +import java.util.HashSet; +import java.util.Set; + +import org.opensearch.action.ActionRequestValidationException; +import org.opensearch.security.spi.resources.ResourceAccessScope; + +public class ResourceValidation { + public static ActionRequestValidationException validateScopes(Set scopes) { + Set validScopes = new HashSet<>(); + validScopes.add(ResourceAccessScope.RESTRICTED); + validScopes.add(ResourceAccessScope.PUBLIC); + + // TODO See if we can add custom scopes as part of this validation routine + + for (String s : scopes) { + if (!validScopes.contains(s)) { + ActionRequestValidationException exception = new ActionRequestValidationException(); + exception.addValidationError("Invalid scope: " + s + ". Scope must be one of: " + validScopes); + return exception; + } + } + return null; + } +} diff --git a/src/test/java/org/opensearch/security/IndexIntegrationTests.java b/src/test/java/org/opensearch/security/IndexIntegrationTests.java index 648a9b1ade..14b84614c4 100644 --- a/src/test/java/org/opensearch/security/IndexIntegrationTests.java +++ b/src/test/java/org/opensearch/security/IndexIntegrationTests.java @@ -846,19 +846,19 @@ public void testIndexResolveMinus() throws Exception { resc = rh.executeGetRequest("/*,-foo*/_search", encodeBasicHeader("foo_all", "nagilum")); assertThat(resc.getStatusCode(), is(HttpStatus.SC_FORBIDDEN)); - resc = rh.executeGetRequest("/*,-*security/_search", encodeBasicHeader("foo_all", "nagilum")); + resc = rh.executeGetRequest("/*,-*security,-*resource*/_search", encodeBasicHeader("foo_all", "nagilum")); assertThat(resc.getStatusCode(), is(HttpStatus.SC_OK)); - resc = rh.executeGetRequest("/*,-*security/_search", encodeBasicHeader("foo_all", "nagilum")); + resc = rh.executeGetRequest("/*,-*security,-*resource*/_search", encodeBasicHeader("foo_all", "nagilum")); assertThat(resc.getStatusCode(), is(HttpStatus.SC_OK)); - resc = rh.executeGetRequest("/*,-*security,-foo*/_search", encodeBasicHeader("foo_all", "nagilum")); + resc = rh.executeGetRequest("/*,-*security,-foo*,-*resource*/_search", encodeBasicHeader("foo_all", "nagilum")); assertThat(resc.getStatusCode(), is(HttpStatus.SC_OK)); - resc = rh.executeGetRequest("/_all,-*security/_search", encodeBasicHeader("foo_all", "nagilum")); + resc = rh.executeGetRequest("/_all,-*security,-*resource*/_search", encodeBasicHeader("foo_all", "nagilum")); assertThat(resc.getStatusCode(), is(HttpStatus.SC_FORBIDDEN)); - resc = rh.executeGetRequest("/_all,-*security/_search", encodeBasicHeader("nagilum", "nagilum")); + resc = rh.executeGetRequest("/_all,-*security,-*resource*/_search", encodeBasicHeader("nagilum", "nagilum")); assertThat(resc.getStatusCode(), is(HttpStatus.SC_BAD_REQUEST)); } diff --git a/src/test/java/org/opensearch/security/SlowIntegrationTests.java b/src/test/java/org/opensearch/security/SlowIntegrationTests.java index 74e3bfa9e4..99ac9fb1b9 100644 --- a/src/test/java/org/opensearch/security/SlowIntegrationTests.java +++ b/src/test/java/org/opensearch/security/SlowIntegrationTests.java @@ -66,6 +66,7 @@ public void testCustomInterclusterRequestEvaluator() throws Exception { ConfigConstants.SECURITY_INTERCLUSTER_REQUEST_EVALUATOR_CLASS, "org.opensearch.security.AlwaysFalseInterClusterRequestEvaluator" ) + .put(ConfigConstants.OPENSEARCH_RESOURCE_SHARING_ENABLED, false) .put("discovery.initial_state_timeout", "8s") .build(); setup(Settings.EMPTY, null, settings, false, ClusterConfiguration.DEFAULT, 5, 1); diff --git a/src/test/java/org/opensearch/security/resources/CreatedByTests.java b/src/test/java/org/opensearch/security/resources/CreatedByTests.java new file mode 100644 index 0000000000..0bc651b4d5 --- /dev/null +++ b/src/test/java/org/opensearch/security/resources/CreatedByTests.java @@ -0,0 +1,287 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.security.resources; + +import java.io.IOException; + +import org.hamcrest.MatcherAssert; + +import org.opensearch.common.io.stream.BytesStreamOutput; +import org.opensearch.common.xcontent.XContentFactory; +import org.opensearch.common.xcontent.json.JsonXContent; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; +import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.core.xcontent.XContentParser; +import org.opensearch.security.test.SingleClusterTest; + +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.greaterThan; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.Matchers.nullValue; +import static org.junit.Assert.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class CreatedByTests extends SingleClusterTest { + + private static final Enum CREATOR_TYPE = Creator.USER; + + public void testCreatedByConstructorWithValidUser() { + String expectedUser = "testUser"; + CreatedBy createdBy = new CreatedBy(CREATOR_TYPE, expectedUser); + + MatcherAssert.assertThat(expectedUser, is(equalTo(createdBy.getCreator()))); + } + + public void testCreatedByFromStreamInput() throws IOException { + String expectedUser = "testUser"; + + try (BytesStreamOutput out = new BytesStreamOutput()) { + out.writeEnum(Creator.valueOf(CREATOR_TYPE.name())); + out.writeString(expectedUser); + + StreamInput in = out.bytes().streamInput(); + + CreatedBy createdBy = new CreatedBy(in); + + MatcherAssert.assertThat(expectedUser, is(equalTo(createdBy.getCreator()))); + } + } + + public void testCreatedByWithEmptyStreamInput() throws IOException { + + try (StreamInput mockStreamInput = mock(StreamInput.class)) { + when(mockStreamInput.readString()).thenThrow(new IOException("EOF")); + + assertThrows(IOException.class, () -> new CreatedBy(mockStreamInput)); + } + } + + public void testCreatedByWithEmptyUser() { + + CreatedBy createdBy = new CreatedBy(CREATOR_TYPE, ""); + MatcherAssert.assertThat("", equalTo(createdBy.getCreator())); + } + + public void testCreatedByWithIOException() throws IOException { + + try (StreamInput mockStreamInput = mock(StreamInput.class)) { + when(mockStreamInput.readString()).thenThrow(new IOException("Test IOException")); + + assertThrows(IOException.class, () -> new CreatedBy(mockStreamInput)); + } + } + + public void testCreatedByWithLongUsername() { + String longUsername = "a".repeat(10000); + CreatedBy createdBy = new CreatedBy(CREATOR_TYPE, longUsername); + MatcherAssert.assertThat(longUsername, equalTo(createdBy.getCreator())); + } + + public void testCreatedByWithUnicodeCharacters() { + String unicodeUsername = "用户こんにちは"; + CreatedBy createdBy = new CreatedBy(CREATOR_TYPE, unicodeUsername); + MatcherAssert.assertThat(unicodeUsername, equalTo(createdBy.getCreator())); + } + + public void testFromXContentThrowsExceptionWhenUserFieldIsMissing() throws IOException { + String emptyJson = "{}"; + IllegalArgumentException exception; + try (XContentParser parser = JsonXContent.jsonXContent.createParser(null, null, emptyJson)) { + + exception = assertThrows(IllegalArgumentException.class, () -> CreatedBy.fromXContent(parser)); + } + + MatcherAssert.assertThat("null is required", equalTo(exception.getMessage())); + } + + public void testFromXContentWithEmptyInput() throws IOException { + String emptyJson = "{}"; + try (XContentParser parser = JsonXContent.jsonXContent.createParser(null, null, emptyJson)) { + + assertThrows(IllegalArgumentException.class, () -> CreatedBy.fromXContent(parser)); + } + } + + public void testFromXContentWithExtraFields() throws IOException { + String jsonWithExtraFields = "{\"user\": \"testUser\", \"extraField\": \"value\"}"; + XContentParser parser = JsonXContent.jsonXContent.createParser(null, null, jsonWithExtraFields); + + CreatedBy.fromXContent(parser); + } + + public void testFromXContentWithIncorrectFieldType() throws IOException { + String jsonWithIncorrectType = "{\"user\": 12345}"; + try (XContentParser parser = JsonXContent.jsonXContent.createParser(null, null, jsonWithIncorrectType)) { + + assertThrows(IllegalArgumentException.class, () -> CreatedBy.fromXContent(parser)); + } + } + + public void testFromXContentWithEmptyUser() throws IOException { + String emptyJson = "{\"" + CREATOR_TYPE + "\": \"\" }"; + CreatedBy createdBy; + try (XContentParser parser = JsonXContent.jsonXContent.createParser(null, null, emptyJson)) { + parser.nextToken(); + + createdBy = CreatedBy.fromXContent(parser); + } + + MatcherAssert.assertThat(CREATOR_TYPE, equalTo(createdBy.getCreatorType())); + MatcherAssert.assertThat("", equalTo(createdBy.getCreator())); + } + + public void testFromXContentWithNullUserValue() throws IOException { + String jsonWithNullUser = "{\"user\": null}"; + try (XContentParser parser = JsonXContent.jsonXContent.createParser(null, null, jsonWithNullUser)) { + + assertThrows(IllegalArgumentException.class, () -> CreatedBy.fromXContent(parser)); + } + } + + public void testFromXContentWithValidUser() throws IOException { + String json = "{\"user\":\"testUser\"}"; + XContentParser parser = JsonXContent.jsonXContent.createParser(null, null, json); + + CreatedBy createdBy = CreatedBy.fromXContent(parser); + + MatcherAssert.assertThat(createdBy, notNullValue()); + MatcherAssert.assertThat("testUser", equalTo(createdBy.getCreator())); + } + + public void testGetCreatorReturnsCorrectValue() { + String expectedUser = "testUser"; + CreatedBy createdBy = new CreatedBy(CREATOR_TYPE, expectedUser); + + String actualUser = createdBy.getCreator(); + + MatcherAssert.assertThat(expectedUser, equalTo(actualUser)); + } + + public void testGetCreatorWithNullString() { + + CreatedBy createdBy = new CreatedBy(CREATOR_TYPE, null); + MatcherAssert.assertThat(createdBy.getCreator(), nullValue()); + } + + public void testGetWriteableNameReturnsCorrectString() { + CreatedBy createdBy = new CreatedBy(CREATOR_TYPE, "testUser"); + MatcherAssert.assertThat("created_by", equalTo(createdBy.getWriteableName())); + } + + public void testToStringWithEmptyUser() { + CreatedBy createdBy = new CreatedBy(CREATOR_TYPE, ""); + String result = createdBy.toString(); + MatcherAssert.assertThat("CreatedBy {user=''}", equalTo(result)); + } + + public void testToStringWithNullUser() { + CreatedBy createdBy = new CreatedBy(CREATOR_TYPE, (String) null); + String result = createdBy.toString(); + MatcherAssert.assertThat("CreatedBy {user='null'}", equalTo(result)); + } + + public void testToStringWithLongUserName() { + + String longUserName = "a".repeat(1000); + CreatedBy createdBy = new CreatedBy(CREATOR_TYPE, longUserName); + String result = createdBy.toString(); + MatcherAssert.assertThat(result.startsWith("CreatedBy {user='"), is(true)); + MatcherAssert.assertThat(result.endsWith("'}"), is(true)); + MatcherAssert.assertThat(1019, equalTo(result.length())); + } + + public void testToXContentWithEmptyUser() throws IOException { + CreatedBy createdBy = new CreatedBy(CREATOR_TYPE, ""); + XContentBuilder builder = JsonXContent.contentBuilder(); + + createdBy.toXContent(builder, null); + String result = builder.toString(); + MatcherAssert.assertThat("{\"user\":\"\"}", equalTo(result)); + } + + public void testWriteToWithExceptionInStreamOutput() throws IOException { + CreatedBy createdBy = new CreatedBy(CREATOR_TYPE, "user1"); + try (StreamOutput failingOutput = new StreamOutput() { + @Override + public void writeByte(byte b) throws IOException { + throw new IOException("Simulated IO exception"); + } + + @Override + public void writeBytes(byte[] b, int offset, int length) throws IOException { + throw new IOException("Simulated IO exception"); + } + + @Override + public void flush() throws IOException { + + } + + @Override + public void close() throws IOException { + + } + + @Override + public void reset() throws IOException { + + } + }) { + + assertThrows(IOException.class, () -> createdBy.writeTo(failingOutput)); + } + } + + public void testWriteToWithLongUserName() throws IOException { + String longUserName = "a".repeat(65536); + CreatedBy createdBy = new CreatedBy(CREATOR_TYPE, longUserName); + BytesStreamOutput out = new BytesStreamOutput(); + createdBy.writeTo(out); + MatcherAssert.assertThat(out.size(), greaterThan(65536)); + } + + public void test_createdByToStringReturnsCorrectFormat() { + String testUser = "testUser"; + CreatedBy createdBy = new CreatedBy(CREATOR_TYPE, testUser); + + String expected = "CreatedBy {user='" + testUser + "'}"; + String actual = createdBy.toString(); + + MatcherAssert.assertThat(expected, equalTo(actual)); + } + + public void test_toXContent_serializesCorrectly() throws IOException { + String expectedUser = "testUser"; + CreatedBy createdBy = new CreatedBy(CREATOR_TYPE, expectedUser); + XContentBuilder builder = XContentFactory.jsonBuilder(); + + createdBy.toXContent(builder, null); + + String expectedJson = "{\"user\":\"testUser\"}"; + MatcherAssert.assertThat(expectedJson, equalTo(builder.toString())); + } + + public void test_writeTo_writesUserCorrectly() throws IOException { + String expectedUser = "testUser"; + CreatedBy createdBy = new CreatedBy(CREATOR_TYPE, expectedUser); + + BytesStreamOutput out = new BytesStreamOutput(); + createdBy.writeTo(out); + + StreamInput in = out.bytes().streamInput(); + in.readString(); + String actualUser = in.readString(); + + MatcherAssert.assertThat(expectedUser, equalTo(actualUser)); + } + +} diff --git a/src/test/java/org/opensearch/security/resources/RecipientTypeRegistryTests.java b/src/test/java/org/opensearch/security/resources/RecipientTypeRegistryTests.java new file mode 100644 index 0000000000..d1a6854c3e --- /dev/null +++ b/src/test/java/org/opensearch/security/resources/RecipientTypeRegistryTests.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.security.resources; + +import org.hamcrest.MatcherAssert; + +import org.opensearch.security.test.SingleClusterTest; + +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; +import static org.junit.Assert.assertThrows; + +public class RecipientTypeRegistryTests extends SingleClusterTest { + + public void testFromValue() { + RecipientTypeRegistry.registerRecipientType("ble1", new RecipientType("ble1")); + RecipientTypeRegistry.registerRecipientType("ble2", new RecipientType("ble2")); + + // Valid Value + RecipientType type = RecipientTypeRegistry.fromValue("ble1"); + MatcherAssert.assertThat(type, notNullValue()); + MatcherAssert.assertThat(type.type(), is(equalTo("ble1"))); + + // Invalid Value + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> RecipientTypeRegistry.fromValue("bleble")); + MatcherAssert.assertThat("Unknown RecipientType: bleble. Must be 1 of these: [ble1, ble2]", is(equalTo(exception.getMessage()))); + } +} diff --git a/src/test/java/org/opensearch/security/resources/ShareWithTests.java b/src/test/java/org/opensearch/security/resources/ShareWithTests.java new file mode 100644 index 0000000000..cec50a8198 --- /dev/null +++ b/src/test/java/org/opensearch/security/resources/ShareWithTests.java @@ -0,0 +1,266 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.security.resources; + +import java.io.IOException; +import java.util.Collections; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import org.hamcrest.MatcherAssert; +import org.junit.Before; + +import org.opensearch.common.xcontent.XContentFactory; +import org.opensearch.common.xcontent.XContentType; +import org.opensearch.common.xcontent.json.JsonXContent; +import org.opensearch.core.common.io.stream.StreamOutput; +import org.opensearch.core.xcontent.NamedXContentRegistry; +import org.opensearch.core.xcontent.ToXContent; +import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.core.xcontent.XContentParser; +import org.opensearch.security.spi.resources.ResourceAccessScope; +import org.opensearch.security.test.SingleClusterTest; + +import org.mockito.Mockito; + +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; +import static org.junit.Assert.assertThrows; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class ShareWithTests extends SingleClusterTest { + + @Before + public void setupResourceRecipientTypes() { + initializeRecipientTypes(); + } + + public void testFromXContentWhenCurrentTokenIsNotStartObject() throws IOException { + String json = "{\"read_only\": {\"users\": [\"user1\"], \"roles\": [], \"backend_roles\": []}}"; + XContentParser parser = JsonXContent.jsonXContent.createParser(null, null, json); + + parser.nextToken(); + + ShareWith shareWith = ShareWith.fromXContent(parser); + + MatcherAssert.assertThat(shareWith, notNullValue()); + Set sharedWithScopes = shareWith.getSharedWithScopes(); + MatcherAssert.assertThat(sharedWithScopes, notNullValue()); + MatcherAssert.assertThat(1, equalTo(sharedWithScopes.size())); + + SharedWithScope scope = sharedWithScopes.iterator().next(); + MatcherAssert.assertThat("read_only", equalTo(scope.getScope())); + + SharedWithScope.ScopeRecipients scopeRecipients = scope.getSharedWithPerScope(); + MatcherAssert.assertThat(scopeRecipients, notNullValue()); + Map> recipients = scopeRecipients.getRecipients(); + MatcherAssert.assertThat(recipients.get(RecipientTypeRegistry.fromValue(DefaultRecipientType.USERS.getName())).size(), is(1)); + MatcherAssert.assertThat(recipients.get(RecipientTypeRegistry.fromValue(DefaultRecipientType.USERS.getName())), contains("user1")); + MatcherAssert.assertThat(recipients.get(RecipientTypeRegistry.fromValue(DefaultRecipientType.ROLES.getName())).size(), is(0)); + MatcherAssert.assertThat( + recipients.get(RecipientTypeRegistry.fromValue(DefaultRecipientType.BACKEND_ROLES.getName())).size(), + is(0) + ); + } + + public void testFromXContentWithEmptyInput() throws IOException { + String emptyJson = "{}"; + XContentParser parser = XContentType.JSON.xContent().createParser(NamedXContentRegistry.EMPTY, null, emptyJson); + + ShareWith result = ShareWith.fromXContent(parser); + + MatcherAssert.assertThat(result, notNullValue()); + MatcherAssert.assertThat(result.getSharedWithScopes(), is(empty())); + } + + public void testFromXContentWithStartObject() throws IOException { + XContentParser parser; + try (XContentBuilder builder = XContentFactory.jsonBuilder()) { + builder.startObject() + .startObject(ResourceAccessScope.RESTRICTED) + .array("users", "user1", "user2") + .array("roles", "role1") + .array("backend_roles", "backend_role1") + .endObject() + .startObject(ResourceAccessScope.PUBLIC) + .array("users", "user3") + .array("roles", "role2", "role3") + .array("backend_roles") + .endObject() + .endObject(); + + parser = JsonXContent.jsonXContent.createParser(null, null, builder.toString()); + } + + parser.nextToken(); + + ShareWith shareWith = ShareWith.fromXContent(parser); + + MatcherAssert.assertThat(shareWith, notNullValue()); + Set scopes = shareWith.getSharedWithScopes(); + MatcherAssert.assertThat(scopes.size(), equalTo(2)); + + for (SharedWithScope scope : scopes) { + SharedWithScope.ScopeRecipients perScope = scope.getSharedWithPerScope(); + Map> recipients = perScope.getRecipients(); + if (scope.getScope().equals(ResourceAccessScope.RESTRICTED)) { + MatcherAssert.assertThat( + recipients.get(RecipientTypeRegistry.fromValue(DefaultRecipientType.USERS.getName())).size(), + is(2) + ); + MatcherAssert.assertThat( + recipients.get(RecipientTypeRegistry.fromValue(DefaultRecipientType.ROLES.getName())).size(), + is(1) + ); + MatcherAssert.assertThat( + recipients.get(RecipientTypeRegistry.fromValue(DefaultRecipientType.BACKEND_ROLES.getName())).size(), + is(1) + ); + } else if (scope.getScope().equals(ResourceAccessScope.PUBLIC)) { + MatcherAssert.assertThat( + recipients.get(RecipientTypeRegistry.fromValue(DefaultRecipientType.USERS.getName())).size(), + is(1) + ); + MatcherAssert.assertThat( + recipients.get(RecipientTypeRegistry.fromValue(DefaultRecipientType.ROLES.getName())).size(), + is(2) + ); + MatcherAssert.assertThat( + recipients.get(RecipientTypeRegistry.fromValue(DefaultRecipientType.BACKEND_ROLES.getName())).size(), + is(0) + ); + } + } + } + + public void testFromXContentWithUnexpectedEndOfInput() throws IOException { + XContentParser mockParser = mock(XContentParser.class); + when(mockParser.currentToken()).thenReturn(XContentParser.Token.START_OBJECT); + when(mockParser.nextToken()).thenReturn(XContentParser.Token.END_OBJECT, (XContentParser.Token) null); + + ShareWith result = ShareWith.fromXContent(mockParser); + + MatcherAssert.assertThat(result, notNullValue()); + MatcherAssert.assertThat(result.getSharedWithScopes(), is(empty())); + } + + public void testToXContentBuildsCorrectly() throws IOException { + SharedWithScope scope = new SharedWithScope( + "scope1", + new SharedWithScope.ScopeRecipients(Map.of(new RecipientType("users"), Set.of("bleh"))) + ); + + Set scopes = new HashSet<>(); + scopes.add(scope); + + ShareWith shareWith = new ShareWith(scopes); + + XContentBuilder builder = JsonXContent.contentBuilder(); + + shareWith.toXContent(builder, ToXContent.EMPTY_PARAMS); + + String result = builder.toString(); + + String expected = "{\"scope1\":{\"users\":[\"bleh\"]}}"; + + MatcherAssert.assertThat(expected.length(), equalTo(result.length())); + MatcherAssert.assertThat(expected, equalTo(result)); + } + + public void testWriteToWithEmptySet() throws IOException { + Set emptySet = Collections.emptySet(); + ShareWith shareWith = new ShareWith(emptySet); + StreamOutput mockOutput = Mockito.mock(StreamOutput.class); + + shareWith.writeTo(mockOutput); + + verify(mockOutput).writeCollection(emptySet); + } + + public void testWriteToWithIOException() throws IOException { + Set set = new HashSet<>(); + set.add(new SharedWithScope("test", new SharedWithScope.ScopeRecipients(Map.of()))); + ShareWith shareWith = new ShareWith(set); + StreamOutput mockOutput = Mockito.mock(StreamOutput.class); + + doThrow(new IOException("Simulated IO exception")).when(mockOutput).writeCollection(set); + + assertThrows(IOException.class, () -> shareWith.writeTo(mockOutput)); + } + + public void testWriteToWithLargeSet() throws IOException { + Set largeSet = new HashSet<>(); + for (int i = 0; i < 10000; i++) { + largeSet.add(new SharedWithScope("scope" + i, new SharedWithScope.ScopeRecipients(Map.of()))); + } + ShareWith shareWith = new ShareWith(largeSet); + StreamOutput mockOutput = Mockito.mock(StreamOutput.class); + + shareWith.writeTo(mockOutput); + + verify(mockOutput).writeCollection(largeSet); + } + + public void test_fromXContent_emptyObject() throws IOException { + XContentParser parser; + try (XContentBuilder builder = XContentFactory.jsonBuilder()) { + builder.startObject().endObject(); + parser = XContentType.JSON.xContent().createParser(null, null, builder.toString()); + } + + ShareWith shareWith = ShareWith.fromXContent(parser); + + MatcherAssert.assertThat(shareWith.getSharedWithScopes(), is(empty())); + } + + public void test_writeSharedWithScopesToStream() throws IOException { + StreamOutput mockStreamOutput = Mockito.mock(StreamOutput.class); + + Set sharedWithScopes = new HashSet<>(); + sharedWithScopes.add(new SharedWithScope(ResourceAccessScope.RESTRICTED, new SharedWithScope.ScopeRecipients(Map.of()))); + sharedWithScopes.add(new SharedWithScope(ResourceAccessScope.PUBLIC, new SharedWithScope.ScopeRecipients(Map.of()))); + + ShareWith shareWith = new ShareWith(sharedWithScopes); + + shareWith.writeTo(mockStreamOutput); + + verify(mockStreamOutput, times(1)).writeCollection(eq(sharedWithScopes)); + } + + private void initializeRecipientTypes() { + RecipientTypeRegistry.registerRecipientType("users", new RecipientType("users")); + RecipientTypeRegistry.registerRecipientType("roles", new RecipientType("roles")); + RecipientTypeRegistry.registerRecipientType("backend_roles", new RecipientType("backend_roles")); + } +} + +enum DefaultRecipientType { + USERS("users"), + ROLES("roles"), + BACKEND_ROLES("backend_roles"); + + private final String name; + + DefaultRecipientType(String name) { + this.name = name; + } + + public String getName() { + return name; + } +}