diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 362bb78d69..028a15eace 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,23 +23,23 @@ jobs: outputs: separateTestsNames: ${{ steps.set-matrix.outputs.separateTestsNames }} steps: - - name: Set up JDK for build and test - uses: actions/setup-java@v4 - with: - distribution: temurin # Temurin is a distribution of adoptium - java-version: 21 + - name: Set up JDK for build and test + uses: actions/setup-java@v4 + with: + distribution: temurin # Temurin is a distribution of adoptium + java-version: 21 - - name: Checkout security - uses: actions/checkout@v4 + - name: Checkout security + uses: actions/checkout@v4 - - name: Generate list of tasks - id: set-matrix - run: | - echo "separateTestsNames=$(./gradlew listTasksAsJSON -q --console=plain | tail -n 1)" >> $GITHUB_OUTPUT + - name: Generate list of tasks + id: set-matrix + run: | + echo "separateTestsNames=$(./gradlew listTasksAsJSON -q --console=plain | tail -n 1)" >> $GITHUB_OUTPUT test-windows: name: test - needs: generate-test-list + needs: [generate-test-list] strategy: fail-fast: false matrix: @@ -49,28 +49,28 @@ jobs: runs-on: ${{ matrix.platform }} steps: - - name: Set up JDK for build and test - uses: actions/setup-java@v4 - with: - distribution: temurin # Temurin is a distribution of adoptium - java-version: ${{ matrix.jdk }} - - - name: Checkout security - uses: actions/checkout@v4 - - - name: Build and Test - uses: gradle/gradle-build-action@v3 - with: - cache-disabled: true - arguments: | - ${{ matrix.gradle_task }} -Dbuild.snapshot=false - - - uses: actions/upload-artifact@v4 - if: always() - with: - name: ${{ matrix.platform }}-JDK${{ matrix.jdk }}-${{ matrix.gradle_task }}-reports - path: | - ./build/reports/ + - name: Set up JDK for build and test + uses: actions/setup-java@v4 + with: + distribution: temurin # Temurin is a distribution of adoptium + java-version: ${{ matrix.jdk }} + + - name: Checkout security + uses: actions/checkout@v4 + + - name: Build and Test + uses: gradle/gradle-build-action@v3 + with: + cache-disabled: true + arguments: | + ${{ matrix.gradle_task }} -Dbuild.snapshot=false + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: ${{ matrix.platform }}-JDK${{ matrix.jdk }}-${{ matrix.gradle_task }}-reports + path: | + ./build/reports/ test-linux: name: test @@ -116,7 +116,7 @@ jobs: ./build/reports/ report-coverage: - needs: ["test-windows", "test-linux", "integration-tests-windows", "integration-tests-linux"] + needs: ["test-windows", "test-linux", "integration-tests-windows", "integration-tests-linux", "spi-tests-linux", "spi-tests-windows"] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -139,7 +139,6 @@ jobs: fail_ci_if_error: true verbose: true - integration-tests-windows: name: integration-tests strategy: @@ -150,28 +149,35 @@ jobs: runs-on: ${{ matrix.platform }} steps: - - name: Set up JDK for build and test - uses: actions/setup-java@v4 - with: - distribution: temurin # Temurin is a distribution of adoptium - java-version: ${{ matrix.jdk }} - - - name: Checkout security - uses: actions/checkout@v4 - - - name: Build and Test - uses: gradle/gradle-build-action@v3 - with: - cache-disabled: true - arguments: | - integrationTest -Dbuild.snapshot=false - - - uses: actions/upload-artifact@v4 - if: always() - with: - name: integration-${{ matrix.platform }}-JDK${{ matrix.jdk }}-reports - path: | - ./build/reports/ + - name: Set up JDK for build and test + uses: actions/setup-java@v4 + with: + distribution: temurin # Temurin is a distribution of adoptium + java-version: ${{ matrix.jdk }} + + - name: Checkout security + uses: actions/checkout@v4 + + - name: Publish components to Maven Local + run: | + gradlew.bat clean ^ + :opensearch-resource-sharing-spi:publishToMavenLocal ^ + -Dbuild.snapshot=false + shell: cmd + + - name: Run Integration Tests + uses: gradle/gradle-build-action@v3 + with: + cache-disabled: true + arguments: | + :integrationTest -Dbuild.snapshot=false + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: integration-${{ matrix.platform }}-JDK${{ matrix.jdk }}-reports + path: | + ./build/reports/ integration-tests-linux: name: integration-tests @@ -192,6 +198,7 @@ jobs: steps: - name: Run start commands run: ${{ needs.Get-CI-Image-Tag.outputs.ci-image-start-command }} + - name: Set up JDK for build and test uses: actions/setup-java@v4 with: @@ -201,12 +208,18 @@ jobs: - name: Checkout security uses: actions/checkout@v4 + - name: Publish components to Maven Local + run: | + ./gradlew clean \ + :opensearch-resource-sharing-spi:publishToMavenLocal \ + -Dbuild.snapshot=false + - name: Build and Test uses: gradle/gradle-build-action@v3 with: cache-disabled: true arguments: | - integrationTest -Dbuild.snapshot=false + :integrationTest -Dbuild.snapshot=false - uses: actions/upload-artifact@v4 if: always() @@ -215,6 +228,169 @@ jobs: path: | ./build/reports/ + sample-plugin-integration-tests-linux: + name: sample-plugin-integration-tests + needs: [ "Get-CI-Image-Tag" ] + strategy: + fail-fast: false + matrix: + jdk: [ 21 ] + platform: [ ubuntu-latest ] + runs-on: ubuntu-latest + container: + # using the same image which is used by opensearch-build team to build the OpenSearch Distribution + # this image tag is subject to change as more dependencies and updates will arrive over time + image: ${{ needs.Get-CI-Image-Tag.outputs.ci-image-version-linux }} + # need to switch to root so that github actions can install runner binary on container without permission issues. + options: ${{ needs.Get-CI-Image-Tag.outputs.ci-image-start-options }} + + steps: + - name: Run start commands + run: ${{ needs.Get-CI-Image-Tag.outputs.ci-image-start-command }} + + - name: Set up JDK for build and test + uses: actions/setup-java@v4 + with: + distribution: temurin # Temurin is a distribution of adoptium + java-version: ${{ matrix.jdk }} + + - name: Checkout security + uses: actions/checkout@v4 + + - name: Publish components to Maven Local + run: | + ./gradlew clean \ + :opensearch-resource-sharing-spi:publishToMavenLocal \ + -Dbuild.snapshot=false + + - name: Run SampleResourcePlugin Integration Tests + uses: gradle/gradle-build-action@v3 + with: + arguments: | + :opensearch-sample-resource-plugin:integrationTest -Dbuild.snapshot=false + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: sample-plugin-integration-${{ matrix.platform }}-JDK${{ matrix.jdk }}-reports + path: | + ./build/reports/ + + sample-plugin-integration-tests-windows: + name: sample-plugin-integration-tests + strategy: + fail-fast: false + matrix: + jdk: [21] + platform: [ubuntu-latest, windows-latest] + runs-on: ${{ matrix.platform }} + + steps: + - name: Set up JDK for build and test + uses: actions/setup-java@v4 + with: + distribution: temurin # Temurin is a distribution of adoptium + java-version: ${{ matrix.jdk }} + + - name: Checkout security + uses: actions/checkout@v4 + + - name: Publish components to Maven Local + run: | + gradlew.bat clean ^ + :opensearch-resource-sharing-spi:publishToMavenLocal ^ + -Dbuild.snapshot=false + shell: cmd + + - name: Run SampleResourcePlugin Integration Tests + uses: gradle/gradle-build-action@v3 + with: + arguments: | + :opensearch-sample-resource-plugin:integrationTest -Dbuild.snapshot=false + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: sample-plugin-integration-${{ matrix.platform }}-JDK${{ matrix.jdk }}-reports + path: | + ./build/reports/ + + spi-tests-linux: + name: spi-tests + needs: ["Get-CI-Image-Tag"] + strategy: + fail-fast: false + matrix: + jdk: [21] + platform: [ubuntu-latest] + runs-on: ${{ matrix.platform }} + container: + # using the same image which is used by opensearch-build to build the OpenSearch Distribution + # this image tag is subject to change as more dependencies and updates will arrive over time + image: ${{ needs.Get-CI-Image-Tag.outputs.ci-image-version-linux }} + # need to switch to root so that github actions can install runner binary on container without permission issues. + options: ${{ needs.Get-CI-Image-Tag.outputs.ci-image-start-options }} + + steps: + - name: Run start commands + run: ${{ needs.Get-CI-Image-Tag.outputs.ci-image-start-command }} + + - name: Set up JDK for build and test + uses: actions/setup-java@v4 + with: + distribution: temurin # Temurin is a distribution of adoptium + java-version: ${{ matrix.jdk }} + + - name: Checkout security + uses: actions/checkout@v4 + + - name: Run SPI Tests + uses: gradle/gradle-build-action@v3 + with: + cache-disabled: true + arguments: | + :opensearch-resource-sharing-spi:test -Dbuild.snapshot=false + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: spi-${{ matrix.platform }}-JDK${{ matrix.jdk }}-reports + path: | + ./build/reports/ + + spi-tests-windows: + name: spi-tests + strategy: + fail-fast: false + matrix: + jdk: [21] + platform: [windows-latest] + runs-on: ${{ matrix.platform }} + + steps: + - name: Set up JDK for build and test + uses: actions/setup-java@v4 + with: + distribution: temurin # Temurin is a distribution of adoptium + java-version: ${{ matrix.jdk }} + + - name: Checkout security + uses: actions/checkout@v4 + + - name: Run SPI Tests + uses: gradle/gradle-build-action@v3 + with: + cache-disabled: true + arguments: | + :opensearch-resource-sharing-spi:test -Dbuild.snapshot=false + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: spi-${{ matrix.platform }}-JDK${{ matrix.jdk }}-reports + path: | + ./build/reports/ + resource-tests: env: CI_ENVIRONMENT: resource-test @@ -226,21 +402,27 @@ jobs: runs-on: ${{ matrix.platform }} steps: - - name: Set up JDK for build and test - uses: actions/setup-java@v4 - with: - distribution: temurin # Temurin is a distribution of adoptium - java-version: ${{ matrix.jdk }} - - - name: Checkout security - uses: actions/checkout@v4 - - - name: Build and Test - uses: gradle/gradle-build-action@v3 - with: - cache-disabled: true - arguments: | - integrationTest -Dbuild.snapshot=false --tests org.opensearch.security.ResourceFocusedTests + - name: Set up JDK for build and test + uses: actions/setup-java@v4 + with: + distribution: temurin # Temurin is a distribution of adoptium + java-version: ${{ matrix.jdk }} + + - name: Checkout security + uses: actions/checkout@v4 + + - name: Publish components to Maven Local + run: | + ./gradlew clean \ + :opensearch-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 backward-compatibility-build: runs-on: ubuntu-latest @@ -269,74 +451,95 @@ jobs: runs-on: ${{ matrix.platform }} steps: - - uses: actions/setup-java@v4 - with: - distribution: temurin # Temurin is a distribution of adoptium - java-version: ${{ matrix.jdk }} - - - name: Checkout Security Repo - uses: actions/checkout@v4 - - - id: build-previous - uses: ./.github/actions/run-bwc-suite - with: - plugin-previous-branch: "2.x" - plugin-next-branch: "current_branch" - report-artifact-name: bwc-${{ matrix.platform }}-jdk${{ matrix.jdk }} - username: admin - password: admin + - uses: actions/setup-java@v4 + with: + distribution: temurin # Temurin is a distribution of adoptium + java-version: ${{ matrix.jdk }} + + - name: Checkout Security Repo + uses: actions/checkout@v4 + + - id: build-previous + uses: ./.github/actions/run-bwc-suite + with: + plugin-previous-branch: "2.x" + plugin-next-branch: "current_branch" + report-artifact-name: bwc-${{ matrix.platform }}-jdk${{ matrix.jdk }} + username: admin + password: admin code-ql: 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 - - uses: github/codeql-action/init@v3 - with: - languages: java - - run: ./gradlew clean assemble - - uses: github/codeql-action/analyze@v3 + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin # Temurin is a distribution of adoptium + java-version: 21 + - uses: github/codeql-action/init@v3 + with: + languages: java + - run: ./gradlew clean assemble + - uses: github/codeql-action/analyze@v3 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 clean :opensearch-resource-sharing-spi:publishToMavenLocal && test -s ./spi/build/libs/opensearch-resource-sharing-spi-$security_plugin_version.jar + ./gradlew clean :opensearch-resource-sharing-spi:publishToMavenLocal -Dbuild.snapshot=false && test -s ./spi/build/libs/opensearch-resource-sharing-spi-$security_plugin_version_no_snapshot.jar + ./gradlew clean :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 clean :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 ./spi/build/libs/opensearch-resource-sharing-spi-$security_plugin_version.jar + + ./gradlew clean assemble -Dbuild.snapshot=false && \ + test -s ./build/distributions/opensearch-security-$security_plugin_version_no_snapshot.zip && \ + test -s ./spi/build/libs/opensearch-resource-sharing-spi-$security_plugin_version_no_snapshot.jar + + ./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 ./spi/build/libs/opensearch-resource-sharing-spi-$security_plugin_version_only_number-$test_qualifier.jar + + ./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 ./spi/build/libs/opensearch-resource-sharing-spi-$security_plugin_version_only_number-$test_qualifier-SNAPSHOT.jar + + ./gradlew clean publishPluginZipPublicationToZipStagingRepository && \ + test -s ./build/distributions/opensearch-security-$security_plugin_version.zip && \ + test -s ./build/distributions/opensearch-security-$security_plugin_version.pom && \ + test -s ./spi/build/libs/opensearch-resource-sharing-spi-$security_plugin_version.jar + + ./gradlew clean publishShadowPublicationToMavenLocal && \ + test -s ./spi/build/libs/opensearch-resource-sharing-spi-$security_plugin_version.jar + + - name: List files in build directory on failure + if: failure() + run: ls -al ./*/build/libs/ ./build/distributions/ diff --git a/build.gradle b/build.gradle index f1b3238a06..d48878d3db 100644 --- a/build.gradle +++ b/build.gradle @@ -498,6 +498,12 @@ configurations { force "org.checkerframework:checker-qual:3.49.1" force "ch.qos.logback:logback-classic:1.5.18" 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.16.1" + 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" } } @@ -505,6 +511,67 @@ configurations { integrationTestRuntimeOnly.extendsFrom runtimeOnly } +allprojects { + configurations { + integrationTestImplementation.extendsFrom implementation + compile.extendsFrom compileOnly + compile.extendsFrom testImplementation + } + dependencies { + // unit test framework + testImplementation 'org.hamcrest:hamcrest:2.2' + testImplementation 'junit:junit:4.13.2' + testImplementation "org.opensearch:opensearch:${opensearch_version}" + testImplementation "org.mockito:mockito-core:5.16.1" + + //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:${bouncycastle_version}" + integrationTestImplementation "org.bouncycastle:bcutil-jdk18on:${bouncycastle_version}" + 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.16.1" + 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}" + integrationTestImplementation project(path:":opensearch-resource-sharing-spi", configuration: 'shadow') + integrationTestImplementation project(path: ":${rootProject.name}-common", configuration: 'shadow') + integrationTestImplementation project(path: ":${rootProject.name}-client", configuration: 'shadow') + } +} + //create source set 'integrationTest' //add classes from the main source set to the compilation and runtime classpaths of the integrationTest sourceSets { @@ -525,6 +592,9 @@ sourceSets { //add new task that runs integration tests task integrationTest(type: Test) { + filter { + excludeTestsMatching 'org.opensearch.sample.*Resource*' + } doFirst { // Only run resources tests on resource-test CI environments or locally if (System.getenv('CI_ENVIRONMENT') != 'resource-test' && System.getenv('CI_ENVIRONMENT') != null) { @@ -580,6 +650,7 @@ configurations.all { } dependencies { + implementation project(path: ":${rootProject.name}-common", configuration: 'shadow') 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}" @@ -735,31 +806,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:${bouncycastle_version}" - integrationTestImplementation "org.bouncycastle:bcutil-jdk18on:${bouncycastle_version}" - integrationTestImplementation('org.awaitility:awaitility:4.3.0') { - 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.16.1" - //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..d8ecd44272 --- /dev/null +++ b/sample-resource-plugin/README.md @@ -0,0 +1,187 @@ +# Resource Sharing and Access Control Plugin + +This plugin demonstrates resource sharing and access control functionality, providing sample resource APIs and marking it as a resource sharing plugin via resource-sharing-spi. The access control is implemented on Security plugin and will be performed under the hood. +At present only admin and resource owners can modify/delete the resource + +## PreRequisites + +Publish SPI to local maven before proceeding: +```shell +./gradlew clean :opensearch-resource-sharing-spi:publishToMavenLocal +``` + +System index feature must be enabled to prevent direct access to resource. Add the following setting in case it has not already been enabled. +```yml +plugins.security.system_indices.enabled: true +``` + +## Features + +- Create, update, get, delete SampleResource, as well as share and revoke access to a resource. + +## Installation + +1. Clone the repository: + ```bash + git clone git@github.com:opensearch-project/security.git + ``` + +2. Navigate to the project directory: + ```bash + cd sample-resource-plugin + ``` + +3. Build and deploy the plugin: + ```bash + $ ./gradlew clean build -x test -x integrationTest -x spotbugsIntegrationTest + $ ./bin/opensearch-plugin install file: /sample-resource-plugin/build/distributions/opensearch-sample-resource-plugin-.zip + ``` + + +## User setup: +1. **No Index-Level Permissions Required** + - **Resource access is controlled at the cluster level**. + - Users **do not** need explicit index-level permissions to access shared resources. + +2. **Sample Role Configurations** + - Below are **two sample roles** demonstrating how to configure permissions in `roles.yml`: + + ```yaml + sample_full_access: + cluster_permissions: + - 'cluster:admin/sample-resource-plugin/*' + + sample_read_access: + cluster_permissions: + - 'cluster:admin/sample-resource-plugin/get' + ``` + +4. **Interaction Rules** + - If a **user is not the resource owner**, they must: + - Be assigned **a role with `sample_read_access`** permissions. + - **Have the resource shared with them** via the resource-sharing API. + - A user **without** the necessary `sample-resource-plugin` cluster permissions: + - **Cannot access the resource**, even if it is shared with them. + - A user **with `sample-resource-plugin` permissions** but **without a shared resource**: + - **Cannot access the resource**, since resource-level access control applies. + + +## API Endpoints + +The plugin exposes the following six API endpoints: + +### 1. Create Resource +- **Endpoint:** `POST /_plugins/sample_resource_sharing/create` +- **Description:** Creates a new resource. Behind the scenes a resource sharing entry will be created if security plugin is installed and feature is enabled. +- **Request Body:** + ```json + { + "name": "" + } + ``` +- **Response:** + ```json + { + "message": "Created resource: 9UdrWpUB99GNznAOkx43" + } + ``` + +### 2. Update Resource +- **Endpoint:** `POST /_plugins/sample_resource_sharing/update/{resourceId}` +- **Description:** Updates a resource if current user has access to it. +- **Request Body:** + ```json + { + "name": "" + } + ``` +- **Response:** + ```json + { + "message": "Resource updated successfully." + } + ``` + +### 3. Delete Resource +- **Endpoint:** `DELETE /_plugins/sample_resource_sharing/delete/{resource_id}` +- **Description:** Deletes a specified resource owned by the requesting user. +- **Response:** + ```json + { + "message": "Resource deleted successfully." + } + ``` + +### 4. Get Resource +- **Endpoint:** `GET /_plugins/sample_resource_sharing/get/{resource_id}` +- **Description:** Get a specified resource owned by or shared_with the requesting user, if the user has access to the resource, else fails. +- **Response:** + ```json + { + "resources" : [{ + "name" : "", + "description" : null, + "attributes" : null + }] + } + ``` +- **Endpoint:** `GET /_plugins/sample_resource_sharing/get` +- **Description:** Get all resources owned by or shared with the requesting user. +- **Response:** + ```json + { + "resources" : [{ + "name" : "", + "description" : null, + "attributes" : null + }] + } + ``` + +### 5. Share Resource +- **Endpoint:** `POST /_plugins/sample_resource_sharing/share/{resource_id}` +- **Description:** Shares a resource with the intended entities. At present, only admin and resource owners can share the resource. +- **Request Body:** + ```json + { + "share_with": { + "users": [ "sample_user" ] + } + } + ``` +- **Response:** + ```json + { + "share_with": { + "default": { + "users": [ "sample_user" ] + } + } + } + ``` + +### 6. Revoke Resource Access +- **Endpoint:** `POST /_plugins/sample_resource_sharing/revoke/{resource_id}` +- **Description:** Shares a resource with the intended entities. At present, only admin and resource owners can share the resource. +- **Request Body:** + ```json + { + "entities_to_revoke": { + "users": [ "sample_user" ] + } + } + ``` +- **Response:** + ```json + { + "share_with" : { } + } + ``` + +## License + +This code is licensed under the Apache 2.0 License. + +## Copyright + +Copyright OpenSearch Contributors. diff --git a/sample-resource-plugin/build.gradle b/sample-resource-plugin/build.gradle new file mode 100644 index 0000000000..6927d24e79 --- /dev/null +++ b/sample-resource-plugin/build.gradle @@ -0,0 +1,120 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +plugins { + id "org.gradle.test-retry" +} +apply plugin: 'opensearch.opensearchplugin' +apply plugin: 'opensearch.testclusters' + +java { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 +} + +opensearchplugin { + name 'opensearch-sample-resource-plugin' + description 'Sample plugin that extends OpenSearch Resource Plugin' + classname 'org.opensearch.sample.SampleResourcePlugin' + extendedPlugins = ['opensearch-security;optional=true'] +} + +dependencyLicenses.enabled = false +thirdPartyAudit.enabled = false +loggerUsageCheck.enabled = false +validateNebulaPom.enabled = false +testingConventions.enabled = false +tasks.configureEach { task -> + if(task.name.contains("forbiddenApisIntegrationTest")) { + task.enabled = false + } +} + +ext { + projectSubstitutions = [:] + licenseFile = rootProject.file('LICENSE.txt') + noticeFile = rootProject.file('NOTICE.txt') + opensearch_version = System.getProperty("opensearch.version", "3.0.0-beta1-SNAPSHOT") + isSnapshot = "true" == System.getProperty("build.snapshot", "true") + buildVersionQualifier = System.getProperty("build.version_qualifier", "beta1") + + version_tokens = opensearch_version.tokenize('-') + opensearch_build = version_tokens[0] + '.0' + + if (buildVersionQualifier) { + opensearch_build += "-${buildVersionQualifier}" + } + if (isSnapshot) { + opensearch_build += "-SNAPSHOT" + } +} + +repositories { + mavenLocal() + mavenCentral() + maven { url "https://aws.oss.sonatype.org/content/repositories/snapshots" } +} + +configurations.all { + resolutionStrategy { + force 'com.carrotsearch.randomizedtesting:randomizedtesting-runner:2.8.2', + 'org.hamcrest:hamcrest:2.2', + 'org.apache.httpcomponents:httpclient:4.5.14', + 'org.apache.httpcomponents:httpcore:4.4.16', + 'org.mockito:mockito-core:5.15.2', + 'net.bytebuddy:byte-buddy:1.15.11', + 'commons-codec:commons-codec:1.16.1', + 'com.fasterxml.jackson.core:jackson-databind:2.18.2' + } +} + +dependencies { + // Main implementation dependencies + compileOnly group: 'org.opensearch', name:'opensearch-resource-sharing-spi', version:"${opensearch_build}" + compileOnly "com.fasterxml.jackson.core:jackson-databind:${versions.jackson_databind}" + + // Integration test dependencies + integrationTestImplementation rootProject.sourceSets.integrationTest.output + integrationTestImplementation rootProject.sourceSets.main.output +} + +sourceSets { + integrationTest { + java { + srcDir file('src/integrationTest/java') + compileClasspath += sourceSets.main.output + runtimeClasspath += sourceSets.main.output + } + resources { + srcDir file('src/integrationTest/resources') + } + } +} + +tasks.register("integrationTest", Test) { + doFirst { + retry { + failOnPassedAfterRetry = false + maxRetries = 5 + maxFailures = 5 + } + } + description = 'Run integration tests for the subproject.' + group = 'verification' + + testClassesDirs = sourceSets.integrationTest.output.classesDirs + classpath = sourceSets.integrationTest.runtimeClasspath + +} + +// Ensure integrationTest task depends on the root project's compile task +tasks.named("integrationTest").configure { + dependsOn rootProject.tasks.named("compileIntegrationTestJava") +} + +tasks.named("integrationTest") { + minHeapSize = "512m" + maxHeapSize = "2g" +} diff --git a/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/AbstractSampleResourcePluginFeatureEnabledTests.java b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/AbstractSampleResourcePluginFeatureEnabledTests.java new file mode 100644 index 0000000000..d3157a43d2 --- /dev/null +++ b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/AbstractSampleResourcePluginFeatureEnabledTests.java @@ -0,0 +1,485 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.sample; + +import org.apache.http.HttpStatus; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import org.opensearch.sample.resource.client.ResourceSharingClientAccessor; +import org.opensearch.security.resources.ResourcePluginInfo; +import org.opensearch.security.resources.ResourceProvider; +import org.opensearch.security.spi.resources.ResourceAccessActionGroups; +import org.opensearch.test.framework.TestSecurityConfig; +import org.opensearch.test.framework.cluster.LocalCluster; +import org.opensearch.test.framework.cluster.TestRestClient; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.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.User.USER_ADMIN; + +/** + * This abstract class defines common tests between different feature flag scenarios + */ +public abstract class AbstractSampleResourcePluginFeatureEnabledTests extends AbstractSampleResourcePluginTests { + + protected abstract LocalCluster getLocalCluster(); + + protected abstract TestSecurityConfig.User getSharedUser(); + + private static LocalCluster cluster; + + ResourcePluginInfo resourcePluginInfo; + + private static TestSecurityConfig.User sharedUser; + + @Before + public void setup() { + cluster = getLocalCluster(); + sharedUser = getSharedUser(); + resourcePluginInfo = cluster.nodes().getFirst().getInjectable(ResourcePluginInfo.class); + } + + @After + public void clearIndices() { + try (TestRestClient client = cluster.getRestClient(cluster.getAdminCertificate())) { + client.delete(RESOURCE_INDEX_NAME); + client.delete(OPENSEARCH_RESOURCE_SHARING_INDEX); + resourcePluginInfo.getResourceIndicesMutable().remove(RESOURCE_INDEX_NAME); + resourcePluginInfo.getResourceProvidersMutable().remove(RESOURCE_INDEX_NAME); + } + } + + @Test + public void testPluginInstalledCorrectly() { + try (TestRestClient client = cluster.getRestClient(USER_ADMIN)) { + TestRestClient.HttpResponse pluginsResponse = client.get("_cat/plugins"); + assertThat(pluginsResponse.getBody(), containsString("org.opensearch.security.OpenSearchSecurityPlugin")); + assertThat(pluginsResponse.getBody(), containsString("org.opensearch.sample.SampleResourcePlugin")); + } + } + + @Test + public void testCreateUpdateDeleteSampleResourceWithSecurityAPIs() throws Exception { + String resourceId; + String resourceSharingDocId; + // create sample resource + try (TestRestClient client = cluster.getRestClient(USER_ADMIN)) { + String sampleResource = "{\"name\":\"sample\"}"; + TestRestClient.HttpResponse response = client.putJson(SAMPLE_RESOURCE_CREATE_ENDPOINT, sampleResource); + response.assertStatusCode(HttpStatus.SC_OK); + + resourceId = response.getTextFromJsonBody("/message").split(":")[1].trim(); + } + + // Create an entry in resource-sharing index + try (TestRestClient client = cluster.getRestClient(cluster.getAdminCertificate())) { + // Since test framework doesn't yet allow loading ex tensions we need to create a resource sharing entry manually + String json = String.format( + "{" + + " \"source_idx\": \"" + + RESOURCE_INDEX_NAME + + "\"," + + " \"resource_id\": \"%s\"," + + " \"created_by\": {" + + " \"user\": \"admin\"" + + " }" + + "}", + resourceId + ); + TestRestClient.HttpResponse response = client.postJson(OPENSEARCH_RESOURCE_SHARING_INDEX + "/_doc", json); + assertThat(response.getStatusReason(), containsString("Created")); + resourceSharingDocId = response.bodyAsJsonNode().get("_id").asText(); + // Also update the in-memory map and get + resourcePluginInfo.getResourceIndicesMutable().add(RESOURCE_INDEX_NAME); + ResourceProvider provider = new ResourceProvider( + SampleResource.class.getCanonicalName(), + RESOURCE_INDEX_NAME, + new SampleResourceParser() + ); + resourcePluginInfo.getResourceProvidersMutable().put(RESOURCE_INDEX_NAME, provider); + + ResourceSharingClientAccessor.setResourceSharingClient(createResourceAccessControlClient(cluster)); + + 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 (sharedUser cannot update admin's resource) + try (TestRestClient client = cluster.getRestClient(sharedUser)) { + String sampleResourceUpdated = "{\"name\":\"sampleUpdated\"}"; + TestRestClient.HttpResponse updateResponse = client.postJson( + SAMPLE_RESOURCE_UPDATE_ENDPOINT + "/" + resourceId, + sampleResourceUpdated + ); + updateResponse.assertStatusCode(HttpStatus.SC_FORBIDDEN); + } + + // Update sample resource (admin should be able to update resource) + try (TestRestClient client = cluster.getRestClient(USER_ADMIN)) { + String sampleResourceUpdated = "{\"name\":\"sampleUpdated\"}"; + TestRestClient.HttpResponse updateResponse = client.postJson( + SAMPLE_RESOURCE_UPDATE_ENDPOINT + "/" + resourceId, + sampleResourceUpdated + ); + updateResponse.assertStatusCode(HttpStatus.SC_OK); + } + + // resource should be visible to super-admin + try (TestRestClient client = cluster.getRestClient(cluster.getAdminCertificate())) { + + TestRestClient.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 sharedUser + try (TestRestClient client = cluster.getRestClient(sharedUser)) { + + TestRestClient.HttpResponse response = client.get(SECURITY_RESOURCE_LIST_ENDPOINT + "/" + RESOURCE_INDEX_NAME); + response.assertStatusCode(HttpStatus.SC_OK); + assertThat(response.bodyAsJsonNode().get("resources").size(), equalTo(0)); + } + + // sharedUser should not be able to share admin's resource with itself + // Only admins and owners can share/revoke access at the moment + try (TestRestClient client = cluster.getRestClient(sharedUser)) { + + TestRestClient.HttpResponse response = client.postJson( + SECURITY_RESOURCE_SHARE_ENDPOINT, + shareWithPayloadSecurityApi(resourceId, sharedUser.getName()) + ); + response.assertStatusCode(HttpStatus.SC_FORBIDDEN); + assertThat( + response.bodyAsJsonNode().get("message").asText(), + containsString("User " + sharedUser.getName() + " is not authorized") + ); + } + + // share resource with shared_with user + try (TestRestClient client = cluster.getRestClient(USER_ADMIN)) { + Thread.sleep(1000); + + TestRestClient.HttpResponse response = client.postJson( + SECURITY_RESOURCE_SHARE_ENDPOINT, + shareWithPayloadSecurityApi(resourceId, sharedUser.getName()) + ); + response.assertStatusCode(HttpStatus.SC_OK); + assertThat( + response.bodyAsJsonNode() + .get("sharing_info") + .get("share_with") + .get(ResourceAccessActionGroups.PLACE_HOLDER) + .get("users") + .get(0) + .asText(), + containsString(sharedUser.getName()) + ); + } + + // resource should now be visible to sharedUser + try (TestRestClient client = cluster.getRestClient(sharedUser)) { + TestRestClient.HttpResponse response = client.get(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())) { + TestRestClient.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(sharedUser)) { + TestRestClient.HttpResponse response = client.postJson(SECURITY_RESOURCE_VERIFY_ENDPOINT, verifyAccessPayload(resourceId)); + 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 + // Only admins and owners can share/revoke access at the moment + try (TestRestClient client = cluster.getRestClient(sharedUser)) { + TestRestClient.HttpResponse response = client.postJson( + SECURITY_RESOURCE_REVOKE_ENDPOINT, + revokeAccessPayloadSecurityApi(resourceId, sharedUser.getName()) + ); + response.assertStatusCode(HttpStatus.SC_FORBIDDEN); + assertThat( + response.bodyAsJsonNode().get("message").asText(), + containsString("User " + sharedUser.getName() + " is not authorized") + ); + } + + // get sample resource with sharedUser + try (TestRestClient client = cluster.getRestClient(sharedUser)) { + TestRestClient.HttpResponse response = client.get(SAMPLE_RESOURCE_GET_ENDPOINT + "/" + resourceId); + response.assertStatusCode(HttpStatus.SC_OK); + } + + // resource should be visible to sharedUser since the resource is shared with this user and this user has * permission + try (TestRestClient client = cluster.getRestClient(sharedUser)) { + TestRestClient.HttpResponse response = client.get(SAMPLE_RESOURCE_GET_ENDPOINT + "/" + resourceId); + response.assertStatusCode(HttpStatus.SC_OK); + } + + // revoke share_with_user's access + try (TestRestClient client = cluster.getRestClient(USER_ADMIN)) { + Thread.sleep(1000); + TestRestClient.HttpResponse response = client.postJson( + SECURITY_RESOURCE_REVOKE_ENDPOINT, + revokeAccessPayloadSecurityApi(resourceId, sharedUser.getName()) + ); + 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(sharedUser)) { + + TestRestClient.HttpResponse response = client.postJson(SECURITY_RESOURCE_VERIFY_ENDPOINT, verifyAccessPayload(resourceId)); + response.assertStatusCode(HttpStatus.SC_OK); + assertThat(response.bodyAsJsonNode().get("has_permission").asBoolean(), equalTo(false)); + } + + // get sample resource with sharedUser + try (TestRestClient client = cluster.getRestClient(sharedUser)) { + TestRestClient.HttpResponse response = client.get(SAMPLE_RESOURCE_GET_ENDPOINT + "/" + resourceId); + response.assertStatusCode(HttpStatus.SC_FORBIDDEN); + } + + // delete sample resource with sharedUser + try (TestRestClient client = cluster.getRestClient(sharedUser)) { + TestRestClient.HttpResponse response = client.delete(SAMPLE_RESOURCE_DELETE_ENDPOINT + "/" + resourceId); + response.assertStatusCode(HttpStatus.SC_FORBIDDEN); + } + + // delete sample resource + try (TestRestClient client = cluster.getRestClient(USER_ADMIN)) { + TestRestClient.HttpResponse response = client.delete(SAMPLE_RESOURCE_DELETE_ENDPOINT + "/" + resourceId); + response.assertStatusCode(HttpStatus.SC_OK); + } + + // corresponding entry should be removed from resource-sharing index + try (TestRestClient client = cluster.getRestClient(cluster.getAdminCertificate())) { + // Since test framework doesn't yet allow loading ex tensions we need to delete the resource sharing entry manually + TestRestClient.HttpResponse response = client.delete(OPENSEARCH_RESOURCE_SHARING_INDEX + "/_doc/" + resourceSharingDocId); + response.assertStatusCode(HttpStatus.SC_OK); + + Thread.sleep(2000); + response = client.get(OPENSEARCH_RESOURCE_SHARING_INDEX + "/_search"); + response.assertStatusCode(HttpStatus.SC_OK); + assertThat(response.bodyAsJsonNode().get("hits").get("hits").size(), equalTo(0)); + } + + // get sample resource with sharedUser + try (TestRestClient client = cluster.getRestClient(sharedUser)) { + TestRestClient.HttpResponse response = client.get(SAMPLE_RESOURCE_GET_ENDPOINT + "/" + resourceId); + response.assertStatusCode(HttpStatus.SC_NOT_FOUND); + } + + // get sample resource with admin + try (TestRestClient client = cluster.getRestClient(USER_ADMIN)) { + TestRestClient.HttpResponse response = client.get(SAMPLE_RESOURCE_GET_ENDPOINT + "/" + resourceId); + response.assertStatusCode(HttpStatus.SC_NOT_FOUND); + } + } + + @Test + public void testCreateUpdateDeleteSampleResource() throws Exception { + String resourceId; + String resourceSharingDocId; + // create sample resource + try (TestRestClient client = cluster.getRestClient(USER_ADMIN)) { + String sampleResource = "{\"name\":\"sample\"}"; + TestRestClient.HttpResponse response = client.putJson(SAMPLE_RESOURCE_CREATE_ENDPOINT, sampleResource); + response.assertStatusCode(HttpStatus.SC_OK); + + resourceId = response.getTextFromJsonBody("/message").split(":")[1].trim(); + } + + // Create an entry in resource-sharing index + try (TestRestClient client = cluster.getRestClient(cluster.getAdminCertificate())) { + // Since test framework doesn't yet allow loading ex tensions we need to create a resource sharing entry manually + String json = String.format( + "{" + + " \"source_idx\": \".sample_resource_sharing_plugin\"," + + " \"resource_id\": \"%s\"," + + " \"created_by\": {" + + " \"user\": \"admin\"" + + " }" + + "}", + resourceId + ); + TestRestClient.HttpResponse response = client.postJson(OPENSEARCH_RESOURCE_SHARING_INDEX + "/_doc", json); + assertThat(response.getStatusReason(), containsString("Created")); + resourceSharingDocId = response.bodyAsJsonNode().get("_id").asText(); + // Also update the in-memory map and get + resourcePluginInfo.getResourceIndicesMutable().add(RESOURCE_INDEX_NAME); + ResourceProvider provider = new ResourceProvider( + SampleResource.class.getCanonicalName(), + RESOURCE_INDEX_NAME, + new SampleResourceParser() + ); + resourcePluginInfo.getResourceProvidersMutable().put(RESOURCE_INDEX_NAME, provider); + + ResourceSharingClientAccessor.setResourceSharingClient(createResourceAccessControlClient(cluster)); + + Thread.sleep(1000); + response = client.get(SAMPLE_RESOURCE_GET_ENDPOINT + "/" + resourceId); + response.assertStatusCode(HttpStatus.SC_OK); + 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\"}"; + TestRestClient.HttpResponse updateResponse = client.postJson( + SAMPLE_RESOURCE_UPDATE_ENDPOINT + "/" + resourceId, + sampleResourceUpdated + ); + updateResponse.assertStatusCode(HttpStatus.SC_OK); + } + + // resource should be visible to super-admin + try (TestRestClient client = cluster.getRestClient(cluster.getAdminCertificate())) { + Thread.sleep(1000); + TestRestClient.HttpResponse response = client.get(SAMPLE_RESOURCE_GET_ENDPOINT + "/" + resourceId); + response.assertStatusCode(HttpStatus.SC_OK); + assertThat(response.getBody(), containsString("sampleUpdated")); + } + + // resource should not be visible to sharedUser + try (TestRestClient client = cluster.getRestClient(sharedUser)) { + + TestRestClient.HttpResponse response = client.get(SAMPLE_RESOURCE_GET_ENDPOINT + "/" + resourceId); + response.assertStatusCode(HttpStatus.SC_FORBIDDEN); + + response = client.get(SAMPLE_RESOURCE_GET_ENDPOINT); + response.assertStatusCode(HttpStatus.SC_OK); + assertThat(response.bodyAsJsonNode().get("resources").size(), equalTo(0)); + } + + // sharedUser should not be able to share admin's resource with itself + // Only admins and owners can share/revoke access at the moment + try (TestRestClient client = cluster.getRestClient(sharedUser)) { + TestRestClient.HttpResponse response = client.postJson( + SAMPLE_RESOURCE_SHARE_ENDPOINT + "/" + resourceId, + shareWithPayload(sharedUser.getName()) + ); + response.assertStatusCode(HttpStatus.SC_FORBIDDEN); + assertThat( + response.bodyAsJsonNode().get("error").get("root_cause").get(0).get("reason").asText(), + containsString("User " + sharedUser.getName() + " is not authorized") + ); + } + + // share resource with shared_with user + try (TestRestClient client = cluster.getRestClient(USER_ADMIN)) { + Thread.sleep(1000); + + TestRestClient.HttpResponse response = client.postJson( + SAMPLE_RESOURCE_SHARE_ENDPOINT + "/" + resourceId, + shareWithPayload(sharedUser.getName()) + ); + response.assertStatusCode(HttpStatus.SC_OK); + assertThat( + response.bodyAsJsonNode().get("share_with").get(ResourceAccessActionGroups.PLACE_HOLDER).get("users").get(0).asText(), + containsString(sharedUser.getName()) + ); + } + + // resource should now be visible to sharedUser + try (TestRestClient client = cluster.getRestClient(sharedUser)) { + TestRestClient.HttpResponse response = client.get(SAMPLE_RESOURCE_GET_ENDPOINT + "/" + resourceId); + response.assertStatusCode(HttpStatus.SC_OK); + assertThat(response.getBody(), containsString("sampleUpdated")); + + response = client.get(SAMPLE_RESOURCE_GET_ENDPOINT); + response.assertStatusCode(HttpStatus.SC_OK); + assertThat(response.bodyAsJsonNode().get("resources").size(), equalTo(1)); + } + + // resource is still visible to super-admin + try (TestRestClient client = cluster.getRestClient(cluster.getAdminCertificate())) { + TestRestClient.HttpResponse response = client.get(SAMPLE_RESOURCE_GET_ENDPOINT + "/" + resourceId); + response.assertStatusCode(HttpStatus.SC_OK); + assertThat(response.getBody(), containsString("sampleUpdated")); + } + + // revoke share_with_user's access + try (TestRestClient client = cluster.getRestClient(USER_ADMIN)) { + Thread.sleep(1000); + TestRestClient.HttpResponse response = client.postJson( + SAMPLE_RESOURCE_REVOKE_ENDPOINT + "/" + resourceId, + revokeAccessPayload(sharedUser.getName()) + ); + response.assertStatusCode(HttpStatus.SC_OK); + assertThat(response.bodyAsJsonNode().get("share_with").size(), equalTo(0)); + } + + // get sample resource with sharedUser, user no longer has access to resource + try (TestRestClient client = cluster.getRestClient(sharedUser)) { + TestRestClient.HttpResponse response = client.get(SAMPLE_RESOURCE_GET_ENDPOINT + "/" + resourceId); + response.assertStatusCode(HttpStatus.SC_FORBIDDEN); + + response = client.get(SAMPLE_RESOURCE_GET_ENDPOINT); + response.assertStatusCode(HttpStatus.SC_OK); + assertThat(response.bodyAsJsonNode().get("resources").size(), equalTo(0)); + } + + // delete sample resource with sharedUser + try (TestRestClient client = cluster.getRestClient(sharedUser)) { + TestRestClient.HttpResponse response = client.delete(SAMPLE_RESOURCE_DELETE_ENDPOINT + "/" + resourceId); + response.assertStatusCode(HttpStatus.SC_FORBIDDEN); + } + + // delete sample resource + try (TestRestClient client = cluster.getRestClient(USER_ADMIN)) { + TestRestClient.HttpResponse response = client.delete(SAMPLE_RESOURCE_DELETE_ENDPOINT + "/" + resourceId); + response.assertStatusCode(HttpStatus.SC_OK); + } + + // corresponding entry should be removed from resource-sharing index + try (TestRestClient client = cluster.getRestClient(cluster.getAdminCertificate())) { + // Since test framework doesn't yet allow loading ex tensions we need to delete the resource sharing entry manually + TestRestClient.HttpResponse response = client.delete(OPENSEARCH_RESOURCE_SHARING_INDEX + "/_doc/" + resourceSharingDocId); + response.assertStatusCode(HttpStatus.SC_OK); + + Thread.sleep(2000); + response = client.get(OPENSEARCH_RESOURCE_SHARING_INDEX + "/_search"); + response.assertStatusCode(HttpStatus.SC_OK); + assertThat(response.bodyAsJsonNode().get("hits").get("hits").size(), equalTo(0)); + } + + // get sample resource with sharedUser + try (TestRestClient client = cluster.getRestClient(sharedUser)) { + TestRestClient.HttpResponse response = client.get(SAMPLE_RESOURCE_GET_ENDPOINT + "/" + resourceId); + response.assertStatusCode(HttpStatus.SC_NOT_FOUND); + } + + // get sample resource with admin + try (TestRestClient client = cluster.getRestClient(USER_ADMIN)) { + TestRestClient.HttpResponse response = client.get(SAMPLE_RESOURCE_GET_ENDPOINT + "/" + resourceId); + response.assertStatusCode(HttpStatus.SC_NOT_FOUND); + } + } +} diff --git a/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/AbstractSampleResourcePluginTests.java b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/AbstractSampleResourcePluginTests.java new file mode 100644 index 0000000000..990fd4f8e1 --- /dev/null +++ b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/AbstractSampleResourcePluginTests.java @@ -0,0 +1,108 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.sample; + +import org.opensearch.cluster.service.ClusterService; +import org.opensearch.common.settings.Settings; +import org.opensearch.security.resources.ResourceAccessControlClient; +import org.opensearch.security.resources.ResourceAccessHandler; +import org.opensearch.security.spi.resources.ResourceSharingClient; +import org.opensearch.test.framework.TestSecurityConfig; +import org.opensearch.test.framework.cluster.LocalCluster; + +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; + +/** + * Abstract class for sample resource plugin tests. Provides common constants and utility methods for testing. This class is not intended to be + * instantiated directly. It is extended by {@link AbstractSampleResourcePluginFeatureEnabledTests}, {@link SampleResourcePluginFeatureDisabledTests}, {@link org.opensearch.sample.nonsystemindex.AbstractResourcePluginNonSystemIndexTests} + */ +public abstract class AbstractSampleResourcePluginTests { + + protected final static TestSecurityConfig.User SHARED_WITH_USER = new TestSecurityConfig.User("resource_sharing_test_user").roles( + new TestSecurityConfig.Role("shared_role").indexPermissions("*").on("*").clusterPermissions("*") + ); + + // No update permission + protected final static TestSecurityConfig.User SHARED_WITH_USER_LIMITED_PERMISSIONS = new TestSecurityConfig.User( + "resource_sharing_test_user_limited_perms" + ).roles( + new TestSecurityConfig.Role("shared_role_limited_perms").clusterPermissions( + "cluster:admin/security/resource_access/*", + "cluster:admin/sample-resource-plugin/get", + "cluster:admin/sample-resource-plugin/create", + "cluster:admin/sample-resource-plugin/share", + "cluster:admin/sample-resource-plugin/revoke" + ) + ); + + protected static final String SAMPLE_RESOURCE_CREATE_ENDPOINT = SAMPLE_RESOURCE_PLUGIN_PREFIX + "/create"; + protected static final String SAMPLE_RESOURCE_GET_ENDPOINT = SAMPLE_RESOURCE_PLUGIN_PREFIX + "/get"; + protected static final String SAMPLE_RESOURCE_UPDATE_ENDPOINT = SAMPLE_RESOURCE_PLUGIN_PREFIX + "/update"; + protected static final String SAMPLE_RESOURCE_DELETE_ENDPOINT = SAMPLE_RESOURCE_PLUGIN_PREFIX + "/delete"; + protected static final String SAMPLE_RESOURCE_SHARE_ENDPOINT = SAMPLE_RESOURCE_PLUGIN_PREFIX + "/share"; + protected static final String SAMPLE_RESOURCE_REVOKE_ENDPOINT = SAMPLE_RESOURCE_PLUGIN_PREFIX + "/revoke"; + private static final String PLUGIN_RESOURCE_ROUTE_PREFIX_NO_LEADING_SLASH = PLUGIN_RESOURCE_ROUTE_PREFIX.replaceFirst("/", ""); + protected static final String SECURITY_RESOURCE_LIST_ENDPOINT = PLUGIN_RESOURCE_ROUTE_PREFIX_NO_LEADING_SLASH + "/list"; + protected static final String SECURITY_RESOURCE_SHARE_ENDPOINT = PLUGIN_RESOURCE_ROUTE_PREFIX_NO_LEADING_SLASH + "/share"; + protected static final String SECURITY_RESOURCE_VERIFY_ENDPOINT = PLUGIN_RESOURCE_ROUTE_PREFIX_NO_LEADING_SLASH + "/verify_access"; + protected static final String SECURITY_RESOURCE_REVOKE_ENDPOINT = PLUGIN_RESOURCE_ROUTE_PREFIX_NO_LEADING_SLASH + "/revoke"; + + protected static ResourceSharingClient createResourceAccessControlClient(LocalCluster cluster) { + ResourceAccessHandler rAH = cluster.nodes().getFirst().getInjectable(ResourceAccessHandler.class); + Settings settings = cluster.node().settings(); + ClusterService clusterService = cluster.nodes().getFirst().getInjectable(ClusterService.class); + return new ResourceAccessControlClient(rAH, settings, clusterService); + } + + protected static String shareWithPayloadSecurityApi(String resourceId, String user) { + return "{" + + "\"resource_id\":\"" + + resourceId + + "\"," + + "\"resource_index\":\"" + + RESOURCE_INDEX_NAME + + "\"," + + "\"share_with\":{" + + "\"users\": [\"" + + user + + "\"]" + + "}" + + "}"; + } + + protected static String shareWithPayload(String user) { + return "{" + "\"share_with\":{" + "\"users\": [\"" + user + "\"]" + "}" + "}"; + } + + protected static String revokeAccessPayloadSecurityApi(String resourceId, String user) { + return "{" + + "\"resource_id\": \"" + + resourceId + + "\"," + + "\"resource_index\": \"" + + RESOURCE_INDEX_NAME + + "\"," + + "\"entities_to_revoke\": {" + + "\"users\": [\"" + + user + + "\"]" + + "}" + + "}"; + } + + protected static String revokeAccessPayload(String user) { + return "{" + "\"entities_to_revoke\": {" + "\"users\": [\"" + user + "\"]" + "}" + "}"; + } + + protected static String verifyAccessPayload(String resourceId) { + return "{" + "\"resource_id\":\"" + resourceId + "\"," + "\"resource_index\":\"" + RESOURCE_INDEX_NAME + "\"}"; + } +} diff --git a/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/SampleResourcePluginFeatureDisabledTests.java b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/SampleResourcePluginFeatureDisabledTests.java new file mode 100644 index 0000000000..451589589e --- /dev/null +++ b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/SampleResourcePluginFeatureDisabledTests.java @@ -0,0 +1,166 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.sample; + +import java.util.Map; + +import com.carrotsearch.randomizedtesting.annotations.ThreadLeakScope; +import org.apache.http.HttpStatus; +import org.junit.After; +import org.junit.ClassRule; +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.opensearch.painless.PainlessModulePlugin; +import org.opensearch.test.framework.cluster.ClusterManager; +import org.opensearch.test.framework.cluster.LocalCluster; +import org.opensearch.test.framework.cluster.TestRestClient; +import org.opensearch.test.framework.cluster.TestRestClient.HttpResponse; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.opensearch.sample.utils.Constants.OPENSEARCH_RESOURCE_SHARING_ENABLED; +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 resource sharing feature disabled. + */ +@RunWith(com.carrotsearch.randomizedtesting.RandomizedRunner.class) +@ThreadLeakScope(ThreadLeakScope.Scope.NONE) +public class SampleResourcePluginFeatureDisabledTests extends AbstractSampleResourcePluginTests { + + @ClassRule + public static LocalCluster cluster = new LocalCluster.Builder().clusterManager(ClusterManager.SINGLENODE) + .plugin(SampleResourcePlugin.class, PainlessModulePlugin.class) + .anonymousAuth(true) + .authc(AUTHC_HTTPBASIC_INTERNAL) + .users(USER_ADMIN, SHARED_WITH_USER) + .nodeSettings(Map.of(OPENSEARCH_RESOURCE_SHARING_ENABLED, false)) + .build(); + + @After + public void clearIndices() { + try (TestRestClient client = cluster.getRestClient(cluster.getAdminCertificate())) { + client.delete(RESOURCE_INDEX_NAME); + } + } + + @Test + public void testPluginInstalledCorrectly() { + try (TestRestClient client = cluster.getRestClient(USER_ADMIN)) { + HttpResponse pluginsResponse = client.get("_cat/plugins"); + assertThat(pluginsResponse.getBody(), containsString("org.opensearch.security.OpenSearchSecurityPlugin")); + assertThat(pluginsResponse.getBody(), containsString("org.opensearch.sample.SampleResourcePlugin")); + } + } + + @Test + public void testNoResourceRestrictions() throws Exception { + String resourceId; + // create sample resource + try (TestRestClient client = cluster.getRestClient(USER_ADMIN)) { + String sampleResource = "{\"name\":\"sample\"}"; + HttpResponse response = client.putJson(SAMPLE_RESOURCE_CREATE_ENDPOINT, sampleResource); + response.assertStatusCode(HttpStatus.SC_OK); + + resourceId = response.getTextFromJsonBody("/message").split(":")[1].trim(); + } + + // assert that resource-sharing index doesn't exist 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.get(SAMPLE_RESOURCE_GET_ENDPOINT + "/" + resourceId); + response.assertStatusCode(HttpStatus.SC_OK); + assertThat(response.getBody(), containsString("sample")); + } + + // resource should be visible to shared_with_user since there is no restriction and this user has * permission + try (TestRestClient client = cluster.getRestClient(SHARED_WITH_USER)) { + HttpResponse response = client.get(SAMPLE_RESOURCE_GET_ENDPOINT + "/" + resourceId); + response.assertStatusCode(HttpStatus.SC_OK); + assertThat(response.getBody(), containsString("sample")); + + response = client.get(SAMPLE_RESOURCE_GET_ENDPOINT); + response.assertStatusCode(HttpStatus.SC_OK); + assertThat(response.bodyAsJsonNode().get("resources").size(), equalTo(1)); + assertThat(response.getBody(), containsString("sample")); + } + + // shared_with_user is able to update admin's resource + try (TestRestClient client = cluster.getRestClient(SHARED_WITH_USER)) { + String updatePayload = "{" + "\"name\": \"sampleUpdated\"" + "}"; + HttpResponse updateResponse = client.postJson(SAMPLE_RESOURCE_UPDATE_ENDPOINT + "/" + resourceId, updatePayload); + updateResponse.assertStatusCode(HttpStatus.SC_OK); + } + + // admin can see updated value + try (TestRestClient client = cluster.getRestClient(USER_ADMIN)) { + HttpResponse response = client.get(SAMPLE_RESOURCE_GET_ENDPOINT + "/" + resourceId); + response.assertStatusCode(HttpStatus.SC_OK); + assertThat(response.getBody(), containsString("sampleUpdated")); + } + + // shared_with_user is able to call sample share api + try (TestRestClient client = cluster.getRestClient(SHARED_WITH_USER)) { + HttpResponse updateResponse = client.postJson( + SAMPLE_RESOURCE_SHARE_ENDPOINT + "/" + resourceId, + shareWithPayload(SHARED_WITH_USER.getName()) + ); + updateResponse.assertStatusCode(HttpStatus.SC_NOT_IMPLEMENTED); + } + + // shared_with_user is able to call sample revoke api + try (TestRestClient client = cluster.getRestClient(SHARED_WITH_USER)) { + HttpResponse updateResponse = client.postJson( + SAMPLE_RESOURCE_REVOKE_ENDPOINT + "/" + resourceId, + revokeAccessPayload(SHARED_WITH_USER.getName()) + ); + updateResponse.assertStatusCode(HttpStatus.SC_NOT_IMPLEMENTED); + } + + // delete sample resource - share_with user delete admin user's resource + try (TestRestClient client = cluster.getRestClient(SHARED_WITH_USER)) { + HttpResponse response = client.delete(SAMPLE_RESOURCE_DELETE_ENDPOINT + "/" + resourceId); + response.assertStatusCode(HttpStatus.SC_OK); + } + + // admin can no longer see the resource + try (TestRestClient client = cluster.getRestClient(USER_ADMIN)) { + HttpResponse response = client.get(SAMPLE_RESOURCE_GET_ENDPOINT + "/" + resourceId); + response.assertStatusCode(HttpStatus.SC_NOT_FOUND); + } + + } +} diff --git a/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/SampleResourcePluginLimitedPermissionsTests.java b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/SampleResourcePluginLimitedPermissionsTests.java new file mode 100644 index 0000000000..36a3868ff0 --- /dev/null +++ b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/SampleResourcePluginLimitedPermissionsTests.java @@ -0,0 +1,213 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.sample; + +import java.util.Map; + +import com.carrotsearch.randomizedtesting.annotations.ThreadLeakScope; +import org.apache.http.HttpStatus; +import org.junit.ClassRule; +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.opensearch.painless.PainlessModulePlugin; +import org.opensearch.sample.resource.client.ResourceSharingClientAccessor; +import org.opensearch.security.resources.ResourceProvider; +import org.opensearch.test.framework.TestSecurityConfig; +import org.opensearch.test.framework.cluster.ClusterManager; +import org.opensearch.test.framework.cluster.LocalCluster; +import org.opensearch.test.framework.cluster.TestRestClient; +import org.opensearch.test.framework.cluster.TestRestClient.HttpResponse; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.opensearch.sample.utils.Constants.OPENSEARCH_RESOURCE_SHARING_ENABLED; +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.SECURITY_SYSTEM_INDICES_ENABLED_KEY; +import static org.opensearch.test.framework.TestSecurityConfig.AuthcDomain.AUTHC_HTTPBASIC_INTERNAL; +import static org.opensearch.test.framework.TestSecurityConfig.User.USER_ADMIN; + +/** + * These tests run with resource sharing enabled and system index protection enabled + */ +@RunWith(com.carrotsearch.randomizedtesting.RandomizedRunner.class) +@ThreadLeakScope(ThreadLeakScope.Scope.NONE) +public class SampleResourcePluginLimitedPermissionsTests extends AbstractSampleResourcePluginFeatureEnabledTests { + + @ClassRule + public static LocalCluster cluster = new LocalCluster.Builder().clusterManager(ClusterManager.SINGLENODE) + .plugin(SampleResourcePlugin.class, PainlessModulePlugin.class) + .anonymousAuth(true) + .authc(AUTHC_HTTPBASIC_INTERNAL) + .users(USER_ADMIN, SHARED_WITH_USER_LIMITED_PERMISSIONS) + .nodeSettings(Map.of(SECURITY_SYSTEM_INDICES_ENABLED_KEY, true, OPENSEARCH_RESOURCE_SHARING_ENABLED, true)) + .build(); + + @Override + protected LocalCluster getLocalCluster() { + return cluster; + } + + @Override + protected TestSecurityConfig.User getSharedUser() { + return SHARED_WITH_USER_LIMITED_PERMISSIONS; + } + + @Test + public void testAccessWithLimitedIP() throws Exception { + String resourceId; + // create sample resource + try (TestRestClient client = cluster.getRestClient(SHARED_WITH_USER_LIMITED_PERMISSIONS)) { + String sampleResource = "{\"name\":\"sample\"}"; + HttpResponse response = client.putJson(SAMPLE_RESOURCE_CREATE_ENDPOINT, sampleResource); + response.assertStatusCode(HttpStatus.SC_OK); + + resourceId = response.getTextFromJsonBody("/message").split(":")[1].trim(); + Thread.sleep(1000); + } + + // 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\": \"" + + RESOURCE_INDEX_NAME + + "\"," + + " \"resource_id\": \"%s\"," + + " \"created_by\": {" + + " \"user\": \"%s\"" + + " }" + + "}", + resourceId, + SHARED_WITH_USER_LIMITED_PERMISSIONS.getName() + ); + HttpResponse response = client.postJson(OPENSEARCH_RESOURCE_SHARING_INDEX + "/_doc", json); + assertThat(response.getStatusReason(), containsString("Created")); + + // Also update the in-memory map and get + resourcePluginInfo.getResourceIndicesMutable().add(RESOURCE_INDEX_NAME); + ResourceProvider provider = new ResourceProvider( + SampleResource.class.getCanonicalName(), + RESOURCE_INDEX_NAME, + new SampleResourceParser() + ); + resourcePluginInfo.getResourceProvidersMutable().put(RESOURCE_INDEX_NAME, provider); + + ResourceSharingClientAccessor.setResourceSharingClient(createResourceAccessControlClient(cluster)); + + 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")); + } + + // user should be able to get its own resource as it has get API access + try (TestRestClient client = cluster.getRestClient(SHARED_WITH_USER_LIMITED_PERMISSIONS)) { + HttpResponse response = client.get(SAMPLE_RESOURCE_GET_ENDPOINT + "/" + resourceId); + response.assertStatusCode(HttpStatus.SC_OK); + } + + // Update user's sample resource + try (TestRestClient client = cluster.getRestClient(SHARED_WITH_USER_LIMITED_PERMISSIONS)) { + String sampleResourceUpdated = "{\"name\":\"sampleUpdated\"}"; + HttpResponse updateResponse = client.postJson(SAMPLE_RESOURCE_UPDATE_ENDPOINT + "/" + resourceId, sampleResourceUpdated); + // cannot update because this user doesnt have access to update API + updateResponse.assertStatusCode(HttpStatus.SC_FORBIDDEN); + assertThat( + updateResponse.bodyAsJsonNode().get("error").get("root_cause").get(0).get("reason").asText(), + containsString( + "no permissions for [cluster:admin/sample-resource-plugin/update] and User [name=resource_sharing_test_user_limited_perms, backend_roles=[], requestedTenant=null]" + ) + ); + } + + // User admin should not be able to update, since resource is not shared with it + try (TestRestClient client = cluster.getRestClient(USER_ADMIN)) { + String sampleResourceUpdated = "{\"name\":\"sampleUpdated\"}"; + HttpResponse updateResponse = client.postJson(SAMPLE_RESOURCE_UPDATE_ENDPOINT + "/" + resourceId, sampleResourceUpdated); + // cannot update because this user doesnt have access to the resource + updateResponse.assertStatusCode(HttpStatus.SC_FORBIDDEN); + } + + // Super admin can update the resource + try (TestRestClient client = cluster.getRestClient(cluster.getAdminCertificate())) { + String sampleResourceUpdated = "{\"name\":\"sampleUpdated\"}"; + HttpResponse updateResponse = client.postJson(SAMPLE_RESOURCE_UPDATE_ENDPOINT + "/" + resourceId, sampleResourceUpdated); + // cannot update because this user doesnt have access to update API + updateResponse.assertStatusCode(HttpStatus.SC_OK); + assertThat(updateResponse.getBody(), containsString("sample")); + } + + // share resource with admin + try (TestRestClient client = cluster.getRestClient(SHARED_WITH_USER_LIMITED_PERMISSIONS)) { + HttpResponse response = client.postJson( + SAMPLE_RESOURCE_SHARE_ENDPOINT + "/" + resourceId, + shareWithPayload(USER_ADMIN.getName()) + ); + + response.assertStatusCode(HttpStatus.SC_OK); + } + + // admin is able to access resource now + try (TestRestClient client = cluster.getRestClient(USER_ADMIN)) { + HttpResponse response = client.get(SAMPLE_RESOURCE_GET_ENDPOINT + "/" + resourceId); + response.assertStatusCode(HttpStatus.SC_OK); + } + + // revoke admin's access + try (TestRestClient client = cluster.getRestClient(SHARED_WITH_USER_LIMITED_PERMISSIONS)) { + HttpResponse response = client.postJson( + SAMPLE_RESOURCE_REVOKE_ENDPOINT + "/" + resourceId, + revokeAccessPayload(USER_ADMIN.getName()) + ); + + response.assertStatusCode(HttpStatus.SC_OK); + } + + // admin can no longer access the resource + try (TestRestClient client = cluster.getRestClient(USER_ADMIN)) { + HttpResponse response = client.get(SAMPLE_RESOURCE_GET_ENDPOINT + "/" + resourceId); + response.assertStatusCode(HttpStatus.SC_FORBIDDEN); + } + + // delete sample resource + try (TestRestClient client = cluster.getRestClient(SHARED_WITH_USER_LIMITED_PERMISSIONS)) { + HttpResponse response = client.delete(SAMPLE_RESOURCE_DELETE_ENDPOINT + "/" + resourceId); + + // cannot delete because this user doesnt have access to delete API + response.assertStatusCode(HttpStatus.SC_FORBIDDEN); + assertThat( + response.bodyAsJsonNode().get("error").get("root_cause").get(0).get("reason").asText(), + containsString( + "no permissions for [cluster:admin/sample-resource-plugin/delete] and User [name=resource_sharing_test_user_limited_perms, backend_roles=[], requestedTenant=null]" + ) + ); + } + + // User admin should not be able to delete share_with_user's resource + try (TestRestClient client = cluster.getRestClient(USER_ADMIN)) { + HttpResponse response = client.delete(SAMPLE_RESOURCE_DELETE_ENDPOINT + "/" + resourceId); + + // cannot delete because user admin doesn't have access to resource + response.assertStatusCode(HttpStatus.SC_FORBIDDEN); + } + + // Super admin can delete the resource + try (TestRestClient client = cluster.getRestClient(cluster.getAdminCertificate())) { + HttpResponse response = client.delete(SAMPLE_RESOURCE_DELETE_ENDPOINT + "/" + resourceId); + + response.assertStatusCode(HttpStatus.SC_OK); + } + } +} diff --git a/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/SampleResourcePluginSystemIndexDisabledTests.java b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/SampleResourcePluginSystemIndexDisabledTests.java new file mode 100644 index 0000000000..661a1c6e40 --- /dev/null +++ b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/SampleResourcePluginSystemIndexDisabledTests.java @@ -0,0 +1,194 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.sample; + +import java.util.Map; + +import com.carrotsearch.randomizedtesting.annotations.ThreadLeakScope; +import org.apache.http.HttpStatus; +import org.junit.ClassRule; +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.opensearch.painless.PainlessModulePlugin; +import org.opensearch.sample.resource.client.ResourceSharingClientAccessor; +import org.opensearch.security.resources.ResourceProvider; +import org.opensearch.security.spi.resources.ResourceAccessActionGroups; +import org.opensearch.test.framework.TestSecurityConfig; +import org.opensearch.test.framework.cluster.ClusterManager; +import org.opensearch.test.framework.cluster.LocalCluster; +import org.opensearch.test.framework.cluster.TestRestClient; +import org.opensearch.test.framework.cluster.TestRestClient.HttpResponse; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.opensearch.sample.utils.Constants.OPENSEARCH_RESOURCE_SHARING_ENABLED; +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 but system index protection disabled + */ +@RunWith(com.carrotsearch.randomizedtesting.RandomizedRunner.class) +@ThreadLeakScope(ThreadLeakScope.Scope.NONE) +public class SampleResourcePluginSystemIndexDisabledTests extends AbstractSampleResourcePluginFeatureEnabledTests { + + @ClassRule + public static LocalCluster cluster = new LocalCluster.Builder().clusterManager(ClusterManager.SINGLENODE) + .plugin(SampleResourcePlugin.class, PainlessModulePlugin.class) + .anonymousAuth(true) + .authc(AUTHC_HTTPBASIC_INTERNAL) + .users(USER_ADMIN, SHARED_WITH_USER) + .nodeSettings(Map.of(OPENSEARCH_RESOURCE_SHARING_ENABLED, true)) + .build(); + + @Override + protected LocalCluster getLocalCluster() { + return cluster; + } + + @Override + protected TestSecurityConfig.User getSharedUser() { + return SHARED_WITH_USER; + } + + @Test + public void testDirectAccess() throws Exception { + String resourceId; + // create sample resource + try (TestRestClient client = cluster.getRestClient(USER_ADMIN)) { + String sampleResource = "{\"name\":\"sample\"}"; + HttpResponse response = client.putJson(SAMPLE_RESOURCE_CREATE_ENDPOINT, sampleResource); + response.assertStatusCode(HttpStatus.SC_OK); + + resourceId = response.getTextFromJsonBody("/message").split(":")[1].trim(); + Thread.sleep(1000); + } + + // 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\": \"" + + RESOURCE_INDEX_NAME + + "\"," + + " \"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 get + resourcePluginInfo.getResourceIndicesMutable().add(RESOURCE_INDEX_NAME); + ResourceProvider provider = new ResourceProvider( + SampleResource.class.getCanonicalName(), + RESOURCE_INDEX_NAME, + new SampleResourceParser() + ); + resourcePluginInfo.getResourceProvidersMutable().put(RESOURCE_INDEX_NAME, provider); + + ResourceSharingClientAccessor.setResourceSharingClient(createResourceAccessControlClient(cluster)); + + 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")); + } + + // admin will be able to access resource directly since system index protection is disabled, and also via sample plugin + try (TestRestClient client = cluster.getRestClient(USER_ADMIN)) { + HttpResponse response = client.get(RESOURCE_INDEX_NAME + "/_doc/" + resourceId); + response.assertStatusCode(HttpStatus.SC_OK); + + response = client.get(SAMPLE_RESOURCE_GET_ENDPOINT + "/" + resourceId); + response.assertStatusCode(HttpStatus.SC_OK); + } + + // shared_with_user will be able to access resource directly since system index protection is disabled even-though resource is not + // shared with this user, but cannot access via sample plugin APIs + try (TestRestClient client = cluster.getRestClient(SHARED_WITH_USER)) { + HttpResponse response = client.get(RESOURCE_INDEX_NAME + "/_doc/" + resourceId); + response.assertStatusCode(HttpStatus.SC_OK); + + response = client.get(SAMPLE_RESOURCE_GET_ENDPOINT + "/" + resourceId); + response.assertStatusCode(HttpStatus.SC_FORBIDDEN); + } + + // Update sample resource shared_with_user will be able to update admin's resource because system index protection is disabled + try (TestRestClient client = cluster.getRestClient(SHARED_WITH_USER)) { + String sampleResourceUpdated = "{\"name\":\"sampleUpdated\"}"; + TestRestClient.HttpResponse updateResponse = client.postJson( + RESOURCE_INDEX_NAME + "/_doc/" + resourceId, + sampleResourceUpdated + ); + updateResponse.assertStatusCode(HttpStatus.SC_OK); + } + + // share resource with shared_with user + try (TestRestClient client = cluster.getRestClient(USER_ADMIN)) { + Thread.sleep(1000); + + HttpResponse response = client.postJson( + SAMPLE_RESOURCE_SHARE_ENDPOINT + "/" + resourceId, + shareWithPayload(SHARED_WITH_USER.getName()) + ); + response.assertStatusCode(HttpStatus.SC_OK); + assertThat( + response.bodyAsJsonNode().get("share_with").get(ResourceAccessActionGroups.PLACE_HOLDER).get("users").get(0).asText(), + containsString(SHARED_WITH_USER.getName()) + ); + } + + // shared_with_user will still be able to access resource directly since system index protection is enabled, but can also access via + // sample plugin + try (TestRestClient client = cluster.getRestClient(SHARED_WITH_USER)) { + HttpResponse response = client.get(RESOURCE_INDEX_NAME + "/_doc/" + resourceId); + response.assertStatusCode(HttpStatus.SC_OK); + + response = client.get(SAMPLE_RESOURCE_GET_ENDPOINT + "/" + resourceId); + response.assertStatusCode(HttpStatus.SC_OK); + } + + // revoke share_with_user's access + try (TestRestClient client = cluster.getRestClient(USER_ADMIN)) { + Thread.sleep(1000); + HttpResponse response = client.postJson( + SAMPLE_RESOURCE_REVOKE_ENDPOINT + "/" + resourceId, + revokeAccessPayload(SHARED_WITH_USER.getName()) + ); + response.assertStatusCode(HttpStatus.SC_OK); + assertThat(response.bodyAsJsonNode().get("share_with").size(), equalTo(0)); + } + + // shared_with_user will still be able to access the resource directly but not via sample plugin since access is revoked + try (TestRestClient client = cluster.getRestClient(SHARED_WITH_USER)) { + HttpResponse response = client.get(RESOURCE_INDEX_NAME + "/_doc/" + resourceId); + response.assertStatusCode(HttpStatus.SC_OK); + + response = client.get(SAMPLE_RESOURCE_GET_ENDPOINT + "/" + resourceId); + response.assertStatusCode(HttpStatus.SC_FORBIDDEN); + } + + // shared_with_user should be able to delete the resource since system index protection is disabled + try (TestRestClient client = cluster.getRestClient(SHARED_WITH_USER)) { + HttpResponse response = client.delete(RESOURCE_INDEX_NAME + "/_doc/" + resourceId); + response.assertStatusCode(HttpStatus.SC_OK); + } + + } + +} diff --git a/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/SampleResourcePluginTests.java b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/SampleResourcePluginTests.java new file mode 100644 index 0000000000..5d966fc84a --- /dev/null +++ b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/SampleResourcePluginTests.java @@ -0,0 +1,200 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.sample; + +import java.util.Map; + +import com.carrotsearch.randomizedtesting.annotations.ThreadLeakScope; +import org.apache.http.HttpStatus; +import org.junit.ClassRule; +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.opensearch.painless.PainlessModulePlugin; +import org.opensearch.sample.resource.client.ResourceSharingClientAccessor; +import org.opensearch.security.resources.ResourcePluginInfo; +import org.opensearch.security.resources.ResourceProvider; +import org.opensearch.security.spi.resources.ResourceAccessActionGroups; +import org.opensearch.test.framework.TestSecurityConfig; +import org.opensearch.test.framework.cluster.ClusterManager; +import org.opensearch.test.framework.cluster.LocalCluster; +import org.opensearch.test.framework.cluster.TestRestClient; +import org.opensearch.test.framework.cluster.TestRestClient.HttpResponse; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.opensearch.sample.utils.Constants.OPENSEARCH_RESOURCE_SHARING_ENABLED; +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.SECURITY_SYSTEM_INDICES_ENABLED_KEY; +import static org.opensearch.test.framework.TestSecurityConfig.AuthcDomain.AUTHC_HTTPBASIC_INTERNAL; +import static org.opensearch.test.framework.TestSecurityConfig.User.USER_ADMIN; + +/** + * These tests run with resource sharing enabled and system index protection enabled + */ +@RunWith(com.carrotsearch.randomizedtesting.RandomizedRunner.class) +@ThreadLeakScope(ThreadLeakScope.Scope.NONE) +public class SampleResourcePluginTests extends AbstractSampleResourcePluginFeatureEnabledTests { + + @ClassRule + public static LocalCluster cluster = new LocalCluster.Builder().clusterManager(ClusterManager.SINGLENODE) + .plugin(SampleResourcePlugin.class, PainlessModulePlugin.class) + .anonymousAuth(true) + .authc(AUTHC_HTTPBASIC_INTERNAL) + .users(USER_ADMIN, SHARED_WITH_USER) + .nodeSettings(Map.of(SECURITY_SYSTEM_INDICES_ENABLED_KEY, true, OPENSEARCH_RESOURCE_SHARING_ENABLED, true)) + .build(); + + @Override + protected LocalCluster getLocalCluster() { + return cluster; + } + + @Override + protected TestSecurityConfig.User getSharedUser() { + return SHARED_WITH_USER; + } + + @Test + public void testDirectAccess() throws Exception { + ResourcePluginInfo resourcePluginInfo = cluster.nodes().getFirst().getInjectable(ResourcePluginInfo.class); + + String resourceId; + // create sample resource + try (TestRestClient client = cluster.getRestClient(USER_ADMIN)) { + String sampleResource = "{\"name\":\"sample\"}"; + HttpResponse response = client.putJson(SAMPLE_RESOURCE_CREATE_ENDPOINT, sampleResource); + response.assertStatusCode(HttpStatus.SC_OK); + + resourceId = response.getTextFromJsonBody("/message").split(":")[1].trim(); + Thread.sleep(1000); + } + + // 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\": \"" + + RESOURCE_INDEX_NAME + + "\"," + + " \"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 get + resourcePluginInfo.getResourceIndicesMutable().add(RESOURCE_INDEX_NAME); + ResourceProvider provider = new ResourceProvider( + SampleResource.class.getCanonicalName(), + RESOURCE_INDEX_NAME, + new SampleResourceParser() + ); + resourcePluginInfo.getResourceProvidersMutable().put(RESOURCE_INDEX_NAME, provider); + + ResourceSharingClientAccessor.setResourceSharingClient(createResourceAccessControlClient(cluster)); + + resourcePluginInfo.getResourceSharingExtensionsMutable().add(new SampleResourcePlugin()); + + 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")); + } + + // admin should not be able to access resource directly since system index protection is enabled, but can access via sample plugin + try (TestRestClient client = cluster.getRestClient(USER_ADMIN)) { + HttpResponse response = client.get(RESOURCE_INDEX_NAME + "/_doc/" + resourceId); + response.assertStatusCode(HttpStatus.SC_NOT_FOUND); + + response = client.get(SAMPLE_RESOURCE_GET_ENDPOINT + "/" + resourceId); + response.assertStatusCode(HttpStatus.SC_OK); + } + + // shared_with_user should not be able to delete the resource since system index protection is enabled + try (TestRestClient client = cluster.getRestClient(SHARED_WITH_USER)) { + HttpResponse response = client.delete(RESOURCE_INDEX_NAME + "/_doc/" + resourceId); + response.assertStatusCode(HttpStatus.SC_FORBIDDEN); + } + + // shared_with_user should not be able to access resource directly since system index protection is enabled, and resource is not + // shared with user + try (TestRestClient client = cluster.getRestClient(SHARED_WITH_USER)) { + HttpResponse response = client.get(RESOURCE_INDEX_NAME + "/_doc/" + resourceId); + response.assertStatusCode(HttpStatus.SC_NOT_FOUND); + + response = client.get(SAMPLE_RESOURCE_GET_ENDPOINT + "/" + resourceId); + response.assertStatusCode(HttpStatus.SC_FORBIDDEN); + } + + // Update sample resource (shared_with_user cannot update admin's resource) because system index protection is enabled + try (TestRestClient client = cluster.getRestClient(SHARED_WITH_USER)) { + String sampleResourceUpdated = "{\"name\":\"sampleUpdated\"}"; + TestRestClient.HttpResponse updateResponse = client.postJson( + RESOURCE_INDEX_NAME + "/_doc/" + resourceId, + sampleResourceUpdated + ); + updateResponse.assertStatusCode(HttpStatus.SC_FORBIDDEN); + } + + // share resource with shared_with user + try (TestRestClient client = cluster.getRestClient(USER_ADMIN)) { + Thread.sleep(1000); + + HttpResponse response = client.postJson( + SAMPLE_RESOURCE_SHARE_ENDPOINT + "/" + resourceId, + shareWithPayload(SHARED_WITH_USER.getName()) + ); + response.assertStatusCode(HttpStatus.SC_OK); + assertThat( + response.bodyAsJsonNode().get("share_with").get(ResourceAccessActionGroups.PLACE_HOLDER).get("users").get(0).asText(), + containsString(SHARED_WITH_USER.getName()) + ); + } + + // shared_with_user should not be able to access resource directly since system index protection is enabled, but can access via + // sample plugin + try (TestRestClient client = cluster.getRestClient(SHARED_WITH_USER)) { + HttpResponse response = client.get(RESOURCE_INDEX_NAME + "/_doc/" + resourceId); + response.assertStatusCode(HttpStatus.SC_NOT_FOUND); + + response = client.get(SAMPLE_RESOURCE_GET_ENDPOINT + "/" + resourceId); + response.assertStatusCode(HttpStatus.SC_OK); + } + + // revoke share_with_user's access + try (TestRestClient client = cluster.getRestClient(USER_ADMIN)) { + Thread.sleep(1000); + HttpResponse response = client.postJson( + SAMPLE_RESOURCE_REVOKE_ENDPOINT + "/" + resourceId, + revokeAccessPayload(SHARED_WITH_USER.getName()) + ); + response.assertStatusCode(HttpStatus.SC_OK); + assertThat(response.bodyAsJsonNode().get("share_with").size(), equalTo(0)); + } + + // shared_with_user should not be able to access the resource directly nor via sample plugin since access is revoked + try (TestRestClient client = cluster.getRestClient(SHARED_WITH_USER)) { + HttpResponse response = client.get(RESOURCE_INDEX_NAME + "/_doc/" + resourceId); + response.assertStatusCode(HttpStatus.SC_NOT_FOUND); + + response = client.postJson(RESOURCE_INDEX_NAME + "/_search", "{\"query\" : {\"match_all\" : {}}}"); + response.assertStatusCode(HttpStatus.SC_OK); + assertThat(response.bodyAsJsonNode().get("hits").get("hits").size(), equalTo(0)); + } + + } +} diff --git a/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/nonsystemindex/AbstractResourcePluginNonSystemIndexTests.java b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/nonsystemindex/AbstractResourcePluginNonSystemIndexTests.java new file mode 100644 index 0000000000..e1563751ab --- /dev/null +++ b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/nonsystemindex/AbstractResourcePluginNonSystemIndexTests.java @@ -0,0 +1,96 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.sample.nonsystemindex; + +import com.carrotsearch.randomizedtesting.annotations.ThreadLeakScope; +import org.apache.http.HttpStatus; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.opensearch.sample.AbstractSampleResourcePluginTests; +import org.opensearch.security.resources.ResourcePluginInfo; +import org.opensearch.test.framework.cluster.LocalCluster; +import org.opensearch.test.framework.cluster.TestRestClient; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.opensearch.sample.nonsystemindex.plugin.ResourceNonSystemIndexPlugin.SAMPLE_NON_SYSTEM_INDEX_NAME; +import static org.opensearch.security.resources.ResourceSharingConstants.OPENSEARCH_RESOURCE_SHARING_INDEX; +import static org.opensearch.test.framework.TestSecurityConfig.User.USER_ADMIN; + +/** + * This abstract class defines common tests between different feature flag scenarios where resource plugin does not register its resource index as system index + */ +@RunWith(com.carrotsearch.randomizedtesting.RandomizedRunner.class) +@ThreadLeakScope(ThreadLeakScope.Scope.NONE) +public abstract class AbstractResourcePluginNonSystemIndexTests extends AbstractSampleResourcePluginTests { + + protected abstract LocalCluster getLocalCluster(); + + private LocalCluster cluster; + ResourcePluginInfo resourcePluginInfo; + + @Before + public void setup() { + cluster = getLocalCluster(); + resourcePluginInfo = cluster.nodes().getFirst().getInjectable(ResourcePluginInfo.class); + } + + @After + public void clearIndices() { + try (TestRestClient client = cluster.getRestClient(cluster.getAdminCertificate())) { + client.delete(SAMPLE_NON_SYSTEM_INDEX_NAME); + client.delete(OPENSEARCH_RESOURCE_SHARING_INDEX); + resourcePluginInfo.getResourceIndicesMutable().remove(SAMPLE_NON_SYSTEM_INDEX_NAME); + resourcePluginInfo.getResourceProvidersMutable().remove(SAMPLE_NON_SYSTEM_INDEX_NAME); + } + } + + @Test + public void testPluginInstalledCorrectly() { + try (TestRestClient client = cluster.getRestClient(USER_ADMIN)) { + TestRestClient.HttpResponse pluginsResponse = client.get("_cat/plugins"); + assertThat(pluginsResponse.getBody(), containsString("org.opensearch.security.OpenSearchSecurityPlugin")); + assertThat( + pluginsResponse.getBody(), + containsString("org.opensearch.sample.nonsystemindex.plugin.ResourceNonSystemIndexPlugin") + ); + } + } + + @Test + public void testSecurityResourceAPIs() { + try (TestRestClient client = cluster.getRestClient(cluster.getAdminCertificate())) { + TestRestClient.HttpResponse response = client.get(SECURITY_RESOURCE_LIST_ENDPOINT + "/" + SAMPLE_NON_SYSTEM_INDEX_NAME); + assertBadResponse(response); + + String samplePayload = "{ \"resource_index\": \"" + SAMPLE_NON_SYSTEM_INDEX_NAME + "\"}"; + response = client.postJson(SECURITY_RESOURCE_VERIFY_ENDPOINT, samplePayload); + assertBadResponse(response); + + response = client.postJson(SECURITY_RESOURCE_SHARE_ENDPOINT, samplePayload); + assertBadResponse(response); + + response = client.postJson(SECURITY_RESOURCE_REVOKE_ENDPOINT, samplePayload); + assertBadResponse(response); + + } + } + + private void assertBadResponse(TestRestClient.HttpResponse response) { + response.assertStatusCode(HttpStatus.SC_BAD_REQUEST); + assertThat( + response.getTextFromJsonBody("/message"), + equalTo("Resource index '" + SAMPLE_NON_SYSTEM_INDEX_NAME + "' is not a system index.") + ); + } +} diff --git a/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/nonsystemindex/ResourceNonSystemIndexSIDisabledTests.java b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/nonsystemindex/ResourceNonSystemIndexSIDisabledTests.java new file mode 100644 index 0000000000..67e538a2c8 --- /dev/null +++ b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/nonsystemindex/ResourceNonSystemIndexSIDisabledTests.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.sample.nonsystemindex; + +import org.junit.ClassRule; + +import org.opensearch.painless.PainlessModulePlugin; +import org.opensearch.sample.nonsystemindex.plugin.ResourceNonSystemIndexPlugin; +import org.opensearch.test.framework.cluster.ClusterManager; +import org.opensearch.test.framework.cluster.LocalCluster; + +import static org.opensearch.test.framework.TestSecurityConfig.AuthcDomain.AUTHC_HTTPBASIC_INTERNAL; +import static org.opensearch.test.framework.TestSecurityConfig.User.USER_ADMIN; + +/** + * These tests run with resource sharing enabled but the plugin does not declare a system index and system index protection is disabled + */ +public class ResourceNonSystemIndexSIDisabledTests extends AbstractResourcePluginNonSystemIndexTests { + + @ClassRule + public static LocalCluster cluster = new LocalCluster.Builder().clusterManager(ClusterManager.SINGLENODE) + .plugin(ResourceNonSystemIndexPlugin.class, PainlessModulePlugin.class) + .anonymousAuth(true) + .authc(AUTHC_HTTPBASIC_INTERNAL) + .users(USER_ADMIN, SHARED_WITH_USER) + .build(); + + @Override + protected LocalCluster getLocalCluster() { + return cluster; + } +} diff --git a/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/nonsystemindex/ResourceNonSystemIndexTests.java b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/nonsystemindex/ResourceNonSystemIndexTests.java new file mode 100644 index 0000000000..614c399040 --- /dev/null +++ b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/nonsystemindex/ResourceNonSystemIndexTests.java @@ -0,0 +1,42 @@ +/* + * 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.nonsystemindex; + +import java.util.Map; + +import org.junit.ClassRule; + +import org.opensearch.painless.PainlessModulePlugin; +import org.opensearch.sample.nonsystemindex.plugin.ResourceNonSystemIndexPlugin; +import org.opensearch.test.framework.cluster.ClusterManager; +import org.opensearch.test.framework.cluster.LocalCluster; + +import static org.opensearch.security.support.ConfigConstants.SECURITY_SYSTEM_INDICES_ENABLED_KEY; +import static org.opensearch.test.framework.TestSecurityConfig.AuthcDomain.AUTHC_HTTPBASIC_INTERNAL; +import static org.opensearch.test.framework.TestSecurityConfig.User.USER_ADMIN; + +/** + * These tests run with resource sharing enabled but the plugin does not declare a system index and system index protection is enabled + */ +public class ResourceNonSystemIndexTests extends AbstractResourcePluginNonSystemIndexTests { + + @ClassRule + public static LocalCluster cluster = new LocalCluster.Builder().clusterManager(ClusterManager.SINGLENODE) + .plugin(ResourceNonSystemIndexPlugin.class, PainlessModulePlugin.class) + .anonymousAuth(true) + .authc(AUTHC_HTTPBASIC_INTERNAL) + .users(USER_ADMIN, SHARED_WITH_USER) + .nodeSettings(Map.of(SECURITY_SYSTEM_INDICES_ENABLED_KEY, true)) + .build(); + + @Override + protected LocalCluster getLocalCluster() { + return cluster; + } +} diff --git a/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/nonsystemindex/plugin/ResourceNonSystemIndexPlugin.java b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/nonsystemindex/plugin/ResourceNonSystemIndexPlugin.java new file mode 100644 index 0000000000..e3019d5b76 --- /dev/null +++ b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/nonsystemindex/plugin/ResourceNonSystemIndexPlugin.java @@ -0,0 +1,48 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.sample.nonsystemindex.plugin; + +import java.nio.file.Path; + +import org.opensearch.common.settings.Settings; +import org.opensearch.plugins.Plugin; +import org.opensearch.sample.SampleResource; +import org.opensearch.sample.SampleResourceParser; +import org.opensearch.security.spi.resources.ResourceSharingClient; +import org.opensearch.security.spi.resources.ResourceSharingExtension; +import org.opensearch.security.spi.resources.ShareableResource; +import org.opensearch.security.spi.resources.ShareableResourceParser; + +/** + * Sample resource sharing plugin that doesn't declare its resource index as system index. + * TESTING ONLY + */ +public class ResourceNonSystemIndexPlugin extends Plugin implements ResourceSharingExtension { + public static final String SAMPLE_NON_SYSTEM_INDEX_NAME = "sample_non_system_index"; + + public ResourceNonSystemIndexPlugin(final Settings settings, final Path path) {} + + @Override + public String getResourceType() { + return SampleResource.class.getName(); + } + + @Override + public String getResourceIndex() { + return SAMPLE_NON_SYSTEM_INDEX_NAME; + } + + @Override + public ShareableResourceParser getShareableResourceParser() { + return new SampleResourceParser(); + } + + @Override + public void assignResourceSharingClient(ResourceSharingClient resourceSharingClient) {} +} diff --git a/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/security_disabled/ResourcePluginSecurityDisabledTests.java b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/security_disabled/ResourcePluginSecurityDisabledTests.java new file mode 100644 index 0000000000..d08228958c --- /dev/null +++ b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/security_disabled/ResourcePluginSecurityDisabledTests.java @@ -0,0 +1,129 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.sample.security_disabled; + +import java.util.Map; + +import com.carrotsearch.randomizedtesting.annotations.ThreadLeakScope; +import org.apache.http.HttpStatus; +import org.junit.After; +import org.junit.ClassRule; +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.opensearch.painless.PainlessModulePlugin; +import org.opensearch.rest.RestRequest; +import org.opensearch.sample.AbstractSampleResourcePluginTests; +import org.opensearch.sample.SampleResourcePlugin; +import org.opensearch.test.framework.cluster.ClusterManager; +import org.opensearch.test.framework.cluster.LocalCluster; +import org.opensearch.test.framework.cluster.TestRestClient; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.opensearch.sample.utils.Constants.RESOURCE_INDEX_NAME; +import static org.opensearch.test.framework.TestSecurityConfig.User.USER_ADMIN; + +/** + * This class defines a test scenario where security plugin is disabled + * It checks access through sample plugin as well as through direct security API calls + */ +@RunWith(com.carrotsearch.randomizedtesting.RandomizedRunner.class) +@ThreadLeakScope(ThreadLeakScope.Scope.NONE) +public class ResourcePluginSecurityDisabledTests extends AbstractSampleResourcePluginTests { + + @ClassRule + public static LocalCluster cluster = new LocalCluster.Builder().clusterManager(ClusterManager.SINGLENODE) + .plugin(SampleResourcePlugin.class, PainlessModulePlugin.class) + .loadConfigurationIntoIndex(false) + .nodeSettings(Map.of("plugins.security.disabled", true, "plugins.security.ssl.http.enabled", false)) + .build(); + + @After + public void clearIndices() { + try (TestRestClient client = cluster.getSecurityDisabledRestClient()) { + client.delete(RESOURCE_INDEX_NAME); + } + } + + @Test + public void testPluginInstalledCorrectly() { + try (TestRestClient client = cluster.getSecurityDisabledRestClient()) { + TestRestClient.HttpResponse pluginsResponse = client.get("_cat/plugins"); + // security plugin is simply disabled but it will still be present in + assertThat(pluginsResponse.getBody(), containsString("org.opensearch.security.OpenSearchSecurityPlugin")); + assertThat(pluginsResponse.getBody(), containsString("org.opensearch.sample.SampleResourcePlugin")); + } + } + + @Test + public void testSamplePluginAPIs() { + try (TestRestClient client = cluster.getSecurityDisabledRestClient()) { + String sampleResource = "{\"name\":\"sample\"}"; + TestRestClient.HttpResponse response = client.putJson(SAMPLE_RESOURCE_CREATE_ENDPOINT, sampleResource); + response.assertStatusCode(HttpStatus.SC_OK); + String resourceId = response.getTextFromJsonBody("/message").split(":")[1].trim(); + ; + + // in sample plugin implementation, get all API is checked against + response = client.get(SAMPLE_RESOURCE_GET_ENDPOINT); + response.assertStatusCode(HttpStatus.SC_OK); + + response = client.get(SAMPLE_RESOURCE_GET_ENDPOINT + "/" + resourceId); + response.assertStatusCode(HttpStatus.SC_OK); + + String sampleResourceUpdated = "{\"name\":\"sampleUpdated\"}"; + response = client.postJson(SAMPLE_RESOURCE_UPDATE_ENDPOINT + "/" + resourceId, sampleResourceUpdated); + response.assertStatusCode(HttpStatus.SC_OK); + + response = client.postJson(SAMPLE_RESOURCE_SHARE_ENDPOINT + "/" + resourceId, shareWithPayload(USER_ADMIN.getName())); + assertNotImplementedResponse(response); + + response = client.postJson(SAMPLE_RESOURCE_REVOKE_ENDPOINT + "/" + resourceId, revokeAccessPayload(USER_ADMIN.getName())); + assertNotImplementedResponse(response); + + response = client.delete(SAMPLE_RESOURCE_DELETE_ENDPOINT + "/" + resourceId); + response.assertStatusCode(HttpStatus.SC_OK); + + } + } + + @Test + public void testSecurityResourceAPIs() { + // APIs are not implemented since security plugin is disabled + try (TestRestClient client = cluster.getSecurityDisabledRestClient()) { + TestRestClient.HttpResponse response = client.get(SECURITY_RESOURCE_LIST_ENDPOINT + "/" + RESOURCE_INDEX_NAME); + assertBadResponse(response, SECURITY_RESOURCE_LIST_ENDPOINT + "/" + RESOURCE_INDEX_NAME, RestRequest.Method.GET.name()); + + String samplePayload = "{ \"resource_index\": \"" + RESOURCE_INDEX_NAME + "\"}"; + response = client.postJson(SECURITY_RESOURCE_VERIFY_ENDPOINT, samplePayload); + assertBadResponse(response, SECURITY_RESOURCE_VERIFY_ENDPOINT, RestRequest.Method.POST.name()); + + response = client.postJson(SECURITY_RESOURCE_SHARE_ENDPOINT, samplePayload); + assertBadResponse(response, SECURITY_RESOURCE_SHARE_ENDPOINT, RestRequest.Method.POST.name()); + + response = client.postJson(SECURITY_RESOURCE_REVOKE_ENDPOINT, samplePayload); + assertBadResponse(response, SECURITY_RESOURCE_REVOKE_ENDPOINT, RestRequest.Method.POST.name()); + + } + } + + private void assertNotImplementedResponse(TestRestClient.HttpResponse response) { + response.assertStatusCode(HttpStatus.SC_NOT_IMPLEMENTED); + assertThat(response.getTextFromJsonBody("/error/reason"), containsString("Security plugin is disabled")); + } + + private void assertBadResponse(TestRestClient.HttpResponse response, String uri, String method) { + response.assertStatusCode(HttpStatus.SC_BAD_REQUEST); + assertThat( + response.getTextFromJsonBody("/error"), + containsString("no handler found for uri [/" + uri + "] and method [" + method + "]") + ); + } +} 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..3007549894 --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/SampleResource.java @@ -0,0 +1,109 @@ +/* + * 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.ToXContent; +import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.core.xcontent.XContentParser; +import org.opensearch.security.spi.resources.ShareableResource; + +import static org.opensearch.core.xcontent.ConstructingObjectParser.constructorArg; +import static org.opensearch.core.xcontent.ConstructingObjectParser.optionalConstructorArg; + +/** + * Sample resource declared by this plugin. + */ +public class SampleResource implements ShareableResource { + + private String name; + private String description; + private Map attributes; + + public SampleResource() throws IOException { + super(); + } + + public SampleResource(StreamInput in) throws IOException { + this.name = in.readString(); + this.description = in.readString(); + this.attributes = in.readMap(StreamInput::readString, StreamInput::readString); + } + + @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, ToXContent.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 getName() { + 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..c29fbcda57 --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/SampleResourceParser.java @@ -0,0 +1,27 @@ +/* + * 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.ShareableResourceParser; + +/** + * Responsible for parsing the XContent into a SampleResource object. + */ +public class SampleResourceParser implements ShareableResourceParser { + @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..90abd6abca --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/SampleResourcePlugin.java @@ -0,0 +1,148 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ +package org.opensearch.sample; + +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.function.Supplier; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import org.opensearch.action.ActionRequest; +import org.opensearch.cluster.metadata.IndexNameExpressionResolver; +import org.opensearch.cluster.node.DiscoveryNodes; +import org.opensearch.cluster.service.ClusterService; +import org.opensearch.common.settings.ClusterSettings; +import org.opensearch.common.settings.IndexScopedSettings; +import org.opensearch.common.settings.Settings; +import org.opensearch.common.settings.SettingsFilter; +import org.opensearch.core.action.ActionResponse; +import org.opensearch.core.common.io.stream.NamedWriteableRegistry; +import org.opensearch.core.xcontent.NamedXContentRegistry; +import org.opensearch.env.Environment; +import org.opensearch.env.NodeEnvironment; +import org.opensearch.indices.SystemIndexDescriptor; +import org.opensearch.plugins.ActionPlugin; +import org.opensearch.plugins.Plugin; +import org.opensearch.plugins.SystemIndexPlugin; +import org.opensearch.repositories.RepositoriesService; +import org.opensearch.rest.RestController; +import org.opensearch.rest.RestHandler; +import org.opensearch.sample.resource.actions.rest.create.CreateResourceAction; +import org.opensearch.sample.resource.actions.rest.create.CreateResourceRestAction; +import org.opensearch.sample.resource.actions.rest.create.UpdateResourceAction; +import org.opensearch.sample.resource.actions.rest.delete.DeleteResourceAction; +import org.opensearch.sample.resource.actions.rest.delete.DeleteResourceRestAction; +import org.opensearch.sample.resource.actions.rest.get.GetResourceAction; +import org.opensearch.sample.resource.actions.rest.get.GetResourceRestAction; +import org.opensearch.sample.resource.actions.rest.revoke.RevokeResourceAccessAction; +import org.opensearch.sample.resource.actions.rest.revoke.RevokeResourceAccessRestAction; +import org.opensearch.sample.resource.actions.rest.share.ShareResourceAction; +import org.opensearch.sample.resource.actions.rest.share.ShareResourceRestAction; +import org.opensearch.sample.resource.actions.transport.CreateResourceTransportAction; +import org.opensearch.sample.resource.actions.transport.DeleteResourceTransportAction; +import org.opensearch.sample.resource.actions.transport.GetResourceTransportAction; +import org.opensearch.sample.resource.actions.transport.RevokeResourceAccessTransportAction; +import org.opensearch.sample.resource.actions.transport.ShareResourceTransportAction; +import org.opensearch.sample.resource.actions.transport.UpdateResourceTransportAction; +import org.opensearch.sample.resource.client.ResourceSharingClientAccessor; +import org.opensearch.script.ScriptService; +import org.opensearch.security.spi.resources.ResourceSharingClient; +import org.opensearch.security.spi.resources.ResourceSharingExtension; +import org.opensearch.security.spi.resources.ShareableResourceParser; +import org.opensearch.threadpool.ThreadPool; +import org.opensearch.transport.client.Client; +import org.opensearch.watcher.ResourceWatcherService; + +import static org.opensearch.sample.utils.Constants.RESOURCE_INDEX_NAME; + +/** + * Sample Resource plugin. + * It uses ".sample_resource_sharing_plugin" index to manage its resources, and exposes few REST APIs that manage CRUD operations on sample resources. + * + */ +public class SampleResourcePlugin extends Plugin implements ActionPlugin, SystemIndexPlugin, 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 + ) { + return Collections.emptyList(); + } + + @Override + public List getRestHandlers( + Settings settings, + RestController restController, + ClusterSettings clusterSettings, + IndexScopedSettings indexScopedSettings, + SettingsFilter settingsFilter, + IndexNameExpressionResolver indexNameExpressionResolver, + Supplier nodesInCluster + ) { + return List.of( + new CreateResourceRestAction(), + new GetResourceRestAction(), + new DeleteResourceRestAction(), + new ShareResourceRestAction(), + new RevokeResourceAccessRestAction() + ); + } + + @Override + public List> getActions() { + return List.of( + new ActionHandler<>(CreateResourceAction.INSTANCE, CreateResourceTransportAction.class), + new ActionHandler<>(GetResourceAction.INSTANCE, GetResourceTransportAction.class), + new ActionHandler<>(UpdateResourceAction.INSTANCE, UpdateResourceTransportAction.class), + new ActionHandler<>(DeleteResourceAction.INSTANCE, DeleteResourceTransportAction.class), + new ActionHandler<>(ShareResourceAction.INSTANCE, ShareResourceTransportAction.class), + new ActionHandler<>(RevokeResourceAccessAction.INSTANCE, RevokeResourceAccessTransportAction.class) + ); + } + + @Override + public Collection getSystemIndexDescriptors(Settings settings) { + final SystemIndexDescriptor systemIndexDescriptor = new SystemIndexDescriptor(RESOURCE_INDEX_NAME, "Sample index with resources"); + return Collections.singletonList(systemIndexDescriptor); + } + + @Override + public String getResourceType() { + return SampleResource.class.getCanonicalName(); + } + + @Override + public String getResourceIndex() { + return RESOURCE_INDEX_NAME; + } + + @Override + public ShareableResourceParser getShareableResourceParser() { + return new SampleResourceParser(); + } + + @Override + public void assignResourceSharingClient(ResourceSharingClient resourceSharingClient) { + ResourceSharingClientAccessor.setResourceSharingClient(resourceSharingClient); + } +} 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..6f1ca2e025 --- /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.ShareableResource; + +/** + * Request object for CreateSampleResource transport action + */ +public class CreateResourceRequest extends ActionRequest { + + private final ShareableResource resource; + + /** + * Default constructor + */ + public CreateResourceRequest(ShareableResource resource) { + this.resource = resource; + } + + public CreateResourceRequest(StreamInput in) throws IOException { + this.resource = in.readNamedWriteable(ShareableResource.class); + } + + @Override + public void writeTo(final StreamOutput out) throws IOException { + resource.writeTo(out); + } + + @Override + public ActionRequestValidationException validate() { + return null; + } + + public ShareableResource 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..8cfc00d013 --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/create/CreateResourceRestAction.java @@ -0,0 +1,96 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.sample.resource.actions.rest.create; + +import java.io.IOException; +import java.util.List; +import java.util.Map; + +import org.opensearch.core.xcontent.XContentParser; +import org.opensearch.rest.BaseRestHandler; +import org.opensearch.rest.RestRequest; +import org.opensearch.rest.action.RestToXContentListener; +import org.opensearch.sample.SampleResource; +import org.opensearch.transport.client.node.NodeClient; + +import static org.opensearch.rest.RestRequest.Method.POST; +import static org.opensearch.rest.RestRequest.Method.PUT; +import static org.opensearch.sample.utils.Constants.SAMPLE_RESOURCE_PLUGIN_API_PREFIX; + +/** + * Rest Action to create a Sample Resource. Registers Create and Update REST APIs. + */ +public class CreateResourceRestAction extends BaseRestHandler { + + public CreateResourceRestAction() {} + + @Override + public List routes() { + return List.of( + new Route(PUT, SAMPLE_RESOURCE_PLUGIN_API_PREFIX + "/create"), + new Route(POST, SAMPLE_RESOURCE_PLUGIN_API_PREFIX + "/update/{resource_id}") + ); + } + + @Override + public String getName() { + return "create_update_sample_resource"; + } + + @Override + protected RestChannelConsumer prepareRequest(RestRequest request, NodeClient client) throws IOException { + Map source; + try (XContentParser parser = request.contentParser()) { + source = parser.map(); + } + + switch (request.method()) { + case PUT: + return createResource(source, client); + case POST: + return updateResource(source, request.param("resource_id"), client); + default: + throw new IllegalArgumentException("Illegal method: " + request.method()); + } + } + + @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) + ); + } + + @SuppressWarnings("unchecked") + 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..ec5f84adfb --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/create/UpdateResourceAction.java @@ -0,0 +1,29 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.sample.resource.actions.rest.create; + +import org.opensearch.action.ActionType; + +/** + * Action to update a sample resource + */ +public class UpdateResourceAction extends ActionType { + /** + * Update sample resource action instance + */ + public static final UpdateResourceAction INSTANCE = new UpdateResourceAction(); + /** + * Update sample resource action name + */ + public static final String NAME = "cluster:admin/sample-resource-plugin/update"; + + private UpdateResourceAction() { + super(NAME, CreateResourceResponse::new); + } +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/create/UpdateResourceRequest.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/create/UpdateResourceRequest.java new file mode 100644 index 0000000000..da1731dda1 --- /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.ShareableResource; + +/** + * Request object for UpdateResource transport action + */ +public class UpdateResourceRequest extends ActionRequest { + + private final String resourceId; + private final ShareableResource resource; + + /** + * Default constructor + */ + public UpdateResourceRequest(String resourceId, ShareableResource resource) { + this.resourceId = resourceId; + this.resource = resource; + } + + public UpdateResourceRequest(StreamInput in) throws IOException { + this.resourceId = in.readString(); + this.resource = in.readNamedWriteable(ShareableResource.class); + } + + @Override + public void writeTo(final StreamOutput out) throws IOException { + out.writeString(resourceId); + resource.writeTo(out); + } + + @Override + public ActionRequestValidationException validate() { + return null; + } + + public ShareableResource getResource() { + return this.resource; + } + + public String getResourceId() { + return this.resourceId; + } +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/delete/DeleteResourceAction.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/delete/DeleteResourceAction.java new file mode 100644 index 0000000000..d7410e6388 --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/delete/DeleteResourceAction.java @@ -0,0 +1,29 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.sample.resource.actions.rest.delete; + +import org.opensearch.action.ActionType; + +/** + * Action to delete a sample resource + */ +public class DeleteResourceAction extends ActionType { + /** + * Delete sample resource action instance + */ + public static final DeleteResourceAction INSTANCE = new DeleteResourceAction(); + /** + * Delete sample resource action name + */ + public static final String NAME = "cluster:admin/sample-resource-plugin/delete"; + + private DeleteResourceAction() { + super(NAME, DeleteResourceResponse::new); + } +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/delete/DeleteResourceRequest.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/delete/DeleteResourceRequest.java new file mode 100644 index 0000000000..9aa4332fe8 --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/delete/DeleteResourceRequest.java @@ -0,0 +1,49 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.sample.resource.actions.rest.delete; + +import java.io.IOException; + +import org.opensearch.action.ActionRequest; +import org.opensearch.action.ActionRequestValidationException; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; + +/** + * Request object for DeleteSampleResource transport action + */ +public class DeleteResourceRequest extends ActionRequest { + + private final String resourceId; + + /** + * Default constructor + */ + public DeleteResourceRequest(String resourceId) { + this.resourceId = resourceId; + } + + public DeleteResourceRequest(StreamInput in) throws IOException { + this.resourceId = in.readString(); + } + + @Override + public void writeTo(final StreamOutput out) throws IOException { + out.writeString(this.resourceId); + } + + @Override + public ActionRequestValidationException validate() { + return null; + } + + public String getResourceId() { + return this.resourceId; + } +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/delete/DeleteResourceResponse.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/delete/DeleteResourceResponse.java new file mode 100644 index 0000000000..7940b664db --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/delete/DeleteResourceResponse.java @@ -0,0 +1,55 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.sample.resource.actions.rest.delete; + +import java.io.IOException; + +import org.opensearch.core.action.ActionResponse; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; +import org.opensearch.core.xcontent.ToXContentObject; +import org.opensearch.core.xcontent.XContentBuilder; + +/** + * Response to a DeleteSampleResourceRequest + */ +public class DeleteResourceResponse extends ActionResponse implements ToXContentObject { + private final String message; + + /** + * Default constructor + * + * @param message The message + */ + public DeleteResourceResponse(String message) { + this.message = message; + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + out.writeString(message); + } + + /** + * Constructor with StreamInput + * + * @param in the stream input + */ + public DeleteResourceResponse(final StreamInput in) throws IOException { + message = in.readString(); + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + builder.startObject(); + builder.field("message", message); + builder.endObject(); + return builder; + } +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/delete/DeleteResourceRestAction.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/delete/DeleteResourceRestAction.java new file mode 100644 index 0000000000..32dec08084 --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/delete/DeleteResourceRestAction.java @@ -0,0 +1,53 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.sample.resource.actions.rest.delete; + +import java.util.List; + +import org.opensearch.core.common.Strings; +import org.opensearch.rest.BaseRestHandler; +import org.opensearch.rest.RestRequest; +import org.opensearch.rest.action.RestToXContentListener; +import org.opensearch.transport.client.node.NodeClient; + +import static java.util.Collections.singletonList; +import static org.opensearch.rest.RestRequest.Method.DELETE; +import static org.opensearch.sample.utils.Constants.SAMPLE_RESOURCE_PLUGIN_API_PREFIX; + +/** + * Rest Action to delete a Sample Resource. + */ +public class DeleteResourceRestAction extends BaseRestHandler { + + public DeleteResourceRestAction() {} + + @Override + public List routes() { + return singletonList(new Route(DELETE, SAMPLE_RESOURCE_PLUGIN_API_PREFIX + "/delete/{resource_id}")); + } + + @Override + public String getName() { + return "delete_sample_resource"; + } + + @Override + protected RestChannelConsumer prepareRequest(RestRequest request, NodeClient client) { + String resourceId = request.param("resource_id"); + if (Strings.isNullOrEmpty(resourceId)) { + throw new IllegalArgumentException("resource_id parameter is required"); + } + final DeleteResourceRequest createSampleResourceRequest = new DeleteResourceRequest(resourceId); + return channel -> client.executeLocally( + DeleteResourceAction.INSTANCE, + createSampleResourceRequest, + new RestToXContentListener<>(channel) + ); + } +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/get/GetResourceAction.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/get/GetResourceAction.java new file mode 100644 index 0000000000..0249a06501 --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/get/GetResourceAction.java @@ -0,0 +1,29 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.sample.resource.actions.rest.get; + +import org.opensearch.action.ActionType; + +/** + * Action to get a sample resource + */ +public class GetResourceAction extends ActionType { + /** + * Get sample resource action instance + */ + public static final GetResourceAction INSTANCE = new GetResourceAction(); + /** + * Get sample resource action name + */ + public static final String NAME = "cluster:admin/sample-resource-plugin/get"; + + private GetResourceAction() { + super(NAME, GetResourceResponse::new); + } +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/get/GetResourceRequest.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/get/GetResourceRequest.java new file mode 100644 index 0000000000..eb8d8abb1f --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/get/GetResourceRequest.java @@ -0,0 +1,49 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.sample.resource.actions.rest.get; + +import java.io.IOException; + +import org.opensearch.action.ActionRequest; +import org.opensearch.action.ActionRequestValidationException; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; + +/** + * Request object for GetSampleResource transport action + */ +public class GetResourceRequest extends ActionRequest { + + private final String resourceId; + + /** + * Default constructor + */ + public GetResourceRequest(String resourceId) { + this.resourceId = resourceId; + } + + public GetResourceRequest(StreamInput in) throws IOException { + this.resourceId = in.readString(); + } + + @Override + public void writeTo(final StreamOutput out) throws IOException { + out.writeString(this.resourceId); + } + + @Override + public ActionRequestValidationException validate() { + return null; + } + + public String getResourceId() { + return this.resourceId; + } +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/get/GetResourceResponse.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/get/GetResourceResponse.java new file mode 100644 index 0000000000..78cc06fe24 --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/get/GetResourceResponse.java @@ -0,0 +1,54 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.sample.resource.actions.rest.get; + +import java.io.IOException; +import java.util.Set; + +import org.opensearch.core.action.ActionResponse; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; +import org.opensearch.core.xcontent.ToXContentObject; +import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.sample.SampleResource; + +public class GetResourceResponse extends ActionResponse implements ToXContentObject { + private final Set resources; + + /** + * Default constructor + * + * @param resources The resources + */ + public GetResourceResponse(Set resources) { + this.resources = resources; + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + out.writeCollection(resources, (o, r) -> r.writeTo(o)); + } + + /** + * Constructor with StreamInput + * + * @param in the stream input + */ + public GetResourceResponse(final StreamInput in) throws IOException { + resources = in.readSet(SampleResource::new); + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + builder.startObject(); + builder.field("resources", resources); + builder.endObject(); + return builder; + } +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/get/GetResourceRestAction.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/get/GetResourceRestAction.java new file mode 100644 index 0000000000..f534543fde --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/get/GetResourceRestAction.java @@ -0,0 +1,48 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.sample.resource.actions.rest.get; + +import java.util.List; + +import org.opensearch.rest.BaseRestHandler; +import org.opensearch.rest.RestRequest; +import org.opensearch.rest.action.RestToXContentListener; +import org.opensearch.transport.client.node.NodeClient; + +import static org.opensearch.rest.RestRequest.Method.GET; +import static org.opensearch.sample.utils.Constants.SAMPLE_RESOURCE_PLUGIN_API_PREFIX; + +/** + * Rest action to get a sample resource + */ +public class GetResourceRestAction extends BaseRestHandler { + + public GetResourceRestAction() {} + + @Override + public List routes() { + return List.of( + new Route(GET, SAMPLE_RESOURCE_PLUGIN_API_PREFIX + "/get/{resource_id}"), + new Route(GET, SAMPLE_RESOURCE_PLUGIN_API_PREFIX + "/get") + ); + } + + @Override + public String getName() { + return "get_sample_resource"; + } + + @Override + protected RestChannelConsumer prepareRequest(RestRequest request, NodeClient client) { + String resourceId = request.param("resource_id"); + + final GetResourceRequest getResourceRequest = new GetResourceRequest(resourceId); + return channel -> client.executeLocally(GetResourceAction.INSTANCE, getResourceRequest, new RestToXContentListener<>(channel)); + } +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/revoke/RevokeResourceAccessAction.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/revoke/RevokeResourceAccessAction.java new file mode 100644 index 0000000000..9231683499 --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/revoke/RevokeResourceAccessAction.java @@ -0,0 +1,29 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.sample.resource.actions.rest.revoke; + +import org.opensearch.action.ActionType; + +/** + * Action to revoke a sample resource + */ +public class RevokeResourceAccessAction extends ActionType { + /** + * Revoke sample resource action instance + */ + public static final RevokeResourceAccessAction INSTANCE = new RevokeResourceAccessAction(); + /** + * Revoke sample resource action name + */ + public static final String NAME = "cluster:admin/sample-resource-plugin/revoke"; + + private RevokeResourceAccessAction() { + super(NAME, RevokeResourceAccessResponse::new); + } +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/revoke/RevokeResourceAccessRequest.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/revoke/RevokeResourceAccessRequest.java new file mode 100644 index 0000000000..a066aefd6e --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/revoke/RevokeResourceAccessRequest.java @@ -0,0 +1,55 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.sample.resource.actions.rest.revoke; + +import java.io.IOException; + +import org.opensearch.action.ActionRequest; +import org.opensearch.action.ActionRequestValidationException; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; +import org.opensearch.security.spi.resources.sharing.SharedWithActionGroup; + +/** + * Request object for revoking access to a sample resource + */ +public class RevokeResourceAccessRequest extends ActionRequest { + + String resourceId; + SharedWithActionGroup.ActionGroupRecipients entitiesToRevoke; + + public RevokeResourceAccessRequest(String resourceId, SharedWithActionGroup.ActionGroupRecipients entitiesToRevoke) { + this.resourceId = resourceId; + this.entitiesToRevoke = entitiesToRevoke; + } + + public RevokeResourceAccessRequest(StreamInput in) throws IOException { + resourceId = in.readString(); + entitiesToRevoke = in.readNamedWriteable(SharedWithActionGroup.ActionGroupRecipients.class); + } + + @Override + public void writeTo(final StreamOutput out) throws IOException { + out.writeString(resourceId); + out.writeNamedWriteable(entitiesToRevoke); + } + + @Override + public ActionRequestValidationException validate() { + return null; + } + + public String getResourceId() { + return resourceId; + } + + public SharedWithActionGroup.ActionGroupRecipients getEntitiesToRevoke() { + return entitiesToRevoke; + } +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/revoke/RevokeResourceAccessResponse.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/revoke/RevokeResourceAccessResponse.java new file mode 100644 index 0000000000..2a1bf47e6f --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/revoke/RevokeResourceAccessResponse.java @@ -0,0 +1,46 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.sample.resource.actions.rest.revoke; + +import java.io.IOException; + +import org.opensearch.core.action.ActionResponse; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; +import org.opensearch.core.xcontent.ToXContentObject; +import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.security.spi.resources.sharing.ShareWith; + +/** + * Response for the RevokeResourceAccessAction + */ +public class RevokeResourceAccessResponse extends ActionResponse implements ToXContentObject { + private final ShareWith shareWith; + + public RevokeResourceAccessResponse(ShareWith shareWith) { + this.shareWith = shareWith; + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + out.writeNamedWriteable(shareWith); + } + + public RevokeResourceAccessResponse(final StreamInput in) throws IOException { + shareWith = new ShareWith(in); + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + builder.startObject(); + builder.field("share_with", shareWith); + builder.endObject(); + return builder; + } +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/revoke/RevokeResourceAccessRestAction.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/revoke/RevokeResourceAccessRestAction.java new file mode 100644 index 0000000000..517a58668b --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/revoke/RevokeResourceAccessRestAction.java @@ -0,0 +1,93 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.sample.resource.actions.rest.revoke; + +import java.io.IOException; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import org.opensearch.core.common.Strings; +import org.opensearch.core.xcontent.XContentParser; +import org.opensearch.rest.BaseRestHandler; +import org.opensearch.rest.RestRequest; +import org.opensearch.rest.action.RestToXContentListener; +import org.opensearch.security.spi.resources.sharing.Recipient; +import org.opensearch.security.spi.resources.sharing.SharedWithActionGroup; +import org.opensearch.transport.client.node.NodeClient; + +import static java.util.Collections.singletonList; +import static org.opensearch.rest.RestRequest.Method.POST; +import static org.opensearch.sample.utils.Constants.SAMPLE_RESOURCE_PLUGIN_API_PREFIX; + +/** + * Rest Action to revoke sample resource access + */ +public class RevokeResourceAccessRestAction extends BaseRestHandler { + + public RevokeResourceAccessRestAction() {} + + @Override + public List routes() { + return singletonList(new Route(POST, SAMPLE_RESOURCE_PLUGIN_API_PREFIX + "/revoke/{resource_id}")); + } + + @Override + public String getName() { + return "revoke_sample_resource"; + } + + @SuppressWarnings("unchecked") + @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"); + } + Map source; + try (XContentParser parser = request.contentParser()) { + source = parser.map(); + } catch (IOException e) { + throw new RuntimeException(e); + } + + final RevokeResourceAccessRequest revokeResourceAccessRequest = new RevokeResourceAccessRequest( + resourceId, + parseRevokedEntities((Map) source.get("entities_to_revoke")) + ); + return channel -> client.executeLocally( + RevokeResourceAccessAction.INSTANCE, + revokeResourceAccessRequest, + new RestToXContentListener<>(channel) + ); + } + + private SharedWithActionGroup.ActionGroupRecipients parseRevokedEntities(Map source) { + if (source == null || source.isEmpty()) { + throw new IllegalArgumentException("entities_to_revoke is required and cannot be empty"); + } + + Map> entitiesToRevoke = source.entrySet() + .stream() + .filter(entry -> entry.getValue() instanceof Collection) + .collect( + Collectors.toMap( + entry -> Recipient.fromValue(entry.getKey()), + entry -> ((Collection) entry.getValue()).stream() + .filter(String.class::isInstance) + .map(String.class::cast) + .collect(Collectors.toSet()) + ) + ); + + return new SharedWithActionGroup.ActionGroupRecipients(entitiesToRevoke); + } +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/share/ShareResourceAction.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/share/ShareResourceAction.java new file mode 100644 index 0000000000..52de757b1b --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/share/ShareResourceAction.java @@ -0,0 +1,29 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.sample.resource.actions.rest.share; + +import org.opensearch.action.ActionType; + +/** + * Action to share a sample resource + */ +public class ShareResourceAction extends ActionType { + /** + * Share sample resource action instance + */ + public static final ShareResourceAction INSTANCE = new ShareResourceAction(); + /** + * Share sample resource action name + */ + public static final String NAME = "cluster:admin/sample-resource-plugin/share"; + + private ShareResourceAction() { + super(NAME, ShareResourceResponse::new); + } +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/share/ShareResourceRequest.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/share/ShareResourceRequest.java new file mode 100644 index 0000000000..3d028b181b --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/share/ShareResourceRequest.java @@ -0,0 +1,56 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.sample.resource.actions.rest.share; + +import java.io.IOException; + +import org.opensearch.action.ActionRequest; +import org.opensearch.action.ActionRequestValidationException; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; +import org.opensearch.security.spi.resources.sharing.SharedWithActionGroup; + +/** + * Request object for sharing sample resource transport action + */ +public class ShareResourceRequest extends ActionRequest { + + private final String resourceId; + + private final SharedWithActionGroup.ActionGroupRecipients shareWith; + + public ShareResourceRequest(String resourceId, SharedWithActionGroup.ActionGroupRecipients shareWith) { + this.resourceId = resourceId; + this.shareWith = shareWith; + } + + public ShareResourceRequest(StreamInput in) throws IOException { + this.resourceId = in.readString(); + this.shareWith = in.readNamedWriteable(SharedWithActionGroup.ActionGroupRecipients.class); + } + + @Override + public void writeTo(final StreamOutput out) throws IOException { + out.writeString(this.resourceId); + out.writeNamedWriteable(shareWith); + } + + @Override + public ActionRequestValidationException validate() { + return null; + } + + public String getResourceId() { + return this.resourceId; + } + + public SharedWithActionGroup.ActionGroupRecipients getShareWith() { + return shareWith; + } +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/share/ShareResourceResponse.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/share/ShareResourceResponse.java new file mode 100644 index 0000000000..e8df82b841 --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/share/ShareResourceResponse.java @@ -0,0 +1,46 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.sample.resource.actions.rest.share; + +import java.io.IOException; + +import org.opensearch.core.action.ActionResponse; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; +import org.opensearch.core.xcontent.ToXContentObject; +import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.security.spi.resources.sharing.ShareWith; + +/** + * Response object for ShareResourceAction + */ +public class ShareResourceResponse extends ActionResponse implements ToXContentObject { + private final ShareWith shareWith; + + public ShareResourceResponse(ShareWith shareWith) { + this.shareWith = shareWith; + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + out.writeNamedWriteable(shareWith); + } + + public ShareResourceResponse(final StreamInput in) throws IOException { + shareWith = new ShareWith(in); + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + builder.startObject(); + builder.field("share_with", shareWith); + builder.endObject(); + return builder; + } +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/share/ShareResourceRestAction.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/share/ShareResourceRestAction.java new file mode 100644 index 0000000000..985ffa658f --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/share/ShareResourceRestAction.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.rest.share; + +import java.io.IOException; +import java.util.List; +import java.util.Map; + +import org.opensearch.common.xcontent.LoggingDeprecationHandler; +import org.opensearch.common.xcontent.XContentFactory; +import org.opensearch.common.xcontent.XContentType; +import org.opensearch.core.common.Strings; +import org.opensearch.core.xcontent.NamedXContentRegistry; +import org.opensearch.core.xcontent.XContentParser; +import org.opensearch.rest.BaseRestHandler; +import org.opensearch.rest.RestRequest; +import org.opensearch.rest.action.RestToXContentListener; +import org.opensearch.security.spi.resources.sharing.SharedWithActionGroup; +import org.opensearch.transport.client.node.NodeClient; + +import static java.util.Collections.singletonList; +import static org.opensearch.rest.RestRequest.Method.POST; +import static org.opensearch.sample.utils.Constants.SAMPLE_RESOURCE_PLUGIN_API_PREFIX; + +/** + * Rest Action to share a resource + */ +public class ShareResourceRestAction extends BaseRestHandler { + + public ShareResourceRestAction() {} + + @Override + public List routes() { + return singletonList(new Route(POST, SAMPLE_RESOURCE_PLUGIN_API_PREFIX + "/share/{resource_id}")); + } + + @Override + public String getName() { + return "share_sample_resource"; + } + + @SuppressWarnings("unchecked") + @Override + protected RestChannelConsumer prepareRequest(RestRequest request, NodeClient client) throws IOException { + String resourceId = request.param("resource_id"); + if (Strings.isNullOrEmpty(resourceId)) { + throw new IllegalArgumentException("resource_id parameter is required"); + } + + Map source; + try (XContentParser parser = request.contentParser()) { + source = parser.map(); + } catch (IOException e) { + throw new RuntimeException(e); + } + + Map shareWith = (Map) source.get("share_with"); + + final ShareResourceRequest shareResourceRequest = new ShareResourceRequest(resourceId, parseShareWith(shareWith)); + return channel -> client.executeLocally(ShareResourceAction.INSTANCE, shareResourceRequest, new RestToXContentListener<>(channel)); + } + + private SharedWithActionGroup.ActionGroupRecipients parseShareWith(Map source) throws IOException { + String jsonString = XContentFactory.jsonBuilder().map(source).toString(); + + try ( + XContentParser parser = XContentType.JSON.xContent() + .createParser(NamedXContentRegistry.EMPTY, LoggingDeprecationHandler.INSTANCE, jsonString) + ) { + return SharedWithActionGroup.ActionGroupRecipients.fromXContent(parser); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Invalid share_with structure: " + e.getMessage(), e); + } + } +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/transport/CreateResourceTransportAction.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/transport/CreateResourceTransportAction.java new file mode 100644 index 0000000000..da67cd2b40 --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/transport/CreateResourceTransportAction.java @@ -0,0 +1,82 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.sample.resource.actions.transport; + +import java.io.IOException; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import org.opensearch.action.index.IndexRequest; +import org.opensearch.action.support.ActionFilters; +import org.opensearch.action.support.HandledTransportAction; +import org.opensearch.action.support.WriteRequest; +import org.opensearch.common.inject.Inject; +import org.opensearch.common.util.concurrent.ThreadContext; +import org.opensearch.core.action.ActionListener; +import org.opensearch.core.xcontent.ToXContent; +import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.sample.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.ShareableResource; +import org.opensearch.tasks.Task; +import org.opensearch.transport.TransportService; +import org.opensearch.transport.client.Client; + +import static org.opensearch.common.xcontent.XContentFactory.jsonBuilder; +import static org.opensearch.sample.utils.Constants.RESOURCE_INDEX_NAME; + +/** + * Transport action for creating a new resource. + */ +public class CreateResourceTransportAction extends HandledTransportAction { + private static final Logger log = LogManager.getLogger(CreateResourceTransportAction.class); + + private final TransportService transportService; + private final Client nodeClient; + + @Inject + public CreateResourceTransportAction(TransportService transportService, ActionFilters actionFilters, Client nodeClient) { + super(CreateResourceAction.NAME, transportService, actionFilters, CreateResourceRequest::new); + this.transportService = transportService; + this.nodeClient = nodeClient; + } + + @Override + protected void doExecute(Task task, CreateResourceRequest request, ActionListener listener) { + ThreadContext threadContext = transportService.getThreadPool().getThreadContext(); + try (ThreadContext.StoredContext ignore = threadContext.stashContext()) { + createResource(request, listener); + } catch (Exception e) { + log.error("Failed to create resource", e); + listener.onFailure(e); + } + } + + private void createResource(CreateResourceRequest request, ActionListener listener) { + ShareableResource sample = request.getResource(); + try (XContentBuilder builder = jsonBuilder()) { + IndexRequest ir = nodeClient.prepareIndex(RESOURCE_INDEX_NAME) + .setWaitForActiveShards(1) + .setRefreshPolicy(WriteRequest.RefreshPolicy.IMMEDIATE) + .setSource(sample.toXContent(builder, ToXContent.EMPTY_PARAMS)) + .request(); + + log.debug("Index Request: {}", ir.toString()); + + nodeClient.index(ir, ActionListener.wrap(idxResponse -> { + log.debug("Created resource: {}", idxResponse.getId()); + listener.onResponse(new CreateResourceResponse("Created resource: " + idxResponse.getId())); + }, listener::onFailure)); + } catch (IOException e) { + listener.onFailure(new RuntimeException(e)); + } + } +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/transport/DeleteResourceTransportAction.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/transport/DeleteResourceTransportAction.java new file mode 100644 index 0000000000..f7df3bf9bc --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/transport/DeleteResourceTransportAction.java @@ -0,0 +1,99 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.sample.resource.actions.transport; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import org.opensearch.OpenSearchStatusException; +import org.opensearch.ResourceNotFoundException; +import org.opensearch.action.DocWriteResponse; +import org.opensearch.action.delete.DeleteRequest; +import org.opensearch.action.delete.DeleteResponse; +import org.opensearch.action.support.ActionFilters; +import org.opensearch.action.support.HandledTransportAction; +import org.opensearch.action.support.WriteRequest; +import org.opensearch.common.inject.Inject; +import org.opensearch.common.util.concurrent.ThreadContext; +import org.opensearch.core.action.ActionListener; +import org.opensearch.core.rest.RestStatus; +import org.opensearch.sample.resource.actions.rest.delete.DeleteResourceAction; +import org.opensearch.sample.resource.actions.rest.delete.DeleteResourceRequest; +import org.opensearch.sample.resource.actions.rest.delete.DeleteResourceResponse; +import org.opensearch.sample.resource.client.ResourceSharingClientAccessor; +import org.opensearch.security.spi.resources.ResourceSharingClient; +import org.opensearch.tasks.Task; +import org.opensearch.transport.TransportService; +import org.opensearch.transport.client.node.NodeClient; + +import static org.opensearch.sample.utils.Constants.RESOURCE_INDEX_NAME; + +/** + * Transport action for deleting a resource + */ +public class DeleteResourceTransportAction extends HandledTransportAction { + private static final Logger log = LogManager.getLogger(DeleteResourceTransportAction.class); + + private final TransportService transportService; + private final NodeClient nodeClient; + + @Inject + public DeleteResourceTransportAction(TransportService transportService, ActionFilters actionFilters, NodeClient nodeClient) { + super(DeleteResourceAction.NAME, transportService, actionFilters, DeleteResourceRequest::new); + this.transportService = transportService; + this.nodeClient = nodeClient; + } + + @Override + protected void doExecute(Task task, DeleteResourceRequest request, ActionListener listener) { + String resourceId = request.getResourceId(); + if (resourceId == null || resourceId.isEmpty()) { + listener.onFailure(new IllegalArgumentException("Resource ID cannot be null or empty")); + return; + } + + // Check permission to resource + ResourceSharingClient resourceSharingClient = ResourceSharingClientAccessor.getResourceSharingClient(); + resourceSharingClient.verifyResourceAccess(resourceId, RESOURCE_INDEX_NAME, ActionListener.wrap(isAuthorized -> { + if (!isAuthorized) { + listener.onFailure( + new OpenSearchStatusException("Current user is not authorized to delete resource: " + resourceId, RestStatus.FORBIDDEN) + ); + return; + } + + // Authorization successful, proceed with deletion + ThreadContext threadContext = transportService.getThreadPool().getThreadContext(); + try (ThreadContext.StoredContext ignored = threadContext.stashContext()) { + deleteResource(resourceId, ActionListener.wrap(deleteResponse -> { + if (deleteResponse.getResult() == DocWriteResponse.Result.NOT_FOUND) { + listener.onFailure(new ResourceNotFoundException("Resource " + resourceId + " not found.")); + } else { + listener.onResponse(new DeleteResourceResponse("Resource " + resourceId + " deleted successfully.")); + } + }, exception -> { + log.error("Failed to delete resource: " + resourceId, exception); + listener.onFailure(exception); + })); + } + }, exception -> { + log.error("Failed to verify resource access: " + resourceId, exception); + listener.onFailure(exception); + })); + } + + private void deleteResource(String resourceId, ActionListener listener) { + DeleteRequest deleteRequest = new DeleteRequest(RESOURCE_INDEX_NAME, resourceId).setRefreshPolicy( + WriteRequest.RefreshPolicy.IMMEDIATE + ); + + nodeClient.delete(deleteRequest, listener); + } + +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/transport/GetResourceTransportAction.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/transport/GetResourceTransportAction.java new file mode 100644 index 0000000000..019e96722a --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/transport/GetResourceTransportAction.java @@ -0,0 +1,182 @@ +/* + * 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.util.HashSet; +import java.util.Set; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import org.opensearch.OpenSearchStatusException; +import org.opensearch.ResourceNotFoundException; +import org.opensearch.action.get.GetRequest; +import org.opensearch.action.get.GetResponse; +import org.opensearch.action.search.SearchRequest; +import org.opensearch.action.search.SearchResponse; +import org.opensearch.action.support.ActionFilters; +import org.opensearch.action.support.HandledTransportAction; +import org.opensearch.common.inject.Inject; +import org.opensearch.common.settings.Settings; +import org.opensearch.common.util.concurrent.ThreadContext; +import org.opensearch.common.xcontent.LoggingDeprecationHandler; +import org.opensearch.common.xcontent.XContentType; +import org.opensearch.core.action.ActionListener; +import org.opensearch.core.rest.RestStatus; +import org.opensearch.core.xcontent.NamedXContentRegistry; +import org.opensearch.core.xcontent.XContentParser; +import org.opensearch.index.query.QueryBuilders; +import org.opensearch.sample.SampleResource; +import org.opensearch.sample.resource.actions.rest.get.GetResourceAction; +import org.opensearch.sample.resource.actions.rest.get.GetResourceRequest; +import org.opensearch.sample.resource.actions.rest.get.GetResourceResponse; +import org.opensearch.sample.resource.client.ResourceSharingClientAccessor; +import org.opensearch.search.SearchHit; +import org.opensearch.search.builder.SearchSourceBuilder; +import org.opensearch.security.spi.resources.ResourceSharingClient; +import org.opensearch.tasks.Task; +import org.opensearch.transport.TransportService; +import org.opensearch.transport.client.node.NodeClient; + +import static org.opensearch.sample.utils.Constants.OPENSEARCH_RESOURCE_SHARING_ENABLED; +import static org.opensearch.sample.utils.Constants.OPENSEARCH_RESOURCE_SHARING_ENABLED_DEFAULT; +import static org.opensearch.sample.utils.Constants.RESOURCE_INDEX_NAME; + +/** + * Transport action for getting a resource + */ +public class GetResourceTransportAction extends HandledTransportAction { + private static final Logger log = LogManager.getLogger(GetResourceTransportAction.class); + + private final TransportService transportService; + private final NodeClient nodeClient; + private final Settings settings; + + @Inject + public GetResourceTransportAction( + Settings settings, + TransportService transportService, + ActionFilters actionFilters, + NodeClient nodeClient + ) { + super(GetResourceAction.NAME, transportService, actionFilters, GetResourceRequest::new); + this.transportService = transportService; + this.nodeClient = nodeClient; + this.settings = settings; + } + + @SuppressWarnings("unchecked") + @Override + protected void doExecute(Task task, GetResourceRequest request, ActionListener listener) { + ResourceSharingClient resourceSharingClient = ResourceSharingClientAccessor.getResourceSharingClient(); + if (request.getResourceId() == null || request.getResourceId().isEmpty()) { + // get all request + if (this.settings.getAsBoolean(OPENSEARCH_RESOURCE_SHARING_ENABLED, OPENSEARCH_RESOURCE_SHARING_ENABLED_DEFAULT)) { + resourceSharingClient.listAllAccessibleResources(RESOURCE_INDEX_NAME, ActionListener.wrap(resources -> { + log.debug("Fetched accessible resources: {}", resources); + listener.onResponse(new GetResourceResponse((Set) resources)); + }, failure -> { + if (failure instanceof OpenSearchStatusException + && ((OpenSearchStatusException) failure).status().equals(RestStatus.NOT_IMPLEMENTED)) { + getAllResourcesAction(listener); + return; + } + listener.onFailure(failure); + })); + } else { + // if feature is disabled, return all resources + getAllResourcesAction(listener); + } + return; + } + + // Check permission to resource + resourceSharingClient.verifyResourceAccess(request.getResourceId(), RESOURCE_INDEX_NAME, ActionListener.wrap(isAuthorized -> { + if (!isAuthorized) { + listener.onFailure( + new OpenSearchStatusException( + "Current user is not authorized to access resource: " + request.getResourceId(), + RestStatus.FORBIDDEN + ) + ); + return; + } + + getResourceAction(request, listener); + }, listener::onFailure)); + } + + private void getResourceAction(GetResourceRequest request, ActionListener listener) { + ThreadContext threadContext = transportService.getThreadPool().getThreadContext(); + try (ThreadContext.StoredContext ignored = threadContext.stashContext()) { + getResource(request, ActionListener.wrap(getResponse -> { + if (getResponse.isSourceEmpty()) { + listener.onFailure(new ResourceNotFoundException("Resource " + request.getResourceId() + " not found.")); + } else { + try ( + XContentParser parser = XContentType.JSON.xContent() + .createParser(NamedXContentRegistry.EMPTY, LoggingDeprecationHandler.INSTANCE, getResponse.getSourceAsString()) + ) { + SampleResource resource = SampleResource.fromXContent(parser); + log.debug("Fetched resource: {}", resource); + listener.onResponse(new GetResourceResponse(Set.of(resource))); + } + } + }, listener::onFailure)); + } + } + + private void getResource(GetResourceRequest request, ActionListener listener) { + GetRequest getRequest = new GetRequest(RESOURCE_INDEX_NAME, request.getResourceId()); + + nodeClient.get(getRequest, listener); + } + + private void getAllResourcesAction(ActionListener listener) { + ThreadContext threadContext = transportService.getThreadPool().getThreadContext(); + try (ThreadContext.StoredContext ignored = threadContext.stashContext()) { + getAllResources(ActionListener.wrap(searchResponse -> { + SearchHit[] hits = searchResponse.getHits().getHits(); + if (hits.length == 0) { + listener.onFailure(new ResourceNotFoundException("No resources found in index: " + RESOURCE_INDEX_NAME)); + return; + } + + Set resources = new HashSet<>(); + try { + for (SearchHit hit : hits) { + try ( + XContentParser parser = XContentType.JSON.xContent() + .createParser(NamedXContentRegistry.EMPTY, LoggingDeprecationHandler.INSTANCE, hit.getSourceAsString()) + ) { + resources.add(SampleResource.fromXContent(parser)); + } + } + log.debug("Fetched resources: {}", resources); + listener.onResponse(new GetResourceResponse(resources)); + } catch (Exception e) { + listener.onFailure( + new OpenSearchStatusException("Failed to parse resources: " + e.getMessage(), RestStatus.INTERNAL_SERVER_ERROR) + ); + } + }, listener::onFailure)); + } + } + + private void getAllResources(ActionListener listener) { + SearchRequest searchRequest = new SearchRequest(RESOURCE_INDEX_NAME); + SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder(); + searchSourceBuilder.query(QueryBuilders.matchAllQuery()); + searchSourceBuilder.size(1000); + + searchRequest.source(searchSourceBuilder); + nodeClient.search(searchRequest, listener); + } + +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/transport/RevokeResourceAccessTransportAction.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/transport/RevokeResourceAccessTransportAction.java new file mode 100644 index 0000000000..2f4464be91 --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/transport/RevokeResourceAccessTransportAction.java @@ -0,0 +1,54 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.sample.resource.actions.transport; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import org.opensearch.action.support.ActionFilters; +import org.opensearch.action.support.HandledTransportAction; +import org.opensearch.common.inject.Inject; +import org.opensearch.core.action.ActionListener; +import org.opensearch.sample.resource.actions.rest.revoke.RevokeResourceAccessAction; +import org.opensearch.sample.resource.actions.rest.revoke.RevokeResourceAccessRequest; +import org.opensearch.sample.resource.actions.rest.revoke.RevokeResourceAccessResponse; +import org.opensearch.sample.resource.client.ResourceSharingClientAccessor; +import org.opensearch.security.spi.resources.ResourceSharingClient; +import org.opensearch.tasks.Task; +import org.opensearch.transport.TransportService; + +import static org.opensearch.sample.utils.Constants.RESOURCE_INDEX_NAME; + +/** + * Transport action for revoking resource access. + */ +public class RevokeResourceAccessTransportAction extends HandledTransportAction { + private static final Logger log = LogManager.getLogger(RevokeResourceAccessTransportAction.class); + + @Inject + public RevokeResourceAccessTransportAction(TransportService transportService, ActionFilters actionFilters) { + super(RevokeResourceAccessAction.NAME, transportService, actionFilters, RevokeResourceAccessRequest::new); + } + + @Override + protected void doExecute(Task task, RevokeResourceAccessRequest request, ActionListener listener) { + ResourceSharingClient resourceSharingClient = ResourceSharingClientAccessor.getResourceSharingClient(); + resourceSharingClient.revokeResourceAccess( + request.getResourceId(), + RESOURCE_INDEX_NAME, + request.getEntitiesToRevoke(), + ActionListener.wrap(success -> { + RevokeResourceAccessResponse response = new RevokeResourceAccessResponse(success.getShareWith()); + log.debug("Revoked resource access: {}", response.toString()); + listener.onResponse(response); + }, listener::onFailure) + ); + } + +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/transport/ShareResourceTransportAction.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/transport/ShareResourceTransportAction.java new file mode 100644 index 0000000000..dec007b0aa --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/transport/ShareResourceTransportAction.java @@ -0,0 +1,59 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.sample.resource.actions.transport; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import org.opensearch.action.support.ActionFilters; +import org.opensearch.action.support.HandledTransportAction; +import org.opensearch.common.inject.Inject; +import org.opensearch.core.action.ActionListener; +import org.opensearch.sample.resource.actions.rest.share.ShareResourceAction; +import org.opensearch.sample.resource.actions.rest.share.ShareResourceRequest; +import org.opensearch.sample.resource.actions.rest.share.ShareResourceResponse; +import org.opensearch.sample.resource.client.ResourceSharingClientAccessor; +import org.opensearch.security.spi.resources.ResourceSharingClient; +import org.opensearch.tasks.Task; +import org.opensearch.transport.TransportService; + +import static org.opensearch.sample.utils.Constants.RESOURCE_INDEX_NAME; + +/** + * Transport action implementation for sharing a resource. + */ +public class ShareResourceTransportAction extends HandledTransportAction { + private static final Logger log = LogManager.getLogger(ShareResourceTransportAction.class); + + @Inject + public ShareResourceTransportAction(TransportService transportService, ActionFilters actionFilters) { + super(ShareResourceAction.NAME, transportService, actionFilters, ShareResourceRequest::new); + } + + @Override + protected void doExecute(Task task, ShareResourceRequest request, ActionListener listener) { + if (request.getResourceId() == null || request.getResourceId().isEmpty()) { + listener.onFailure(new IllegalArgumentException("Resource ID cannot be null or empty")); + return; + } + + ResourceSharingClient resourceSharingClient = ResourceSharingClientAccessor.getResourceSharingClient(); + resourceSharingClient.shareResource( + request.getResourceId(), + RESOURCE_INDEX_NAME, + request.getShareWith(), + ActionListener.wrap(sharing -> { + ShareResourceResponse response = new ShareResourceResponse(sharing.getShareWith()); + log.debug("Shared resource: {}", response.toString()); + listener.onResponse(response); + }, listener::onFailure) + ); + } + +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/transport/UpdateResourceTransportAction.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/transport/UpdateResourceTransportAction.java new file mode 100644 index 0000000000..8a27e4e5a2 --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/transport/UpdateResourceTransportAction.java @@ -0,0 +1,107 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.sample.resource.actions.transport; + +import java.io.IOException; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import org.opensearch.OpenSearchStatusException; +import org.opensearch.action.support.ActionFilters; +import org.opensearch.action.support.HandledTransportAction; +import org.opensearch.action.support.WriteRequest; +import org.opensearch.action.update.UpdateRequest; +import org.opensearch.common.inject.Inject; +import org.opensearch.common.util.concurrent.ThreadContext; +import org.opensearch.core.action.ActionListener; +import org.opensearch.core.rest.RestStatus; +import org.opensearch.core.xcontent.ToXContent; +import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.sample.resource.actions.rest.create.CreateResourceResponse; +import org.opensearch.sample.resource.actions.rest.create.UpdateResourceAction; +import org.opensearch.sample.resource.actions.rest.create.UpdateResourceRequest; +import org.opensearch.sample.resource.client.ResourceSharingClientAccessor; +import org.opensearch.security.spi.resources.ResourceSharingClient; +import org.opensearch.security.spi.resources.ShareableResource; +import org.opensearch.tasks.Task; +import org.opensearch.transport.TransportService; +import org.opensearch.transport.client.node.NodeClient; + +import static org.opensearch.common.xcontent.XContentFactory.jsonBuilder; +import static org.opensearch.sample.utils.Constants.RESOURCE_INDEX_NAME; + +/** + * Transport action for updating a resource. + */ +public class UpdateResourceTransportAction extends HandledTransportAction { + private static final Logger log = LogManager.getLogger(UpdateResourceTransportAction.class); + + private final TransportService transportService; + private final NodeClient nodeClient; + + @Inject + public UpdateResourceTransportAction(TransportService transportService, ActionFilters actionFilters, NodeClient nodeClient) { + super(UpdateResourceAction.NAME, transportService, actionFilters, UpdateResourceRequest::new); + this.transportService = transportService; + this.nodeClient = nodeClient; + } + + @Override + protected void doExecute(Task task, UpdateResourceRequest request, ActionListener listener) { + if (request.getResourceId() == null || request.getResourceId().isEmpty()) { + listener.onFailure(new IllegalArgumentException("Resource ID cannot be null or empty")); + return; + } + // Check permission to resource + ResourceSharingClient resourceSharingClient = ResourceSharingClientAccessor.getResourceSharingClient(); + resourceSharingClient.verifyResourceAccess(request.getResourceId(), RESOURCE_INDEX_NAME, ActionListener.wrap(isAuthorized -> { + if (!isAuthorized) { + listener.onFailure( + new OpenSearchStatusException( + "Current user is not authorized to access resource: " + request.getResourceId(), + RestStatus.FORBIDDEN + ) + ); + return; + } + + updateResource(request, listener); + }, listener::onFailure)); + } + + private void updateResource(UpdateResourceRequest request, ActionListener listener) { + ThreadContext threadContext = this.transportService.getThreadPool().getThreadContext(); + try (ThreadContext.StoredContext ignore = threadContext.stashContext()) { + String resourceId = request.getResourceId(); + ShareableResource sample = request.getResource(); + try (XContentBuilder builder = jsonBuilder()) { + UpdateRequest ur = new UpdateRequest(RESOURCE_INDEX_NAME, resourceId).setRefreshPolicy(WriteRequest.RefreshPolicy.IMMEDIATE) + .doc(sample.toXContent(builder, ToXContent.EMPTY_PARAMS)); + + log.debug("Update Request: {}", ur.toString()); + + nodeClient.update( + ur, + ActionListener.wrap( + updateResponse -> { log.debug("Updated resource: {}", updateResponse.toString()); }, + listener::onFailure + ) + ); + } catch (IOException e) { + listener.onFailure(new RuntimeException(e)); + } + listener.onResponse(new CreateResourceResponse("Resource " + request.getResource().getName() + " updated successfully.")); + } catch (Exception e) { + log.error("Failed to update resource: {}", request.getResourceId(), e); + listener.onFailure(e); + } + + } +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/client/ResourceSharingClientAccessor.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/client/ResourceSharingClientAccessor.java new file mode 100644 index 0000000000..942178e3c5 --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/client/ResourceSharingClientAccessor.java @@ -0,0 +1,35 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.sample.resource.client; + +import org.opensearch.security.spi.resources.NoopResourceSharingClient; +import org.opensearch.security.spi.resources.ResourceSharingClient; + +/** + * Accessor for resource sharing client supplied by the SPI. + */ +public class ResourceSharingClientAccessor { + private static ResourceSharingClient CLIENT; + + private ResourceSharingClientAccessor() {} + + /** + * Set the resource sharing client + */ + public static void setResourceSharingClient(ResourceSharingClient client) { + CLIENT = client; + } + + /** + * Get the resource sharing client + */ + public static ResourceSharingClient getResourceSharingClient() { + return CLIENT == null ? new NoopResourceSharingClient() : CLIENT; + } +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/utils/Constants.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/utils/Constants.java new file mode 100644 index 0000000000..51c002f74a --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/utils/Constants.java @@ -0,0 +1,22 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.sample.utils; + +/** + * Constants for Sample Resource Sharing Plugin + */ +public class Constants { + public static final String RESOURCE_INDEX_NAME = ".sample_resource_sharing_plugin"; + + public static final String SAMPLE_RESOURCE_PLUGIN_PREFIX = "_plugins/sample_resource_sharing"; + public static final String SAMPLE_RESOURCE_PLUGIN_API_PREFIX = "/" + SAMPLE_RESOURCE_PLUGIN_PREFIX; + + public static final String OPENSEARCH_RESOURCE_SHARING_ENABLED = "plugins.security.resource_sharing.enabled"; + public static final boolean OPENSEARCH_RESOURCE_SHARING_ENABLED_DEFAULT = true; +} 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/src/integrationTest/java/org/opensearch/test/framework/cluster/OpenSearchClientProvider.java b/src/integrationTest/java/org/opensearch/test/framework/cluster/OpenSearchClientProvider.java index b797646763..07d2a26abd 100644 --- a/src/integrationTest/java/org/opensearch/test/framework/cluster/OpenSearchClientProvider.java +++ b/src/integrationTest/java/org/opensearch/test/framework/cluster/OpenSearchClientProvider.java @@ -1,30 +1,30 @@ /* -* Copyright 2020 floragunn GmbH -* -* Licensed under the Apache License, Version 2.0 (the "License"); -* you may not use this file except in compliance with the License. -* You may obtain a copy of the License at -* -* http://www.apache.org/licenses/LICENSE-2.0 -* -* Unless required by applicable law or agreed to in writing, software -* distributed under the License is distributed on an "AS IS" BASIS, -* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -* See the License for the specific language governing permissions and -* limitations under the License. -* -*/ + * Copyright 2020 floragunn GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ /* -* SPDX-License-Identifier: Apache-2.0 -* -* The OpenSearch Contributors require contributions made to -* this file be licensed under the Apache-2.0 license or a -* compatible open source license. -* -* Modifications Copyright OpenSearch Contributors. See -* GitHub history for details. -*/ + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ package org.opensearch.test.framework.cluster; @@ -70,12 +70,12 @@ import static org.opensearch.test.framework.cluster.TestRestClientConfiguration.getBasicAuthHeader; /** -* OpenSearchClientProvider provides methods to get a REST client for an underlying cluster or node. -* -* This interface is implemented by both LocalCluster and LocalOpenSearchCluster.Node. Thus, it is possible to get a -* REST client for a whole cluster (without choosing the node it is operating on) or to get a REST client for a specific -* node. -*/ + * OpenSearchClientProvider provides methods to get a REST client for an underlying cluster or node. + * + * This interface is implemented by both LocalCluster and LocalOpenSearchCluster.Node. Thus, it is possible to get a + * REST client for a whole cluster (without choosing the node it is operating on) or to get a REST client for a specific + * node. + */ public interface OpenSearchClientProvider { String getClusterName(); @@ -92,12 +92,12 @@ default URI getHttpAddressAsURI() { } /** - * Returns a REST client that sends requests with basic authentication for the specified User object. Optionally, - * additional HTTP headers can be specified which will be sent with each request. - * - * This method should be usually preferred. The other getRestClient() methods shall be only used for specific - * situations. - */ + * Returns a REST client that sends requests with basic authentication for the specified User object. Optionally, + * additional HTTP headers can be specified which will be sent with each request. + * + * This method should be usually preferred. The other getRestClient() methods shall be only used for specific + * situations. + */ default TestRestClient getRestClient(UserCredentialsHolder user, CertificateData useCertificateData, Header... headers) { return getRestClient(user.getName(), user.getPassword(), useCertificateData, headers); } @@ -128,8 +128,8 @@ default RestHighLevelClient getRestHighLevelClient(UserCredentialsHolder user, C BasicCredentialsProvider credentialsProvider = new BasicCredentialsProvider(); credentialsProvider.setCredentials( - new AuthScope(null, -1), - new UsernamePasswordCredentials(user.getName(), user.getPassword().toCharArray()) + new AuthScope(null, -1), + new UsernamePasswordCredentials(user.getName(), user.getPassword().toCharArray()) ); return getRestHighLevelClient(credentialsProvider, defaultHeaders); @@ -140,21 +140,21 @@ default RestHighLevelClient getRestHighLevelClient(Collection } default RestHighLevelClient getRestHighLevelClient( - BasicCredentialsProvider credentialsProvider, - Collection defaultHeaders + BasicCredentialsProvider credentialsProvider, + Collection defaultHeaders ) { RestClientBuilder.HttpClientConfigCallback configCallback = httpClientBuilder -> { TlsStrategy tlsStrategy = ClientTlsStrategyBuilder.create() - .setSslContext(getSSLContext()) - .setHostnameVerifier(NoopHostnameVerifier.INSTANCE) - // See please https://issues.apache.org/jira/browse/HTTPCLIENT-2219 - .setTlsDetailsFactory(new Factory() { - @Override - public TlsDetails create(final SSLEngine sslEngine) { - return new TlsDetails(sslEngine.getSession(), sslEngine.getApplicationProtocol()); - } - }) - .build(); + .setSslContext(getSSLContext()) + .setHostnameVerifier(NoopHostnameVerifier.INSTANCE) + // See please https://issues.apache.org/jira/browse/HTTPCLIENT-2219 + .setTlsDetailsFactory(new Factory() { + @Override + public TlsDetails create(final SSLEngine sslEngine) { + return new TlsDetails(sslEngine.getSession(), sslEngine.getApplicationProtocol()); + } + }) + .build(); final AsyncClientConnectionManager cm = PoolingAsyncClientConnectionManagerBuilder.create().setTlsStrategy(tlsStrategy).build(); @@ -169,7 +169,7 @@ public TlsDetails create(final SSLEngine sslEngine) { InetSocketAddress httpAddress = getHttpAddress(); RestClientBuilder builder = RestClient.builder(new HttpHost("https", httpAddress.getHostString(), httpAddress.getPort())) - .setHttpClientConfigCallback(configCallback); + .setHttpClientConfigCallback(configCallback); return new RestHighLevelClient(builder); } @@ -180,12 +180,12 @@ default CloseableHttpClient getClosableHttpClient(String[] supportedCipherSuit) } /** - * Returns a REST client that sends requests with basic authentication for the specified user name and password. Optionally, - * additional HTTP headers can be specified which will be sent with each request. - * - * Normally, you should use the method with the User object argument instead. Use this only if you need more - * control over username and password - for example, when you want to send a wrong password. - */ + * Returns a REST client that sends requests with basic authentication for the specified user name and password. Optionally, + * additional HTTP headers can be specified which will be sent with each request. + * + * Normally, you should use the method with the User object argument instead. Use this only if you need more + * control over username and password - for example, when you want to send a wrong password. + */ default TestRestClient getRestClient(String user, String password, Header... headers) { return createGenericClientRestClient(new TestRestClientConfiguration().username(user).password(password).headers(headers)); } @@ -200,9 +200,9 @@ default TestRestClient getRestClient(String user, String password, CertificateDa } /** - * Returns a REST client. You can specify additional HTTP headers that will be sent with each request. Use this - * method to test non-basic authentication, such as JWT bearer authentication. - */ + * Returns a REST client. You can specify additional HTTP headers that will be sent with each request. Use this + * method to test non-basic authentication, such as JWT bearer authentication. + */ default TestRestClient getRestClient(CertificateData useCertificateData, Header... headers) { return getRestClient(Arrays.asList(headers), useCertificateData); } @@ -220,16 +220,27 @@ default TestRestClient getRestClient(List
headers, CertificateData useCe return createGenericClientRestClient(headers, useCertificateData, null); } + default TestRestClient getSecurityDisabledRestClient() { + return new TestRestClient(getHttpAddress(), List.of(), getSSLContext(null), null, false, false); + } + default TestRestClient createGenericClientRestClient( - List
headers, - CertificateData useCertificateData, - InetAddress sourceInetAddress + List
headers, + CertificateData useCertificateData, + InetAddress sourceInetAddress ) { - return new TestRestClient(getHttpAddress(), headers, getSSLContext(useCertificateData), sourceInetAddress); + return new TestRestClient(getHttpAddress(), headers, getSSLContext(useCertificateData), sourceInetAddress, true, false); } default TestRestClient createGenericClientRestClient(TestRestClientConfiguration configuration) { - return new TestRestClient(getHttpAddress(), configuration.getHeaders(), getSSLContext(), configuration.getSourceInetAddress()); + return new TestRestClient( + getHttpAddress(), + configuration.getHeaders(), + getSSLContext(), + configuration.getSourceInetAddress(), + true, + false + ); } private SSLContext getSSLContext() { @@ -277,4 +288,4 @@ public interface UserCredentialsHolder { String getPassword(); } -} +} \ No newline at end of file diff --git a/src/integrationTest/java/org/opensearch/test/framework/cluster/TestRestClient.java b/src/integrationTest/java/org/opensearch/test/framework/cluster/TestRestClient.java index f560ef713f..5cc2055749 100644 --- a/src/integrationTest/java/org/opensearch/test/framework/cluster/TestRestClient.java +++ b/src/integrationTest/java/org/opensearch/test/framework/cluster/TestRestClient.java @@ -1,30 +1,30 @@ /* -* Copyright 2021 floragunn GmbH -* -* Licensed under the Apache License, Version 2.0 (the "License"); -* you may not use this file except in compliance with the License. -* You may obtain a copy of the License at -* -* http://www.apache.org/licenses/LICENSE-2.0 -* -* Unless required by applicable law or agreed to in writing, software -* distributed under the License is distributed on an "AS IS" BASIS, -* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -* See the License for the specific language governing permissions and -* limitations under the License. -* -*/ + * Copyright 2021 floragunn GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ /* -* SPDX-License-Identifier: Apache-2.0 -* -* The OpenSearch Contributors require contributions made to -* this file be licensed under the Apache-2.0 license or a -* compatible open source license. -* -* Modifications Copyright OpenSearch Contributors. See -* GitHub history for details. -*/ + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ package org.opensearch.test.framework.cluster; @@ -79,18 +79,18 @@ import static org.hamcrest.Matchers.notNullValue; /** -* A OpenSearch REST client, which is tailored towards use in integration tests. Instances of this class can be -* obtained via the OpenSearchClientProvider interface, which is implemented by LocalCluster and Node. -* -* Usually, an instance of this class sends constant authentication headers which are defined when obtaining the -* instance from OpenSearchClientProvider. -*/ + * A OpenSearch REST client, which is tailored towards use in integration tests. Instances of this class can be + * obtained via the OpenSearchClientProvider interface, which is implemented by LocalCluster and Node. + * + * Usually, an instance of this class sends constant authentication headers which are defined when obtaining the + * instance from OpenSearchClientProvider. + */ public class TestRestClient implements AutoCloseable { private static final Logger log = LogManager.getLogger(TestRestClient.class); - private boolean enableHTTPClientSSL = true; - private boolean sendHTTPClientCertificate = false; + private boolean enableHTTPClientSSL; + private boolean sendHTTPClientCertificate; private InetSocketAddress nodeHttpAddress; private RequestConfig requestConfig; private List
headers = new ArrayList<>(); @@ -99,11 +99,20 @@ public class TestRestClient implements AutoCloseable { private final InetAddress sourceInetAddress; - public TestRestClient(InetSocketAddress nodeHttpAddress, List
headers, SSLContext sslContext, InetAddress sourceInetAddress) { + public TestRestClient( + InetSocketAddress nodeHttpAddress, + List
headers, + SSLContext sslContext, + InetAddress sourceInetAddress, + boolean enableHTTPClientSSL, + boolean sendHTTPClientCertificate + ) { this.nodeHttpAddress = nodeHttpAddress; this.headers.addAll(headers); this.sslContext = sslContext; this.sourceInetAddress = sourceInetAddress; + this.enableHTTPClientSSL = enableHTTPClientSSL; + this.sendHTTPClientCertificate = sendHTTPClientCertificate; } public HttpResponse get(String path, Header... headers) { @@ -126,7 +135,7 @@ public HttpResponse securityHealth(Header... headers) { public HttpResponse getAuthInfo(Map urlParams, Header... headers) { String urlParamsString = "?" - + urlParams.entrySet().stream().map(e -> e.getKey() + "=" + e.getValue()).collect(Collectors.joining("&")); + + urlParams.entrySet().stream().map(e -> e.getKey() + "=" + e.getValue()).collect(Collectors.joining("&")); return executeRequest(new HttpGet(getHttpServerUri() + "/_opendistro/_security/authinfo" + urlParamsString), headers); } @@ -308,9 +317,9 @@ private void verifyContentType() { assertThat("Response body format was not json, body: " + body, body.charAt(0), equalTo('{')); } else { assertThat( - "Response body format was json, whereas content-type was " + contentType + ", body: " + body, - body.charAt(0), - not(equalTo('{')) + "Response body format was json, whereas content-type was " + contentType + ", body: " + body, + body.charAt(0), + not(equalTo('{')) ); } @@ -346,8 +355,8 @@ public Header[] getHeader() { public Optional
findHeader(String name) { return Arrays.stream(header) - .filter(header -> requireNonNull(name, "Header name is mandatory.").equalsIgnoreCase(header.getName())) - .findFirst(); + .filter(header -> requireNonNull(name, "Header name is mandatory.").equalsIgnoreCase(header.getName())) + .findFirst(); } public Header getHeader(String name) { @@ -376,8 +385,8 @@ public String getTextFromJsonBody(String jsonPointer) { public List getTextArrayFromJsonBody(String jsonPointer) { return StreamSupport.stream(getJsonNodeAt(jsonPointer).spliterator(), false) - .map(JsonNode::textValue) - .collect(Collectors.toList()); + .map(JsonNode::textValue) + .collect(Collectors.toList()); } public int getIntFromJsonBody(String jsonPointer) { @@ -411,16 +420,16 @@ private JsonNode toJsonNode() throws JsonProcessingException, IOException { @Override public String toString() { return "HttpResponse [inner=" - + inner - + ", body=" - + body - + ", header=" - + Arrays.toString(header) - + ", statusCode=" - + statusCode - + ", statusReason=" - + statusReason - + "]"; + + inner + + ", body=" + + body + + ", header=" + + Arrays.toString(header) + + ", statusCode=" + + statusCode + + ", statusReason=" + + statusReason + + "]"; } public T getBodyAs(Class authInfoClass) { @@ -471,4 +480,4 @@ public void close() { // TODO: Is there anything to clean up here? } -} +} \ No newline at end of file