diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5a41062883..31aea8597a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,9 +32,37 @@ jobs: run: | echo "separateTestsNames=$(./gradlew listTasksAsJSON -q --console=plain | tail -n 1)" >> $GITHUB_OUTPUT + publish-components-to-maven-local: + runs-on: ubuntu-latest + 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: Checkout security + uses: actions/checkout@v4 + + - name: Publish components to Maven Local + run: | + ./gradlew clean \ + :opensearch-resource-sharing-spi:publishToMavenLocal \ + :opensearch-security-common:publishToMavenLocal \ + :opensearch-security-client:publishToMavenLocal \ + -Dbuild.snapshot=false + + - name: Cache artifacts for dependent jobs + uses: actions/cache@v4.2.2 + with: + path: ~/.m2/repository/org/opensearch/ + key: maven-local-${{ github.run_id }} + restore-keys: | + maven-local- + test: name: test - needs: generate-test-list + needs: [generate-test-list, publish-components-to-maven-local] strategy: fail-fast: false matrix: @@ -53,6 +81,14 @@ jobs: - name: Checkout security uses: actions/checkout@v4 + - name: Restore Maven Local Cache + uses: actions/cache@v4.2.2 + with: + path: ~/.m2/repository/org/opensearch/ + key: maven-local-${{ github.run_id }} + restore-keys: | + maven-local- + - name: Build and Test uses: gradle/gradle-build-action@v3 with: @@ -68,7 +104,7 @@ jobs: ./build/reports/ report-coverage: - needs: ["test", "integration-tests"] + needs: ["test", "integration-tests", "spi-tests", "sample-plugin-integration-tests"] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -91,7 +127,6 @@ jobs: fail_ci_if_error: true verbose: true - integration-tests: name: integration-tests strategy: @@ -111,12 +146,20 @@ jobs: - name: Checkout security uses: actions/checkout@v4 - - name: Build and Test + - name: Restore Maven Local Cache + uses: actions/cache@v4.2.2 + with: + path: ~/.m2/repository/org/opensearch/ + key: maven-local-${{ github.run_id }} + restore-keys: | + maven-local- + + - name: Run Integration Tests 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() @@ -125,10 +168,93 @@ jobs: path: | ./build/reports/ + sample-plugin-integration-tests: + name: sample-plugin-integration-tests + needs: publish-components-to-maven-local + 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: Restore Maven Local Cache + uses: actions/cache@v4.2.2 + with: + path: ~/.m2/repository/org/opensearch/ + key: maven-local-${{ github.run_id }} + restore-keys: | + maven-local- + + - 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: + name: spi-tests + needs: publish-components-to-maven-local + 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: Restore Maven Local Cache + uses: actions/cache@v4.2.2 + with: + path: ~/.m2/repository/org/opensearch/ + key: maven-local-${{ github.run_id }} + restore-keys: | + maven-local- + + - 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 + needs: publish-components-to-maven-local strategy: fail-fast: false matrix: @@ -146,12 +272,20 @@ jobs: - name: Checkout security uses: actions/checkout@v4 - - name: Build and Test + - name: Restore Maven Local Cache + uses: actions/cache@v4.2.2 + with: + path: ~/.m2/repository/org/opensearch/ + key: maven-local-${{ github.run_id }} + restore-keys: | + maven-local- + + - name: Run Resource Tests uses: gradle/gradle-build-action@v3 with: cache-disabled: true arguments: | - integrationTest -Dbuild.snapshot=false --tests org.opensearch.security.ResourceFocusedTests + :integrationTest -Dbuild.snapshot=false --tests org.opensearch.security.ResourceFocusedTests backward-compatibility-build: runs-on: ubuntu-latest @@ -214,40 +348,89 @@ jobs: build-artifact-names: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - name: Setup Environment + uses: actions/checkout@v4 - - uses: actions/setup-java@v4 + - name: Configure Java + uses: actions/setup-java@v4 with: - distribution: temurin # Temurin is a distribution of adoptium + distribution: temurin 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 - - - 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: List files in the build directory if there was an error - run: ls -al ./build/distributions/ + - 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-all.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-all.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-all.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-all.jar + + # Publish Common + ./gradlew clean :opensearch-security-common:publishToMavenLocal && test -s ./common/build/libs/opensearch-security-common-$security_plugin_version-all.jar + ./gradlew clean :opensearch-security-common:publishToMavenLocal -Dbuild.snapshot=false && test -s ./common/build/libs/opensearch-security-common-$security_plugin_version_no_snapshot-all.jar + ./gradlew clean :opensearch-security-common:publishToMavenLocal -Dbuild.snapshot=false -Dbuild.version_qualifier=$test_qualifier && test -s ./common/build/libs/opensearch-security-common-$security_plugin_version_only_number-$test_qualifier-all.jar + ./gradlew clean :opensearch-security-common:publishToMavenLocal -Dbuild.version_qualifier=$test_qualifier && test -s ./common/build/libs/opensearch-security-common-$security_plugin_version_only_number-$test_qualifier-SNAPSHOT-all.jar + + # Publish Client + ./gradlew clean :opensearch-security-client:publishToMavenLocal && test -s ./client/build/libs/opensearch-security-client-$security_plugin_version-all.jar + ./gradlew clean :opensearch-security-client:publishToMavenLocal -Dbuild.snapshot=false && test -s ./client/build/libs/opensearch-security-client-$security_plugin_version_no_snapshot-all.jar + ./gradlew clean :opensearch-security-client:publishToMavenLocal -Dbuild.snapshot=false -Dbuild.version_qualifier=$test_qualifier && test -s ./client/build/libs/opensearch-security-client-$security_plugin_version_only_number-$test_qualifier-all.jar + ./gradlew clean :opensearch-security-client:publishToMavenLocal -Dbuild.version_qualifier=$test_qualifier && test -s ./client/build/libs/opensearch-security-client-$security_plugin_version_only_number-$test_qualifier-SNAPSHOT-all.jar + + # Build artifacts + ./gradlew clean :assemble && \ + test -s ./build/distributions/opensearch-security-$security_plugin_version.zip && \ + test -s ./sample-resource-plugin/build/distributions/opensearch-sample-resource-plugin-$security_plugin_version.zip && \ + test -s ./spi/build/libs/opensearch-resource-sharing-spi-$security_plugin_version.jar && \ + test -s ./common/build/libs/opensearch-security-common-$security_plugin_version.jar && \ + test -s ./client/build/libs/opensearch-security-client-$security_plugin_version.jar + + + ./gradlew clean assemble -Dbuild.snapshot=false && \ + test -s ./build/distributions/opensearch-security-$security_plugin_version_no_snapshot.zip && \ + test -s ./sample-resource-plugin/build/distributions/opensearch-sample-resource-plugin-$security_plugin_version_no_snapshot.zip && \ + test -s ./spi/build/libs/opensearch-resource-sharing-spi-$security_plugin_version_no_snapshot.jar && \ + test -s ./common/build/libs/opensearch-security-common-$security_plugin_version_no_snapshot.jar && \ + test -s ./client/build/libs/opensearch-security-client-$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 ./sample-resource-plugin/build/distributions/opensearch-sample-resource-plugin-$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 && \ + test -s ./common/build/libs/opensearch-security-common-$security_plugin_version_only_number-$test_qualifier.jar && \ + test -s ./client/build/libs/opensearch-security-client-$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 ./sample-resource-plugin/build/distributions/opensearch-sample-resource-plugin-$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 && \ + test -s ./common/build/libs/opensearch-security-common-$security_plugin_version_only_number-$test_qualifier-SNAPSHOT.jar && \ + test -s ./client/build/libs/opensearch-security-client-$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-all.jar && \ + test -s ./common/build/libs/opensearch-security-common-$security_plugin_version-all.jar + + ./gradlew clean publishShadowPublicationToMavenLocal && \ + test -s ./spi/build/libs/opensearch-resource-sharing-spi-$security_plugin_version-all.jar && \ + test -s ./common/build/libs/opensearch-security-common-$security_plugin_version-all.jar && \ + test -s ./client/build/libs/opensearch-security-client-$security_plugin_version-all.jar + + - name: List files in build directory on failure if: failure() + run: ls -al ./*/build/libs/ ./build/distributions/ diff --git a/.github/workflows/maven-publish.yml b/.github/workflows/maven-publish.yml index d10fd67beb..42d07fbb0a 100644 --- a/.github/workflows/maven-publish.yml +++ b/.github/workflows/maven-publish.yml @@ -32,4 +32,4 @@ jobs: export SONATYPE_PASSWORD=$(aws secretsmanager get-secret-value --secret-id maven-snapshots-password --query SecretString --output text) echo "::add-mask::$SONATYPE_USERNAME" echo "::add-mask::$SONATYPE_PASSWORD" - ./gradlew publishPluginZipPublicationToSnapshotsRepository + ./gradlew --no-daemon publishPluginZipPublicationToSnapshotsRepository publishShadowPublicationToSnapshotsRepository diff --git a/.gitignore b/.gitignore index 6fbfafabac..5eb2da999f 100644 --- a/.gitignore +++ b/.gitignore @@ -43,7 +43,3 @@ out/ build/ gradle-build/ .gradle/ - -# nodejs -node_modules/ -package-lock.json diff --git a/RESOURCE_ACCESS_CONTROL_FOR_PLUGINS.md b/RESOURCE_ACCESS_CONTROL_FOR_PLUGINS.md new file mode 100644 index 0000000000..42c0c61731 --- /dev/null +++ b/RESOURCE_ACCESS_CONTROL_FOR_PLUGINS.md @@ -0,0 +1,464 @@ +# **Resource Sharing and Access Control in OpenSearch** + +This guide provides an **in-depth overview** for **plugin developers**, covering the **features, setup, and utilization** of the **Resource Sharing and Access Control** functionality in OpenSearch. + +## **1. What is the Feature?** +The **Resource Sharing and Access Control** feature in OpenSearch Security Plugin enables fine-grained access management for resources declared by plugins. It allows: +- Users to **share and revoke access** to their own resources. +- **Super admins** to access all resources. +- Plugins to **define and manage resource access** via a standardized interface. + +This feature ensures **secure** and **controlled** access to resources while leveraging existing **index-level authorization** in OpenSearch. + +--- + +## **2. What are the Components?** +This feature introduces **two primary components** for plugin developers: + +### **1. `opensearch-security-client`** +- Provides a client with methods for **resource access control**. +- Plugins must declare a **dependency** on this client to integrate with security features. + +### **2. `opensearch-resource-sharing-spi`** +- A **Service Provider Interface (SPI)** that plugins must implement to declare themselves as **Resource Plugins**. +- The security plugin keeps track of these plugins (similar to how JobScheduler tracks `JobSchedulerExtension`). + +### **Plugin Implementation Requirements:** + +- This feature is marked as **`@opensearch.experimental`** and can be toggled using the feature flag: **`plugins.security.resource_sharing.enabled`**, which is **enabled by default**. +- **Resource indices must be system indices**, and **system index protection must be enabled** (`plugins.security.system_indices.enabled: true`) to prevent unauthorized direct access. +- Plugins must declare dependencies on **`opensearch-security-client`** and **`opensearch-resource-sharing-spi`** in their `build.gradle`. + +### **Plugin Implementation Requirements** +Each plugin must: +- **Declare a dependency** on `opensearch-security-client` package: +```build.gradle +implementation group: 'org.opensearch', name:'opensearch-security-client', version: "${opensearch_build}" +``` +- **Extend** `opensearch-security` plugin with optional flag: +```build.gradle +opensearchplugin { + name '' + description '' + classname '' + extendedPlugins = ['opensearch-security;optional=true', ] +} +``` +- **Implement** the `ResourceSharingExtension` class. +- **Ensure** that its declared resources implement the `Resource` interface. +- **Provide a resource parser**, which the security plugin uses to extract resource details from the resource index. +- **Register itself** in `META-INF/services` by creating the following file: + ``` + src/main/resources/META-INF/services/org.opensearch.security.spi.ResourceSharingExtension + ``` + - This file must contain a **single line** specifying the **fully qualified class name** of the plugin’s `ResourceSharingExtension` implementation, e.g.: + ``` + org.opensearch.sample.SampleResourcePlugin + ``` +--- + +## **3. Feature Flag** +This feature is controlled by the following flag: + +- **Feature flag:** `plugins.security.resource_sharing.enabled` +- **Default value:** `true` +- **How to disable?** Set the flag to `false` in the opensearch configuration: + ```yaml + plugins.security.resource_sharing.enabled: false + ``` + +--- + +## **4. Declaring a Resource Plugin and Using the Client for Access Control** +### **Declaring a Plugin as a Resource Plugin** +To integrate with the security plugin, your plugin must: +1. Extend `ResourceSharingExtension` and implement required methods. +2. Implement the `Resource` interface for resource declaration. +3. Implement a resource parser to extract resource details. + +[`opensearch-resource-sharing-spi` README.md](./spi/README.md) is a great resource to learn more about the components of SPI and how to set up. + +Tip: Refer to the `org.opensearch.sample.SampleResourcePlugin` class to understand the setup in further detail. + +Example usage: +```java + +public class SampleResourcePlugin extends Plugin implements SystemIndexPlugin, ResourceSharingExtension { + + // override any required methods + + @Override + public Collection getSystemIndexDescriptors(Settings settings) { + final SystemIndexDescriptor systemIndexDescriptor = new SystemIndexDescriptor(RESOURCE_INDEX_NAME, "Sample index with resources"); + return Collections.singletonList(systemIndexDescriptor); + } + + @Override + public String getResourceType() { + return SampleResource.class.getCanonicalName(); + } + + @Override + public String getResourceIndex() { + return RESOURCE_INDEX_NAME; + } + + @Override + public ResourceParser getResourceParser() { + return new SampleResourceParser(); + } +} +``` + + +### **Calling Access Control Methods from the ResourceSharingClient Client** +Plugins must **declare a dependency** on `opensearch-security-client` and use it to call access control methods. +The client provides **four access control methods** for plugins. For detailed usage and implementation, refer to the [`opensearch-security-client` README.md](./client/README.md) + + +Tip: Refer to the `org.opensearch.sample.resource.client.ResourceSharingClientAccessor` class to understand the client setup in further detail. + +Example usage: +```java + @Override +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(nodeClient, settings); + resourceSharingClient.shareResource( + request.getResourceId(), + RESOURCE_INDEX_NAME, + request.getShareWith(), + ActionListener.wrap(sharing -> { + ShareResourceResponse response = new ShareResourceResponse(sharing.getShareWith()); + listener.onResponse(response); + }, listener::onFailure) + ); +} +``` + + +--- + +## **5. What are Scopes?** + +This feature introduces a new **sharing mechanism** called **scopes**. Scopes define **the level of access** granted to users for a resource. They are **defined and maintained by plugins**, and the security plugin does **not** interpret or enforce their specific meanings. This approach gives plugins the **flexibility** to define scope names and behaviors based on their use case. + +Each plugin must **document its scope definitions** so that users understand the **sharing semantics** and how different scopes affect access control. + +Scopes enable **granular access control**, allowing resources to be shared with **customized permission levels**, making the system more flexible and adaptable to different use cases. + +### **Common Scopes for Plugins to declare** +| Scope | Description | +|-------------|-----------------------------------------------------| +| `PUBLIC` | The resource is accessible to all users. | +| `READ_ONLY` | Users can view but not modify the resource. | +| `READ_WRITE` | Users can view and modify the resource. | + +By default, all resources are private and only visible to the owner and super-admins. Resources become accessible to others only when explicitly shared. + +SPI provides you an interface, with two default scopes `PUBLIC` and `RESTRICTED`, which can be extended to introduce more plugin-specific values. + +### **Using Scopes in API Design** +- APIs should be logically paired with correct scopes. + - Example, **GET APIs** should be logically paired with **`READ_ONLY`**, **`READ_WRITE`**, or **`PUBLIC`** scopes. When verifying access, these scopes must be **passed to the security plugin** via the `ResourceSharingNodeClient` to determine whether a user has the required permissions. + + +--- + +## **6. User Setup** + +To enable users to interact with the **Resource Sharing and Access Control** feature, they must be assigned the appropriate cluster permissions along with resource-specific access. + +### **Required Cluster Permissions** +Users must be assigned the following **cluster permissions** in `roles.yml`: + +- **`cluster:admin/security/resource_access`** → Required to evaluate resource permissions. +- **Plugin-specific cluster permissions** → Required to interact with the plugin’s APIs. + +#### **Example Role Configurations** +```yaml +sample_full_access: + cluster_permissions: + - 'cluster:admin/security/resource_access' + - 'cluster:admin/sample-resource-plugin/*' + +sample_read_access: + cluster_permissions: + - 'cluster:admin/security/resource_access' + - 'cluster:admin/sample-resource-plugin/get' +``` + + +### **User Access Rules** +1. **Users must have the required cluster permissions** + - Even if a resource is shared with a user, they **cannot access it** unless they have the **plugin’s cluster permissions**. + +2. **Granting plugin API permissions does not automatically grant resource access** + - A resource must be **explicitly shared** with the user. + - **Or, the user must be the resource owner.** + +3. **No index permissions are required** + - Access control is **handled at the cluster level**. + - The `.opensearch_resource_sharing` index and the resource indices are protected under system index security. + + +### **Summary** +| **Requirement** | **Description** | +|---------------|---------------------------------------------------------------------------------------| +| **Cluster Permission** | `cluster:admin/security/resource_access` required for resource evaluation. | +| **Plugin API Permissions** | Users must also have relevant plugin API cluster permissions. | +| **Resource Sharing** | Access is granted only if the resource is shared with the user or they are the owner. | +| **No Index Permissions Needed** | The `.opensearch_resource_sharing` index and resource indices are system-protected. | + + +--- + +## **7. Restrictions** +1. At present, **only resource owners can share/revoke access** to their own resources. + - **Super admins** can manage access for any resource. +2. **Resources must be stored in a system index**, and system index protection **must be enabled**. + - **Disabling system index protection** allows users to access resources **directly** if they have relevant index permissions. + +--- + +## **8. REST APIs Introduced by the Security Plugin** + +In addition to client methods, the **Security Plugin** introduces new **REST APIs** for managing resource access when the feature is enabled. These APIs allow users to **verify, grant, revoke, and list access** to resources. + +--- + +### **1. Verify Access** +- **Endpoint:** + ``` + POST /_plugins/_security/resources/verify_access + ``` +- **Description:** + Verifies whether the current user has access to a specified resource within the given index and scopes. + +#### **Request Body:** +```json +{ + "resource_id": "my-resource", + "resource_index": "resource-index", + "scopes": ["READ_ONLY"] +} +``` + +#### **Request Fields:** +| Field | Type | Description | +|-----------------|----------|-------------| +| `resource_id` | String | Unique identifier of the resource being accessed. | +| `resource_index`| String | The OpenSearch index where the resource is stored. | +| `scopes` | Array | The list of scopes to check access against (e.g., `"READ_ONLY"`, `"READ_WRITE"`). | + +#### **Response:** +Returns whether the user has permission to access the resource. +```json +{ + "has_permission": true +} +``` + +#### **Response Fields:** +| Field | Type | Description | +|-----------------|---------|-------------| +| `has_permission` | Boolean | `true` if the user has access, `false` otherwise. | + +--- + +### **2. Grant Access** +- **Endpoint:** + ``` + POST /_plugins/_security/resources/share + ``` +- **Description:** + Grants access to a resource for specified **users, roles, and backend roles** under defined **scopes**. + +#### **Request Body:** +```json +{ + "resource_id": "my-resource", + "resource_index": "resource-index", + "share_with": { + "your-scope-name": { + "users": ["shared-user-name"], + "backend_roles": ["shared-backend-roles"] + }, + "your-scope-name-2": { + "roles": ["shared-roles"] + } + } +} +``` + +#### **Request Fields:** +| Field | Type | Description | +|-----------------|---------|-------------| +| `resource_id` | String | The unique identifier of the resource to be shared. | +| `resource_index`| String | The OpenSearch index where the resource is stored. | +| `share_with` | Object | Defines which **users, roles, or backend roles** will gain access. | +| `your-scope-name` | Object | The scope under which the resource is shared (e.g., `"READ_ONLY"`, `"PUBLIC"`). | +| `users` | Array | List of usernames allowed to access the resource. | +| `roles` | Array | List of role names granted access. | +| `backend_roles`| Array | List of backend roles assigned to the resource. | + +#### **Response:** +Returns the updated **resource sharing state**. +```json +{ + "sharing_info": { + "source_idx": "resource-index", + "resource_id": "my-resource", + "created_by": { + "user": "you" + }, + "share_with": { + "your-scope-name": { + "users": ["shared-user-name"], + "backend_roles": ["shared-backend-roles"] + }, + "your-scope-name-2": { + "roles": ["shared-roles"] + } + } + } +} +``` + +#### **Response Fields:** +| Field | Type | Description | +|---------------|---------|-------------| +| `sharing_info` | Object | Contains information about how the resource is shared. | +| `source_idx` | String | The OpenSearch index containing the resource. | +| `resource_id` | String | The unique identifier of the resource being shared. | +| `created_by` | Object | Information about the user who created the sharing entry. | +| `share_with` | Object | Defines users, roles, and backend roles with access to the resource. | + +--- + +### **3. Revoke Access** +- **Endpoint:** + ``` + POST /_plugins/_security/resources/revoke + ``` +- **Description:** + Revokes access to a resource for specific users, roles, or backend roles under certain scopes. + +#### **Request Body:** +```json +{ + "resource_id": "my-resource", + "resource_index": "resource-index", + "entities_to_revoke": { + "roles": ["shared-roles"] + }, + "scopes": ["your-scope-name-2"] +} +``` + +#### **Request Fields:** +| Field | Type | Description | +|-----------------|---------|-------------| +| `resource_id` | String | The unique identifier of the resource whose access is being revoked. | +| `resource_index`| String | The OpenSearch index where the resource is stored. | +| `entities_to_revoke` | Object | Specifies which **users, roles, or backend roles** should have their access removed. | +| `roles` | Array | List of roles to revoke access from. | +| `scopes` | Array | List of scopes from which access should be revoked. | + +#### **Response:** +Returns the updated **resource sharing state** after revocation. +```json +{ + "sharing_info": { + "source_idx": "resource-index", + "resource_id": "my-resource", + "created_by": { + "user": "admin" + }, + "share_with": { + "your-scope-name": { + "users": ["shared-user-name"], + "backend_roles": ["shared-backend-roles"] + } + } + } +} +``` + +#### **Response Fields:** +| Field | Type | Description | +|---------------|---------|-------------| +| `sharing_info` | Object | Contains information about the updated resource sharing state. | +| `source_idx` | String | The OpenSearch index containing the resource. | +| `resource_id` | String | The unique identifier of the resource. | +| `created_by` | Object | Information about the user who created the sharing entry. | +| `share_with` | Object | Defines users, roles, and backend roles that still have access to the resource. | + +--- + +### **4. List Accessible Resources** +- **Endpoint:** + ``` + GET /_plugins/_security/resources/list/{resource_index} + ``` +- **Description:** + Retrieves a list of **resources that the current user has access to** within the specified `{resource_index}`. + +#### **Response:** +Returns an array of accessible resources. +```json +{ + "resources": [ + { + "name": "my-resource-name", + "description": "My resource description.", + "attributes": { + "type": "model" + } + } + ] +} +``` +*This is an example resource. Actual structure will vary based on your configuration.* + +--- + +## **Additional Notes** +- **Feature Flag:** These APIs are available only when `plugins.security.resource_sharing.enabled` is set to `true` in the configuration. +- **Index Restrictions:** Resources must be stored in **system indices**, and **system index protection** must be enabled to prevent unauthorized access. +- **Scopes Flexibility:** The `share_with` field allows defining **custom access scopes** as per plugin requirements. + +--- + +## **9. Best Practices** +### **For Plugin Developers** +- **Declare resources properly** in the `ResourceSharingExtension`. +- **Use the security client** instead of direct index queries to check access. +- **Implement a resource parser** to ensure correct resource extraction. + +### **For Users & Admins** +- **Keep system index protection enabled** for better security. +- **Grant access only when necessary** to limit exposure. + +--- + +## **Conclusion** +The **Resource Sharing and Access Control** feature enhances OpenSearch security by introducing an **additional layer of fine-grained access management** for plugin-defined resources. While **Fine-Grained Access Control (FGAC)** is already enabled, this feature provides **even more granular control** specifically for **resource-level access** within plugins. + +By implementing the **Service Provider Interface (SPI)**, utilizing the **security client**, and following **best practices**, developers can seamlessly integrate this feature into their plugins to enforce controlled resource sharing and access management. + +For detailed implementation and examples, refer to the **[sample plugin](./sample-resource-plugin/README.md)** included in the security plugin repository. + +--- + +## **License** +This project is licensed under the **Apache 2.0 License**. + +--- + +## **Copyright** +© OpenSearch Contributors. diff --git a/build.gradle b/build.gradle index c7ba88a881..13f1949a67 100644 --- a/build.gradle +++ b/build.gradle @@ -500,6 +500,12 @@ configurations { force "org.checkerframework:checker-qual:3.49.1" force "ch.qos.logback:logback-classic:1.5.17" 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" } } @@ -507,6 +513,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:${versions.bouncycastle}" + integrationTestImplementation "org.bouncycastle:bcutil-jdk18on:${versions.bouncycastle}" + integrationTestImplementation('org.awaitility:awaitility:4.2.2') { + exclude(group: 'org.hamcrest', module: 'hamcrest') + } + integrationTestImplementation 'com.unboundid:unboundid-ldapsdk:4.0.14' + integrationTestImplementation "org.opensearch.plugin:mapper-size:${opensearch_version}" + integrationTestImplementation "org.apache.httpcomponents:httpclient-cache:4.5.14" + integrationTestImplementation "org.apache.httpcomponents:httpclient:4.5.14" + integrationTestImplementation "org.apache.httpcomponents:fluent-hc:4.5.14" + integrationTestImplementation "org.apache.httpcomponents:httpcore:4.4.16" + integrationTestImplementation "org.apache.httpcomponents:httpasyncclient:4.1.5" + integrationTestImplementation "org.mockito:mockito-core:5.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 { @@ -527,6 +594,9 @@ sourceSets { //add new task that runs integration tests task integrationTest(type: Test) { + filter { + excludeTestsMatching 'org.opensearch.sample.*ResourcePlugin*' + } doFirst { // Only run resources tests on resource-test CI environments or locally if (System.getenv('CI_ENVIRONMENT') != 'resource-test' && System.getenv('CI_ENVIRONMENT') != null) { @@ -575,6 +645,7 @@ tasks.integrationTest.finalizedBy(jacocoTestReport) // report is always generate check.dependsOn integrationTest 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}" @@ -730,35 +801,12 @@ dependencies { compileOnly "org.opensearch:opensearch:${opensearch_version}" - //integration test framework: - integrationTestImplementation('com.carrotsearch.randomizedtesting:randomizedtesting-runner:2.8.2') { - exclude(group: 'junit', module: 'junit') - } - integrationTestImplementation 'junit:junit:4.13.2' - integrationTestImplementation "org.opensearch.plugin:reindex-client:${opensearch_version}" - integrationTestImplementation "org.opensearch.plugin:percolator-client:${opensearch_version}" - integrationTestImplementation 'commons-io:commons-io:2.18.0' - integrationTestImplementation "org.apache.logging.log4j:log4j-core:${versions.log4j}" - integrationTestImplementation "org.apache.logging.log4j:log4j-jul:${versions.log4j}" - integrationTestImplementation 'org.hamcrest:hamcrest:2.2' - integrationTestImplementation "org.bouncycastle:bcpkix-jdk18on:${versions.bouncycastle}" - integrationTestImplementation "org.bouncycastle:bcutil-jdk18on:${versions.bouncycastle}" - integrationTestImplementation('org.awaitility:awaitility:4.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' } + + testImplementation project(path: ":${rootProject.name}-common", configuration: 'shadow') } jar { diff --git a/client/README.md b/client/README.md new file mode 100644 index 0000000000..2f944adb35 --- /dev/null +++ b/client/README.md @@ -0,0 +1,233 @@ +Here's a **refined and corrected** version of your `README.md` file with improved clarity, grammar, and formatting: + +--- + +# **Resource Sharing Client** + +This package provides a **ResourceSharing client** that resource plugins can use to **implement access control** by communicating with the **OpenSearch Security Plugin**. + +--- + +## **Usage** + +### **1. Creating a Client Accessor with Singleton Pattern** +To ensure a single instance of the `ResourceSharingNodeClient`, use the **Singleton pattern**: + +```java +public class ResourceSharingClientAccessor { + private static ResourceSharingNodeClient INSTANCE; + + private ResourceSharingClientAccessor() {} + + /** + * Get the resource sharing client instance. + * + * @param nodeClient The OpenSearch NodeClient instance. + * @param settings The OpenSearch settings. + * @return A singleton instance of ResourceSharingNodeClient. + */ + public static ResourceSharingNodeClient getResourceSharingClient(NodeClient nodeClient, Settings settings) { + if (INSTANCE == null) { + INSTANCE = new ResourceSharingNodeClient(nodeClient, settings); + } + return INSTANCE; + } +} +``` + +--- + +### **2. Using the Client in a Transport Action** +The following example demonstrates how to use the **Resource Sharing Client** inside a `TransportAction` to verify **delete permissions** before deleting a resource. + +```java +@Override +protected void doExecute(Task task, DeleteResourceRequest request, ActionListener listener) { + String resourceId = request.getResourceId(); + + ResourceSharingClient resourceSharingClient = ResourceSharingClientAccessor.getResourceSharingClient(nodeClient, settings); + + resourceSharingClient.verifyResourceAccess( + resourceId, + RESOURCE_INDEX_NAME, + SampleResourceScope.PUBLIC.value(), + ActionListener.wrap(isAuthorized -> { + if (!isAuthorized) { + listener.onFailure(new ResourceSharingException("Current user is not authorized to delete resource: " + resourceId)); + 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); + }) + ); +} +``` + +--- + +## **Available Java APIs** + +The **`ResourceSharingClient`** provides **four Java APIs** for **resource access control**, enabling plugins to **verify, share, revoke, and list** resources. + +**Package Location:** +[`org.opensearch.security.client.resources.ResourceSharingClient`](../client/src/main/java/org/opensearch/security/client/resources/ResourceSharingClient.java) + +--- + +### **API Usage Examples** +Below are examples demonstrating how to use each API effectively. + +--- + +### **1. `verifyResourceAccess`** +**Checks if the current user has access to a resource** based on predefined **scopes**. + +#### **Method Signature:** +```java +void verifyResourceAccess(String resourceId, String resourceIndex, Set scopes, ActionListener listener); +``` + +#### **Example Usage:** +```java +Set scopes = Set.of("READ_ONLY"); +resourceSharingClient.verifyResourceAccess( + "resource-123", + "resource_index", + scopes, + ActionListener.wrap(isAuthorized -> { + if (isAuthorized) { + System.out.println("User has access to the resource."); + } else { + System.out.println("Access denied."); + } + }, e -> { + System.err.println("Failed to verify access: " + e.getMessage()); + }) +); +``` +> **Use Case:** Before performing operations like **deletion or modifications**, ensure the user has the right permissions. + +--- + +### **2. `shareResource`** +**Grants access to a resource** for specific users, roles, or backend roles. + +#### **Method Signature:** +```java +void shareResource(String resourceId, String resourceIndex, Map shareWith, ActionListener listener); +``` + +#### **Example Usage:** +```java +Map shareWith = Map.of( + "users", List.of("user_1", "user_2"), + "roles", List.of("admin_role"), + "backend_roles", List.of("backend_group") +); + +resourceSharingClient.shareResource( + "resource-123", + "resource_index", + shareWith, + ActionListener.wrap(response -> { + System.out.println("Resource successfully shared with: " + shareWith); + }, e -> { + System.err.println("Failed to share resource: " + e.getMessage()); + }) +); +``` +> **Use Case:** Used when an **owner/admin wants to share a resource** with specific users or groups. + +--- + +### **3. `revokeResourceAccess`** +**Removes access permissions** for specified users, roles, or backend roles. + +#### **Method Signature:** +```java +void revokeResourceAccess(String resourceId, String resourceIndex, Map entitiesToRevoke, Set scopes, ActionListener listener); +``` + +#### **Example Usage:** +```java +Map entitiesToRevoke = Map.of( + "users", List.of("user_2"), + "roles", List.of("viewer_role") +); +Set scopesToRevoke = Set.of("READ_ONLY"); + +resourceSharingClient.revokeResourceAccess( + "resource-123", + "resource_index", + entitiesToRevoke, + scopesToRevoke, + ActionListener.wrap(response -> { + System.out.println("Resource access successfully revoked for: " + entitiesToRevoke); + }, e -> { + System.err.println("Failed to revoke access: " + e.getMessage()); + }) +); +``` +> **Use Case:** When a user no longer needs access to a **resource**, their permissions can be revoked. + +--- + +### **4. `listAllAccessibleResources`** +**Retrieves all resources the current user has access to.** + +#### **Method Signature:** +```java +void listAllAccessibleResources(String resourceIndex, ActionListener> listener); +``` + +#### **Example Usage:** +```java +resourceSharingClient.listAllAccessibleResources( + "resource_index", + ActionListener.wrap(resources -> { + for (Resource resource : resources) { + System.out.println("Accessible Resource: " + resource.getId()); + } + }, e -> { + System.err.println("Failed to list accessible resources: " + e.getMessage()); + }) +); +``` +> **Use Case:** Helps a user identify **which resources they can interact with**. + +--- + +## **Conclusion** +These APIs provide essential methods for **fine-grained resource access control**, enabling: + +✔ **Verification** of resource access. +✔ **Granting and revoking** access dynamically. +✔ **Retrieval** of all accessible resources. + +For further details, refer to the [`ResourceSharingClient` Java class](../client/src/main/java/org/opensearch/security/client/resources/ResourceSharingClient.java). + +--- + +## **License** +This project is licensed under the **Apache 2.0 License**. + +--- + +## **Copyright** +© OpenSearch Contributors. diff --git a/client/build.gradle b/client/build.gradle new file mode 100644 index 0000000000..8bef3910bc --- /dev/null +++ b/client/build.gradle @@ -0,0 +1,101 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +plugins { + id 'java' + id 'maven-publish' + id 'io.github.goooler.shadow' version "8.1.7" +} + +ext { + opensearch_version = System.getProperty("opensearch.version", "3.0.0-alpha1-SNAPSHOT") + isSnapshot = "true" == System.getProperty("build.snapshot", "true") + buildVersionQualifier = System.getProperty("build.version_qualifier", "alpha1") + + // 2.0.0-rc1-SNAPSHOT -> 2.0.0.0-rc1-SNAPSHOT + 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" } +} + +dependencies { + compileOnly "org.opensearch:opensearch:${opensearch_version}" + // SPI dependency comes through common + implementation project(path: ":${rootProject.name}-common", configuration: 'shadow') +} + +java { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 +} + +task sourcesJar(type: Jar) { + archiveClassifier.set 'sources' + from sourceSets.main.allJava +} + +task javadocJar(type: Jar) { + archiveClassifier.set 'javadoc' + from tasks.javadoc +} + +publishing { + publications { + shadow(MavenPublication) { publication -> + project.shadow.component(publication) + artifact sourcesJar + artifact javadocJar + pom { + name.set("OpenSearch Security Client") + packaging = "jar" + description.set("OpenSearch Security Client") + url.set("https://github.com/opensearch-project/security") + licenses { + license { + name.set("The Apache License, Version 2.0") + url.set("http://www.apache.org/licenses/LICENSE-2.0.txt") + } + } + scm { + connection.set("scm:git@github.com:opensearch-project/security.git") + developerConnection.set("scm:git@github.com:opensearch-project/security.git") + url.set("https://github.com/opensearch-project/security.git") + } + developers { + developer { + name.set("OpenSearch Contributors") + url.set("https://github.com/opensearch-project") + } + } + } + } + } + repositories { + maven { + name = "Snapshots" + url = "https://aws.oss.sonatype.org/content/repositories/snapshots" + credentials { + username "$System.env.SONATYPE_USERNAME" + password "$System.env.SONATYPE_PASSWORD" + } + } + maven { + name = 'staging' + url = "${rootProject.buildDir}/local-staging-repo" + } + } +} diff --git a/client/src/main/java/org/opensearch/security/client/resources/ResourceSharingClient.java b/client/src/main/java/org/opensearch/security/client/resources/ResourceSharingClient.java new file mode 100644 index 0000000000..615f27ed68 --- /dev/null +++ b/client/src/main/java/org/opensearch/security/client/resources/ResourceSharingClient.java @@ -0,0 +1,65 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.security.client.resources; + +import java.util.Map; +import java.util.Set; + +import org.opensearch.core.action.ActionListener; +import org.opensearch.security.spi.resources.Resource; +import org.opensearch.security.spi.resources.sharing.ResourceSharing; + +/** + * Interface for resource sharing client operations. + * + * @opensearch.experimental + */ +public interface ResourceSharingClient { + + /** + * Verifies if the current user has access to the specified resource. + * @param resourceId The ID of the resource to verify access for. + * @param resourceIndex The index containing the resource. + * @param scopes The scopes to be checked against. + * @param listener The listener to be notified with the access verification result. + */ + void verifyResourceAccess(String resourceId, String resourceIndex, Set scopes, ActionListener listener); + + /** + * Shares a resource with the specified users, roles, and backend roles. + * @param resourceId The ID of the resource to share. + * @param resourceIndex The index containing the resource. + * @param shareWith The users, roles, and backend roles to share the resource with. + * @param listener The listener to be notified with the updated ResourceSharing document. + */ + void shareResource(String resourceId, String resourceIndex, Map shareWith, ActionListener listener); + + /** + * Revokes access to a resource for the specified entities and scopes. + * @param resourceId The ID of the resource to revoke access for. + * @param resourceIndex The index containing the resource. + * @param entitiesToRevoke The entities to revoke access for. + * @param scopes The scopes to revoke access for. + * @param listener The listener to be notified with the updated ResourceSharing document. + */ + void revokeResourceAccess( + String resourceId, + String resourceIndex, + Map entitiesToRevoke, + Set scopes, + ActionListener listener + ); + + /** + * Lists all resources accessible by the current user. + * @param resourceIndex The index containing the resources. + * @param listener The listener to be notified with the set of accessible resources. + */ + void listAllAccessibleResources(String resourceIndex, ActionListener> listener); +} diff --git a/client/src/main/java/org/opensearch/security/client/resources/ResourceSharingNodeClient.java b/client/src/main/java/org/opensearch/security/client/resources/ResourceSharingNodeClient.java new file mode 100644 index 0000000000..239e23e128 --- /dev/null +++ b/client/src/main/java/org/opensearch/security/client/resources/ResourceSharingNodeClient.java @@ -0,0 +1,186 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.security.client.resources; + +import java.util.Map; +import java.util.Set; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import org.opensearch.common.settings.Settings; +import org.opensearch.core.action.ActionListener; +import org.opensearch.core.rest.RestStatus; +import org.opensearch.security.common.resources.rest.ResourceAccessAction; +import org.opensearch.security.common.resources.rest.ResourceAccessRequest; +import org.opensearch.security.common.resources.rest.ResourceAccessResponse; +import org.opensearch.security.common.support.ConfigConstants; +import org.opensearch.security.spi.resources.Resource; +import org.opensearch.security.spi.resources.exceptions.ResourceSharingException; +import org.opensearch.security.spi.resources.sharing.ResourceSharing; +import org.opensearch.transport.client.Client; + +/** + * Client for resource sharing operations. + * + * @opensearch.experimental + */ +public final class ResourceSharingNodeClient implements ResourceSharingClient { + + private static final Logger log = LogManager.getLogger(ResourceSharingNodeClient.class); + + private final Client client; + private final boolean resourceSharingEnabled; + private final boolean isSecurityDisabled; + + public ResourceSharingNodeClient(Client client, Settings settings) { + this.client = client; + this.resourceSharingEnabled = settings.getAsBoolean( + ConfigConstants.OPENSEARCH_RESOURCE_SHARING_ENABLED, + ConfigConstants.OPENSEARCH_RESOURCE_SHARING_ENABLED_DEFAULT + ); + this.isSecurityDisabled = settings.getAsBoolean( + ConfigConstants.OPENSEARCH_SECURITY_DISABLED, + ConfigConstants.OPENSEARCH_SECURITY_DISABLED_DEFAULT + ); + } + + /** + * Verifies if the current user has access to the specified resource. + * @param resourceId The ID of the resource to verify access for. + * @param resourceIndex The index containing the resource. + * @param scopes The scopes to be checked against. + * @param listener The listener to be notified with the access verification result. + */ + @Override + public void verifyResourceAccess(String resourceId, String resourceIndex, Set scopes, ActionListener listener) { + if (isSecurityDisabled || !resourceSharingEnabled) { + String message = isSecurityDisabled ? "Security Plugin is disabled." : "Resource Access Control feature is disabled."; + + log.warn("{} {}", message, "Access to resource is automatically granted"); + listener.onResponse(true); + return; + } + ResourceAccessRequest request = new ResourceAccessRequest.Builder().operation(ResourceAccessRequest.Operation.VERIFY) + .resourceId(resourceId) + .resourceIndex(resourceIndex) + .scopes(scopes) + .build(); + client.execute(ResourceAccessAction.INSTANCE, request, verifyAccessResponseListener(listener)); + } + + /** + * Shares the specified resource with the given users, roles, and backend roles. + * @param resourceId The ID of the resource to share. + * @param resourceIndex The index containing the resource. + * @param shareWith The users, roles, and backend roles to share the resource with. + * @param listener The listener to be notified with the updated ResourceSharing document. + */ + @Override + public void shareResource( + String resourceId, + String resourceIndex, + Map shareWith, + ActionListener listener + ) { + if (isResourceAccessControlOrSecurityPluginDisabled("Resource is not shareable.", listener)) { + return; + } + ResourceAccessRequest request = new ResourceAccessRequest.Builder().operation(ResourceAccessRequest.Operation.SHARE) + .resourceId(resourceId) + .resourceIndex(resourceIndex) + .shareWith(shareWith) + .build(); + client.execute(ResourceAccessAction.INSTANCE, request, sharingInfoResponseListener(listener)); + } + + /** + * Revokes access to the specified resource for the given entities and scopes. + * @param resourceId The ID of the resource to revoke access for. + * @param resourceIndex The index containing the resource. + * @param entitiesToRevoke The entities to revoke access for. + * @param scopes The scopes to revoke access for. + * @param listener The listener to be notified with the updated ResourceSharing document. + */ + @Override + public void revokeResourceAccess( + String resourceId, + String resourceIndex, + Map entitiesToRevoke, + Set scopes, + ActionListener listener + ) { + if (isResourceAccessControlOrSecurityPluginDisabled("Resource access is not revoked.", listener)) { + return; + } + ResourceAccessRequest request = new ResourceAccessRequest.Builder().operation(ResourceAccessRequest.Operation.REVOKE) + .resourceId(resourceId) + .resourceIndex(resourceIndex) + .revokedEntities(entitiesToRevoke) + .scopes(scopes) + .build(); + client.execute(ResourceAccessAction.INSTANCE, request, sharingInfoResponseListener(listener)); + } + + /** + * Lists all resources accessible by the current user. + * + * @param listener The listener to be notified with the set of accessible resources. + */ + @Override + public void listAllAccessibleResources(String resourceIndex, ActionListener> listener) { + if (isResourceAccessControlOrSecurityPluginDisabled("Unable to list all accessible resources.", listener)) { + return; + } + ResourceAccessRequest request = new ResourceAccessRequest.Builder().operation(ResourceAccessRequest.Operation.LIST) + .resourceIndex(resourceIndex) + .build(); + client.execute( + ResourceAccessAction.INSTANCE, + request, + ActionListener.wrap(response -> { listener.onResponse(response.getResources()); }, listener::onFailure) + ); + } + + /** + * Checks if resource sharing or the security plugin is disabled and handles the error accordingly. + * + * @param disabledMessage The message to be logged if the feature is disabled. + * @param listener The listener to be notified with the error. + * @return {@code true} if either resource sharing or the security plugin is disabled, otherwise {@code false}. + */ + private boolean isResourceAccessControlOrSecurityPluginDisabled(String disabledMessage, ActionListener listener) { + if (isSecurityDisabled || !resourceSharingEnabled) { + String message = (isSecurityDisabled ? "Security Plugin" : "Resource Access Control feature") + " is disabled."; + + log.warn("{} {}", message, disabledMessage); + listener.onFailure(new ResourceSharingException(message + " " + disabledMessage, RestStatus.NOT_IMPLEMENTED)); + return true; + } + return false; + } + + /** + * Notifies the listener with the access request result. + * @param listener The listener to be notified with the access request result. + * @return An ActionListener that handles the ResourceAccessResponse and notifies the listener. + */ + private ActionListener verifyAccessResponseListener(ActionListener listener) { + return ActionListener.wrap(response -> listener.onResponse(response.getHasPermission()), listener::onFailure); + } + + /** + * Notifies the listener with the updated ResourceSharing document. + * @param listener The listener to be notified with the updated ResourceSharing document. + * @return An ActionListener that handles the ResourceAccessResponse and notifies the listener. + */ + private ActionListener sharingInfoResponseListener(ActionListener listener) { + return ActionListener.wrap(response -> listener.onResponse(response.getResourceSharing()), listener::onFailure); + } +} diff --git a/client/src/main/java/org/opensearch/security/client/resources/package-info.java b/client/src/main/java/org/opensearch/security/client/resources/package-info.java new file mode 100644 index 0000000000..1e15c4c46d --- /dev/null +++ b/client/src/main/java/org/opensearch/security/client/resources/package-info.java @@ -0,0 +1,14 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +/** + * This package defines a resource sharing client that will be utilized by resource plugins to call security plugin's transport actions, which handle resource access + * + * @opensearch.experimental + */ +package org.opensearch.security.client.resources; diff --git a/common/build.gradle b/common/build.gradle new file mode 100644 index 0000000000..2b8e67add5 --- /dev/null +++ b/common/build.gradle @@ -0,0 +1,91 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +plugins { + id 'java' + id 'maven-publish' + id 'io.github.goooler.shadow' version "8.1.7" +} + +ext { + opensearch_version = System.getProperty("opensearch.version", "3.0.0-alpha1-SNAPSHOT") +} + +repositories { + mavenLocal() + mavenCentral() + maven { url "https://aws.oss.sonatype.org/content/repositories/snapshots" } +} + +dependencies { + compileOnly "com.fasterxml.jackson.core:jackson-databind:${versions.jackson_databind}" + compileOnly "org.opensearch.plugin:lang-painless:${opensearch_version}" + implementation project(path: ":opensearch-resource-sharing-spi", configuration: 'shadow') + compileOnly "org.apache.commons:commons-lang3:${versions.commonslang}" + compileOnly 'com.password4j:password4j:1.8.2' + compileOnly "com.google.guava:guava:${guava_version}" +} + +java { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 +} + +task sourcesJar(type: Jar) { + archiveClassifier.set 'sources' + from sourceSets.main.allJava +} + +task javadocJar(type: Jar) { + archiveClassifier.set 'javadoc' + from tasks.javadoc +} + +publishing { + publications { + shadow(MavenPublication) { publication -> + project.shadow.component(publication) + artifact sourcesJar + artifact javadocJar + pom { + name.set("OpenSearch Security Common") + packaging = "jar" + description.set("OpenSearch Security Common") + url.set("https://github.com/opensearch-project/security") + licenses { + license { + name.set("The Apache License, Version 2.0") + url.set("http://www.apache.org/licenses/LICENSE-2.0.txt") + } + } + scm { + connection.set("scm:git@github.com:opensearch-project/security.git") + developerConnection.set("scm:git@github.com:opensearch-project/security.git") + url.set("https://github.com/opensearch-project/security.git") + } + developers { + developer { + name.set("OpenSearch Contributors") + url.set("https://github.com/opensearch-project") + } + } + } + } + } + repositories { + maven { + name = "Snapshots" + url = "https://aws.oss.sonatype.org/content/repositories/snapshots" + credentials { + username "$System.env.SONATYPE_USERNAME" + password "$System.env.SONATYPE_PASSWORD" + } + } + maven { + name = 'staging' + url = "${rootProject.buildDir}/local-staging-repo" + } + } +} diff --git a/common/src/main/java/org/opensearch/security/common/DefaultObjectMapper.java b/common/src/main/java/org/opensearch/security/common/DefaultObjectMapper.java new file mode 100644 index 0000000000..7a2dc137a6 --- /dev/null +++ b/common/src/main/java/org/opensearch/security/common/DefaultObjectMapper.java @@ -0,0 +1,298 @@ +/* + * Copyright 2015-2018 _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. + */ + +package org.opensearch.security.common; + +import java.io.IOException; +import java.security.AccessController; +import java.security.PrivilegedActionException; +import java.security.PrivilegedExceptionAction; +import java.util.Map; +import java.util.Set; + +import com.google.common.collect.ImmutableSet; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.InjectableValues; +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.exc.InvalidFormatException; +import com.fasterxml.jackson.databind.exc.MismatchedInputException; +import com.fasterxml.jackson.databind.introspect.BeanPropertyDefinition; +import com.fasterxml.jackson.databind.module.SimpleModule; +import com.fasterxml.jackson.databind.ser.std.StdSerializer; +import com.fasterxml.jackson.databind.type.TypeFactory; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; + +import org.opensearch.SpecialPermission; + +class ConfigMapSerializer extends StdSerializer> { + private static final Set SENSITIVE_CONFIG_KEYS = Set.of("password"); + + @SuppressWarnings("unchecked") + public ConfigMapSerializer() { + // Pass Map.class to the superclass + super((Class>) (Class) Map.class); + } + + @Override + public void serialize(Map value, JsonGenerator gen, SerializerProvider serializers) throws IOException { + gen.writeStartObject(); + for (Map.Entry entry : value.entrySet()) { + if (SENSITIVE_CONFIG_KEYS.contains(entry.getKey())) { + gen.writeStringField(entry.getKey(), "******"); // Redact + } else { + gen.writeObjectField(entry.getKey(), entry.getValue()); + } + } + gen.writeEndObject(); + } +} + +public class DefaultObjectMapper { + public static final ObjectMapper objectMapper = new ObjectMapper(); + public final static ObjectMapper YAML_MAPPER = new ObjectMapper(new YAMLFactory()); + private static final ObjectMapper defaulOmittingObjectMapper = new ObjectMapper(); + + static { + objectMapper.setSerializationInclusion(Include.NON_NULL); + // exclude sensitive information from the request body, + // if jackson cant parse the entity, e.g. passwords, hashes and so on, + // but provides which property is unknown + objectMapper.disable(JsonParser.Feature.INCLUDE_SOURCE_IN_LOCATION); + defaulOmittingObjectMapper.disable(JsonParser.Feature.INCLUDE_SOURCE_IN_LOCATION); + YAML_MAPPER.disable(JsonParser.Feature.INCLUDE_SOURCE_IN_LOCATION); + // objectMapper.enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS); + objectMapper.enable(JsonParser.Feature.STRICT_DUPLICATE_DETECTION); + defaulOmittingObjectMapper.setSerializationInclusion(Include.NON_DEFAULT); + defaulOmittingObjectMapper.enable(JsonParser.Feature.STRICT_DUPLICATE_DETECTION); + YAML_MAPPER.enable(JsonParser.Feature.STRICT_DUPLICATE_DETECTION); + } + + private DefaultObjectMapper() {} + + public static void inject(final InjectableValues.Std injectableValues) { + objectMapper.setInjectableValues(injectableValues); + YAML_MAPPER.setInjectableValues(injectableValues); + defaulOmittingObjectMapper.setInjectableValues(injectableValues); + } + + public static boolean getOrDefault(Map properties, String key, boolean defaultValue) throws JsonProcessingException { + Object value = properties.get(key); + if (value == null) { + return defaultValue; + } else if (value instanceof Boolean) { + return (boolean) value; + } else if (value instanceof String) { + String text = ((String) value).trim(); + if ("true".equals(text) || "True".equals(text)) { + return true; + } + if ("false".equals(text) || "False".equals(text)) { + return false; + } + throw InvalidFormatException.from( + null, + "Cannot deserialize value of type 'boolean' from String \"" + text + "\": only \"true\" or \"false\" recognized)", + null, + Boolean.class + ); + } + throw MismatchedInputException.from( + null, + Boolean.class, + "Cannot deserialize instance of 'boolean' out of '" + value + "' (Property: " + key + ")" + ); + } + + @SuppressWarnings("unchecked") + public static T getOrDefault(Map properties, String key, T defaultValue) { + T value = (T) properties.get(key); + return value != null ? value : defaultValue; + } + + @SuppressWarnings("removal") + public static T readTree(JsonNode node, Class clazz) throws IOException { + + final SecurityManager sm = System.getSecurityManager(); + + if (sm != null) { + sm.checkPermission(new SpecialPermission()); + } + + try { + return AccessController.doPrivileged((PrivilegedExceptionAction) () -> objectMapper.treeToValue(node, clazz)); + } catch (final PrivilegedActionException e) { + throw (IOException) e.getCause(); + } + } + + @SuppressWarnings("removal") + public static T readValue(String string, Class clazz) throws IOException { + + final SecurityManager sm = System.getSecurityManager(); + + if (sm != null) { + sm.checkPermission(new SpecialPermission()); + } + + try { + return AccessController.doPrivileged((PrivilegedExceptionAction) () -> objectMapper.readValue(string, clazz)); + } catch (final PrivilegedActionException e) { + throw (IOException) e.getCause(); + } + } + + @SuppressWarnings("removal") + public static JsonNode readTree(String string) throws IOException { + + final SecurityManager sm = System.getSecurityManager(); + + if (sm != null) { + sm.checkPermission(new SpecialPermission()); + } + + try { + return AccessController.doPrivileged((PrivilegedExceptionAction) () -> objectMapper.readTree(string)); + } catch (final PrivilegedActionException e) { + throw (IOException) e.getCause(); + } + } + + @SuppressWarnings("removal") + public static String writeValueAsString(Object value, boolean omitDefaults) throws JsonProcessingException { + + final SecurityManager sm = System.getSecurityManager(); + + if (sm != null) { + sm.checkPermission(new SpecialPermission()); + } + + try { + return AccessController.doPrivileged( + (PrivilegedExceptionAction) () -> (omitDefaults ? defaulOmittingObjectMapper : objectMapper).writeValueAsString( + value + ) + ); + } catch (final PrivilegedActionException e) { + throw (JsonProcessingException) e.getCause(); + } + + } + + @SuppressWarnings("removal") + public static String writeValueAsStringAndRedactSensitive(Object value) throws JsonProcessingException { + final SecurityManager sm = System.getSecurityManager(); + + if (sm != null) { + sm.checkPermission(new SpecialPermission()); + } + + SimpleModule module = new SimpleModule(); + module.addSerializer(new ConfigMapSerializer()); + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(module); + + try { + return AccessController.doPrivileged((PrivilegedExceptionAction) () -> mapper.writeValueAsString(value)); + } catch (final PrivilegedActionException e) { + throw (JsonProcessingException) e.getCause(); + } + + } + + @SuppressWarnings("removal") + public static T readValue(String string, TypeReference tr) throws IOException { + + final SecurityManager sm = System.getSecurityManager(); + + if (sm != null) { + sm.checkPermission(new SpecialPermission()); + } + + try { + return AccessController.doPrivileged(new PrivilegedExceptionAction() { + @Override + public T run() throws Exception { + return objectMapper.readValue(string, tr); + } + }); + } catch (final PrivilegedActionException e) { + throw (IOException) e.getCause(); + } + + } + + @SuppressWarnings("removal") + public static T readValue(String string, JavaType jt) throws IOException { + + final SecurityManager sm = System.getSecurityManager(); + + if (sm != null) { + sm.checkPermission(new SpecialPermission()); + } + + try { + return AccessController.doPrivileged((PrivilegedExceptionAction) () -> objectMapper.readValue(string, jt)); + } catch (final PrivilegedActionException e) { + throw (IOException) e.getCause(); + } + } + + @SuppressWarnings("removal") + public static T convertValue(JsonNode jsonNode, JavaType jt) throws IOException { + + final SecurityManager sm = System.getSecurityManager(); + + if (sm != null) { + sm.checkPermission(new SpecialPermission()); + } + + try { + return AccessController.doPrivileged((PrivilegedExceptionAction) () -> objectMapper.convertValue(jsonNode, jt)); + } catch (final PrivilegedActionException e) { + throw (IOException) e.getCause(); + } + } + + public static TypeFactory getTypeFactory() { + return objectMapper.getTypeFactory(); + } + + public static Set getFields(Class cls) { + return objectMapper.getSerializationConfig() + .introspect(getTypeFactory().constructType(cls)) + .findProperties() + .stream() + .map(BeanPropertyDefinition::getName) + .collect(ImmutableSet.toImmutableSet()); + } +} diff --git a/common/src/main/java/org/opensearch/security/common/auditlog/impl/AuditCategory.java b/common/src/main/java/org/opensearch/security/common/auditlog/impl/AuditCategory.java new file mode 100644 index 0000000000..3526404bbd --- /dev/null +++ b/common/src/main/java/org/opensearch/security/common/auditlog/impl/AuditCategory.java @@ -0,0 +1,40 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.common.auditlog.impl; + +import java.util.Collection; +import java.util.Collections; +import java.util.Set; + +import com.google.common.collect.ImmutableSet; + +public enum AuditCategory { + BAD_HEADERS, + FAILED_LOGIN, + MISSING_PRIVILEGES, + GRANTED_PRIVILEGES, + OPENDISTRO_SECURITY_INDEX_ATTEMPT, + SSL_EXCEPTION, + AUTHENTICATED, + INDEX_EVENT, + COMPLIANCE_DOC_READ, + COMPLIANCE_DOC_WRITE, + COMPLIANCE_EXTERNAL_CONFIG, + COMPLIANCE_INTERNAL_CONFIG_READ, + COMPLIANCE_INTERNAL_CONFIG_WRITE; + + public static Set parse(final Collection categories) { + if (categories.isEmpty()) return Collections.emptySet(); + + return categories.stream().map(String::toUpperCase).map(AuditCategory::valueOf).collect(ImmutableSet.toImmutableSet()); + } +} diff --git a/src/main/java/org/opensearch/security/auth/UserSubjectImpl.java b/common/src/main/java/org/opensearch/security/common/auth/UserSubjectImpl.java similarity index 83% rename from src/main/java/org/opensearch/security/auth/UserSubjectImpl.java rename to common/src/main/java/org/opensearch/security/common/auth/UserSubjectImpl.java index 63adc559e3..620250be53 100644 --- a/src/main/java/org/opensearch/security/auth/UserSubjectImpl.java +++ b/common/src/main/java/org/opensearch/security/common/auth/UserSubjectImpl.java @@ -7,7 +7,7 @@ * compatible open source license. * */ -package org.opensearch.security.auth; +package org.opensearch.security.common.auth; import java.security.Principal; import java.util.concurrent.Callable; @@ -16,8 +16,8 @@ import org.opensearch.identity.NamedPrincipal; import org.opensearch.identity.UserSubject; import org.opensearch.identity.tokens.AuthToken; -import org.opensearch.security.support.ConfigConstants; -import org.opensearch.security.user.User; +import org.opensearch.security.common.support.ConfigConstants; +import org.opensearch.security.common.user.User; import org.opensearch.threadpool.ThreadPool; public class UserSubjectImpl implements UserSubject { @@ -25,7 +25,7 @@ public class UserSubjectImpl implements UserSubject { private final ThreadPool threadPool; private final User user; - UserSubjectImpl(ThreadPool threadPool, User user) { + public UserSubjectImpl(ThreadPool threadPool, User user) { this.threadPool = threadPool; this.user = user; this.userPrincipal = new NamedPrincipal(user.getName()); @@ -48,4 +48,8 @@ public T runAs(Callable callable) throws Exception { return callable.call(); } } + + public User getUser() { + return user; + } } diff --git a/common/src/main/java/org/opensearch/security/common/configuration/AdminDNs.java b/common/src/main/java/org/opensearch/security/common/configuration/AdminDNs.java new file mode 100644 index 0000000000..22647e6685 --- /dev/null +++ b/common/src/main/java/org/opensearch/security/common/configuration/AdminDNs.java @@ -0,0 +1,162 @@ +/* + * Copyright 2015-2018 _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. + */ + +package org.opensearch.security.common.configuration; + +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.Function; +import javax.naming.InvalidNameException; +import javax.naming.ldap.LdapName; + +import com.google.common.collect.ImmutableMap; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import org.opensearch.common.settings.Settings; +import org.opensearch.security.common.support.ConfigConstants; +import org.opensearch.security.common.support.WildcardMatcher; +import org.opensearch.security.common.user.User; + +public class AdminDNs { + + protected final Logger log = LogManager.getLogger(AdminDNs.class); + private final Set adminDn = new HashSet(); + private final Set adminUsernames = new HashSet(); + private final Map allowedDnsImpersonations; + private final Map allowedRestImpersonations; + private boolean injectUserEnabled; + private boolean injectAdminUserEnabled; + + public AdminDNs(final Settings settings) { + + this.injectUserEnabled = settings.getAsBoolean(ConfigConstants.SECURITY_UNSUPPORTED_INJECT_USER_ENABLED, false); + this.injectAdminUserEnabled = settings.getAsBoolean(ConfigConstants.SECURITY_UNSUPPORTED_INJECT_ADMIN_USER_ENABLED, false); + + final List adminDnsA = settings.getAsList(ConfigConstants.SECURITY_AUTHCZ_ADMIN_DN, Collections.emptyList()); + + for (String dn : adminDnsA) { + try { + log.debug("{} is registered as an admin dn", dn); + adminDn.add(new LdapName(dn)); + } catch (final InvalidNameException e) { + // make sure to log correctly depending on user injection settings + if (injectUserEnabled && injectAdminUserEnabled) { + if (log.isDebugEnabled()) { + log.debug("Admin DN not an LDAP name, but admin user injection enabled. Will add {} to admin usernames", dn); + } + adminUsernames.add(dn); + } else { + log.error("Unable to parse admin dn {}", dn, e); + } + } + } + + log.debug("Loaded {} admin DN's {}", adminDn.size(), adminDn); + + final Settings impersonationDns = settings.getByPrefix(ConfigConstants.SECURITY_AUTHCZ_IMPERSONATION_DN + "."); + + allowedDnsImpersonations = impersonationDns.keySet() + .stream() + .map(this::toLdapName) + .filter(Objects::nonNull) + .collect( + ImmutableMap.toImmutableMap( + Function.identity(), + ldapName -> WildcardMatcher.from(settings.getAsList(ConfigConstants.SECURITY_AUTHCZ_IMPERSONATION_DN + "." + ldapName)) + ) + ); + + log.debug("Loaded {} impersonation DN's {}", allowedDnsImpersonations.size(), allowedDnsImpersonations); + + final Settings impersonationUsersRest = settings.getByPrefix(ConfigConstants.SECURITY_AUTHCZ_REST_IMPERSONATION_USERS + "."); + + allowedRestImpersonations = impersonationUsersRest.keySet() + .stream() + .collect( + ImmutableMap.toImmutableMap( + Function.identity(), + user -> WildcardMatcher.from(settings.getAsList(ConfigConstants.SECURITY_AUTHCZ_REST_IMPERSONATION_USERS + "." + user)) + ) + ); + + log.debug("Loaded {} impersonation users for REST {}", allowedRestImpersonations.size(), allowedRestImpersonations); + } + + private LdapName toLdapName(String dn) { + try { + return new LdapName(dn); + } catch (final InvalidNameException e) { + log.error("Unable to parse allowedImpersonations dn {}", dn, e); + } + return null; + } + + public boolean isAdmin(User user) { + if (isAdminDN(user.getName())) { + return true; + } + + // ThreadContext injected user, may be admin user, only if both flags are enabled and user is injected + if (injectUserEnabled && injectAdminUserEnabled && user.isInjected() && adminUsernames.contains(user.getName())) { + return true; + } + return false; + } + + public boolean isAdminDN(String dn) { + + if (dn == null) return false; + + try { + return isAdminDN(new LdapName(dn)); + } catch (InvalidNameException e) { + return false; + } + } + + private boolean isAdminDN(LdapName dn) { + if (dn == null) return false; + + boolean isAdmin = adminDn.contains(dn); + + if (log.isTraceEnabled()) { + log.trace("Is principal {} an admin cert? {}", dn.toString(), isAdmin); + } + + return isAdmin; + } + + public boolean isRestImpersonationAllowed(final String originalUser, final String impersonated) { + return (originalUser != null) + ? allowedRestImpersonations.getOrDefault(originalUser, WildcardMatcher.NONE).test(impersonated) + : false; + } +} diff --git a/common/src/main/java/org/opensearch/security/common/dlic/rest/api/Responses.java b/common/src/main/java/org/opensearch/security/common/dlic/rest/api/Responses.java new file mode 100644 index 0000000000..e2258e9e6e --- /dev/null +++ b/common/src/main/java/org/opensearch/security/common/dlic/rest/api/Responses.java @@ -0,0 +1,106 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.common.dlic.rest.api; + +import java.io.IOException; + +import org.opensearch.ExceptionsHelper; +import org.opensearch.core.rest.RestStatus; +import org.opensearch.core.xcontent.ToXContent; +import org.opensearch.rest.BytesRestResponse; +import org.opensearch.rest.RestChannel; +import org.opensearch.rest.RestRequest; + +public class Responses { + + public static void ok(final RestChannel channel, final String message) { + response(channel, RestStatus.OK, message); + } + + public static void ok(final RestChannel channel, final ToXContent toXContent) { + response(channel, RestStatus.OK, toXContent); + } + + public static void created(final RestChannel channel, final String message) { + response(channel, RestStatus.CREATED, message); + } + + public static void methodNotImplemented(final RestChannel channel, final RestRequest.Method method) { + notImplemented(channel, "Method " + method.name() + " not supported for this action."); + } + + public static void notImplemented(final RestChannel channel, final String message) { + response(channel, RestStatus.NOT_IMPLEMENTED, message); + } + + public static void notFound(final RestChannel channel, final String message) { + response(channel, RestStatus.NOT_FOUND, message); + } + + public static void conflict(final RestChannel channel, final String message) { + response(channel, RestStatus.CONFLICT, message); + } + + public static void internalServerError(final RestChannel channel, final String message) { + response(channel, RestStatus.INTERNAL_SERVER_ERROR, message); + } + + public static void forbidden(final RestChannel channel, final String message) { + response(channel, RestStatus.FORBIDDEN, message); + } + + public static void badRequest(final RestChannel channel, final String message) { + response(channel, RestStatus.BAD_REQUEST, message); + } + + public static void unauthorized(final RestChannel channel) { + response(channel, RestStatus.UNAUTHORIZED, "Unauthorized"); + } + + public static void response(RestChannel channel, RestStatus status, String message) { + response(channel, status, payload(status, message)); + } + + public static void response(final RestChannel channel, final RestStatus status, final ToXContent toXContent) { + try (final var builder = channel.newBuilder()) { + toXContent.toXContent(builder, ToXContent.EMPTY_PARAMS); + channel.sendResponse(new BytesRestResponse(status, builder)); + } catch (final IOException ioe) { + throw ExceptionsHelper.convertToOpenSearchException(ioe); + } + } + + public static ToXContent forbiddenMessage(final String message) { + return payload(RestStatus.FORBIDDEN, message); + } + + public static ToXContent badRequestMessage(final String message) { + return payload(RestStatus.BAD_REQUEST, message); + } + + public static ToXContent methodNotImplementedMessage(final RestRequest.Method method) { + return payload(RestStatus.NOT_FOUND, "Method " + method.name() + " not supported for this action."); + } + + public static ToXContent notFoundMessage(final String message) { + return payload(RestStatus.NOT_FOUND, message); + } + + public static ToXContent conflictMessage(final String message) { + return payload(RestStatus.CONFLICT, message); + } + + public static ToXContent payload(final RestStatus status, final String message) { + return (builder, params) -> builder.startObject().field("status", status.name()).field("message", message).endObject(); + } + +} diff --git a/common/src/main/java/org/opensearch/security/common/package-info.java b/common/src/main/java/org/opensearch/security/common/package-info.java new file mode 100644 index 0000000000..01e2ead134 --- /dev/null +++ b/common/src/main/java/org/opensearch/security/common/package-info.java @@ -0,0 +1,15 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +/** + * This package defines common classes required to implement resource access control in OpenSearch. + * TODO: At present it contains multiple duplicates, which will be address in a fast follow PR. + * + * @opensearch.experimental + */ +package org.opensearch.security.common; diff --git a/common/src/main/java/org/opensearch/security/common/resources/ResourceAccessHandler.java b/common/src/main/java/org/opensearch/security/common/resources/ResourceAccessHandler.java new file mode 100644 index 0000000000..3912001aa1 --- /dev/null +++ b/common/src/main/java/org/opensearch/security/common/resources/ResourceAccessHandler.java @@ -0,0 +1,549 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.common.resources; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import org.opensearch.action.StepListener; +import org.opensearch.common.util.concurrent.ThreadContext; +import org.opensearch.core.action.ActionListener; +import org.opensearch.security.common.auth.UserSubjectImpl; +import org.opensearch.security.common.configuration.AdminDNs; +import org.opensearch.security.common.support.ConfigConstants; +import org.opensearch.security.common.user.User; +import org.opensearch.security.spi.resources.Resource; +import org.opensearch.security.spi.resources.ResourceParser; +import org.opensearch.security.spi.resources.exceptions.ResourceSharingException; +import org.opensearch.security.spi.resources.sharing.Recipient; +import org.opensearch.security.spi.resources.sharing.RecipientType; +import org.opensearch.security.spi.resources.sharing.RecipientTypeRegistry; +import org.opensearch.security.spi.resources.sharing.ResourceSharing; +import org.opensearch.security.spi.resources.sharing.ShareWith; +import org.opensearch.security.spi.resources.sharing.SharedWithScope; +import org.opensearch.threadpool.ThreadPool; + +/** + * This class handles resource access permissions for users, roles and backend-roles. + * It provides methods to check if a user has permission to access a resource + * based on the resource sharing configuration. + * + * @opensearch.experimental + */ +public class ResourceAccessHandler { + private static final Logger LOGGER = LogManager.getLogger(ResourceAccessHandler.class); + + private final ThreadContext threadContext; + private final ResourceSharingIndexHandler resourceSharingIndexHandler; + private final AdminDNs adminDNs; + + public ResourceAccessHandler( + final ThreadPool threadPool, + final ResourceSharingIndexHandler resourceSharingIndexHandler, + AdminDNs adminDns + ) { + this.threadContext = threadPool.getThreadContext(); + this.resourceSharingIndexHandler = resourceSharingIndexHandler; + this.adminDNs = adminDns; + } + + /** + * Initializes the recipient types for users, roles, and backend roles. + * These recipient types are used to identify the types of recipients for resource sharing. + */ + public void initializeRecipientTypes() { + RecipientTypeRegistry.registerRecipientType(Recipient.USERS.getName(), new RecipientType(Recipient.USERS.getName())); + RecipientTypeRegistry.registerRecipientType(Recipient.ROLES.getName(), new RecipientType(Recipient.ROLES.getName())); + RecipientTypeRegistry.registerRecipientType( + Recipient.BACKEND_ROLES.getName(), + new RecipientType(Recipient.BACKEND_ROLES.getName()) + ); + } + + /** + * Returns a set of accessible resource IDs for the current user within the specified resource index. + * + * @param resourceIndex The resource index to check for accessible resources. + * @param listener The listener to be notified with the set of accessible resource IDs. + */ + public void getAccessibleResourceIdsForCurrentUser(String resourceIndex, ActionListener> listener) { + final UserSubjectImpl userSubject = (UserSubjectImpl) threadContext.getPersistent( + ConfigConstants.OPENDISTRO_SECURITY_AUTHENTICATED_USER + ); + final User user = (userSubject == null) ? null : userSubject.getUser(); + + // If no user is authenticated, return an empty set + if (user == null) { + LOGGER.warn("Unable to fetch user details. User is null."); + listener.onResponse(Collections.emptySet()); + return; + } + + LOGGER.debug("Listing accessible resources within the resource index {} for user: {}", resourceIndex, user.getName()); + + // 2. If the user is admin, simply fetch all resources + if (adminDNs.isAdmin(user)) { + loadAllResources(resourceIndex, ActionListener.wrap(listener::onResponse, listener::onFailure)); + return; + } + + // StepListener for the user’s "own" resources + StepListener> ownResourcesListener = new StepListener<>(); + + // StepListener for resources shared with the user’s name + StepListener> userNameResourcesListener = new StepListener<>(); + + // StepListener for resources shared with the user’s roles + StepListener> rolesResourcesListener = new StepListener<>(); + + // StepListener for resources shared with the user’s backend roles + StepListener> backendRolesResourcesListener = new StepListener<>(); + + // Load own resources for the user. + loadOwnResources(resourceIndex, user.getName(), ownResourcesListener); + + // Load resources shared with the user by its name. + ownResourcesListener.whenComplete( + ownResources -> loadSharedWithResources( + resourceIndex, + Set.of(user.getName()), + Recipient.USERS.getName(), + userNameResourcesListener + ), + listener::onFailure + ); + + // Load resources shared with the user’s roles. + userNameResourcesListener.whenComplete( + userNameResources -> loadSharedWithResources( + resourceIndex, + user.getSecurityRoles(), + Recipient.ROLES.getName(), + rolesResourcesListener + ), + listener::onFailure + ); + + // Load resources shared with the user’s backend roles. + rolesResourcesListener.whenComplete( + rolesResources -> loadSharedWithResources( + resourceIndex, + user.getRoles(), + Recipient.BACKEND_ROLES.getName(), + backendRolesResourcesListener + ), + listener::onFailure + ); + + // Combine all results and pass them back to the original listener. + backendRolesResourcesListener.whenComplete(backendRolesResources -> { + Set allResources = new HashSet<>(); + + // Retrieve results from each StepListener + allResources.addAll(ownResourcesListener.result()); + allResources.addAll(userNameResourcesListener.result()); + allResources.addAll(rolesResourcesListener.result()); + allResources.addAll(backendRolesResourcesListener.result()); + + LOGGER.debug("Found {} accessible resources for user {}", allResources.size(), user.getName()); + listener.onResponse(allResources); + }, listener::onFailure); + } + + /** + * Returns a set of accessible resources for the current user within the specified resource index. + * + * @param resourceIndex The resource index to check for accessible resources. + * @param listener The listener to be notified with the set of accessible resources. + */ + @SuppressWarnings("unchecked") + public void getAccessibleResourcesForCurrentUser(String resourceIndex, ActionListener> listener) { + try { + validateArguments(resourceIndex); + + ResourceParser parser = ResourcePluginInfo.getInstance().getResourceProviders().get(resourceIndex).resourceParser(); + + StepListener> resourceIdsListener = new StepListener<>(); + StepListener> resourcesListener = new StepListener<>(); + + // Fetch resource IDs + getAccessibleResourceIdsForCurrentUser(resourceIndex, resourceIdsListener); + + // Fetch docs + resourceIdsListener.whenComplete(resourceIds -> { + if (resourceIds.isEmpty()) { + // No accessible resources => immediately respond with empty set + listener.onResponse(Collections.emptySet()); + } else { + // Fetch the resource documents asynchronously + this.resourceSharingIndexHandler.getResourceDocumentsFromIds(resourceIds, resourceIndex, parser, resourcesListener); + } + }, listener::onFailure); + + // Send final response + resourcesListener.whenComplete( + listener::onResponse, + ex -> listener.onFailure(new ResourceSharingException("Failed to get accessible resources: " + ex.getMessage(), ex)) + ); + } catch (Exception e) { + LOGGER.warn("Failed to process accessible resources request: {}", e.getMessage()); + listener.onFailure(new ResourceSharingException("Failed to process accessible resources request: " + e.getMessage(), e)); + } + } + + /** + * Checks whether current user has given permission (scope) to access given resource. + * + * @param resourceId The resource ID to check access for. + * @param resourceIndex The resource index containing the resource. + * @param scopes The permission scope(s) to check. + * @param listener The listener to be notified with the permission check result. + */ + public void hasPermission(String resourceId, String resourceIndex, Set scopes, ActionListener listener) { + validateArguments(resourceId, resourceIndex, scopes); + + final UserSubjectImpl userSubject = (UserSubjectImpl) threadContext.getPersistent( + ConfigConstants.OPENDISTRO_SECURITY_AUTHENTICATED_USER + ); + final User user = (userSubject == null) ? null : userSubject.getUser(); + + if (user == null) { + LOGGER.warn("No authenticated user found. Access to resource {} is not authorized", resourceId); + listener.onResponse(false); + return; + } + + LOGGER.debug("Checking if user '{}' has '{}' permission to resource '{}'", user.getName(), scopes.toString(), resourceId); + + if (adminDNs.isAdmin(user)) { + LOGGER.debug( + "User '{}' is admin, automatically granted '{}' permission on '{}'", + user.getName(), + scopes.toString(), + resourceId + ); + listener.onResponse(true); + return; + } + + Set userRoles = new HashSet<>(user.getSecurityRoles()); + Set userBackendRoles = new HashSet<>(user.getRoles()); + + this.resourceSharingIndexHandler.fetchDocumentById(resourceIndex, resourceId, ActionListener.wrap(document -> { + if (document == null) { + LOGGER.warn("Resource '{}' not found in index '{}'", resourceId, resourceIndex); + listener.onFailure(new ResourceSharingException("Resource " + resourceId + " not found in index " + resourceIndex)); + return; + } + + // All public entities are designated with "*" + userRoles.add("*"); + userBackendRoles.add("*"); + if (isOwnerOfResource(document, user.getName()) + || isSharedWithEveryone(document) + || isSharedWithEntity(document, Recipient.USERS, Set.of(user.getName(), "*"), scopes) + || isSharedWithEntity(document, Recipient.ROLES, userRoles, scopes) + || isSharedWithEntity(document, Recipient.BACKEND_ROLES, userBackendRoles, scopes)) { + + LOGGER.debug("User '{}' has '{}' permission to resource '{}'", user.getName(), scopes.toString(), resourceId); + listener.onResponse(true); + } else { + LOGGER.debug("User '{}' does not have '{}' permission to resource '{}'", user.getName(), scopes.toString(), resourceId); + listener.onResponse(false); + } + }, exception -> { + LOGGER.error( + "Failed to fetch resource sharing document for resource '{}' in index '{}': {}", + resourceId, + resourceIndex, + exception.getMessage() + ); + listener.onFailure(exception); + })); + } + + /** + * Shares a resource with the specified users, roles, and backend roles. + * + * @param resourceId The resource ID to share. + * @param resourceIndex The index where resource is store + * @param shareWith The users, roles, and backend roles as well as scope to share the resource with. + * @param listener The listener to be notified with the updated ResourceSharing document. + */ + public void shareWith(String resourceId, String resourceIndex, ShareWith shareWith, ActionListener listener) { + validateArguments(resourceId, resourceIndex, shareWith); + + final UserSubjectImpl userSubject = (UserSubjectImpl) threadContext.getPersistent( + ConfigConstants.OPENDISTRO_SECURITY_AUTHENTICATED_USER + ); + final User user = (userSubject == null) ? null : userSubject.getUser(); + + if (user == null) { + LOGGER.warn("No authenticated user found. Failed to share resource {}", resourceId); + listener.onFailure(new ResourceSharingException("No authenticated user found. Failed to share resource " + resourceId)); + return; + } + + LOGGER.debug("Sharing resource {} created by {} with {}", resourceId, user.getName(), shareWith.toString()); + + boolean isAdmin = adminDNs.isAdmin(user); + + this.resourceSharingIndexHandler.updateResourceSharingInfo( + resourceId, + resourceIndex, + user.getName(), + shareWith, + isAdmin, + ActionListener.wrap(updatedResourceSharing -> { + LOGGER.debug("Successfully shared resource {} with {}", resourceId, shareWith.toString()); + listener.onResponse(updatedResourceSharing); + }, e -> { + LOGGER.error("Failed to share resource {} with {}: {}", resourceId, shareWith.toString(), e.getMessage()); + listener.onFailure(e); + }) + ); + } + + /** + * Revokes access to a resource for the specified users, roles, and backend roles. + * + * @param resourceId The resource ID to revoke access from. + * @param resourceIndex The index where resource is store + * @param revokeAccess The users, roles, and backend roles to revoke access for. + * @param scopes The permission scopes to revoke access for. + * @param listener The listener to be notified with the updated ResourceSharing document. + */ + public void revokeAccess( + String resourceId, + String resourceIndex, + Map> revokeAccess, + Set scopes, + ActionListener listener + ) { + validateArguments(resourceId, resourceIndex, revokeAccess, scopes); + + final UserSubjectImpl userSubject = (UserSubjectImpl) threadContext.getPersistent( + ConfigConstants.OPENDISTRO_SECURITY_AUTHENTICATED_USER + ); + final User user = (userSubject == null) ? null : userSubject.getUser(); + + if (user == null) { + LOGGER.warn("No authenticated user found. Failed to revoker access to resource {}", resourceId); + listener.onFailure( + new ResourceSharingException("No authenticated user found. Failed to revoke access to resource {}" + resourceId) + ); + return; + } + + LOGGER.debug("User {} revoking access to resource {} for {} for scopes {}.", user.getName(), resourceId, revokeAccess, scopes); + + boolean isAdmin = adminDNs.isAdmin(user); + + this.resourceSharingIndexHandler.revokeAccess( + resourceId, + resourceIndex, + revokeAccess, + scopes, + user.getName(), + isAdmin, + ActionListener.wrap(listener::onResponse, exception -> { + LOGGER.error("Failed to revoke access to resource {} in index {}: {}", resourceId, resourceIndex, exception.getMessage()); + listener.onFailure(exception); + }) + ); + } + + /** + * Deletes a resource sharing record by its ID and the resource index it belongs to. + * + * @param resourceId The resource ID to delete. + * @param resourceIndex The resource index containing the resource. + * @param listener The listener to be notified with the deletion result. + */ + public void deleteResourceSharingRecord(String resourceId, String resourceIndex, ActionListener listener) { + try { + validateArguments(resourceId, resourceIndex); + + LOGGER.debug("Deleting resource sharing record for resource {} in {}", resourceId, resourceIndex); + + StepListener deleteDocListener = new StepListener<>(); + resourceSharingIndexHandler.deleteResourceSharingRecord(resourceId, resourceIndex, deleteDocListener); + deleteDocListener.whenComplete(listener::onResponse, listener::onFailure); + + } catch (Exception e) { + LOGGER.error("Failed to delete resource sharing record for resource {}", resourceId, e); + listener.onFailure(e); + } + } + + /** + * Deletes all resource sharing records for the current user. + * + * @param listener The listener to be notified with the deletion result. + */ + public void deleteAllResourceSharingRecordsForCurrentUser(ActionListener listener) { + final UserSubjectImpl userSubject = (UserSubjectImpl) threadContext.getPersistent( + ConfigConstants.OPENDISTRO_SECURITY_AUTHENTICATED_USER + ); + final User user = (userSubject == null) ? null : userSubject.getUser(); + + if (user == null) { + listener.onFailure(new ResourceSharingException("No authenticated user available.")); + return; + } + + LOGGER.debug("Deleting all resource sharing records for user {}", user.getName()); + + resourceSharingIndexHandler.deleteAllRecordsForUser(user.getName(), ActionListener.wrap(listener::onResponse, exception -> { + LOGGER.error( + "Failed to delete all resource sharing records for user {}: {}", + user.getName(), + exception.getMessage(), + exception + ); + listener.onFailure(exception); + })); + } + + /** + * Loads all resources within the specified resource index. + * + * @param resourceIndex The resource index to load resources from. + * @param listener The listener to be notified with the set of resource IDs. + */ + private void loadAllResources(String resourceIndex, ActionListener> listener) { + this.resourceSharingIndexHandler.fetchAllDocuments(resourceIndex, listener); + } + + /** + * Loads resources owned by the specified user within the given resource index. + * + * @param resourceIndex The resource index to load resources from. + * @param userName The username of the owner. + * @param listener The listener to be notified with the set of resource IDs. + */ + private void loadOwnResources(String resourceIndex, String userName, ActionListener> listener) { + this.resourceSharingIndexHandler.fetchDocumentsByField(resourceIndex, "created_by.user", userName, listener); + } + + /** + * Loads resources shared with the specified entities within the given resource index, including public resources. + * + * @param resourceIndex The resource index to load resources from. + * @param entities The set of entities to check for shared resources. + * @param recipientType The type of entity (e.g., users, roles, backend_roles). + * @param listener The listener to be notified with the set of resource IDs. + */ + private void loadSharedWithResources( + String resourceIndex, + Set entities, + String recipientType, + ActionListener> listener + ) { + Set entitiesCopy = new HashSet<>(entities); + // To allow "public" resources to be matched for any user, role, backend_role + entitiesCopy.add("*"); + this.resourceSharingIndexHandler.fetchDocumentsForAllScopes(resourceIndex, entitiesCopy, recipientType, listener); + } + + /** + * Checks if the given resource is owned by the specified user. + * + * @param document The ResourceSharing document to check. + * @param userName The username to check ownership against. + * @return True if the resource is owned by the user, false otherwise. + */ + private boolean isOwnerOfResource(ResourceSharing document, String userName) { + return document.getCreatedBy() != null && document.getCreatedBy().getCreator().equals(userName); + } + + /** + * Checks if the given resource is shared with the specified entities and scope. + * + * @param document The ResourceSharing document to check. + * @param recipient The recipient entity + * @param entities The set of entities to check for sharing. + * @param scopes The permission scope(s) to check. + * @return True if the resource is shared with the entities and scope, false otherwise. + */ + private boolean isSharedWithEntity(ResourceSharing document, Recipient recipient, Set entities, Set scopes) { + for (String entity : entities) { + if (checkSharing(document, recipient, entity, scopes)) { + return true; + } + } + return false; + } + + /** + * Checks if the given resource is shared with everyone, i.e. the scope is named "*" + * + * @param document The ResourceSharing document to check. + * @return True if the resource is shared with everyone, false otherwise. + */ + private boolean isSharedWithEveryone(ResourceSharing document) { + return document.getShareWith() != null + && document.getShareWith().getSharedWithScopes().stream().anyMatch(sharedWithScope -> sharedWithScope.getScope().equals("*")); + } + + /** + * Checks if the given resource is shared with the specified entity and scope. + * + * @param document The ResourceSharing document to check. + * @param recipient The recipient entity + * @param entity The entity to check for sharing. + * @param scopes The permission scope(s) to check. + * @return True if the resource is shared with the entity and scope, false otherwise. + */ + private boolean checkSharing(ResourceSharing document, Recipient recipient, String entity, Set scopes) { + if (document.getShareWith() == null) { + return false; + } + + return document.getShareWith() + .getSharedWithScopes() + .stream() + .filter(sharedWithScope -> scopes.contains(sharedWithScope.getScope())) + .findFirst() + .map(sharedWithScope -> { + SharedWithScope.ScopeRecipients scopePermissions = sharedWithScope.getSharedWithPerScope(); + Map> recipients = scopePermissions.getRecipients(); + + return switch (recipient) { + case Recipient.USERS, Recipient.ROLES, Recipient.BACKEND_ROLES -> recipients.get( + RecipientTypeRegistry.fromValue(recipient.getName()) + ).contains(entity); + }; + }) + .orElse(false); // Return false if no matching scope is found + } + + private void validateArguments(Object... args) { + if (args == null) { + throw new IllegalArgumentException("Arguments cannot be null"); + } + for (Object arg : args) { + if (arg == null) { + throw new IllegalArgumentException("Argument cannot be null"); + } + // Additional check for String type arguments + if (arg instanceof String && ((String) arg).trim().isEmpty()) { + throw new IllegalArgumentException("Arguments cannot be empty"); + } + } + } +} diff --git a/common/src/main/java/org/opensearch/security/common/resources/ResourceIndexListener.java b/common/src/main/java/org/opensearch/security/common/resources/ResourceIndexListener.java new file mode 100644 index 0000000000..4b502096b4 --- /dev/null +++ b/common/src/main/java/org/opensearch/security/common/resources/ResourceIndexListener.java @@ -0,0 +1,123 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.security.common.resources; + +import java.io.IOException; +import java.util.Objects; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import org.opensearch.core.action.ActionListener; +import org.opensearch.core.index.shard.ShardId; +import org.opensearch.index.engine.Engine; +import org.opensearch.index.shard.IndexingOperationListener; +import org.opensearch.security.common.auth.UserSubjectImpl; +import org.opensearch.security.common.support.ConfigConstants; +import org.opensearch.security.common.user.User; +import org.opensearch.security.spi.resources.sharing.CreatedBy; +import org.opensearch.security.spi.resources.sharing.Creator; +import org.opensearch.security.spi.resources.sharing.ResourceSharing; +import org.opensearch.threadpool.ThreadPool; +import org.opensearch.transport.client.Client; + +/** + * This class implements an index operation listener for operations performed on resources stored in plugin's indices. + * + * @opensearch.experimental + */ +public class ResourceIndexListener implements IndexingOperationListener { + + private static final Logger log = LogManager.getLogger(ResourceIndexListener.class); + private static final ResourceIndexListener INSTANCE = new ResourceIndexListener(); + private ResourceSharingIndexHandler resourceSharingIndexHandler; + + private boolean initialized; + private ThreadPool threadPool; + + private ResourceIndexListener() {} + + public static ResourceIndexListener getInstance() { + return ResourceIndexListener.INSTANCE; + } + + public void initialize(ThreadPool threadPool, Client client) { + if (initialized) { + return; + } + initialized = true; + this.threadPool = threadPool; + this.resourceSharingIndexHandler = new ResourceSharingIndexHandler( + ResourceSharingConstants.OPENSEARCH_RESOURCE_SHARING_INDEX, + client, + threadPool + ); + } + + public boolean isInitialized() { + return initialized; + } + + /** + * Creates a resource sharing entry for the newly created resource. + */ + @Override + public void postIndex(ShardId shardId, Engine.Index index, Engine.IndexResult result) { + String resourceIndex = shardId.getIndexName(); + log.debug("postIndex called on {}", resourceIndex); + + String resourceId = index.id(); + + // Only proceed if this was a create operation + if (!result.isCreated()) { + log.debug("Skipping resource sharing entry creation as this was an update operation for resource {}", resourceId); + return; + } + + final UserSubjectImpl userSubject = (UserSubjectImpl) threadPool.getThreadContext() + .getPersistent(ConfigConstants.OPENDISTRO_SECURITY_AUTHENTICATED_USER); + final User user = userSubject.getUser(); + + try { + Objects.requireNonNull(user); + ResourceSharing sharing = this.resourceSharingIndexHandler.indexResourceSharing( + resourceId, + resourceIndex, + new CreatedBy(Creator.USER, user.getName()), + null + ); + log.debug( + "Successfully created a resource sharing entry {} for resource {} within index {}", + sharing, + resourceId, + resourceIndex + ); + } catch (IOException e) { + log.debug("Failed to create a resource sharing entry for resource: {}", resourceId, e); + } + } + + /** + * Deletes the resource sharing entry for the deleted resource. + */ + @Override + public void postDelete(ShardId shardId, Engine.Delete delete, Engine.DeleteResult result) { + String resourceIndex = shardId.getIndexName(); + log.debug("postDelete called on {}", resourceIndex); + + String resourceId = delete.id(); + this.resourceSharingIndexHandler.deleteResourceSharingRecord(resourceId, resourceIndex, ActionListener.wrap(deleted -> { + if (deleted) { + log.debug("Successfully deleted resource sharing entry for resource {}", resourceId); + } else { + log.debug("No resource sharing entry found for resource {}", resourceId); + } + }, exception -> log.error("Failed to delete resource sharing entry for resource {}", resourceId, exception))); + } +} diff --git a/common/src/main/java/org/opensearch/security/common/resources/ResourcePluginInfo.java b/common/src/main/java/org/opensearch/security/common/resources/ResourcePluginInfo.java new file mode 100644 index 0000000000..fde006c198 --- /dev/null +++ b/common/src/main/java/org/opensearch/security/common/resources/ResourcePluginInfo.java @@ -0,0 +1,58 @@ +package org.opensearch.security.common.resources; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; + +/** + * This class provides information about resource plugins and their associated resource providers and indices. + * It follows the Singleton pattern to ensure that only one instance of the class exists. + * + * @opensearch.experimental + */ +public class ResourcePluginInfo { + private static ResourcePluginInfo INSTANCE; + + private final Map resourceProviderMap = new HashMap<>(); + private final Set resourceIndices = new HashSet<>(); + + private ResourcePluginInfo() {} + + public static ResourcePluginInfo getInstance() { + if (INSTANCE == null) { + INSTANCE = new ResourcePluginInfo(); + } + return INSTANCE; + } + + public void setResourceProviders(Map providerMap) { + resourceProviderMap.clear(); + resourceProviderMap.putAll(providerMap); + } + + public void setResourceIndices(Set indices) { + resourceIndices.clear(); + resourceIndices.addAll(indices); + } + + public Map getResourceProviders() { + return ImmutableMap.copyOf(resourceProviderMap); + } + + public Set getResourceIndices() { + return ImmutableSet.copyOf(resourceIndices); + } + + // TODO following should be removed once core test framework allows loading extended classes + public Map getResourceProvidersMutable() { + return resourceProviderMap; + } + + public Set getResourceIndicesMutable() { + return resourceIndices; + } +} diff --git a/common/src/main/java/org/opensearch/security/common/resources/ResourceProvider.java b/common/src/main/java/org/opensearch/security/common/resources/ResourceProvider.java new file mode 100644 index 0000000000..b2537fc849 --- /dev/null +++ b/common/src/main/java/org/opensearch/security/common/resources/ResourceProvider.java @@ -0,0 +1,21 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.security.common.resources; + +import org.opensearch.security.spi.resources.ResourceParser; + +/** + * This record class represents a resource provider. + * It holds information about the resource type, resource index name, and a resource parser. + * + * @opensearch.experimental + */ +public record ResourceProvider(String resourceType, String resourceIndexName, ResourceParser resourceParser) { + +} diff --git a/common/src/main/java/org/opensearch/security/common/resources/ResourceSharingConstants.java b/common/src/main/java/org/opensearch/security/common/resources/ResourceSharingConstants.java new file mode 100644 index 0000000000..a1004566e5 --- /dev/null +++ b/common/src/main/java/org/opensearch/security/common/resources/ResourceSharingConstants.java @@ -0,0 +1,21 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ +package org.opensearch.security.common.resources; + +/** + * This class contains constants related to resource sharing in OpenSearch. + * + * @opensearch.experimental + */ +public class ResourceSharingConstants { + // Resource sharing index + public static final String OPENSEARCH_RESOURCE_SHARING_INDEX = ".opensearch_resource_sharing"; +} diff --git a/common/src/main/java/org/opensearch/security/common/resources/ResourceSharingIndexHandler.java b/common/src/main/java/org/opensearch/security/common/resources/ResourceSharingIndexHandler.java new file mode 100644 index 0000000000..8ff771d74e --- /dev/null +++ b/common/src/main/java/org/opensearch/security/common/resources/ResourceSharingIndexHandler.java @@ -0,0 +1,1402 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + */ +package org.opensearch.security.common.resources; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.Callable; + +import com.fasterxml.jackson.core.type.TypeReference; +import org.apache.commons.lang3.StringUtils; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import org.opensearch.action.DocWriteRequest; +import org.opensearch.action.StepListener; +import org.opensearch.action.admin.indices.create.CreateIndexRequest; +import org.opensearch.action.admin.indices.create.CreateIndexResponse; +import org.opensearch.action.get.MultiGetItemResponse; +import org.opensearch.action.get.MultiGetRequest; +import org.opensearch.action.index.IndexRequest; +import org.opensearch.action.index.IndexResponse; +import org.opensearch.action.search.ClearScrollRequest; +import org.opensearch.action.search.SearchRequest; +import org.opensearch.action.search.SearchResponse; +import org.opensearch.action.search.SearchScrollRequest; +import org.opensearch.action.support.WriteRequest; +import org.opensearch.common.unit.TimeValue; +import org.opensearch.common.util.concurrent.ThreadContext; +import org.opensearch.common.xcontent.LoggingDeprecationHandler; +import org.opensearch.common.xcontent.XContentFactory; +import org.opensearch.common.xcontent.XContentHelper; +import org.opensearch.common.xcontent.XContentType; +import org.opensearch.core.action.ActionListener; +import org.opensearch.core.common.bytes.BytesReference; +import org.opensearch.core.xcontent.NamedXContentRegistry; +import org.opensearch.core.xcontent.ToXContent; +import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.core.xcontent.XContentParser; +import org.opensearch.index.IndexNotFoundException; +import org.opensearch.index.query.BoolQueryBuilder; +import org.opensearch.index.query.MultiMatchQueryBuilder; +import org.opensearch.index.query.QueryBuilders; +import org.opensearch.index.reindex.BulkByScrollResponse; +import org.opensearch.index.reindex.DeleteByQueryAction; +import org.opensearch.index.reindex.DeleteByQueryRequest; +import org.opensearch.index.reindex.UpdateByQueryAction; +import org.opensearch.index.reindex.UpdateByQueryRequest; +import org.opensearch.script.Script; +import org.opensearch.script.ScriptType; +import org.opensearch.search.Scroll; +import org.opensearch.search.SearchHit; +import org.opensearch.search.builder.SearchSourceBuilder; +import org.opensearch.security.common.DefaultObjectMapper; +import org.opensearch.security.spi.resources.Resource; +import org.opensearch.security.spi.resources.ResourceParser; +import org.opensearch.security.spi.resources.exceptions.ResourceSharingException; +import org.opensearch.security.spi.resources.sharing.CreatedBy; +import org.opensearch.security.spi.resources.sharing.RecipientType; +import org.opensearch.security.spi.resources.sharing.ResourceSharing; +import org.opensearch.security.spi.resources.sharing.ShareWith; +import org.opensearch.threadpool.ThreadPool; +import org.opensearch.transport.client.Client; + +import static org.opensearch.common.xcontent.XContentFactory.jsonBuilder; + +/** + * This class handles the creation and management of the resource sharing index. + * It provides methods to create the index, index resource sharing entries along with updates and deletion, retrieve shared resources. + * + * @opensearch.experimental + */ +public class ResourceSharingIndexHandler { + + private static final Logger LOGGER = LogManager.getLogger(ResourceSharingIndexHandler.class); + + private final Client client; + + private final String resourceSharingIndex; + + private final ThreadPool threadPool; + + public ResourceSharingIndexHandler(final String indexName, final Client client, final ThreadPool threadPool) { + this.resourceSharingIndex = indexName; + this.client = client; + this.threadPool = threadPool; + } + + public final static Map INDEX_SETTINGS = Map.of( + "index.number_of_shards", + 1, + "index.auto_expand_replicas", + "0-all", + "index.hidden", + "true" + ); + + /** + * Creates the resource sharing index if it doesn't already exist. + * This method initializes the index with predefined mappings and settings + * for storing resource sharing information. + * The index will be created with the following structure: + * - source_idx (keyword): The source index containing the original document + * - resource_id (keyword): The ID of the shared resource + * - created_by (object): Information about the user who created the sharing + * - user (keyword): Username of the creator + * - share_with (object): Access control configuration for shared resources + * - [scope] (object): Name of the scope + * - users (array): List of users with access + * - roles (array): List of roles with access + * - backend_roles (array): List of backend roles with access + * + * @throws RuntimeException if there are issues reading/writing index settings + * or communicating with the cluster + */ + + public void createResourceSharingIndexIfAbsent(Callable callable) { + // TODO: Once stashContext is replaced with switchContext this call will have to be modified + try (ThreadContext.StoredContext ctx = this.threadPool.getThreadContext().stashContext()) { + + CreateIndexRequest cir = new CreateIndexRequest(resourceSharingIndex).settings(INDEX_SETTINGS).waitForActiveShards(1); + ActionListener cirListener = ActionListener.wrap(response -> { + LOGGER.info("Resource sharing index {} created.", resourceSharingIndex); + if (callable != null) { + callable.call(); + } + }, (failResponse) -> { + /* Index already exists, ignore and continue */ + LOGGER.info("Index {} already exists.", resourceSharingIndex); + try { + if (callable != null) { + callable.call(); + } + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + this.client.admin().indices().create(cir, cirListener); + } + } + + /** + * Creates or updates a resource sharing record in the dedicated resource sharing index. + * This method handles the persistence of sharing metadata for resources, including + * the creator information and sharing permissions. + * + * @param resourceId The unique identifier of the resource being shared + * @param resourceIndex The source index where the original resource is stored + * @param createdBy Object containing information about the user creating/updating the sharing + * @param shareWith Object containing the sharing permissions' configuration. Can be null for initial creation. + * When provided, it should contain the access control settings for different groups: + * { + * "scope": { + * "users": ["user1", "user2"], + * "roles": ["role1", "role2"], + * "backend_roles": ["backend_role1"] + * } + * } + * @return ResourceSharing Returns resourceSharing object if the operation was successful, null otherwise + * @throws IOException if there are issues with index operations or JSON processing + */ + public ResourceSharing indexResourceSharing(String resourceId, String resourceIndex, CreatedBy createdBy, ShareWith shareWith) + throws IOException { + // TODO: Once stashContext is replaced with switchContext this call will have to be modified + try (ThreadContext.StoredContext ctx = this.threadPool.getThreadContext().stashContext()) { + ResourceSharing entry = new ResourceSharing(resourceIndex, resourceId, createdBy, shareWith); + + IndexRequest ir = client.prepareIndex(resourceSharingIndex) + .setRefreshPolicy(WriteRequest.RefreshPolicy.IMMEDIATE) + .setSource(entry.toXContent(jsonBuilder(), ToXContent.EMPTY_PARAMS)) + .setOpType(DocWriteRequest.OpType.CREATE) // only create if an entry doesn't exist + .request(); + + ActionListener irListener = ActionListener.wrap( + idxResponse -> LOGGER.info( + "Successfully created {} entry for resource {} in index {}.", + resourceSharingIndex, + resourceId, + resourceIndex + ), + (failResponse) -> { + LOGGER.error(failResponse.getMessage()); + } + ); + client.index(ir, irListener); + return entry; + } catch (Exception e) { + LOGGER.error("Failed to create {} entry.", resourceSharingIndex, e); + throw new ResourceSharingException("Failed to create " + resourceSharingIndex + " entry.", e); + } + } + + /** + * Fetches all resource sharing records that match the specified system index. This method retrieves + * a get of resource IDs associated with the given system index from the resource sharing index. + * + *

The method executes the following steps: + *

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

Example query structure: + *

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

The method executes the following steps: + *

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

Example query structure: + *

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

The method executes the following steps: + *

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

Example query structure: + *

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

The method executes the following steps: + *

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

Example query structure: + *

+     * {
+     *   "query": {
+     *     "bool": {
+     *       "must": [
+     *         { "term": { "source_idx": "system_index_value" } },
+     *         { "term": { "field_name": "field_value" } }
+     *       ]
+     *     }
+     *   },
+     *   "_source": ["resource_id"],
+     *   "size": 1000
+     * }
+     * 
+ * + * @param pluginIndex The source index to match against the source_idx field + * @param field The field name to search in. Must be a valid field in the index mapping + * @param value The value to match for the specified field. Performs exact term matching + * @param listener The listener to be notified when the operation completes. + * The listener receives a set of resource IDs as a result. + * @throws IllegalArgumentException if any parameter is null or empty + * @throws RuntimeException if the search operation fails, wrapping the underlying exception + * @apiNote This method: + *
    + *
  • Uses scroll API with 1-minute timeout for handling large result sets
  • + *
  • Performs exact term matching (not analyzed) on field values
  • + *
  • Processes results in batches of 1000 documents
  • + *
  • Uses source filtering to only fetch resource_id field
  • + *
  • Automatically cleans up scroll context after use
  • + *
+ *

+ * Example usage: + *

+     * Set resources = fetchDocumentsByField("myIndex", "status", "active");
+     * 
+ */ + public void fetchDocumentsByField(String pluginIndex, String field, String value, ActionListener> listener) { + if (StringUtils.isBlank(pluginIndex) || StringUtils.isBlank(field) || StringUtils.isBlank(value)) { + listener.onFailure(new IllegalArgumentException("pluginIndex, field, and value must not be null or empty")); + return; + } + + LOGGER.debug("Fetching documents from index: {}, where {} = {}", pluginIndex, field, value); + + Set resourceIds = new HashSet<>(); + final Scroll scroll = new Scroll(TimeValue.timeValueMinutes(1L)); + + // TODO: Once stashContext is replaced with switchContext this call will have to be modified + try (ThreadContext.StoredContext ctx = this.threadPool.getThreadContext().stashContext()) { + SearchRequest searchRequest = new SearchRequest(resourceSharingIndex); + searchRequest.scroll(scroll); + + BoolQueryBuilder boolQuery = QueryBuilders.boolQuery() + .must(QueryBuilders.termQuery("source_idx.keyword", pluginIndex)) + .must(QueryBuilders.termQuery(field + ".keyword", value)); + + executeSearchRequest(resourceIds, scroll, searchRequest, boolQuery, ActionListener.wrap(success -> { + LOGGER.debug("Found {} documents in {} where {} = {}", resourceIds.size(), resourceSharingIndex, field, value); + listener.onResponse(resourceIds); + }, exception -> { + LOGGER.error("Failed to fetch documents from {} where {} = {}", resourceSharingIndex, field, value, exception); + listener.onFailure(new RuntimeException("Failed to fetch documents: " + exception.getMessage(), exception)); + })); + } catch (Exception e) { + LOGGER.error("Failed to initiate fetch from {} where {} = {}", resourceSharingIndex, field, value, e); + listener.onFailure(new RuntimeException("Failed to initiate fetch: " + e.getMessage(), e)); + } + + } + + /** + * Fetches a specific resource sharing document by its resource ID and system index. + * This method performs an exact match search and parses the result into a ResourceSharing object. + * + *

The method executes the following steps: + *

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

Example query structure: + *

+     * {
+     *   "query": {
+     *     "bool": {
+     *       "must": [
+     *         { "term": { "source_idx": "resource_index_name" } },
+     *         { "term": { "resource_id": "resource_id_value" } }
+     *       ]
+     *     }
+     *   },
+     *   "size": 1
+     * }
+     * 
+ * + * @param pluginIndex The source index to match against the source_idx field + * @param resourceId The resource ID to fetch. Must exactly match the resource_id field + * @param listener The listener to be notified when the operation completes. + * The listener receives the parsed ResourceSharing object or null if not found + * @throws IllegalArgumentException if pluginIndexName or resourceId is null or empty + * @throws RuntimeException if the search operation fails or parsing errors occur, + * wrapping the underlying exception + * @apiNote This method: + *
    + *
  • Uses term queries for exact matching
  • + *
  • Expects only one matching document per resource ID
  • + *
  • Uses XContent parsing for consistent object creation
  • + *
  • Returns null instead of throwing exceptions for non-existent documents
  • + *
  • Provides detailed logging for troubleshooting
  • + *
+ *

+ * Example usage: + *

+     * ResourceSharing sharing = fetchDocumentById("myIndex", "resource123");
+     * if (sharing != null) {
+     *     // Process the resource sharing object
+     * }
+     * 
+ */ + public void fetchDocumentById(String pluginIndex, String resourceId, ActionListener listener) { + if (StringUtils.isBlank(pluginIndex) || StringUtils.isBlank(resourceId)) { + listener.onFailure(new IllegalArgumentException("pluginIndex and resourceId must not be null or empty")); + return; + } + LOGGER.debug("Fetching document from index: {}, resourceId: {}", pluginIndex, resourceId); + + try (ThreadContext.StoredContext ctx = this.threadPool.getThreadContext().stashContext()) { + BoolQueryBuilder boolQuery = QueryBuilders.boolQuery() + .must(QueryBuilders.termQuery("source_idx.keyword", pluginIndex)) + .must(QueryBuilders.termQuery("resource_id.keyword", resourceId)); + + SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder().query(boolQuery).size(1); // There is only one document for + // a single resource + + SearchRequest searchRequest = new SearchRequest(resourceSharingIndex).source(searchSourceBuilder); + + client.search(searchRequest, new ActionListener<>() { + @Override + public void onResponse(SearchResponse searchResponse) { + try { + SearchHit[] hits = searchResponse.getHits().getHits(); + if (hits.length == 0) { + LOGGER.debug("No document found for resourceId: {} in index: {}", resourceId, pluginIndex); + listener.onResponse(null); + return; + } + + SearchHit hit = hits[0]; + try ( + XContentParser parser = XContentType.JSON.xContent() + .createParser(NamedXContentRegistry.EMPTY, LoggingDeprecationHandler.INSTANCE, hit.getSourceAsString()) + ) { + parser.nextToken(); + ResourceSharing resourceSharing = ResourceSharing.fromXContent(parser); + + LOGGER.debug("Successfully fetched document for resourceId: {} from index: {}", resourceId, pluginIndex); + + listener.onResponse(resourceSharing); + } + } catch (Exception e) { + LOGGER.error("Failed to parse document for resourceId: {} from index: {}", resourceId, pluginIndex, e); + listener.onFailure( + new ResourceSharingException( + "Failed to parse document for resourceId: " + resourceId + " from index: " + pluginIndex, + e + ) + ); + } + } + + @Override + public void onFailure(Exception e) { + + LOGGER.error("Failed to fetch document for resourceId: {} from index: {}", resourceId, pluginIndex, e); + listener.onFailure( + new ResourceSharingException( + "Failed to fetch document for resourceId: " + resourceId + " from index: " + pluginIndex, + e + ) + ); + + } + }); + } catch (Exception e) { + LOGGER.error("Failed to fetch document for resourceId: {} from index: {}", resourceId, pluginIndex, e); + listener.onFailure( + new ResourceSharingException("Failed to fetch document for resourceId: " + resourceId + " from index: " + pluginIndex, e) + ); + } + } + + /** + * Updates the sharing configuration for an existing resource in the resource sharing index. + * NOTE: This method only grants new access. To remove access use {@link #revokeAccess(String, String, Map, Set, String, boolean, ActionListener)} + * This method modifies the sharing permissions for a specific resource identified by its + * resource ID and source index. + * + * @param resourceId The unique identifier of the resource whose sharing configuration needs to be updated + * @param sourceIdx The source index where the original resource is stored + * @param requestUserName The user requesting to revoke the resource + * @param shareWith Updated sharing configuration object containing access control settings: + * { + * "scope": { + * "users": ["user1", "user2"], + * "roles": ["role1", "role2"], + * "backend_roles": ["backend_role1"] + * } + * } + * @param isAdmin Boolean indicating whether the user requesting to revoke is an admin or not + * @param listener Listener to be notified when the operation completes + * @throws RuntimeException if there's an error during the update operation + */ + public void updateResourceSharingInfo( + String resourceId, + String sourceIdx, + String requestUserName, + ShareWith shareWith, + boolean isAdmin, + ActionListener listener + ) { + XContentBuilder builder; + Map shareWithMap; + try { + builder = XContentFactory.jsonBuilder(); + shareWith.toXContent(builder, ToXContent.EMPTY_PARAMS); + String json = builder.toString(); + shareWithMap = DefaultObjectMapper.readValue(json, new TypeReference<>() { + }); + } catch (IOException e) { + LOGGER.error("Failed to build json content", e); + listener.onFailure(new ResourceSharingException("Failed to build json content", e)); + return; + } + + StepListener fetchDocListener = new StepListener<>(); + StepListener updateScriptListener = new StepListener<>(); + StepListener updatedSharingListener = new StepListener<>(); + + // Fetch resource sharing doc + fetchDocumentById(sourceIdx, resourceId, fetchDocListener); + + // build update script + fetchDocListener.whenComplete(currentSharingInfo -> { + // Check if user can share. At present only the resource creator and admin is allowed to share the resource + if (!isAdmin && currentSharingInfo != null && !currentSharingInfo.getCreatedBy().getCreator().equals(requestUserName)) { + + LOGGER.error("User {} is not authorized to share resource {}", requestUserName, resourceId); + listener.onFailure( + new ResourceSharingException("User " + requestUserName + " is not authorized to share resource " + resourceId) + ); + } + + Script updateScript = new Script(ScriptType.INLINE, "painless", """ + if (ctx._source.share_with == null) { + ctx._source.share_with = [:]; + } + + for (def entry : params.shareWith.entrySet()) { + def scopeName = entry.getKey(); + def newScope = entry.getValue(); + + if (!ctx._source.share_with.containsKey(scopeName)) { + def newScopeEntry = [:]; + for (def field : newScope.entrySet()) { + if (field.getValue() != null && !field.getValue().isEmpty()) { + newScopeEntry[field.getKey()] = new HashSet(field.getValue()); + } + } + ctx._source.share_with[scopeName] = newScopeEntry; + } else { + def existingScope = ctx._source.share_with[scopeName]; + + for (def field : newScope.entrySet()) { + def fieldName = field.getKey(); + def newValues = field.getValue(); + + if (newValues != null && !newValues.isEmpty()) { + if (!existingScope.containsKey(fieldName)) { + existingScope[fieldName] = new HashSet(); + } + + for (def value : newValues) { + if (!existingScope[fieldName].contains(value)) { + existingScope[fieldName].add(value); + } + } + } + } + } + } + """, Collections.singletonMap("shareWith", shareWithMap)); + + updateByQueryResourceSharing(sourceIdx, resourceId, updateScript, updateScriptListener); + + }, listener::onFailure); + + // Build & return the updated ResourceSharing + updateScriptListener.whenComplete(success -> { + if (!success) { + LOGGER.error("Failed to update resource sharing info for resource {}", resourceId); + listener.onResponse(null); + return; + } + // TODO check if this should be replaced by Java in-memory computation (current intuition is that it will be more memory + // intensive to do it in java) + fetchDocumentById(sourceIdx, resourceId, updatedSharingListener); + }, listener::onFailure); + + updatedSharingListener.whenComplete(listener::onResponse, listener::onFailure); + } + + /** + * Revokes access for specified entities from a resource sharing document. This method removes the specified + * entities (users, roles, or backend roles) from the existing sharing configuration while preserving other + * sharing settings. + * + *

The method performs the following steps: + *

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

Example document structure: + *

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

The method executes the following steps: + *

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

Example document structure that will be deleted: + *

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

The method executes the following steps: + *

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

Example query structure: + *

+     * {
+     *   "query": {
+     *     "term": {
+     *       "created_by.user": "username"
+     *     }
+     *   }
+     * }
+     * 
+ * + * @param name The username to match against the created_by.user field + * @param listener The listener to be notified when the operation completes + * @throws IllegalArgumentException if name is null or empty + * @implNote Implementation details: + *
    + *
  • Uses DeleteByQueryRequest for efficient bulk deletion
  • + *
  • Sets refresh=true for immediate consistency
  • + *
  • Uses term query for exact username matching
  • + *
  • Implements comprehensive error handling and logging
  • + *
+ *

+ * Example usage: + *

+     * boolean success = deleteAllRecordsForUser("john.doe");
+     * if (success) {
+     *     // Records were successfully deleted
+     * } else {
+     *     // No matching records found or operation failed
+     * }
+     * 
+ */ + public void deleteAllRecordsForUser(String name, ActionListener listener) { + if (StringUtils.isBlank(name)) { + listener.onFailure(new IllegalArgumentException("Username must not be null or empty")); + return; + } + + LOGGER.debug("Deleting all records for user {} asynchronously", name); + + try (ThreadContext.StoredContext ctx = this.threadPool.getThreadContext().stashContext()) { + DeleteByQueryRequest deleteRequest = new DeleteByQueryRequest(resourceSharingIndex).setQuery( + QueryBuilders.termQuery("created_by.user", name) + ).setRefresh(true); + + client.execute(DeleteByQueryAction.INSTANCE, deleteRequest, new ActionListener<>() { + @Override + public void onResponse(BulkByScrollResponse response) { + long deletedDocs = response.getDeleted(); + if (deletedDocs > 0) { + LOGGER.debug("Successfully deleted {} documents created by user {}", deletedDocs, name); + listener.onResponse(true); + } else { + LOGGER.warn("No documents found for user {}", name); + // No documents matched => success = false + listener.onResponse(false); + } + } + + @Override + public void onFailure(Exception e) { + LOGGER.error("Failed to delete documents for user {}", name, e); + listener.onFailure(e); + } + }); + } catch (Exception e) { + LOGGER.error("Failed to delete documents for user {} before request submission", name, e); + listener.onFailure(e); + } + } + + /** + * Fetches all documents from the specified resource index and deserializes them into the specified class. + * + * @param resourceIndex The resource index to fetch documents from. + * @param parser The class to deserialize the documents into a specified type defined by the parser. + * @param listener The listener to be notified with the set of deserialized documents. + * @param The type of the deserialized documents. + */ + public void getResourceDocumentsFromIds( + Set resourceIds, + String resourceIndex, + ResourceParser parser, + ActionListener> listener + ) { + if (resourceIds.isEmpty()) { + listener.onResponse(new HashSet<>()); + return; + } + + // stashing Context to avoid permission issues in-case resourceIndex is a system index + try (ThreadContext.StoredContext ctx = this.threadPool.getThreadContext().stashContext()) { + MultiGetRequest request = new MultiGetRequest(); + for (String id : resourceIds) { + request.add(new MultiGetRequest.Item(resourceIndex, id)); + } + + client.multiGet(request, ActionListener.wrap(response -> { + Set result = new HashSet<>(); + try { + for (MultiGetItemResponse itemResponse : response.getResponses()) { + if (!itemResponse.isFailed() && itemResponse.getResponse().isExists()) { + BytesReference sourceAsString = itemResponse.getResponse().getSourceAsBytesRef(); + XContentParser xContentParser = XContentHelper.createParser( + NamedXContentRegistry.EMPTY, + LoggingDeprecationHandler.INSTANCE, + sourceAsString, + XContentType.JSON + ); + T resource = parser.parseXContent(xContentParser); + result.add(resource); + } + } + listener.onResponse(result); + } catch (Exception e) { + listener.onFailure(new ResourceSharingException("Failed to parse resources: " + e.getMessage(), e)); + } + }, e -> { + if (e instanceof IndexNotFoundException) { + LOGGER.error("Index {} does not exist", resourceIndex, e); + listener.onFailure(e); + } else { + LOGGER.error("Failed to fetch resources with ids {} from index {}", resourceIds, resourceIndex, e); + listener.onFailure(new ResourceSharingException("Failed to fetch resources: " + e.getMessage(), e)); + } + })); + } + } + + /** + * Updates resource sharing entries that match the specified source index and resource ID + * using the provided update script. This method performs an update-by-query operation + * in the resource sharing index. + * + *

The method executes the following steps: + *

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

Example document matching structure: + *

+     * {
+     *   "source_idx": "source_index_name",
+     *   "resource_id": "resource_id_value",
+     *   "share_with": {
+     *     // sharing configuration to be updated
+     *   }
+     * }
+     * 
+ * + * @param sourceIdx The source index to match in the query (exact match) + * @param resourceId The resource ID to match in the query (exact match) + * @param updateScript The script containing the update operations to be performed. + * This script defines how the matching documents should be modified + * @param listener Listener to be notified when the operation completes + * @apiNote This method: + *
    + *
  • Uses term queries for exact matching of source_idx and resource_id
  • + *
  • Returns false for both "no matching documents" and "operation failure" cases
  • + *
  • Logs the complete update request for debugging purposes
  • + *
  • Provides detailed logging for success and failure scenarios
  • + *
+ * @implNote The update operation uses a bool query with two must clauses: + *
+     * {
+     *   "query": {
+     *     "bool": {
+     *       "must": [
+     *         { "term": { "source_idx.keyword": sourceIdx } },
+     *         { "term": { "resource_id.keyword": resourceId } }
+     *       ]
+     *     }
+     *   }
+     * }
+     * 
+ */ + private void updateByQueryResourceSharing(String sourceIdx, String resourceId, Script updateScript, ActionListener listener) { + try (ThreadContext.StoredContext ctx = this.threadPool.getThreadContext().stashContext()) { + BoolQueryBuilder query = QueryBuilders.boolQuery() + .must(QueryBuilders.termQuery("source_idx.keyword", sourceIdx)) + .must(QueryBuilders.termQuery("resource_id.keyword", resourceId)); + + UpdateByQueryRequest ubq = new UpdateByQueryRequest(resourceSharingIndex).setQuery(query) + .setScript(updateScript) + .setRefresh(true); + + client.execute(UpdateByQueryAction.INSTANCE, ubq, new ActionListener<>() { + @Override + public void onResponse(BulkByScrollResponse response) { + long updated = response.getUpdated(); + if (updated > 0) { + LOGGER.debug("Successfully updated {} documents in {}.", updated, resourceSharingIndex); + listener.onResponse(true); + } else { + LOGGER.debug( + "No documents found to update in {} for source_idx: {} and resource_id: {}", + resourceSharingIndex, + sourceIdx, + resourceId + ); + listener.onResponse(false); + } + } + + @Override + public void onFailure(Exception e) { + LOGGER.error("Failed to update documents in {}.", resourceSharingIndex, e); + listener.onFailure(e); + + } + }); + } catch (Exception e) { + LOGGER.error("Failed to update documents in {} before request submission.", resourceSharingIndex, e); + listener.onFailure(e); + } + } + + /** + * Helper method to execute a search request and collect resource IDs from the results. + * + * @param resourceIds List to collect resource IDs + * @param scroll Search Scroll + * @param searchRequest Request to execute + * @param boolQuery Query to execute with the request + * @param listener Listener to be notified when the operation completes + */ + private void executeSearchRequest( + Set resourceIds, + Scroll scroll, + SearchRequest searchRequest, + BoolQueryBuilder boolQuery, + ActionListener listener + ) { + SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder().query(boolQuery) + .size(1000) + .fetchSource(new String[] { "resource_id" }, null); + + searchRequest.source(searchSourceBuilder); + + StepListener searchStep = new StepListener<>(); + + client.search(searchRequest, searchStep); + + searchStep.whenComplete(initialResponse -> { + String scrollId = initialResponse.getScrollId(); + processScrollResults(resourceIds, scroll, scrollId, initialResponse.getHits().getHits(), listener); + }, listener::onFailure); + } + + /** + * Helper method to process scroll results recursively. + * + * @param resourceIds List to collect resource IDs + * @param scroll Search Scroll + * @param scrollId Scroll ID + * @param hits Search hits + * @param listener Listener to be notified when the operation completes + */ + private void processScrollResults( + Set resourceIds, + Scroll scroll, + String scrollId, + SearchHit[] hits, + ActionListener listener + ) { + // If no hits, clean up and complete + if (hits == null || hits.length == 0) { + clearScroll(scrollId, listener); + return; + } + + // Process current batch of hits + for (SearchHit hit : hits) { + Map sourceAsMap = hit.getSourceAsMap(); + if (sourceAsMap != null && sourceAsMap.containsKey("resource_id")) { + resourceIds.add(sourceAsMap.get("resource_id").toString()); + } + } + + // Prepare next scroll request + SearchScrollRequest scrollRequest = new SearchScrollRequest(scrollId); + scrollRequest.scroll(scroll); + + // Execute next scroll + client.searchScroll(scrollRequest, ActionListener.wrap(scrollResponse -> { + // Process next batch recursively + processScrollResults(resourceIds, scroll, scrollResponse.getScrollId(), scrollResponse.getHits().getHits(), listener); + }, e -> { + // Clean up scroll context on failure + clearScroll(scrollId, ActionListener.wrap(r -> listener.onFailure(e), ex -> { + e.addSuppressed(ex); + listener.onFailure(e); + })); + })); + } + + /** + * Helper method to clear scroll context. + * + * @param scrollId Scroll ID + * @param listener Listener to be notified when the operation completes + */ + private void clearScroll(String scrollId, ActionListener listener) { + if (scrollId == null) { + listener.onResponse(null); + return; + } + + ClearScrollRequest clearScrollRequest = new ClearScrollRequest(); + clearScrollRequest.addScrollId(scrollId); + + client.clearScroll(clearScrollRequest, ActionListener.wrap(r -> listener.onResponse(null), e -> { + LOGGER.warn("Failed to clear scroll context", e); + listener.onResponse(null); + })); + } + +} diff --git a/common/src/main/java/org/opensearch/security/common/resources/ResourceSharingIndexManagementRepository.java b/common/src/main/java/org/opensearch/security/common/resources/ResourceSharingIndexManagementRepository.java new file mode 100644 index 0000000000..166d410f86 --- /dev/null +++ b/common/src/main/java/org/opensearch/security/common/resources/ResourceSharingIndexManagementRepository.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. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.common.resources; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +/** + * This class is responsible for managing the resource sharing index. + * It provides methods to create the index if it doesn't exist. + * + * @opensearch.experimental + */ +public class ResourceSharingIndexManagementRepository { + + private static final Logger LOGGER = LogManager.getLogger(ResourceSharingIndexManagementRepository.class); + + private final ResourceSharingIndexHandler resourceSharingIndexHandler; + private final boolean resourceSharingEnabled; + + protected ResourceSharingIndexManagementRepository( + final ResourceSharingIndexHandler resourceSharingIndexHandler, + boolean isResourceSharingEnabled + ) { + this.resourceSharingIndexHandler = resourceSharingIndexHandler; + this.resourceSharingEnabled = isResourceSharingEnabled; + } + + public static ResourceSharingIndexManagementRepository create( + ResourceSharingIndexHandler resourceSharingIndexHandler, + boolean isResourceSharingEnabled + ) { + return new ResourceSharingIndexManagementRepository(resourceSharingIndexHandler, isResourceSharingEnabled); + } + + /** + * Creates the resource sharing index if it doesn't already exist. + * This method is called during the initialization phase of the repository. + * It ensures that the index is set up with the necessary mappings and settings + * before any operations are performed on the index. + */ + public void createResourceSharingIndexIfAbsent() { + // TODO check if this should be wrapped in an atomic completable future + if (resourceSharingEnabled) { + LOGGER.debug("Attempting to create Resource Sharing index"); + this.resourceSharingIndexHandler.createResourceSharingIndexIfAbsent(() -> null); + } + + } +} diff --git a/common/src/main/java/org/opensearch/security/common/resources/rest/ResourceAccessAction.java b/common/src/main/java/org/opensearch/security/common/resources/rest/ResourceAccessAction.java new file mode 100644 index 0000000000..5820d21a8c --- /dev/null +++ b/common/src/main/java/org/opensearch/security/common/resources/rest/ResourceAccessAction.java @@ -0,0 +1,28 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.security.common.resources.rest; + +import org.opensearch.action.ActionType; + +/** + * This class represents the action type for resource access. + * It is used to execute the resource access request and retrieve the response. + * + * @opensearch.experimental + */ +public class ResourceAccessAction extends ActionType { + + public static final ResourceAccessAction INSTANCE = new ResourceAccessAction(); + + public static final String NAME = "cluster:admin/security/resource_access"; + + private ResourceAccessAction() { + super(NAME, ResourceAccessResponse::new); + } +} diff --git a/common/src/main/java/org/opensearch/security/common/resources/rest/ResourceAccessRequest.java b/common/src/main/java/org/opensearch/security/common/resources/rest/ResourceAccessRequest.java new file mode 100644 index 0000000000..1df9c244bb --- /dev/null +++ b/common/src/main/java/org/opensearch/security/common/resources/rest/ResourceAccessRequest.java @@ -0,0 +1,236 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.security.common.resources.rest; + +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.apache.commons.lang3.StringUtils; + +import org.opensearch.action.ActionRequest; +import org.opensearch.action.ActionRequestValidationException; +import org.opensearch.common.xcontent.LoggingDeprecationHandler; +import org.opensearch.common.xcontent.XContentFactory; +import org.opensearch.common.xcontent.XContentType; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; +import org.opensearch.core.xcontent.NamedXContentRegistry; +import org.opensearch.core.xcontent.XContentParser; +import org.opensearch.security.spi.resources.sharing.ShareWith; + +/** + * This class represents a request to access a resource. + * It encapsulates the operation, resource ID, resource index, scope, share with information, revoked entities, and scopes. + * + * @opensearch.experimental + */ +public class ResourceAccessRequest extends ActionRequest { + + public enum Operation { + LIST, + SHARE, + REVOKE, + VERIFY + } + + private final Operation operation; + private final String resourceId; + private final String resourceIndex; + private final ShareWith shareWith; + private final Map> revokedEntities; + private final Set scopes; + + /** + * Private constructor to enforce usage of Builder + */ + private ResourceAccessRequest(Builder builder) { + this.operation = builder.operation; + this.resourceId = builder.resourceId; + this.resourceIndex = builder.resourceIndex; + this.shareWith = builder.shareWith; + this.revokedEntities = builder.revokedEntities; + this.scopes = builder.scopes; + } + + /** + * Static factory method to initialize ResourceAccessRequest from a Map. + */ + @SuppressWarnings("unchecked") + public static ResourceAccessRequest from(Map source, Map params) throws IOException { + Builder builder = new Builder(); + + if (source.containsKey("operation")) { + builder.operation((Operation) source.get("operation")); + } else { + throw new IllegalArgumentException("Missing required field: operation"); + } + + builder.resourceId((String) source.get("resource_id")); + String resourceIndex = params.getOrDefault("resource_index", (String) source.get("resource_index")); + if (StringUtils.isEmpty(resourceIndex)) { + throw new IllegalArgumentException("Missing required field: resource_index"); + } + builder.resourceIndex(resourceIndex); + + if (source.containsKey("share_with")) { + builder.shareWith((Map) source.get("share_with")); + } + + if (source.containsKey("entities_to_revoke")) { + builder.revokedEntities((Map) source.get("entities_to_revoke")); + } + + if (source.containsKey("scopes")) { + builder.scopes(Set.copyOf((List) source.get("scopes"))); // Ensuring Set type + } + + return builder.build(); + } + + public ResourceAccessRequest(StreamInput in) throws IOException { + super(in); + this.operation = in.readEnum(Operation.class); + this.resourceId = in.readOptionalString(); + this.resourceIndex = in.readOptionalString(); + this.shareWith = in.readOptionalWriteable(ShareWith::new); + this.revokedEntities = in.readMap(StreamInput::readString, valIn -> valIn.readSet(StreamInput::readString)); + this.scopes = in.readSet(StreamInput::readString); + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + out.writeEnum(operation); + out.writeOptionalString(resourceId); + out.writeOptionalString(resourceIndex); + out.writeOptionalWriteable(shareWith); + out.writeMap(revokedEntities, StreamOutput::writeString, StreamOutput::writeStringCollection); + out.writeStringCollection(scopes); + } + + @Override + public ActionRequestValidationException validate() { + return null; + } + + public Operation getOperation() { + return operation; + } + + public String getResourceId() { + return resourceId; + } + + public String getResourceIndex() { + return resourceIndex; + } + + public ShareWith getShareWith() { + return shareWith; + } + + public Map> getRevokedEntities() { + return revokedEntities; + } + + public Set getScopes() { + return scopes; + } + + /** + * Builder for ResourceAccessRequest + */ + public static class Builder { + private Operation operation; + private String resourceId; + private String resourceIndex; + private ShareWith shareWith; + private Map> revokedEntities; + private Set scopes; + + public Builder operation(Operation operation) { + this.operation = operation; + return this; + } + + public Builder resourceId(String resourceId) { + this.resourceId = resourceId; + return this; + } + + public Builder resourceIndex(String resourceIndex) { + this.resourceIndex = resourceIndex; + return this; + } + + public Builder shareWith(Map source) { + try { + this.shareWith = parseShareWith(source); + } catch (Exception e) { + this.shareWith = null; + } + return this; + } + + public Builder revokedEntities(Map source) { + try { + this.revokedEntities = parseRevokedEntities(source); + } catch (Exception e) { + this.revokedEntities = null; + } + return this; + } + + public Builder scopes(Set scopes) { + this.scopes = scopes; + return this; + } + + public ResourceAccessRequest build() { + return new ResourceAccessRequest(this); + } + + private ShareWith parseShareWith(Map source) throws IOException { + if (source == null || source.isEmpty()) { + throw new IllegalArgumentException("share_with is required and cannot be empty"); + } + + String jsonString = XContentFactory.jsonBuilder().map(source).toString(); + + try ( + XContentParser parser = XContentType.JSON.xContent() + .createParser(NamedXContentRegistry.EMPTY, LoggingDeprecationHandler.INSTANCE, jsonString) + ) { + + return ShareWith.fromXContent(parser); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Invalid share_with structure: " + e.getMessage(), e); + } + } + + private Map> parseRevokedEntities(Map source) { + + return source.entrySet() + .stream() + .filter(entry -> entry.getValue() instanceof Collection) + .collect( + Collectors.toMap( + Map.Entry::getKey, + entry -> ((Collection) entry.getValue()).stream() + .filter(String.class::isInstance) + .map(String.class::cast) + .collect(Collectors.toSet()) + ) + ); + } + } +} diff --git a/common/src/main/java/org/opensearch/security/common/resources/rest/ResourceAccessRequestParams.java b/common/src/main/java/org/opensearch/security/common/resources/rest/ResourceAccessRequestParams.java new file mode 100644 index 0000000000..880cfe00ec --- /dev/null +++ b/common/src/main/java/org/opensearch/security/common/resources/rest/ResourceAccessRequestParams.java @@ -0,0 +1,32 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.security.common.resources.rest; + +import java.io.IOException; + +import org.opensearch.core.common.io.stream.NamedWriteable; +import org.opensearch.core.common.io.stream.StreamOutput; + +/** + * This class is used to represent the request parameters for resource access. + * It implements the NamedWriteable interface to allow serialization and deserialization of the request parameters. + * + * @opensearch.experimental + */ +public class ResourceAccessRequestParams implements NamedWriteable { + @Override + public String getWriteableName() { + return "resource_access_request_params"; + } + + @Override + public void writeTo(StreamOutput streamOutput) throws IOException { + + } +} diff --git a/common/src/main/java/org/opensearch/security/common/resources/rest/ResourceAccessResponse.java b/common/src/main/java/org/opensearch/security/common/resources/rest/ResourceAccessResponse.java new file mode 100644 index 0000000000..ac3ebf602f --- /dev/null +++ b/common/src/main/java/org/opensearch/security/common/resources/rest/ResourceAccessResponse.java @@ -0,0 +1,98 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.security.common.resources.rest; + +import java.io.IOException; +import java.util.Collections; +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.security.spi.resources.Resource; +import org.opensearch.security.spi.resources.sharing.ResourceSharing; + +/** + * This class is used to represent the response of a resource access request. + * It contains the response type and the response data. + * + * @opensearch.experimental + */ +public class ResourceAccessResponse extends ActionResponse implements ToXContentObject { + public enum ResponseType { + RESOURCES, + RESOURCE_SHARING, + BOOLEAN + } + + private final ResponseType responseType; + private final Object responseData; + + public ResourceAccessResponse(final StreamInput in) throws IOException { + this.responseType = in.readEnum(ResponseType.class); + this.responseData = null; + } + + public ResourceAccessResponse(Set resources) { + this.responseType = ResponseType.RESOURCES; + this.responseData = resources; + } + + public ResourceAccessResponse(ResourceSharing resourceSharing) { + this.responseType = ResponseType.RESOURCE_SHARING; + this.responseData = resourceSharing; + } + + public ResourceAccessResponse(boolean hasPermission) { + this.responseType = ResponseType.BOOLEAN; + this.responseData = hasPermission; + } + + @SuppressWarnings("unchecked") + @Override + public void writeTo(StreamOutput out) throws IOException { + out.writeEnum(responseType); + switch (responseType) { + case RESOURCES -> out.writeCollection((Set) responseData); + case RESOURCE_SHARING -> ((ResourceSharing) responseData).writeTo(out); + case BOOLEAN -> out.writeBoolean((Boolean) responseData); + } + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + builder.startObject(); + switch (responseType) { + case RESOURCES -> builder.field("resources", responseData); + case RESOURCE_SHARING -> builder.field("sharing_info", responseData); + case BOOLEAN -> builder.field("has_permission", responseData); + } + return builder.endObject(); + } + + @SuppressWarnings("unchecked") + public Set getResources() { + return responseType == ResponseType.RESOURCES ? (Set) responseData : Collections.emptySet(); + } + + public ResourceSharing getResourceSharing() { + return responseType == ResponseType.RESOURCE_SHARING ? (ResourceSharing) responseData : null; + } + + public Boolean getHasPermission() { + return responseType == ResponseType.BOOLEAN ? (Boolean) responseData : null; + } + + @Override + public String toString() { + return "ResourceAccessResponse [responseType=" + responseType + ", responseData=" + responseData + "]"; + } +} diff --git a/common/src/main/java/org/opensearch/security/common/resources/rest/ResourceAccessRestAction.java b/common/src/main/java/org/opensearch/security/common/resources/rest/ResourceAccessRestAction.java new file mode 100644 index 0000000000..700a064ed5 --- /dev/null +++ b/common/src/main/java/org/opensearch/security/common/resources/rest/ResourceAccessRestAction.java @@ -0,0 +1,151 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.security.common.resources.rest; + +import java.io.IOException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import com.google.common.collect.ImmutableList; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import org.opensearch.core.action.ActionListener; +import org.opensearch.core.rest.RestStatus; +import org.opensearch.core.xcontent.XContentParser; +import org.opensearch.rest.BaseRestHandler; +import org.opensearch.rest.BytesRestResponse; +import org.opensearch.rest.RestChannel; +import org.opensearch.rest.RestRequest; +import org.opensearch.transport.client.node.NodeClient; + +import static org.opensearch.rest.RestRequest.Method.GET; +import static org.opensearch.rest.RestRequest.Method.POST; +import static org.opensearch.security.common.dlic.rest.api.Responses.badRequest; +import static org.opensearch.security.common.dlic.rest.api.Responses.forbidden; +import static org.opensearch.security.common.dlic.rest.api.Responses.ok; +import static org.opensearch.security.common.dlic.rest.api.Responses.unauthorized; +import static org.opensearch.security.common.resources.rest.ResourceAccessRequest.Operation.LIST; +import static org.opensearch.security.common.resources.rest.ResourceAccessRequest.Operation.REVOKE; +import static org.opensearch.security.common.resources.rest.ResourceAccessRequest.Operation.SHARE; +import static org.opensearch.security.common.resources.rest.ResourceAccessRequest.Operation.VERIFY; +import static org.opensearch.security.common.support.Utils.PLUGIN_RESOURCE_ROUTE_PREFIX; +import static org.opensearch.security.common.support.Utils.addRoutesPrefix; + +/** + * This class handles the REST API for resource access management. + * It provides endpoints for listing, revoking, sharing, and verifying resource access. + * + * @opensearch.experimental + */ +public class ResourceAccessRestAction extends BaseRestHandler { + private static final Logger LOGGER = LogManager.getLogger(ResourceAccessRestAction.class); + + public ResourceAccessRestAction() {} + + @Override + public List routes() { + return addRoutesPrefix( + ImmutableList.of( + new Route(GET, "/list/{resource_index}"), + new Route(POST, "/revoke"), + new Route(POST, "/share"), + new Route(POST, "/verify_access") + ), + PLUGIN_RESOURCE_ROUTE_PREFIX + ); + } + + @Override + public String getName() { + return "resource_api_action"; + } + + @Override + protected RestChannelConsumer prepareRequest(RestRequest request, NodeClient client) throws IOException { + consumeParams(request); // early consume params to avoid 400s + + Map source = new HashMap<>(); + if (request.hasContent()) { + try (XContentParser parser = request.contentParser()) { + source = parser.map(); + } + } + + String path = request.path().split(PLUGIN_RESOURCE_ROUTE_PREFIX)[1].split("/")[1]; + switch (path) { + case "list" -> source.put("operation", LIST); + case "revoke" -> source.put("operation", REVOKE); + case "share" -> source.put("operation", SHARE); + case "verify_access" -> source.put("operation", VERIFY); + default -> { + return channel -> badRequest(channel, "Unknown route: " + path); + } + } + + ResourceAccessRequest resourceAccessRequest = ResourceAccessRequest.from(source, request.params()); + return channel -> { + client.executeLocally(ResourceAccessAction.INSTANCE, resourceAccessRequest, new ActionListener<>() { + + @Override + public void onResponse(ResourceAccessResponse response) { + try { + sendResponse(channel, response); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + @Override + public void onFailure(Exception e) { + handleError(channel, e); + } + + }); + }; + } + + /** + * Consume params early to avoid 400s. + * + * @param request from which the params must be consumed + */ + private void consumeParams(RestRequest request) { + request.param("resource_index", ""); + } + + /** + * Send the appropriate response to the channel. + * @param channel the channel to send the response to + * @param response the response to send + * @throws IOException if an I/O error occurs + */ + private void sendResponse(RestChannel channel, ResourceAccessResponse response) throws IOException { + ok(channel, response::toXContent); + } + + /** + * Handle errors that occur during request processing. + * @param channel the channel to send the error response to + * @param e the exception that caused the error + */ + private void handleError(RestChannel channel, Exception e) { + String message = e.getMessage(); + LOGGER.error(message, e); + if (message.contains("not authorized")) { + forbidden(channel, message); + } else if (message.contains("no authenticated")) { + unauthorized(channel); + } else if (message.contains("not a system index")) { + badRequest(channel, message); + } + channel.sendResponse(new BytesRestResponse(RestStatus.INTERNAL_SERVER_ERROR, message)); + } +} diff --git a/common/src/main/java/org/opensearch/security/common/resources/rest/ResourceAccessTransportAction.java b/common/src/main/java/org/opensearch/security/common/resources/rest/ResourceAccessTransportAction.java new file mode 100644 index 0000000000..6bd58246c8 --- /dev/null +++ b/common/src/main/java/org/opensearch/security/common/resources/rest/ResourceAccessTransportAction.java @@ -0,0 +1,117 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.security.common.resources.rest; + +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +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.indices.SystemIndices; +import org.opensearch.security.common.resources.ResourceAccessHandler; +import org.opensearch.security.spi.resources.exceptions.ResourceSharingException; +import org.opensearch.security.spi.resources.sharing.RecipientType; +import org.opensearch.security.spi.resources.sharing.RecipientTypeRegistry; +import org.opensearch.tasks.Task; +import org.opensearch.transport.TransportService; + +/** + * Transport action for handling resource access requests. + * + * @opensearch.experimental + */ +public class ResourceAccessTransportAction extends HandledTransportAction { + private final ResourceAccessHandler resourceAccessHandler; + + private final SystemIndices systemIndices; + + @Inject + public ResourceAccessTransportAction( + TransportService transportService, + ActionFilters actionFilters, + SystemIndices systemIndices, + ResourceAccessHandler resourceAccessHandler + ) { + super(ResourceAccessAction.NAME, transportService, actionFilters, ResourceAccessRequest::new); + this.systemIndices = systemIndices; + this.resourceAccessHandler = resourceAccessHandler; + } + + @Override + protected void doExecute(Task task, ResourceAccessRequest request, ActionListener actionListener) { + // verify that the request if for a system index + if (!this.systemIndices.isSystemIndex(request.getResourceIndex())) { + actionListener.onFailure( + new ResourceSharingException("Resource index '" + request.getResourceIndex() + "' is not a system index.") + ); + return; + } + + switch (request.getOperation()) { + case LIST: + handleListResources(request, actionListener); + break; + case SHARE: + handleGrantAccess(request, actionListener); + break; + case REVOKE: + handleRevokeAccess(request, actionListener); + break; + case VERIFY: + handleVerifyAccess(request, actionListener); + break; + default: + actionListener.onFailure(new IllegalArgumentException("Unknown action type: " + request.getOperation())); + } + } + + private void handleListResources(ResourceAccessRequest request, ActionListener listener) { + resourceAccessHandler.getAccessibleResourcesForCurrentUser( + request.getResourceIndex(), + ActionListener.wrap(resources -> listener.onResponse(new ResourceAccessResponse(resources)), listener::onFailure) + ); + } + + private void handleGrantAccess(ResourceAccessRequest request, ActionListener listener) { + resourceAccessHandler.shareWith( + request.getResourceId(), + request.getResourceIndex(), + request.getShareWith(), + ActionListener.wrap(response -> listener.onResponse(new ResourceAccessResponse(response)), listener::onFailure) + ); + } + + private void handleRevokeAccess(ResourceAccessRequest request, ActionListener listener) { + resourceAccessHandler.revokeAccess( + request.getResourceId(), + request.getResourceIndex(), + parseRevokedEntities(request.getRevokedEntities()), + request.getScopes(), + ActionListener.wrap(success -> listener.onResponse(new ResourceAccessResponse(success)), listener::onFailure) + ); + } + + private void handleVerifyAccess(ResourceAccessRequest request, ActionListener listener) { + resourceAccessHandler.hasPermission( + request.getResourceId(), + request.getResourceIndex(), + request.getScopes(), + ActionListener.wrap(hasPermission -> listener.onResponse(new ResourceAccessResponse(hasPermission)), listener::onFailure) + ); + } + + private Map> parseRevokedEntities(Map> revokeSource) { + return revokeSource.entrySet() + .stream() + .collect(Collectors.toMap(entry -> RecipientTypeRegistry.fromValue(entry.getKey()), Map.Entry::getValue)); + } +} diff --git a/common/src/main/java/org/opensearch/security/common/support/ConfigConstants.java b/common/src/main/java/org/opensearch/security/common/support/ConfigConstants.java new file mode 100644 index 0000000000..46c01a4d36 --- /dev/null +++ b/common/src/main/java/org/opensearch/security/common/support/ConfigConstants.java @@ -0,0 +1,402 @@ +/* + * Copyright 2015-2018 _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. + */ + +package org.opensearch.security.common.support; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; + +import org.opensearch.common.settings.Settings; +import org.opensearch.security.common.auditlog.impl.AuditCategory; + +import com.password4j.types.Hmac; + +public class ConfigConstants { + + public static final String OPENDISTRO_SECURITY_CONFIG_PREFIX = "_opendistro_security_"; + public static final String SECURITY_SETTINGS_PREFIX = "plugins.security."; + + public static final String OPENSEARCH_SECURITY_DISABLED = SECURITY_SETTINGS_PREFIX + "disabled"; + public static final boolean OPENSEARCH_SECURITY_DISABLED_DEFAULT = false; + + public static final String OPENDISTRO_SECURITY_CHANNEL_TYPE = OPENDISTRO_SECURITY_CONFIG_PREFIX + "channel_type"; + + public static final String OPENDISTRO_SECURITY_ORIGIN = OPENDISTRO_SECURITY_CONFIG_PREFIX + "origin"; + public static final String OPENDISTRO_SECURITY_ORIGIN_HEADER = OPENDISTRO_SECURITY_CONFIG_PREFIX + "origin_header"; + + public static final String OPENDISTRO_SECURITY_DLS_QUERY_HEADER = OPENDISTRO_SECURITY_CONFIG_PREFIX + "dls_query"; + + public static final String OPENDISTRO_SECURITY_DLS_FILTER_LEVEL_QUERY_HEADER = OPENDISTRO_SECURITY_CONFIG_PREFIX + + "dls_filter_level_query"; + public static final String OPENDISTRO_SECURITY_DLS_FILTER_LEVEL_QUERY_TRANSIENT = OPENDISTRO_SECURITY_CONFIG_PREFIX + + "dls_filter_level_query_t"; + + public static final String OPENDISTRO_SECURITY_DLS_MODE_HEADER = OPENDISTRO_SECURITY_CONFIG_PREFIX + "dls_mode"; + public static final String OPENDISTRO_SECURITY_DLS_MODE_TRANSIENT = OPENDISTRO_SECURITY_CONFIG_PREFIX + "dls_mode_t"; + + public static final String OPENDISTRO_SECURITY_FLS_FIELDS_HEADER = OPENDISTRO_SECURITY_CONFIG_PREFIX + "fls_fields"; + + public static final String OPENDISTRO_SECURITY_MASKED_FIELD_HEADER = OPENDISTRO_SECURITY_CONFIG_PREFIX + "masked_fields"; + + public static final String OPENDISTRO_SECURITY_DOC_ALLOWLIST_HEADER = OPENDISTRO_SECURITY_CONFIG_PREFIX + "doc_allowlist"; + public static final String OPENDISTRO_SECURITY_DOC_ALLOWLIST_TRANSIENT = OPENDISTRO_SECURITY_CONFIG_PREFIX + "doc_allowlist_t"; + + public static final String OPENDISTRO_SECURITY_FILTER_LEVEL_DLS_DONE = OPENDISTRO_SECURITY_CONFIG_PREFIX + "filter_level_dls_done"; + + public static final String OPENDISTRO_SECURITY_DLS_QUERY_CCS = OPENDISTRO_SECURITY_CONFIG_PREFIX + "dls_query_ccs"; + + public static final String OPENDISTRO_SECURITY_FLS_FIELDS_CCS = OPENDISTRO_SECURITY_CONFIG_PREFIX + "fls_fields_ccs"; + + public static final String OPENDISTRO_SECURITY_MASKED_FIELD_CCS = OPENDISTRO_SECURITY_CONFIG_PREFIX + "masked_fields_ccs"; + + public static final String OPENDISTRO_SECURITY_CONF_REQUEST_HEADER = OPENDISTRO_SECURITY_CONFIG_PREFIX + "conf_request"; + + public static final String OPENDISTRO_SECURITY_REMOTE_ADDRESS = OPENDISTRO_SECURITY_CONFIG_PREFIX + "remote_address"; + public static final String OPENDISTRO_SECURITY_REMOTE_ADDRESS_HEADER = OPENDISTRO_SECURITY_CONFIG_PREFIX + "remote_address_header"; + + public static final String OPENDISTRO_SECURITY_INITIAL_ACTION_CLASS_HEADER = OPENDISTRO_SECURITY_CONFIG_PREFIX + + "initial_action_class_header"; + + /** + * Set by SSL plugin for https requests only + */ + public static final String OPENDISTRO_SECURITY_SSL_PEER_CERTIFICATES = OPENDISTRO_SECURITY_CONFIG_PREFIX + "ssl_peer_certificates"; + + /** + * Set by SSL plugin for https requests only + */ + public static final String OPENDISTRO_SECURITY_SSL_PRINCIPAL = OPENDISTRO_SECURITY_CONFIG_PREFIX + "ssl_principal"; + + /** + * If this is set to TRUE then the request comes from a Server Node (fully trust) + * Its expected that there is a _opendistro_security_user attached as header + */ + public static final String OPENDISTRO_SECURITY_SSL_TRANSPORT_INTERCLUSTER_REQUEST = OPENDISTRO_SECURITY_CONFIG_PREFIX + + "ssl_transport_intercluster_request"; + + public static final String OPENDISTRO_SECURITY_SSL_TRANSPORT_TRUSTED_CLUSTER_REQUEST = OPENDISTRO_SECURITY_CONFIG_PREFIX + + "ssl_transport_trustedcluster_request"; + + // CS-SUPPRESS-SINGLE: RegexpSingleline Extensions manager used to allow/disallow TLS connections to extensions + public static final String OPENDISTRO_SECURITY_SSL_TRANSPORT_EXTENSION_REQUEST = OPENDISTRO_SECURITY_CONFIG_PREFIX + + "ssl_transport_extension_request"; + // CS-ENFORCE-SINGLE + + /** + * Set by the SSL plugin, this is the peer node certificate on the transport layer + */ + public static final String OPENDISTRO_SECURITY_SSL_TRANSPORT_PRINCIPAL = OPENDISTRO_SECURITY_CONFIG_PREFIX + "ssl_transport_principal"; + + public static final String OPENDISTRO_SECURITY_USER = OPENDISTRO_SECURITY_CONFIG_PREFIX + "user"; + public static final String OPENDISTRO_SECURITY_USER_HEADER = OPENDISTRO_SECURITY_CONFIG_PREFIX + "user_header"; + + // persistent header. This header is set once and cannot be stashed + public static final String OPENDISTRO_SECURITY_AUTHENTICATED_USER = OPENDISTRO_SECURITY_CONFIG_PREFIX + "authenticated_user"; + + public static final String OPENDISTRO_SECURITY_USER_INFO_THREAD_CONTEXT = OPENDISTRO_SECURITY_CONFIG_PREFIX + "user_info"; + + public static final String OPENDISTRO_SECURITY_INJECTED_USER = "injected_user"; + public static final String OPENDISTRO_SECURITY_INJECTED_USER_HEADER = "injected_user_header"; + + public static final String OPENDISTRO_SECURITY_XFF_DONE = OPENDISTRO_SECURITY_CONFIG_PREFIX + "xff_done"; + + public static final String SSO_LOGOUT_URL = OPENDISTRO_SECURITY_CONFIG_PREFIX + "sso_logout_url"; + + public static final String OPENDISTRO_SECURITY_DEFAULT_CONFIG_INDEX = ".opendistro_security"; + + public static final String SECURITY_ENABLE_SNAPSHOT_RESTORE_PRIVILEGE = SECURITY_SETTINGS_PREFIX + "enable_snapshot_restore_privilege"; + public static final boolean SECURITY_DEFAULT_ENABLE_SNAPSHOT_RESTORE_PRIVILEGE = true; + + public static final String SECURITY_CHECK_SNAPSHOT_RESTORE_WRITE_PRIVILEGES = SECURITY_SETTINGS_PREFIX + + "check_snapshot_restore_write_privileges"; + public static final boolean SECURITY_DEFAULT_CHECK_SNAPSHOT_RESTORE_WRITE_PRIVILEGES = true; + public static final Set SECURITY_SNAPSHOT_RESTORE_NEEDED_WRITE_PRIVILEGES = Collections.unmodifiableSet( + new HashSet(Arrays.asList("indices:admin/create", "indices:data/write/index" + // "indices:data/write/bulk" + )) + ); + + public static final String SECURITY_INTERCLUSTER_REQUEST_EVALUATOR_CLASS = SECURITY_SETTINGS_PREFIX + + "cert.intercluster_request_evaluator_class"; + public static final String OPENDISTRO_SECURITY_ACTION_NAME = OPENDISTRO_SECURITY_CONFIG_PREFIX + "action_name"; + + public static final String SECURITY_AUTHCZ_ADMIN_DN = SECURITY_SETTINGS_PREFIX + "authcz.admin_dn"; + public static final String SECURITY_CONFIG_INDEX_NAME = SECURITY_SETTINGS_PREFIX + "config_index_name"; + public static final String SECURITY_AUTHCZ_IMPERSONATION_DN = SECURITY_SETTINGS_PREFIX + "authcz.impersonation_dn"; + public static final String SECURITY_AUTHCZ_REST_IMPERSONATION_USERS = SECURITY_SETTINGS_PREFIX + "authcz.rest_impersonation_user"; + + public static final String BCRYPT = "bcrypt"; + public static final String PBKDF2 = "pbkdf2"; + + public static final String SECURITY_PASSWORD_HASHING_BCRYPT_ROUNDS = SECURITY_SETTINGS_PREFIX + "password.hashing.bcrypt.rounds"; + public static final int SECURITY_PASSWORD_HASHING_BCRYPT_ROUNDS_DEFAULT = 12; + public static final String SECURITY_PASSWORD_HASHING_BCRYPT_MINOR = SECURITY_SETTINGS_PREFIX + "password.hashing.bcrypt.minor"; + public static final String SECURITY_PASSWORD_HASHING_BCRYPT_MINOR_DEFAULT = "Y"; + + public static final String SECURITY_PASSWORD_HASHING_ALGORITHM = SECURITY_SETTINGS_PREFIX + "password.hashing.algorithm"; + public static final String SECURITY_PASSWORD_HASHING_ALGORITHM_DEFAULT = BCRYPT; + public static final String SECURITY_PASSWORD_HASHING_PBKDF2_ITERATIONS = SECURITY_SETTINGS_PREFIX + + "password.hashing.pbkdf2.iterations"; + public static final int SECURITY_PASSWORD_HASHING_PBKDF2_ITERATIONS_DEFAULT = 600_000; + public static final String SECURITY_PASSWORD_HASHING_PBKDF2_LENGTH = SECURITY_SETTINGS_PREFIX + "password.hashing.pbkdf2.length"; + public static final int SECURITY_PASSWORD_HASHING_PBKDF2_LENGTH_DEFAULT = 256; + public static final String SECURITY_PASSWORD_HASHING_PBKDF2_FUNCTION = SECURITY_SETTINGS_PREFIX + "password.hashing.pbkdf2.function"; + public static final String SECURITY_PASSWORD_HASHING_PBKDF2_FUNCTION_DEFAULT = Hmac.SHA256.name(); + + public static final String SECURITY_AUDIT_TYPE_DEFAULT = SECURITY_SETTINGS_PREFIX + "audit.type"; + public static final String SECURITY_AUDIT_CONFIG_DEFAULT = SECURITY_SETTINGS_PREFIX + "audit.config"; + public static final String SECURITY_AUDIT_CONFIG_ROUTES = SECURITY_SETTINGS_PREFIX + "audit.routes"; + public static final String SECURITY_AUDIT_CONFIG_ENDPOINTS = SECURITY_SETTINGS_PREFIX + "audit.endpoints"; + public static final String SECURITY_AUDIT_THREADPOOL_SIZE = SECURITY_SETTINGS_PREFIX + "audit.threadpool.size"; + public static final String SECURITY_AUDIT_THREADPOOL_MAX_QUEUE_LEN = SECURITY_SETTINGS_PREFIX + "audit.threadpool.max_queue_len"; + public static final String OPENDISTRO_SECURITY_AUDIT_LOG_REQUEST_BODY = "opendistro_security.audit.log_request_body"; + public static final String OPENDISTRO_SECURITY_AUDIT_RESOLVE_INDICES = "opendistro_security.audit.resolve_indices"; + public static final String OPENDISTRO_SECURITY_AUDIT_ENABLE_REST = "opendistro_security.audit.enable_rest"; + public static final String OPENDISTRO_SECURITY_AUDIT_ENABLE_TRANSPORT = "opendistro_security.audit.enable_transport"; + public static final String OPENDISTRO_SECURITY_AUDIT_CONFIG_DISABLED_TRANSPORT_CATEGORIES = + "opendistro_security.audit.config.disabled_transport_categories"; + public static final String OPENDISTRO_SECURITY_AUDIT_CONFIG_DISABLED_REST_CATEGORIES = + "opendistro_security.audit.config.disabled_rest_categories"; + public static final List OPENDISTRO_SECURITY_AUDIT_DISABLED_CATEGORIES_DEFAULT = ImmutableList.of( + AuditCategory.AUTHENTICATED.toString(), + AuditCategory.GRANTED_PRIVILEGES.toString() + ); + public static final String OPENDISTRO_SECURITY_AUDIT_IGNORE_USERS = "opendistro_security.audit.ignore_users"; + public static final String OPENDISTRO_SECURITY_AUDIT_IGNORE_REQUESTS = "opendistro_security.audit.ignore_requests"; + public static final String SECURITY_AUDIT_IGNORE_HEADERS = SECURITY_SETTINGS_PREFIX + "audit.ignore_headers"; + public static final String OPENDISTRO_SECURITY_AUDIT_RESOLVE_BULK_REQUESTS = "opendistro_security.audit.resolve_bulk_requests"; + public static final boolean OPENDISTRO_SECURITY_AUDIT_SSL_VERIFY_HOSTNAMES_DEFAULT = true; + public static final boolean OPENDISTRO_SECURITY_AUDIT_SSL_ENABLE_SSL_CLIENT_AUTH_DEFAULT = false; + public static final String OPENDISTRO_SECURITY_AUDIT_EXCLUDE_SENSITIVE_HEADERS = "opendistro_security.audit.exclude_sensitive_headers"; + + public static final String SECURITY_AUDIT_CONFIG_DEFAULT_PREFIX = SECURITY_SETTINGS_PREFIX + "audit.config."; + + // Internal Opensearch data_stream + public static final String SECURITY_AUDIT_OPENSEARCH_DATASTREAM_NAME = "data_stream.name"; + public static final String SECURITY_AUDIT_OPENSEARCH_DATASTREAM_TEMPLATE_MANAGE = "data_stream.template.manage"; + public static final String SECURITY_AUDIT_OPENSEARCH_DATASTREAM_TEMPLATE_NAME = "data_stream.template.name"; + public static final String SECURITY_AUDIT_OPENSEARCH_DATASTREAM_TEMPLATE_NUMBER_OF_REPLICAS = "data_stream.template.number_of_replicas"; + public static final String SECURITY_AUDIT_OPENSEARCH_DATASTREAM_TEMPLATE_NUMBER_OF_SHARDS = "data_stream.template.number_of_shards"; + + // Internal / External OpenSearch + public static final String SECURITY_AUDIT_OPENSEARCH_INDEX = "index"; + public static final String SECURITY_AUDIT_OPENSEARCH_TYPE = "type"; + + // External OpenSearch + public static final String SECURITY_AUDIT_EXTERNAL_OPENSEARCH_HTTP_ENDPOINTS = "http_endpoints"; + public static final String SECURITY_AUDIT_EXTERNAL_OPENSEARCH_USERNAME = "username"; + public static final String SECURITY_AUDIT_EXTERNAL_OPENSEARCH_PASSWORD = "password"; + public static final String SECURITY_AUDIT_EXTERNAL_OPENSEARCH_ENABLE_SSL = "enable_ssl"; + public static final String SECURITY_AUDIT_EXTERNAL_OPENSEARCH_VERIFY_HOSTNAMES = "verify_hostnames"; + public static final String SECURITY_AUDIT_EXTERNAL_OPENSEARCH_ENABLE_SSL_CLIENT_AUTH = "enable_ssl_client_auth"; + public static final String SECURITY_AUDIT_EXTERNAL_OPENSEARCH_PEMKEY_FILEPATH = "pemkey_filepath"; + public static final String SECURITY_AUDIT_EXTERNAL_OPENSEARCH_PEMKEY_CONTENT = "pemkey_content"; + public static final String SECURITY_AUDIT_EXTERNAL_OPENSEARCH_PEMKEY_PASSWORD = "pemkey_password"; + public static final String SECURITY_AUDIT_EXTERNAL_OPENSEARCH_PEMCERT_FILEPATH = "pemcert_filepath"; + public static final String SECURITY_AUDIT_EXTERNAL_OPENSEARCH_PEMCERT_CONTENT = "pemcert_content"; + public static final String SECURITY_AUDIT_EXTERNAL_OPENSEARCH_PEMTRUSTEDCAS_FILEPATH = "pemtrustedcas_filepath"; + public static final String SECURITY_AUDIT_EXTERNAL_OPENSEARCH_PEMTRUSTEDCAS_CONTENT = "pemtrustedcas_content"; + public static final String SECURITY_AUDIT_EXTERNAL_OPENSEARCH_JKS_CERT_ALIAS = "cert_alias"; + public static final String SECURITY_AUDIT_EXTERNAL_OPENSEARCH_ENABLED_SSL_CIPHERS = "enabled_ssl_ciphers"; + public static final String SECURITY_AUDIT_EXTERNAL_OPENSEARCH_ENABLED_SSL_PROTOCOLS = "enabled_ssl_protocols"; + + // Webhooks + public static final String SECURITY_AUDIT_WEBHOOK_URL = "webhook.url"; + public static final String SECURITY_AUDIT_WEBHOOK_FORMAT = "webhook.format"; + public static final String SECURITY_AUDIT_WEBHOOK_SSL_VERIFY = "webhook.ssl.verify"; + public static final String SECURITY_AUDIT_WEBHOOK_PEMTRUSTEDCAS_FILEPATH = "webhook.ssl.pemtrustedcas_filepath"; + public static final String SECURITY_AUDIT_WEBHOOK_PEMTRUSTEDCAS_CONTENT = "webhook.ssl.pemtrustedcas_content"; + + // Log4j + public static final String SECURITY_AUDIT_LOG4J_LOGGER_NAME = "log4j.logger_name"; + public static final String SECURITY_AUDIT_LOG4J_LEVEL = "log4j.level"; + + // retry + public static final String SECURITY_AUDIT_RETRY_COUNT = SECURITY_SETTINGS_PREFIX + "audit.config.retry_count"; + public static final String SECURITY_AUDIT_RETRY_DELAY_MS = SECURITY_SETTINGS_PREFIX + "audit.config.retry_delay_ms"; + + public static final String SECURITY_KERBEROS_KRB5_FILEPATH = SECURITY_SETTINGS_PREFIX + "kerberos.krb5_filepath"; + public static final String SECURITY_KERBEROS_ACCEPTOR_KEYTAB_FILEPATH = SECURITY_SETTINGS_PREFIX + "kerberos.acceptor_keytab_filepath"; + public static final String SECURITY_KERBEROS_ACCEPTOR_PRINCIPAL = SECURITY_SETTINGS_PREFIX + "kerberos.acceptor_principal"; + public static final String SECURITY_CERT_OID = SECURITY_SETTINGS_PREFIX + "cert.oid"; + public static final String SECURITY_CERT_INTERCLUSTER_REQUEST_EVALUATOR_CLASS = SECURITY_SETTINGS_PREFIX + + "cert.intercluster_request_evaluator_class"; + public static final String SECURITY_ADVANCED_MODULES_ENABLED = SECURITY_SETTINGS_PREFIX + "advanced_modules_enabled"; + public static final String SECURITY_NODES_DN = SECURITY_SETTINGS_PREFIX + "nodes_dn"; + public static final String SECURITY_NODES_DN_DYNAMIC_CONFIG_ENABLED = SECURITY_SETTINGS_PREFIX + "nodes_dn_dynamic_config_enabled"; + public static final String SECURITY_DISABLED = SECURITY_SETTINGS_PREFIX + "disabled"; + + public static final String SECURITY_CACHE_TTL_MINUTES = SECURITY_SETTINGS_PREFIX + "cache.ttl_minutes"; + public static final String SECURITY_ALLOW_UNSAFE_DEMOCERTIFICATES = SECURITY_SETTINGS_PREFIX + "allow_unsafe_democertificates"; + public static final String SECURITY_ALLOW_DEFAULT_INIT_SECURITYINDEX = SECURITY_SETTINGS_PREFIX + "allow_default_init_securityindex"; + + public static final String SECURITY_ALLOW_DEFAULT_INIT_USE_CLUSTER_STATE = SECURITY_SETTINGS_PREFIX + + "allow_default_init_securityindex.use_cluster_state"; + + public static final String SECURITY_BACKGROUND_INIT_IF_SECURITYINDEX_NOT_EXIST = SECURITY_SETTINGS_PREFIX + + "background_init_if_securityindex_not_exist"; + + public static final String SECURITY_ROLES_MAPPING_RESOLUTION = SECURITY_SETTINGS_PREFIX + "roles_mapping_resolution"; + + public static final String OPENDISTRO_SECURITY_COMPLIANCE_HISTORY_WRITE_METADATA_ONLY = + "opendistro_security.compliance.history.write.metadata_only"; + public static final String OPENDISTRO_SECURITY_COMPLIANCE_HISTORY_READ_METADATA_ONLY = + "opendistro_security.compliance.history.read.metadata_only"; + public static final String OPENDISTRO_SECURITY_COMPLIANCE_HISTORY_READ_WATCHED_FIELDS = + "opendistro_security.compliance.history.read.watched_fields"; + public static final String OPENDISTRO_SECURITY_COMPLIANCE_HISTORY_WRITE_WATCHED_INDICES = + "opendistro_security.compliance.history.write.watched_indices"; + public static final String OPENDISTRO_SECURITY_COMPLIANCE_HISTORY_WRITE_LOG_DIFFS = + "opendistro_security.compliance.history.write.log_diffs"; + public static final String OPENDISTRO_SECURITY_COMPLIANCE_HISTORY_READ_IGNORE_USERS = + "opendistro_security.compliance.history.read.ignore_users"; + public static final String OPENDISTRO_SECURITY_COMPLIANCE_HISTORY_WRITE_IGNORE_USERS = + "opendistro_security.compliance.history.write.ignore_users"; + public static final String OPENDISTRO_SECURITY_COMPLIANCE_HISTORY_EXTERNAL_CONFIG_ENABLED = + "opendistro_security.compliance.history.external_config_enabled"; + public static final String OPENDISTRO_SECURITY_SOURCE_FIELD_CONTEXT = OPENDISTRO_SECURITY_CONFIG_PREFIX + "source_field_context"; + public static final String SECURITY_COMPLIANCE_DISABLE_ANONYMOUS_AUTHENTICATION = SECURITY_SETTINGS_PREFIX + + "compliance.disable_anonymous_authentication"; + public static final String SECURITY_COMPLIANCE_IMMUTABLE_INDICES = SECURITY_SETTINGS_PREFIX + "compliance.immutable_indices"; + public static final String SECURITY_COMPLIANCE_SALT = SECURITY_SETTINGS_PREFIX + "compliance.salt"; + public static final String SECURITY_COMPLIANCE_SALT_DEFAULT = "e1ukloTsQlOgPquJ";// 16 chars + public static final String SECURITY_COMPLIANCE_HISTORY_INTERNAL_CONFIG_ENABLED = + "opendistro_security.compliance.history.internal_config_enabled"; + public static final String SECURITY_SSL_ONLY = SECURITY_SETTINGS_PREFIX + "ssl_only"; + public static final String SECURITY_CONFIG_SSL_DUAL_MODE_ENABLED = "plugins.security_config.ssl_dual_mode_enabled"; + public static final String SECURITY_SSL_DUAL_MODE_SKIP_SECURITY = OPENDISTRO_SECURITY_CONFIG_PREFIX + "passive_security"; + public static final String LEGACY_OPENDISTRO_SECURITY_CONFIG_SSL_DUAL_MODE_ENABLED = "opendistro_security_config.ssl_dual_mode_enabled"; + public static final String SECURITY_SSL_CERT_RELOAD_ENABLED = SECURITY_SETTINGS_PREFIX + "ssl_cert_reload_enabled"; + public static final String SECURITY_SSL_CERTIFICATES_HOT_RELOAD_ENABLED = SECURITY_SETTINGS_PREFIX + + "ssl.certificates_hot_reload.enabled"; + public static final String SECURITY_DISABLE_ENVVAR_REPLACEMENT = SECURITY_SETTINGS_PREFIX + "disable_envvar_replacement"; + public static final String SECURITY_DFM_EMPTY_OVERRIDES_ALL = SECURITY_SETTINGS_PREFIX + "dfm_empty_overrides_all"; + + public enum RolesMappingResolution { + MAPPING_ONLY, + BACKENDROLES_ONLY, + BOTH + } + + public static final String SECURITY_FILTER_SECURITYINDEX_FROM_ALL_REQUESTS = SECURITY_SETTINGS_PREFIX + + "filter_securityindex_from_all_requests"; + public static final String SECURITY_DLS_MODE = SECURITY_SETTINGS_PREFIX + "dls.mode"; + // REST API + public static final String SECURITY_RESTAPI_ROLES_ENABLED = SECURITY_SETTINGS_PREFIX + "restapi.roles_enabled"; + public static final String SECURITY_RESTAPI_ADMIN_ENABLED = SECURITY_SETTINGS_PREFIX + "restapi.admin.enabled"; + public static final String SECURITY_RESTAPI_ENDPOINTS_DISABLED = SECURITY_SETTINGS_PREFIX + "restapi.endpoints_disabled"; + public static final String SECURITY_RESTAPI_PASSWORD_VALIDATION_REGEX = SECURITY_SETTINGS_PREFIX + "restapi.password_validation_regex"; + public static final String SECURITY_RESTAPI_PASSWORD_VALIDATION_ERROR_MESSAGE = SECURITY_SETTINGS_PREFIX + + "restapi.password_validation_error_message"; + public static final String SECURITY_RESTAPI_PASSWORD_MIN_LENGTH = SECURITY_SETTINGS_PREFIX + "restapi.password_min_length"; + public static final String SECURITY_RESTAPI_PASSWORD_SCORE_BASED_VALIDATION_STRENGTH = SECURITY_SETTINGS_PREFIX + + "restapi.password_score_based_validation_strength"; + // Illegal Opcodes from here on + public static final String SECURITY_UNSUPPORTED_DISABLE_REST_AUTH_INITIALLY = SECURITY_SETTINGS_PREFIX + + "unsupported.disable_rest_auth_initially"; + public static final String SECURITY_UNSUPPORTED_DELAY_INITIALIZATION_SECONDS = SECURITY_SETTINGS_PREFIX + + "unsupported.delay_initialization_seconds"; + public static final String SECURITY_UNSUPPORTED_DISABLE_INTERTRANSPORT_AUTH_INITIALLY = SECURITY_SETTINGS_PREFIX + + "unsupported.disable_intertransport_auth_initially"; + public static final String SECURITY_UNSUPPORTED_PASSIVE_INTERTRANSPORT_AUTH_INITIALLY = SECURITY_SETTINGS_PREFIX + + "unsupported.passive_intertransport_auth_initially"; + public static final String SECURITY_UNSUPPORTED_RESTORE_SECURITYINDEX_ENABLED = SECURITY_SETTINGS_PREFIX + + "unsupported.restore.securityindex.enabled"; + public static final String SECURITY_UNSUPPORTED_INJECT_USER_ENABLED = SECURITY_SETTINGS_PREFIX + "unsupported.inject_user.enabled"; + public static final String SECURITY_UNSUPPORTED_INJECT_ADMIN_USER_ENABLED = SECURITY_SETTINGS_PREFIX + + "unsupported.inject_user.admin.enabled"; + public static final String SECURITY_UNSUPPORTED_ALLOW_NOW_IN_DLS = SECURITY_SETTINGS_PREFIX + "unsupported.allow_now_in_dls"; + + public static final String SECURITY_UNSUPPORTED_RESTAPI_ALLOW_SECURITYCONFIG_MODIFICATION = SECURITY_SETTINGS_PREFIX + + "unsupported.restapi.allow_securityconfig_modification"; + public static final String SECURITY_UNSUPPORTED_LOAD_STATIC_RESOURCES = SECURITY_SETTINGS_PREFIX + "unsupported.load_static_resources"; + public static final String SECURITY_UNSUPPORTED_ACCEPT_INVALID_CONFIG = SECURITY_SETTINGS_PREFIX + "unsupported.accept_invalid_config"; + + public static final String SECURITY_PROTECTED_INDICES_ENABLED_KEY = SECURITY_SETTINGS_PREFIX + "protected_indices.enabled"; + public static final Boolean SECURITY_PROTECTED_INDICES_ENABLED_DEFAULT = false; + public static final String SECURITY_PROTECTED_INDICES_KEY = SECURITY_SETTINGS_PREFIX + "protected_indices.indices"; + public static final List SECURITY_PROTECTED_INDICES_DEFAULT = Collections.emptyList(); + public static final String SECURITY_PROTECTED_INDICES_ROLES_KEY = SECURITY_SETTINGS_PREFIX + "protected_indices.roles"; + public static final List SECURITY_PROTECTED_INDICES_ROLES_DEFAULT = Collections.emptyList(); + + // Roles injection for plugins + public static final String OPENDISTRO_SECURITY_INJECTED_ROLES = "opendistro_security_injected_roles"; + public static final String OPENDISTRO_SECURITY_INJECTED_ROLES_HEADER = "opendistro_security_injected_roles_header"; + + // Roles validation for the plugins + public static final String OPENDISTRO_SECURITY_INJECTED_ROLES_VALIDATION = "opendistro_security_injected_roles_validation"; + public static final String OPENDISTRO_SECURITY_INJECTED_ROLES_VALIDATION_HEADER = + "opendistro_security_injected_roles_validation_header"; + + // System indices settings + public static final String SYSTEM_INDEX_PERMISSION = "system:admin/system_index"; + public static final String SECURITY_SYSTEM_INDICES_ENABLED_KEY = SECURITY_SETTINGS_PREFIX + "system_indices.enabled"; + public static final Boolean SECURITY_SYSTEM_INDICES_ENABLED_DEFAULT = false; + public static final String SECURITY_SYSTEM_INDICES_PERMISSIONS_ENABLED_KEY = SECURITY_SETTINGS_PREFIX + + "system_indices.permission.enabled"; + public static final Boolean SECURITY_SYSTEM_INDICES_PERMISSIONS_DEFAULT = false; + public static final String SECURITY_SYSTEM_INDICES_KEY = SECURITY_SETTINGS_PREFIX + "system_indices.indices"; + public static final List SECURITY_SYSTEM_INDICES_DEFAULT = Collections.emptyList(); + public static final String SECURITY_MASKED_FIELDS_ALGORITHM_DEFAULT = SECURITY_SETTINGS_PREFIX + "masked_fields.algorithm.default"; + + public static final String TENANCY_PRIVATE_TENANT_NAME = "private"; + public static final String TENANCY_GLOBAL_TENANT_NAME = "global"; + public static final String TENANCY_GLOBAL_TENANT_DEFAULT_NAME = ""; + + public static final String USE_JDK_SERIALIZATION = SECURITY_SETTINGS_PREFIX + "use_jdk_serialization"; + + // On-behalf-of endpoints settings + // CS-SUPPRESS-SINGLE: RegexpSingleline get Extensions Settings + public static final String EXTENSIONS_BWC_PLUGIN_MODE = "bwcPluginMode"; + public static final boolean EXTENSIONS_BWC_PLUGIN_MODE_DEFAULT = false; + // CS-ENFORCE-SINGLE + + // Variable for initial admin password support + public static final String OPENSEARCH_INITIAL_ADMIN_PASSWORD = "OPENSEARCH_INITIAL_ADMIN_PASSWORD"; + + // Resource sharing feature-flag + public static final String OPENSEARCH_RESOURCE_SHARING_ENABLED = SECURITY_SETTINGS_PREFIX + "resource_sharing.enabled"; + public static final boolean OPENSEARCH_RESOURCE_SHARING_ENABLED_DEFAULT = true; + + public static Set getSettingAsSet( + final Settings settings, + final String key, + final List defaultList, + final boolean ignoreCaseForNone + ) { + final List list = settings.getAsList(key, defaultList); + if (list.size() == 1 && "NONE".equals(ignoreCaseForNone ? list.get(0).toUpperCase() : list.get(0))) { + return Collections.emptySet(); + } + return ImmutableSet.copyOf(list); + } +} diff --git a/common/src/main/java/org/opensearch/security/common/support/Utils.java b/common/src/main/java/org/opensearch/security/common/support/Utils.java new file mode 100644 index 0000000000..ffdc8d9390 --- /dev/null +++ b/common/src/main/java/org/opensearch/security/common/support/Utils.java @@ -0,0 +1,285 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.common.support; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.security.AccessController; +import java.security.PrivilegedActionException; +import java.security.PrivilegedExceptionAction; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.commons.lang3.tuple.Pair; + +import org.opensearch.ExceptionsHelper; +import org.opensearch.OpenSearchParseException; +import org.opensearch.SpecialPermission; +import org.opensearch.common.CheckedSupplier; +import org.opensearch.common.util.concurrent.ThreadContext; +import org.opensearch.common.xcontent.XContentHelper; +import org.opensearch.common.xcontent.XContentType; +import org.opensearch.common.xcontent.json.JsonXContent; +import org.opensearch.core.common.Strings; +import org.opensearch.core.common.bytes.BytesReference; +import org.opensearch.core.common.transport.TransportAddress; +import org.opensearch.core.xcontent.NamedXContentRegistry; +import org.opensearch.core.xcontent.ToXContent; +import org.opensearch.core.xcontent.XContentParser; +import org.opensearch.rest.NamedRoute; +import org.opensearch.rest.RestHandler.DeprecatedRoute; +import org.opensearch.rest.RestHandler.Route; +import org.opensearch.security.common.DefaultObjectMapper; +import org.opensearch.security.common.user.User; + +import static org.opensearch.core.xcontent.DeprecationHandler.THROW_UNSUPPORTED_OPERATION; + +public class Utils { + @Deprecated + public static final String LEGACY_OPENDISTRO_PREFIX = "_opendistro/_security"; + public static final String PLUGINS_PREFIX = "_plugins/_security"; + + public final static String PLUGIN_ROUTE_PREFIX = "/" + PLUGINS_PREFIX; + + @Deprecated + public final static String LEGACY_PLUGIN_ROUTE_PREFIX = "/" + LEGACY_OPENDISTRO_PREFIX; + + public final static String PLUGIN_API_ROUTE_PREFIX = PLUGIN_ROUTE_PREFIX + "/api"; + + @Deprecated + public final static String LEGACY_PLUGIN_API_ROUTE_PREFIX = LEGACY_PLUGIN_ROUTE_PREFIX + "/api"; + + public final static String OPENDISTRO_API_DEPRECATION_MESSAGE = + "[_opendistro/_security] is a deprecated endpoint path. Please use _plugins/_security instead."; + + public final static String PLUGIN_RESOURCE_ROUTE_PREFIX = PLUGIN_ROUTE_PREFIX + "/resources"; + + private static final ObjectMapper internalMapper = new ObjectMapper(); + + public static Map convertJsonToxToStructuredMap(ToXContent jsonContent) { + Map map = null; + try { + final BytesReference bytes = XContentHelper.toXContent(jsonContent, XContentType.JSON, false); + map = XContentHelper.convertToMap(bytes, false, XContentType.JSON).v2(); + } catch (IOException e1) { + throw ExceptionsHelper.convertToOpenSearchException(e1); + } + + return map; + } + + public static Map convertJsonToxToStructuredMap(String jsonContent) { + try ( + XContentParser parser = XContentType.JSON.xContent() + .createParser(NamedXContentRegistry.EMPTY, THROW_UNSUPPORTED_OPERATION, jsonContent) + ) { + return parser.map(); + } catch (IOException e1) { + throw ExceptionsHelper.convertToOpenSearchException(e1); + } + } + + private static BytesReference convertStructuredMapToBytes(Map structuredMap) { + try { + return BytesReference.bytes(JsonXContent.contentBuilder().map(structuredMap)); + } catch (IOException e) { + throw new OpenSearchParseException("Failed to convert map", e); + } + } + + public static String convertStructuredMapToJson(Map structuredMap) { + try { + return XContentHelper.convertToJson(convertStructuredMapToBytes(structuredMap), false, XContentType.JSON); + } catch (IOException e) { + throw new OpenSearchParseException("Failed to convert map", e); + } + } + + public static JsonNode convertJsonToJackson(BytesReference jsonContent) { + try { + return DefaultObjectMapper.readTree(jsonContent.utf8ToString()); + } catch (IOException e1) { + throw ExceptionsHelper.convertToOpenSearchException(e1); + } + + } + + public static JsonNode toJsonNode(final String content) throws IOException { + return DefaultObjectMapper.readTree(content); + } + + public static Object toConfigObject(final JsonNode content, final Class clazz) throws IOException { + return DefaultObjectMapper.readTree(content, clazz); + } + + public static JsonNode convertJsonToJackson(ToXContent jsonContent, boolean omitDefaults) { + try { + return DefaultObjectMapper.readTree( + Strings.toString( + XContentType.JSON, + jsonContent, + new ToXContent.MapParams(Map.of("omit_defaults", String.valueOf(omitDefaults))) + ) + ); + } catch (IOException e1) { + throw ExceptionsHelper.convertToOpenSearchException(e1); + } + + } + + @SuppressWarnings("removal") + public static byte[] jsonMapToByteArray(Map jsonAsMap) throws IOException { + + final SecurityManager sm = System.getSecurityManager(); + + if (sm != null) { + sm.checkPermission(new SpecialPermission()); + } + + try { + return AccessController.doPrivileged((PrivilegedExceptionAction) () -> internalMapper.writeValueAsBytes(jsonAsMap)); + } catch (final PrivilegedActionException e) { + if (e.getCause() instanceof JsonProcessingException) { + throw (JsonProcessingException) e.getCause(); + } else if (e.getCause() instanceof RuntimeException) { + throw (RuntimeException) e.getCause(); + } else { + throw new RuntimeException(e.getCause()); + } + } + } + + @SuppressWarnings("removal") + public static Map byteArrayToMutableJsonMap(byte[] jsonBytes) throws IOException { + + final SecurityManager sm = System.getSecurityManager(); + + if (sm != null) { + sm.checkPermission(new SpecialPermission()); + } + + try { + return AccessController.doPrivileged( + (PrivilegedExceptionAction>) () -> internalMapper.readValue( + jsonBytes, + new TypeReference>() { + } + ) + ); + } catch (final PrivilegedActionException e) { + if (e.getCause() instanceof IOException) { + throw (IOException) e.getCause(); + } else if (e.getCause() instanceof RuntimeException) { + throw (RuntimeException) e.getCause(); + } else { + throw new RuntimeException(e.getCause()); + } + } + } + + /** + * Generate field resource paths + * @param fields fields + * @param prefix prefix path + * @return new set of fields resource paths + */ + public static Set generateFieldResourcePaths(final Set fields, final String prefix) { + return fields.stream().map(field -> prefix + field).collect(ImmutableSet.toImmutableSet()); + } + + /** + * Add prefixes(_plugins/_security/api) to rest API routes + * @param routes routes + * @return new list of API routes prefixed with and _plugins/_security/api + */ + public static List addRoutesPrefix(List routes) { + return addRoutesPrefix(routes, PLUGIN_API_ROUTE_PREFIX); + } + + /** + * Add prefixes(_opendistro/_security/api) to rest API routes + * Deprecated in favor of addRoutesPrefix(List routes) + * @param routes routes + * @return new list of API routes prefixed with and _opendistro/_security/api + */ + @Deprecated + public static List addLegacyRoutesPrefix(List routes) { + return addDeprecatedRoutesPrefix(routes, LEGACY_PLUGIN_API_ROUTE_PREFIX); + } + + /** + * Add customized prefix(_opendistro... and _plugins...)to API rest routes + * @param routes routes + * @param prefixes all api prefix + * @return new list of API routes prefixed with the strings listed in prefixes + * Total number of routes will be expanded len(prefixes) as much comparing to the list passed in + */ + public static List addRoutesPrefix(List routes, final String... prefixes) { + return routes.stream().flatMap(r -> Arrays.stream(prefixes).map(p -> { + if (r instanceof NamedRoute) { + NamedRoute nr = (NamedRoute) r; + return new NamedRoute.Builder().method(nr.getMethod()) + .path(p + nr.getPath()) + .uniqueName(nr.name()) + .legacyActionNames(nr.actionNames()) + .build(); + } + return new Route(r.getMethod(), p + r.getPath()); + })).collect(ImmutableList.toImmutableList()); + } + + /** + * Add prefixes(_plugins...) to rest API routes + * @param deprecatedRoutes Routes being deprecated + * @return new list of API routes prefixed with _opendistro... and _plugins... + *Total number of routes is expanded as twice as the number of routes passed in + */ + public static List addDeprecatedRoutesPrefix(List deprecatedRoutes) { + return addDeprecatedRoutesPrefix(deprecatedRoutes, LEGACY_PLUGIN_API_ROUTE_PREFIX, PLUGIN_API_ROUTE_PREFIX); + } + + /** + * Add customized prefix(_opendistro... and _plugins...)to API rest routes + * @param deprecatedRoutes Routes being deprecated + * @param prefixes all api prefix + * @return new list of API routes prefixed with the strings listed in prefixes + * Total number of routes will be expanded len(prefixes) as much comparing to the list passed in + */ + public static List addDeprecatedRoutesPrefix(List deprecatedRoutes, final String... prefixes) { + return deprecatedRoutes.stream() + .flatMap(r -> Arrays.stream(prefixes).map(p -> new DeprecatedRoute(r.getMethod(), p + r.getPath(), r.getDeprecationMessage()))) + .collect(ImmutableList.toImmutableList()); + } + + public static Pair userAndRemoteAddressFrom(final ThreadContext threadContext) { + final User user = threadContext.getTransient(ConfigConstants.OPENDISTRO_SECURITY_USER); + final TransportAddress remoteAddress = threadContext.getTransient(ConfigConstants.OPENDISTRO_SECURITY_REMOTE_ADDRESS); + return Pair.of(user, remoteAddress); + } + + public static T withIOException(final CheckedSupplier action) { + try { + return action.get(); + } catch (final IOException ioe) { + throw new UncheckedIOException(ioe); + } + } + +} diff --git a/common/src/main/java/org/opensearch/security/common/support/WildcardMatcher.java b/common/src/main/java/org/opensearch/security/common/support/WildcardMatcher.java new file mode 100644 index 0000000000..4e5ab5b29b --- /dev/null +++ b/common/src/main/java/org/opensearch/security/common/support/WildcardMatcher.java @@ -0,0 +1,556 @@ +/* + * Copyright 2015-2018 _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. + */ + +package org.opensearch.security.common.support; + +import java.util.Arrays; +import java.util.Collection; +import java.util.Iterator; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.regex.Pattern; +import java.util.stream.Collector; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import com.google.common.base.Preconditions; +import com.google.common.collect.ImmutableSet; + +public abstract class WildcardMatcher implements Predicate { + + public static final WildcardMatcher ANY = new WildcardMatcher() { + + @Override + public boolean matchAny(Stream candidates) { + return true; + } + + @Override + public boolean matchAny(Collection candidates) { + return true; + } + + @Override + public boolean matchAny(String... candidates) { + return true; + } + + @Override + public boolean matchAll(Stream candidates) { + return true; + } + + @Override + public boolean matchAll(Collection candidates) { + return true; + } + + @Override + public boolean matchAll(String[] candidates) { + return true; + } + + @Override + public > T getMatchAny(Stream candidates, Collector collector) { + return candidates.collect(collector); + } + + @Override + public boolean test(String candidate) { + return true; + } + + @Override + public String toString() { + return "*"; + } + }; + + public static final WildcardMatcher NONE = new WildcardMatcher() { + + @Override + public boolean matchAny(Stream candidates) { + return false; + } + + @Override + public boolean matchAny(Collection candidates) { + return false; + } + + @Override + public boolean matchAny(String... candidates) { + return false; + } + + @Override + public boolean matchAll(Stream candidates) { + return false; + } + + @Override + public boolean matchAll(Collection candidates) { + return false; + } + + @Override + public boolean matchAll(String[] candidates) { + return false; + } + + @Override + public > T getMatchAny(Stream candidates, Collector collector) { + return Stream.empty().collect(collector); + } + + @Override + public > T getMatchAny(Collection candidate, Collector collector) { + return Stream.empty().collect(collector); + } + + @Override + public > T getMatchAny(String[] candidate, Collector collector) { + return Stream.empty().collect(collector); + } + + @Override + public boolean test(String candidate) { + return false; + } + + @Override + public String toString() { + return ""; + } + }; + + public static WildcardMatcher from(String pattern, boolean caseSensitive) { + if (pattern == null) { + return NONE; + } else if (pattern.equals("*")) { + return ANY; + } else if (pattern.startsWith("/") && pattern.endsWith("/")) { + return new RegexMatcher(pattern, caseSensitive); + } else if (pattern.indexOf('?') >= 0 || pattern.indexOf('*') >= 0) { + return caseSensitive ? new SimpleMatcher(pattern) : new CasefoldingMatcher(pattern, SimpleMatcher::new); + } else { + return caseSensitive ? new Exact(pattern) : new CasefoldingMatcher(pattern, Exact::new); + } + } + + public static WildcardMatcher from(String pattern) { + return from(pattern, true); + } + + // This may in future use more optimized techniques to combine multiple WildcardMatchers in a single automaton + public static WildcardMatcher from(Stream stream, boolean caseSensitive) { + Collection matchers = stream.map(t -> { + if (t == null) { + return NONE; + } else if (t instanceof String) { + return WildcardMatcher.from(((String) t), caseSensitive); + } else if (t instanceof WildcardMatcher) { + return ((WildcardMatcher) t); + } + throw new UnsupportedOperationException("WildcardMatcher can't be constructed from " + t.getClass().getSimpleName()); + }).collect(ImmutableSet.toImmutableSet()); + + if (matchers.isEmpty()) { + return NONE; + } else if (matchers.size() == 1) { + return matchers.stream().findFirst().get(); + } + return new MatcherCombiner(matchers); + } + + public static WildcardMatcher from(Collection collection, boolean caseSensitive) { + if (collection == null || collection.isEmpty()) { + return NONE; + } else if (collection.size() == 1) { + T t = collection.stream().findFirst().get(); + if (t instanceof String) { + return from(((String) t), caseSensitive); + } else if (t instanceof WildcardMatcher) { + return ((WildcardMatcher) t); + } + throw new UnsupportedOperationException("WildcardMatcher can't be constructed from " + t.getClass().getSimpleName()); + } + return from(collection.stream(), caseSensitive); + } + + public static WildcardMatcher from(String[] patterns, boolean caseSensitive) { + if (patterns == null || patterns.length == 0) { + return NONE; + } else if (patterns.length == 1) { + return from(patterns[0], caseSensitive); + } + return from(Arrays.stream(patterns), caseSensitive); + } + + public static WildcardMatcher from(Stream patterns) { + return from(patterns, true); + } + + public static WildcardMatcher from(Collection patterns) { + return from(patterns, true); + } + + public static WildcardMatcher from(String... patterns) { + return from(patterns, true); + } + + public WildcardMatcher concat(Stream matchers) { + return new MatcherCombiner(Stream.concat(matchers, Stream.of(this)).collect(ImmutableSet.toImmutableSet())); + } + + public WildcardMatcher concat(Collection matchers) { + if (matchers.isEmpty()) { + return this; + } + return concat(matchers.stream()); + } + + public WildcardMatcher concat(WildcardMatcher... matchers) { + if (matchers.length == 0) { + return this; + } + return concat(Arrays.stream(matchers)); + } + + public boolean matchAny(Stream candidates) { + return candidates.anyMatch(this); + } + + public boolean matchAny(Collection candidates) { + return matchAny(candidates.stream()); + } + + public boolean matchAny(String... candidates) { + return matchAny(Arrays.stream(candidates)); + } + + public boolean matchAll(Stream candidates) { + return candidates.allMatch(this); + } + + public boolean matchAll(Collection candidates) { + return matchAll(candidates.stream()); + } + + public boolean matchAll(String[] candidates) { + return matchAll(Arrays.stream(candidates)); + } + + public > T getMatchAny(Stream candidates, Collector collector) { + return candidates.filter(this).collect(collector); + } + + public > T getMatchAny(Collection candidate, Collector collector) { + return getMatchAny(candidate.stream(), collector); + } + + public > T getMatchAny(final String[] candidate, Collector collector) { + return getMatchAny(Arrays.stream(candidate), collector); + } + + public Optional findFirst(final String candidate) { + return Optional.ofNullable(test(candidate) ? this : null); + } + + public Iterable iterateMatching(Iterable candidates) { + return iterateMatching(candidates, Function.identity()); + } + + public Iterable iterateMatching(Iterable candidates, Function toStringFunction) { + return new Iterable() { + + @Override + public Iterator iterator() { + Iterator delegate = candidates.iterator(); + + return new Iterator() { + private E next; + + @Override + public boolean hasNext() { + if (next == null) { + init(); + } + + return next != null; + } + + @Override + public E next() { + if (next == null) { + init(); + } + + E result = next; + next = null; + return result; + } + + private void init() { + while (delegate.hasNext()) { + E candidate = delegate.next(); + + if (test(toStringFunction.apply(candidate))) { + next = candidate; + break; + } + } + } + }; + } + }; + } + + public static List matchers(Collection patterns) { + return patterns.stream().map(p -> WildcardMatcher.from(p, true)).collect(Collectors.toList()); + } + + public static List getAllMatchingPatterns(final Collection matchers, final String candidate) { + return matchers.stream().filter(p -> p.test(candidate)).map(Objects::toString).collect(Collectors.toList()); + } + + public static List getAllMatchingPatterns(final Collection pattern, final Collection candidates) { + return pattern.stream().filter(p -> p.matchAny(candidates)).map(Objects::toString).collect(Collectors.toList()); + } + + public static boolean isExact(String pattern) { + return pattern == null || !(pattern.contains("*") || pattern.contains("?") || (pattern.startsWith("/") && pattern.endsWith("/"))); + } + + // + // --- Implementation specializations --- + // + // Casefolding matcher - sits on top of case-sensitive matcher + // and proxies toLower() of input string to the wrapped matcher + private static final class CasefoldingMatcher extends WildcardMatcher { + + private final WildcardMatcher inner; + + public CasefoldingMatcher(String pattern, Function simpleWildcardMatcher) { + this.inner = simpleWildcardMatcher.apply(pattern.toLowerCase()); + } + + @Override + public boolean test(String candidate) { + return inner.test(candidate.toLowerCase()); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + CasefoldingMatcher that = (CasefoldingMatcher) o; + return inner.equals(that.inner); + } + + @Override + public int hashCode() { + return inner.hashCode(); + } + + @Override + public String toString() { + return inner.toString(); + } + } + + public static final class Exact extends WildcardMatcher { + + private final String pattern; + + private Exact(String pattern) { + this.pattern = pattern; + } + + @Override + public boolean test(String candidate) { + return pattern.equals(candidate); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Exact that = (Exact) o; + return pattern.equals(that.pattern); + } + + @Override + public int hashCode() { + return pattern.hashCode(); + } + + @Override + public String toString() { + return pattern; + } + } + + // RegexMatcher uses JDK Pattern to test for matching, + // assumes "//" strings as input pattern + private static final class RegexMatcher extends WildcardMatcher { + + private final Pattern pattern; + + private RegexMatcher(String pattern, boolean caseSensitive) { + Preconditions.checkArgument(pattern.length() > 1 && pattern.startsWith("/") && pattern.endsWith("/")); + final String stripSlashesPattern = pattern.substring(1, pattern.length() - 1); + this.pattern = caseSensitive + ? Pattern.compile(stripSlashesPattern) + : Pattern.compile(stripSlashesPattern, Pattern.CASE_INSENSITIVE); + } + + @Override + public boolean test(String candidate) { + return pattern.matcher(candidate).matches(); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + RegexMatcher that = (RegexMatcher) o; + return pattern.pattern().equals(that.pattern.pattern()); + } + + @Override + public int hashCode() { + return pattern.pattern().hashCode(); + } + + @Override + public String toString() { + return "/" + pattern.pattern() + "/"; + } + } + + // Simple implementation of WildcardMatcher matcher with * and ? without + // using exlicit stack or recursion (as long as we don't need sub-matches it does work) + // allows us to save on resources and heap allocations unless Regex is required + private static final class SimpleMatcher extends WildcardMatcher { + + private final String pattern; + + SimpleMatcher(String pattern) { + this.pattern = pattern; + } + + @Override + public boolean test(String candidate) { + int i = 0; + int j = 0; + int n = candidate.length(); + int m = pattern.length(); + int text_backup = -1; + int wild_backup = -1; + while (i < n) { + if (j < m && pattern.charAt(j) == '*') { + text_backup = i; + wild_backup = ++j; + } else if (j < m && (pattern.charAt(j) == '?' || pattern.charAt(j) == candidate.charAt(i))) { + i++; + j++; + } else { + if (wild_backup == -1) return false; + i = ++text_backup; + j = wild_backup; + } + } + while (j < m && pattern.charAt(j) == '*') + j++; + return j >= m; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + SimpleMatcher that = (SimpleMatcher) o; + return pattern.equals(that.pattern); + } + + @Override + public int hashCode() { + return pattern.hashCode(); + } + + @Override + public String toString() { + return pattern; + } + } + + // MatcherCombiner is a combination of a set of matchers + // matches if any of the set do + // Empty MultiMatcher always returns false + private static final class MatcherCombiner extends WildcardMatcher { + + private final Collection wildcardMatchers; + private final int hashCode; + + MatcherCombiner(Collection wildcardMatchers) { + Preconditions.checkArgument(wildcardMatchers.size() > 1); + this.wildcardMatchers = wildcardMatchers; + hashCode = wildcardMatchers.hashCode(); + } + + @Override + public boolean test(String candidate) { + return wildcardMatchers.stream().anyMatch(m -> m.test(candidate)); + } + + @Override + public Optional findFirst(final String candidate) { + return wildcardMatchers.stream().filter(m -> m.test(candidate)).findFirst(); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + MatcherCombiner that = (MatcherCombiner) o; + return wildcardMatchers.equals(that.wildcardMatchers); + } + + @Override + public int hashCode() { + return hashCode; + } + + @Override + public String toString() { + return wildcardMatchers.toString(); + } + } +} diff --git a/common/src/main/java/org/opensearch/security/common/user/AuthCredentials.java b/common/src/main/java/org/opensearch/security/common/user/AuthCredentials.java new file mode 100644 index 0000000000..9255b63dba --- /dev/null +++ b/common/src/main/java/org/opensearch/security/common/user/AuthCredentials.java @@ -0,0 +1,254 @@ +/* + * Copyright 2015-2018 _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. + */ + +package org.opensearch.security.common.user; + +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.opensearch.OpenSearchSecurityException; + +/** + * AuthCredentials are an abstraction to encapsulate credentials like passwords or generic + * native credentials like GSS tokens. + * + */ +public final class AuthCredentials { + + private static final String DIGEST_ALGORITHM = "SHA-256"; + private final String username; + private byte[] password; + private Object nativeCredentials; + private final Set securityRoles = new HashSet(); + private final Set backendRoles = new HashSet(); + private boolean complete; + private final byte[] internalPasswordHash; + private final Map attributes = new HashMap<>(); + + /** + * Create new credentials with a username and native credentials + * + * @param username The username, must not be null or empty + * @param nativeCredentials Arbitrary credentials (like GSS tokens), must not be null + * @throws IllegalArgumentException if username or nativeCredentials are null or empty + */ + public AuthCredentials(final String username, final Object nativeCredentials) { + this(username, null, nativeCredentials); + + if (nativeCredentials == null) { + throw new IllegalArgumentException("nativeCredentials must not be null or empty"); + } + } + + /** + * Create new credentials with a username and password + * + * @param username The username, must not be null or empty + * @param password The password, must not be null or empty + * @throws IllegalArgumentException if username or password is null or empty + */ + public AuthCredentials(final String username, final byte[] password) { + this(username, password, null); + + if (password == null || password.length == 0) { + throw new IllegalArgumentException("password must not be null or empty"); + } + } + + /** + * Create new credentials with a username, a initial optional set of roles and empty password/native credentials + + * @param username The username, must not be null or empty + * @param backendRoles set of roles this user is a member of + * @throws IllegalArgumentException if username is null or empty + */ + public AuthCredentials(final String username, String... backendRoles) { + this(username, null, null, backendRoles); + } + + /** + * Create new credentials with a username, a initial optional set of roles and empty password/native credentials + * @param username The username, must not be null or empty + * @param securityRoles The internal roles the user has been mapped to + * @param backendRoles set of roles this user is a member of + * @throws IllegalArgumentException if username is null or empty + */ + public AuthCredentials(final String username, List securityRoles, String... backendRoles) { + this(username, null, null, backendRoles); + this.securityRoles.addAll(securityRoles); + } + + private AuthCredentials(final String username, byte[] password, Object nativeCredentials, String... backendRoles) { + super(); + + if (username == null || username.isEmpty()) { + throw new IllegalArgumentException("username must not be null or empty"); + } + + this.username = username; + // make defensive copy + this.password = password == null ? null : Arrays.copyOf(password, password.length); + + if (this.password != null) { + try { + MessageDigest digester = MessageDigest.getInstance(DIGEST_ALGORITHM); + internalPasswordHash = digester.digest(this.password); + } catch (NoSuchAlgorithmException e) { + throw new OpenSearchSecurityException("Unable to digest password", e); + } + } else { + internalPasswordHash = null; + } + + if (password != null) { + Arrays.fill(password, (byte) '\0'); + password = null; + } + + this.nativeCredentials = nativeCredentials; + nativeCredentials = null; + + if (backendRoles != null && backendRoles.length > 0) { + this.backendRoles.addAll(Arrays.asList(backendRoles)); + } + } + + /** + * Wipe password and native credentials + */ + public void clearSecrets() { + if (password != null) { + Arrays.fill(password, (byte) '\0'); + password = null; + } + + nativeCredentials = null; + } + + public String getUsername() { + return username; + } + + /** + * + * @return Defensive copy of the password + */ + public byte[] getPassword() { + // make defensive copy + return password == null ? null : Arrays.copyOf(password, password.length); + } + + public Object getNativeCredentials() { + return nativeCredentials; + } + + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + Arrays.hashCode(internalPasswordHash); + result = prime * result + ((username == null) ? 0 : username.hashCode()); + return result; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) return true; + if (obj == null) return false; + if (getClass() != obj.getClass()) return false; + AuthCredentials other = (AuthCredentials) obj; + if (internalPasswordHash == null + || other.internalPasswordHash == null + || !MessageDigest.isEqual(internalPasswordHash, other.internalPasswordHash)) return false; + if (username == null) { + if (other.username != null) return false; + } else if (!username.equals(other.username)) return false; + return true; + } + + @Override + public String toString() { + return "AuthCredentials [username=" + + username + + ", password empty=" + + (password == null) + + ", nativeCredentials empty=" + + (nativeCredentials == null) + + ",backendRoles=" + + backendRoles + + "]"; + } + + /** + * + * @return Defensive copy of the roles this user is member of. + */ + public Set getBackendRoles() { + return new HashSet(backendRoles); + } + + /** + * + * @return Defensive copy of the security roles this user is member of. + */ + public Set getSecurityRoles() { + return Set.copyOf(securityRoles); + } + + public boolean isComplete() { + return complete; + } + + /** + * If the credentials are complete and no further roundtrips with the originator are due + * then this method must be called so that the authentication flow can proceed. + *

+ * If this credentials are already marked a complete then a call to this method does nothing. + * + * @return this + */ + public AuthCredentials markComplete() { + this.complete = true; + return this; + } + + public void addAttribute(String name, String value) { + if (name != null && !name.isEmpty()) { + this.attributes.put(name, value); + } + } + + public Map getAttributes() { + return Collections.unmodifiableMap(this.attributes); + } +} diff --git a/common/src/main/java/org/opensearch/security/common/user/CustomAttributesAware.java b/common/src/main/java/org/opensearch/security/common/user/CustomAttributesAware.java new file mode 100644 index 0000000000..144bb04002 --- /dev/null +++ b/common/src/main/java/org/opensearch/security/common/user/CustomAttributesAware.java @@ -0,0 +1,34 @@ +/* + * Copyright 2015-2018 _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. + */ + +package org.opensearch.security.common.user; + +import java.util.Map; + +public interface CustomAttributesAware { + + Map getCustomAttributesMap(); +} diff --git a/common/src/main/java/org/opensearch/security/common/user/User.java b/common/src/main/java/org/opensearch/security/common/user/User.java new file mode 100644 index 0000000000..015ddf7fb1 --- /dev/null +++ b/common/src/main/java/org/opensearch/security/common/user/User.java @@ -0,0 +1,312 @@ +/* + * Copyright 2015-2018 _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. + */ + +package org.opensearch.security.common.user; + +import java.io.IOException; +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import com.google.common.collect.Lists; + +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; +import org.opensearch.core.common.io.stream.Writeable; + +/** + * A authenticated user and attributes associated to them (like roles, tenant, custom attributes) + *

+ * Do not subclass from this class! + */ +public class User implements Serializable, Writeable, CustomAttributesAware { + + public static final User ANONYMOUS = new User( + "opendistro_security_anonymous", + Lists.newArrayList("opendistro_security_anonymous_backendrole"), + null + ); + + // This is a default user that is injected into a transport request when a user info is not present and passive_intertransport_auth is + // enabled. + // This is to be used in scenarios where some of the nodes do not have security enabled, and therefore do not pass any user information + // in threadcontext, yet we need the communication to not break between the nodes. + // Attach the required permissions to either the user or the backend role. + public static final User DEFAULT_TRANSPORT_USER = new User( + "opendistro_security_default_transport_user", + Lists.newArrayList("opendistro_security_default_transport_backendrole"), + null + ); + + private static final long serialVersionUID = -5500938501822658596L; + private final String name; + /** + * roles == backend_roles + */ + private final Set roles = Collections.synchronizedSet(new HashSet()); + private final Set securityRoles = Collections.synchronizedSet(new HashSet()); + private String requestedTenant; + private Map attributes = Collections.synchronizedMap(new HashMap<>()); + private boolean isInjected = false; + + public User(final StreamInput in) throws IOException { + super(); + name = in.readString(); + roles.addAll(in.readList(StreamInput::readString)); + requestedTenant = in.readString(); + if (requestedTenant.isEmpty()) { + requestedTenant = null; + } + attributes = Collections.synchronizedMap(in.readMap(StreamInput::readString, StreamInput::readString)); + securityRoles.addAll(in.readList(StreamInput::readString)); + } + + /** + * Create a new authenticated user + * + * @param name The username (must not be null or empty) + * @param roles Roles of which the user is a member off (maybe null) + * @param customAttributes Custom attributes associated with this (maybe null) + * @throws IllegalArgumentException if name is null or empty + */ + public User(final String name, final Collection roles, final AuthCredentials customAttributes) { + super(); + + if (name == null || name.isEmpty()) { + throw new IllegalArgumentException("name must not be null or empty"); + } + + this.name = name; + + if (roles != null) { + this.addRoles(roles); + } + + if (customAttributes != null) { + this.attributes.putAll(customAttributes.getAttributes()); + } + + } + + /** + * Create a new authenticated user without roles and attributes + * + * @param name The username (must not be null or empty) + * @throws IllegalArgumentException if name is null or empty + */ + public User(final String name) { + this(name, null, null); + } + + public final String getName() { + return name; + } + + /** + * @return A unmodifiable set of the backend roles this user is a member of + */ + public final Set getRoles() { + return Collections.unmodifiableSet(roles); + } + + /** + * Associate this user with a backend role + * + * @param role The backend role + */ + public final void addRole(final String role) { + this.roles.add(role); + } + + /** + * Associate this user with a set of backend roles + * + * @param roles The backend roles + */ + public final void addRoles(final Collection roles) { + if (roles != null) { + this.roles.addAll(roles); + } + } + + /** + * Check if this user is a member of a backend role + * + * @param role The backend role + * @return true if this user is a member of the backend role, false otherwise + */ + public final boolean isUserInRole(final String role) { + return this.roles.contains(role); + } + + /** + * Associate this user with a set of custom attributes + * + * @param attributes custom attributes + */ + public final void addAttributes(final Map attributes) { + if (attributes != null) { + this.attributes.putAll(attributes); + } + } + + public final String getRequestedTenant() { + return requestedTenant; + } + + public final void setRequestedTenant(String requestedTenant) { + this.requestedTenant = requestedTenant; + } + + public boolean isInjected() { + return isInjected; + } + + public void setInjected(boolean isInjected) { + this.isInjected = isInjected; + } + + public final String toStringWithAttributes() { + return "User [name=" + + name + + ", backend_roles=" + + roles + + ", requestedTenant=" + + requestedTenant + + ", attributes=" + + attributes + + "]"; + } + + @Override + public final String toString() { + return "User [name=" + name + ", backend_roles=" + roles + ", requestedTenant=" + requestedTenant + "]"; + } + + @Override + public final int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + (name == null ? 0 : name.hashCode()); + return result; + } + + @Override + public final boolean equals(final Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (!(obj instanceof User)) { + return false; + } + final User other = (User) obj; + if (name == null) { + if (other.name != null) { + return false; + } + } else if (!name.equals(other.name)) { + return false; + } + return true; + } + + /** + * Copy all backend roles from another user + * + * @param user The user from which the backend roles should be copied over + */ + public final void copyRolesFrom(final User user) { + if (user != null) { + this.addRoles(user.getRoles()); + } + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + out.writeString(name); + out.writeStringCollection(new ArrayList(roles)); + out.writeString(requestedTenant == null ? "" : requestedTenant); + out.writeMap(attributes, StreamOutput::writeString, StreamOutput::writeString); + out.writeStringCollection(securityRoles == null ? Collections.emptyList() : new ArrayList(securityRoles)); + } + + /** + * Get the custom attributes associated with this user + * + * @return A modifiable map with all the current custom attributes associated with this user + */ + public synchronized final Map getCustomAttributesMap() { + if (attributes == null) { + attributes = Collections.synchronizedMap(new HashMap<>()); + } + return attributes; + } + + public final void addSecurityRoles(final Collection securityRoles) { + if (securityRoles != null && this.securityRoles != null) { + this.securityRoles.addAll(securityRoles); + } + } + + public final Set getSecurityRoles() { + return this.securityRoles == null + ? Collections.synchronizedSet(Collections.emptySet()) + : Collections.unmodifiableSet(this.securityRoles); + } + + /** + * Check the custom attributes associated with this user + * + * @return true if it has a service account attributes, otherwise false + */ + public boolean isServiceAccount() { + Map userAttributesMap = this.getCustomAttributesMap(); + return userAttributesMap != null && "true".equals(userAttributesMap.get("attr.internal.service")); + } + + /** + * Check the custom attributes associated with this user + * + * @return true if it has a plugin account attributes, otherwise false + */ + public boolean isPluginUser() { + return name != null && name.startsWith("plugin:"); + } + + public void setAttributes(Map attributes) { + if (attributes == null) { + this.attributes = Collections.synchronizedMap(new HashMap<>()); + } + } +} diff --git a/sample-resource-plugin/README.md b/sample-resource-plugin/README.md new file mode 100644 index 0000000000..b6101f463f --- /dev/null +++ b/sample-resource-plugin/README.md @@ -0,0 +1,200 @@ +# 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, Common and Client to local maven before proceeding: +```shell +./gradlew clean :opensearch-resource-sharing-spi:publishToMavenLocal + +./gradlew clean :opensearch-security-common:publishToMavenLocal + +./gradlew clean :opensearch-security-client: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 resources, 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. **New Security Permission Requirement** + - Users need **`cluster:admin/security/resource_access`** in their role to **interact with shared resources**. + - This applies **in addition to** any plugin-specific cluster permissions. + +2. **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. + +3. **Sample Role Configurations** + - Below are **two sample roles** demonstrating how to configure permissions in `roles.yml`: + + ```yaml + sample_full_access: + cluster_permissions: + - 'cluster:admin/security/resource_access' + - 'cluster:admin/sample-resource-plugin/*' + + sample_read_access: + cluster_permissions: + - 'cluster:admin/security/resource_access' + - '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": { + "public": { + "users": [ "sample_user" ] + } + } + } + ``` +- **Response:** + ```json + { + "share_with": { + "public": { + "users": [ "sample_user" ] + } + } + } + ``` + +### 6. Revoke Resource Access +- **Endpoint:** `POST /_plugins/sample_resource_sharing/revoke/{resource_id}` +- **Description:** Shares a resource with the intended entities for given scopes. At present, only admin and resource owners can share the resource. +- **Request Body:** + ```json + { + "entities_to_revoke": { + "users": [ "sample_user" ] + }, + "scopes": [ "public" ] + } + ``` +- **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..ebcd270fcf --- /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-alpha1-SNAPSHOT") + isSnapshot = "true" == System.getProperty("build.snapshot", "true") + buildVersionQualifier = System.getProperty("build.version_qualifier", "alpha1") + + 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 + implementation project(path: ":${rootProject.name}-client", configuration: 'shadow') + 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..742a2d7cd0 --- /dev/null +++ b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/AbstractSampleResourcePluginFeatureEnabledTests.java @@ -0,0 +1,476 @@ +/* + * 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.security.common.resources.ResourcePluginInfo; +import org.opensearch.security.common.resources.ResourceProvider; +import org.opensearch.test.framework.TestSecurityConfig; +import org.opensearch.test.framework.cluster.LocalCluster; +import org.opensearch.test.framework.cluster.TestRestClient; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.nullValue; +import static org.opensearch.sample.utils.Constants.RESOURCE_INDEX_NAME; +import static org.opensearch.security.common.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; + + private static TestSecurityConfig.User sharedUser; + + @Before + public void setup() { + cluster = getLocalCluster(); + sharedUser = getSharedUser(); + } + + @After + public void clearIndices() { + try (TestRestClient client = cluster.getRestClient(cluster.getAdminCertificate())) { + client.delete(RESOURCE_INDEX_NAME); + client.delete(OPENSEARCH_RESOURCE_SHARING_INDEX); + ResourcePluginInfo.getInstance().getResourceIndicesMutable().remove(RESOURCE_INDEX_NAME); + ResourcePluginInfo.getInstance().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.getInstance().getResourceIndicesMutable().add(RESOURCE_INDEX_NAME); + ResourceProvider provider = new ResourceProvider( + SampleResource.class.getCanonicalName(), + RESOURCE_INDEX_NAME, + new SampleResourceParser() + ); + ResourcePluginInfo.getInstance().getResourceProvidersMutable().put(RESOURCE_INDEX_NAME, provider); + + Thread.sleep(1000); + response = client.get(SECURITY_RESOURCE_LIST_ENDPOINT + "/" + RESOURCE_INDEX_NAME); + response.assertStatusCode(HttpStatus.SC_OK); + assertThat(response.bodyAsJsonNode().get("resources").size(), equalTo(1)); + assertThat(response.getBody(), containsString("sample")); + } + + // Update sample resource (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(SampleResourceScope.PUBLIC.value()) + .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.getInstance().getResourceIndicesMutable().add(RESOURCE_INDEX_NAME); + ResourceProvider provider = new ResourceProvider( + SampleResource.class.getCanonicalName(), + RESOURCE_INDEX_NAME, + new SampleResourceParser() + ); + ResourcePluginInfo.getInstance().getResourceProvidersMutable().put(RESOURCE_INDEX_NAME, provider); + + 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(SampleResourceScope.PUBLIC.value()).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..c3361c8642 --- /dev/null +++ b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/AbstractSampleResourcePluginTests.java @@ -0,0 +1,132 @@ +/* + * 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.security.spi.resources.ResourceAccessScope; +import org.opensearch.test.framework.TestSecurityConfig; + +import static org.opensearch.sample.utils.Constants.RESOURCE_INDEX_NAME; +import static org.opensearch.sample.utils.Constants.SAMPLE_RESOURCE_PLUGIN_PREFIX; +import static org.opensearch.security.dlic.rest.support.Utils.PLUGIN_RESOURCE_ROUTE_PREFIX; + +/** + * 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 String shareWithPayloadSecurityApi(String resourceId, String user) { + return "{" + + "\"resource_id\":\"" + + resourceId + + "\"," + + "\"resource_index\":\"" + + RESOURCE_INDEX_NAME + + "\"," + + "\"share_with\":{" + + "\"" + + SampleResourceScope.PUBLIC.value() + + "\":{" + + "\"users\": [\"" + + user + + "\"]" + + "}" + + "}" + + "}"; + } + + protected static String shareWithPayload(String user) { + return "{" + + "\"share_with\":{" + + "\"" + + SampleResourceScope.PUBLIC.value() + + "\":{" + + "\"users\": [\"" + + user + + "\"]" + + "}" + + "}" + + "}"; + } + + protected static String revokeAccessPayloadSecurityApi(String resourceId, String user) { + return "{" + + "\"resource_id\": \"" + + resourceId + + "\"," + + "\"resource_index\": \"" + + RESOURCE_INDEX_NAME + + "\"," + + "\"entities_to_revoke\": {" + + "\"users\": [\"" + + user + + "\"]" + + "}," + + "\"scopes\": [\"" + + ResourceAccessScope.PUBLIC + + "\"]" + + "}"; + } + + protected static String revokeAccessPayload(String user) { + return "{" + + "\"entities_to_revoke\": {" + + "\"users\": [\"" + + user + + "\"]" + + "}," + + "\"scopes\": [\"" + + ResourceAccessScope.PUBLIC + + "\"]" + + "}"; + } + + protected static String verifyAccessPayload(String resourceId) { + return "{" + + "\"resource_id\":\"" + + resourceId + + "\"," + + "\"resource_index\":\"" + + RESOURCE_INDEX_NAME + + "\"," + + "\"scopes\":[\"" + + ResourceAccessScope.PUBLIC + + "\"]" + + "}"; + } +} 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..bdf3d9c3e9 --- /dev/null +++ b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/SampleResourcePluginFeatureDisabledTests.java @@ -0,0 +1,165 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.sample; + +import java.util.Map; + +import com.carrotsearch.randomizedtesting.annotations.ThreadLeakScope; +import org.apache.http.HttpStatus; +import org.junit.After; +import org.junit.ClassRule; +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.opensearch.painless.PainlessModulePlugin; +import org.opensearch.test.framework.cluster.ClusterManager; +import org.opensearch.test.framework.cluster.LocalCluster; +import org.opensearch.test.framework.cluster.TestRestClient; +import org.opensearch.test.framework.cluster.TestRestClient.HttpResponse; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.opensearch.sample.utils.Constants.RESOURCE_INDEX_NAME; +import static org.opensearch.security.common.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..907dc45229 --- /dev/null +++ b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/SampleResourcePluginLimitedPermissionsTests.java @@ -0,0 +1,211 @@ +/* + * 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.security.common.resources.ResourcePluginInfo; +import org.opensearch.security.common.resources.ResourceProvider; +import org.opensearch.test.framework.TestSecurityConfig; +import org.opensearch.test.framework.cluster.ClusterManager; +import org.opensearch.test.framework.cluster.LocalCluster; +import org.opensearch.test.framework.cluster.TestRestClient; +import org.opensearch.test.framework.cluster.TestRestClient.HttpResponse; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.opensearch.sample.utils.Constants.RESOURCE_INDEX_NAME; +import static org.opensearch.security.common.resources.ResourceSharingConstants.OPENSEARCH_RESOURCE_SHARING_INDEX; +import static org.opensearch.security.support.ConfigConstants.OPENSEARCH_RESOURCE_SHARING_ENABLED; +import static org.opensearch.security.support.ConfigConstants.SECURITY_SYSTEM_INDICES_ENABLED_KEY; +import static org.opensearch.test.framework.TestSecurityConfig.AuthcDomain.AUTHC_HTTPBASIC_INTERNAL; +import static org.opensearch.test.framework.TestSecurityConfig.User.USER_ADMIN; + +/** + * These tests run with resource sharing enabled and system index protection enabled + */ +@RunWith(com.carrotsearch.randomizedtesting.RandomizedRunner.class) +@ThreadLeakScope(ThreadLeakScope.Scope.NONE) +public class SampleResourcePluginLimitedPermissionsTests extends AbstractSampleResourcePluginFeatureEnabledTests { + + @ClassRule + public static LocalCluster cluster = new LocalCluster.Builder().clusterManager(ClusterManager.SINGLENODE) + .plugin(SampleResourcePlugin.class, PainlessModulePlugin.class) + .anonymousAuth(true) + .authc(AUTHC_HTTPBASIC_INTERNAL) + .users(USER_ADMIN, SHARED_WITH_USER_LIMITED_PERMISSIONS) + .nodeSettings(Map.of(SECURITY_SYSTEM_INDICES_ENABLED_KEY, true, OPENSEARCH_RESOURCE_SHARING_ENABLED, true)) + .build(); + + @Override + protected LocalCluster getLocalCluster() { + return cluster; + } + + @Override + protected TestSecurityConfig.User getSharedUser() { + return SHARED_WITH_USER_LIMITED_PERMISSIONS; + } + + @Test + public void testAccessWithLimitedIP() throws Exception { + String resourceId; + // create sample resource + try (TestRestClient client = cluster.getRestClient(SHARED_WITH_USER_LIMITED_PERMISSIONS)) { + String sampleResource = "{\"name\":\"sample\"}"; + HttpResponse response = client.putJson(SAMPLE_RESOURCE_CREATE_ENDPOINT, sampleResource); + response.assertStatusCode(HttpStatus.SC_OK); + + resourceId = response.getTextFromJsonBody("/message").split(":")[1].trim(); + 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.getInstance().getResourceIndicesMutable().add(RESOURCE_INDEX_NAME); + ResourceProvider provider = new ResourceProvider( + SampleResource.class.getCanonicalName(), + RESOURCE_INDEX_NAME, + new SampleResourceParser() + ); + ResourcePluginInfo.getInstance().getResourceProvidersMutable().put(RESOURCE_INDEX_NAME, provider); + + Thread.sleep(1000); + response = client.get(SECURITY_RESOURCE_LIST_ENDPOINT + "/" + RESOURCE_INDEX_NAME); + response.assertStatusCode(HttpStatus.SC_OK); + assertThat(response.bodyAsJsonNode().get("resources").size(), equalTo(1)); + assertThat(response.getBody(), containsString("sample")); + } + + // 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..c82e09052b --- /dev/null +++ b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/SampleResourcePluginSystemIndexDisabledTests.java @@ -0,0 +1,191 @@ +/* + * 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.security.common.resources.ResourcePluginInfo; +import org.opensearch.security.common.resources.ResourceProvider; +import org.opensearch.test.framework.TestSecurityConfig; +import org.opensearch.test.framework.cluster.ClusterManager; +import org.opensearch.test.framework.cluster.LocalCluster; +import org.opensearch.test.framework.cluster.TestRestClient; +import org.opensearch.test.framework.cluster.TestRestClient.HttpResponse; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.opensearch.sample.utils.Constants.RESOURCE_INDEX_NAME; +import static org.opensearch.security.common.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 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.getInstance().getResourceIndicesMutable().add(RESOURCE_INDEX_NAME); + ResourceProvider provider = new ResourceProvider( + SampleResource.class.getCanonicalName(), + RESOURCE_INDEX_NAME, + new SampleResourceParser() + ); + ResourcePluginInfo.getInstance().getResourceProvidersMutable().put(RESOURCE_INDEX_NAME, provider); + + Thread.sleep(1000); + response = client.get(SECURITY_RESOURCE_LIST_ENDPOINT + "/" + RESOURCE_INDEX_NAME); + response.assertStatusCode(HttpStatus.SC_OK); + assertThat(response.bodyAsJsonNode().get("resources").size(), equalTo(1)); + assertThat(response.getBody(), containsString("sample")); + } + + // 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(SampleResourceScope.PUBLIC.value()).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..70cffff075 --- /dev/null +++ b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/SampleResourcePluginTests.java @@ -0,0 +1,192 @@ +/* + * 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.security.common.resources.ResourcePluginInfo; +import org.opensearch.security.common.resources.ResourceProvider; +import org.opensearch.test.framework.TestSecurityConfig; +import org.opensearch.test.framework.cluster.ClusterManager; +import org.opensearch.test.framework.cluster.LocalCluster; +import org.opensearch.test.framework.cluster.TestRestClient; +import org.opensearch.test.framework.cluster.TestRestClient.HttpResponse; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.opensearch.sample.utils.Constants.RESOURCE_INDEX_NAME; +import static org.opensearch.security.common.resources.ResourceSharingConstants.OPENSEARCH_RESOURCE_SHARING_INDEX; +import static org.opensearch.security.support.ConfigConstants.OPENSEARCH_RESOURCE_SHARING_ENABLED; +import static org.opensearch.security.support.ConfigConstants.SECURITY_SYSTEM_INDICES_ENABLED_KEY; +import static org.opensearch.test.framework.TestSecurityConfig.AuthcDomain.AUTHC_HTTPBASIC_INTERNAL; +import static org.opensearch.test.framework.TestSecurityConfig.User.USER_ADMIN; + +/** + * These tests run with resource sharing enabled and system index protection enabled + */ +@RunWith(com.carrotsearch.randomizedtesting.RandomizedRunner.class) +@ThreadLeakScope(ThreadLeakScope.Scope.NONE) +public class SampleResourcePluginTests extends AbstractSampleResourcePluginFeatureEnabledTests { + + @ClassRule + public static LocalCluster cluster = new LocalCluster.Builder().clusterManager(ClusterManager.SINGLENODE) + .plugin(SampleResourcePlugin.class, PainlessModulePlugin.class) + .anonymousAuth(true) + .authc(AUTHC_HTTPBASIC_INTERNAL) + .users(USER_ADMIN, SHARED_WITH_USER) + .nodeSettings(Map.of(SECURITY_SYSTEM_INDICES_ENABLED_KEY, true, OPENSEARCH_RESOURCE_SHARING_ENABLED, true)) + .build(); + + @Override + protected LocalCluster getLocalCluster() { + return cluster; + } + + @Override + protected TestSecurityConfig.User getSharedUser() { + return SHARED_WITH_USER; + } + + @Test + public void testDirectAccess() throws Exception { + 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.getInstance().getResourceIndicesMutable().add(RESOURCE_INDEX_NAME); + ResourceProvider provider = new ResourceProvider( + SampleResource.class.getCanonicalName(), + RESOURCE_INDEX_NAME, + new SampleResourceParser() + ); + ResourcePluginInfo.getInstance().getResourceProvidersMutable().put(RESOURCE_INDEX_NAME, provider); + + Thread.sleep(1000); + response = client.get(SECURITY_RESOURCE_LIST_ENDPOINT + "/" + RESOURCE_INDEX_NAME); + response.assertStatusCode(HttpStatus.SC_OK); + assertThat(response.bodyAsJsonNode().get("resources").size(), equalTo(1)); + assertThat(response.getBody(), containsString("sample")); + } + + // 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(SampleResourceScope.PUBLIC.value()).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..33e6505a18 --- /dev/null +++ b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/nonsystemindex/AbstractResourcePluginNonSystemIndexTests.java @@ -0,0 +1,94 @@ +/* + * 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.common.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.common.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; + + @Before + public void setup() { + cluster = getLocalCluster(); + } + + @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.getInstance().getResourceIndicesMutable().remove(SAMPLE_NON_SYSTEM_INDEX_NAME); + ResourcePluginInfo.getInstance().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..c7e0d8f3a6 --- /dev/null +++ b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/nonsystemindex/plugin/ResourceNonSystemIndexPlugin.java @@ -0,0 +1,44 @@ +/* + * 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.Resource; +import org.opensearch.security.spi.resources.ResourceParser; +import org.opensearch.security.spi.resources.ResourceSharingExtension; + +/** + * 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 ResourceParser getResourceParser() { + return new SampleResourceParser(); + } +} 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..6516ce9069 --- /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..a7fe2bccf3 --- /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.Resource; + +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 Resource { + + 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 getResourceName() { + return name; + } +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/SampleResourceParser.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/SampleResourceParser.java new file mode 100644 index 0000000000..0b4601f5a3 --- /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.ResourceParser; + +/** + * Responsible for parsing the XContent into a SampleResource object. + */ +public class SampleResourceParser implements ResourceParser { + @Override + public SampleResource parseXContent(XContentParser parser) throws IOException { + return SampleResource.fromXContent(parser); + } +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/SampleResourcePlugin.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/SampleResourcePlugin.java new file mode 100644 index 0000000000..1ea1096e74 --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/SampleResourcePlugin.java @@ -0,0 +1,141 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ +package org.opensearch.sample; + +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.function.Supplier; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import org.opensearch.action.ActionRequest; +import org.opensearch.cluster.metadata.IndexNameExpressionResolver; +import org.opensearch.cluster.node.DiscoveryNodes; +import org.opensearch.cluster.service.ClusterService; +import org.opensearch.common.settings.ClusterSettings; +import org.opensearch.common.settings.IndexScopedSettings; +import org.opensearch.common.settings.Settings; +import org.opensearch.common.settings.SettingsFilter; +import org.opensearch.core.action.ActionResponse; +import org.opensearch.core.common.io.stream.NamedWriteableRegistry; +import org.opensearch.core.xcontent.NamedXContentRegistry; +import org.opensearch.env.Environment; +import org.opensearch.env.NodeEnvironment; +import org.opensearch.indices.SystemIndexDescriptor; +import org.opensearch.plugins.ActionPlugin; +import org.opensearch.plugins.Plugin; +import org.opensearch.plugins.SystemIndexPlugin; +import org.opensearch.repositories.RepositoriesService; +import org.opensearch.rest.RestController; +import org.opensearch.rest.RestHandler; +import org.opensearch.sample.resource.actions.rest.create.CreateResourceAction; +import org.opensearch.sample.resource.actions.rest.create.CreateResourceRestAction; +import org.opensearch.sample.resource.actions.rest.create.UpdateResourceAction; +import org.opensearch.sample.resource.actions.rest.delete.DeleteResourceAction; +import org.opensearch.sample.resource.actions.rest.delete.DeleteResourceRestAction; +import org.opensearch.sample.resource.actions.rest.get.GetResourceAction; +import org.opensearch.sample.resource.actions.rest.get.GetResourceRestAction; +import org.opensearch.sample.resource.actions.rest.revoke.RevokeResourceAccessAction; +import org.opensearch.sample.resource.actions.rest.revoke.RevokeResourceAccessRestAction; +import org.opensearch.sample.resource.actions.rest.share.ShareResourceAction; +import org.opensearch.sample.resource.actions.rest.share.ShareResourceRestAction; +import org.opensearch.sample.resource.actions.transport.CreateResourceTransportAction; +import org.opensearch.sample.resource.actions.transport.DeleteResourceTransportAction; +import org.opensearch.sample.resource.actions.transport.GetResourceTransportAction; +import org.opensearch.sample.resource.actions.transport.RevokeResourceAccessTransportAction; +import org.opensearch.sample.resource.actions.transport.ShareResourceTransportAction; +import org.opensearch.sample.resource.actions.transport.UpdateResourceTransportAction; +import org.opensearch.script.ScriptService; +import org.opensearch.security.spi.resources.ResourceParser; +import org.opensearch.security.spi.resources.ResourceSharingExtension; +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 ResourceParser getResourceParser() { + return new SampleResourceParser(); + } +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/SampleResourceScope.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/SampleResourceScope.java new file mode 100644 index 0000000000..a473e46b61 --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/SampleResourceScope.java @@ -0,0 +1,40 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.sample; + +import org.opensearch.security.spi.resources.ResourceAccessScope; + +/** + * This class implements two scopes for the sample plugin. + * The first scope is SAMPLE_FULL_ACCESS, which allows full access to the sample plugin. + * The second scope is PUBLIC, which allows public access to the sample plugin. + */ +public enum SampleResourceScope implements ResourceAccessScope { + + SAMPLE_FULL_ACCESS("sample_full_access"), + SAMPLE_READ_ACCESS("sample_read_access"), + SAMPLE_WRITE_ACCESS("sample_write_access"), + SAMPLE_DELETE_ACCESS("sample_delete_access"), + + PUBLIC("public"); + + private final String name; + + SampleResourceScope(String scopeName) { + this.name = scopeName; + } + + @Override + public String value() { + return name; + } +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/create/CreateResourceAction.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/create/CreateResourceAction.java new file mode 100644 index 0000000000..3e73b95f79 --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/create/CreateResourceAction.java @@ -0,0 +1,29 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.sample.resource.actions.rest.create; + +import org.opensearch.action.ActionType; + +/** + * Action to create a sample resource + */ +public class CreateResourceAction extends ActionType { + /** + * Create sample resource action instance + */ + public static final CreateResourceAction INSTANCE = new CreateResourceAction(); + /** + * Create sample resource action name + */ + public static final String NAME = "cluster:admin/sample-resource-plugin/create"; + + private CreateResourceAction() { + super(NAME, CreateResourceResponse::new); + } +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/create/CreateResourceRequest.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/create/CreateResourceRequest.java new file mode 100644 index 0000000000..d3e9a7a468 --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/create/CreateResourceRequest.java @@ -0,0 +1,50 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.sample.resource.actions.rest.create; + +import java.io.IOException; + +import org.opensearch.action.ActionRequest; +import org.opensearch.action.ActionRequestValidationException; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; +import org.opensearch.security.spi.resources.Resource; + +/** + * Request object for CreateSampleResource transport action + */ +public class CreateResourceRequest extends ActionRequest { + + private final Resource resource; + + /** + * Default constructor + */ + public CreateResourceRequest(Resource resource) { + this.resource = resource; + } + + public CreateResourceRequest(StreamInput in) throws IOException { + this.resource = in.readNamedWriteable(Resource.class); + } + + @Override + public void writeTo(final StreamOutput out) throws IOException { + resource.writeTo(out); + } + + @Override + public ActionRequestValidationException validate() { + return null; + } + + public Resource getResource() { + return this.resource; + } +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/create/CreateResourceResponse.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/create/CreateResourceResponse.java new file mode 100644 index 0000000000..33c8b0b1e6 --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/create/CreateResourceResponse.java @@ -0,0 +1,55 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.sample.resource.actions.rest.create; + +import java.io.IOException; + +import org.opensearch.core.action.ActionResponse; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; +import org.opensearch.core.xcontent.ToXContentObject; +import org.opensearch.core.xcontent.XContentBuilder; + +/** + * Response to a CreateSampleResourceRequest + */ +public class CreateResourceResponse extends ActionResponse implements ToXContentObject { + private final String message; + + /** + * Default constructor + * + * @param message The message + */ + public CreateResourceResponse(String message) { + this.message = message; + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + out.writeString(message); + } + + /** + * Constructor with StreamInput + * + * @param in the stream input + */ + public CreateResourceResponse(final StreamInput in) throws IOException { + message = in.readString(); + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + builder.startObject(); + builder.field("message", message); + builder.endObject(); + return builder; + } +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/create/CreateResourceRestAction.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/create/CreateResourceRestAction.java new file mode 100644 index 0000000000..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..db74525a3a --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/create/UpdateResourceRequest.java @@ -0,0 +1,58 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.sample.resource.actions.rest.create; + +import java.io.IOException; + +import org.opensearch.action.ActionRequest; +import org.opensearch.action.ActionRequestValidationException; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; +import org.opensearch.security.spi.resources.Resource; + +/** + * Request object for UpdateResource transport action + */ +public class UpdateResourceRequest extends ActionRequest { + + private final String resourceId; + private final Resource resource; + + /** + * Default constructor + */ + public UpdateResourceRequest(String resourceId, Resource resource) { + this.resourceId = resourceId; + this.resource = resource; + } + + public UpdateResourceRequest(StreamInput in) throws IOException { + this.resourceId = in.readString(); + this.resource = in.readNamedWriteable(Resource.class); + } + + @Override + public void writeTo(final StreamOutput out) throws IOException { + out.writeString(resourceId); + resource.writeTo(out); + } + + @Override + public ActionRequestValidationException validate() { + return null; + } + + public Resource getResource() { + return this.resource; + } + + public String getResourceId() { + return this.resourceId; + } +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/delete/DeleteResourceAction.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/delete/DeleteResourceAction.java new file mode 100644 index 0000000000..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..f32e54c203 --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/revoke/RevokeResourceAccessRequest.java @@ -0,0 +1,67 @@ +/* + * 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.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +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 revoking access to a sample resource + */ +public class RevokeResourceAccessRequest extends ActionRequest { + + String resourceId; + Map entitiesToRevoke; + Set scopes; + + public RevokeResourceAccessRequest(String resourceId, Map entitiesToRevoke, List scopes) { + this.resourceId = resourceId; + this.entitiesToRevoke = entitiesToRevoke; + this.scopes = new HashSet<>(scopes); + } + + public RevokeResourceAccessRequest(StreamInput in) throws IOException { + resourceId = in.readString(); + entitiesToRevoke = in.readMap(); + scopes = in.readSet(StreamInput::readString); + } + + @Override + public void writeTo(final StreamOutput out) throws IOException { + out.writeString(resourceId); + out.writeMap(entitiesToRevoke); + out.writeStringCollection(scopes); + + } + + @Override + public ActionRequestValidationException validate() { + return null; + } + + public String getResourceId() { + return resourceId; + } + + public Map getEntitiesToRevoke() { + return entitiesToRevoke; + } + + public Set getScopes() { + return scopes; + } +} 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..03d1cf8053 --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/revoke/RevokeResourceAccessRestAction.java @@ -0,0 +1,68 @@ +/* + * 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.List; +import java.util.Map; + +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.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 getResourceRequest = new RevokeResourceAccessRequest( + resourceId, + (Map) source.get("entities_to_revoke"), + (List) source.get("scopes") + ); + return channel -> client.executeLocally( + RevokeResourceAccessAction.INSTANCE, + getResourceRequest, + new RestToXContentListener<>(channel) + ); + } +} 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..7cca2bddee --- /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 java.util.Map; + +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 sharing sample resource transport action + */ +public class ShareResourceRequest extends ActionRequest { + + private final String resourceId; + + private final Map shareWith; + + public ShareResourceRequest(String resourceId, Map shareWith) { + this.resourceId = resourceId; + this.shareWith = shareWith; + } + + public ShareResourceRequest(StreamInput in) throws IOException { + this.resourceId = in.readString(); + this.shareWith = in.readMap(); + } + + @Override + public void writeTo(final StreamOutput out) throws IOException { + out.writeString(this.resourceId); + out.writeMap(shareWith); + } + + @Override + public ActionRequestValidationException validate() { + return null; + } + + public String getResourceId() { + return this.resourceId; + } + + public Map 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..00665f66fb --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/share/ShareResourceRestAction.java @@ -0,0 +1,63 @@ +/* + * 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.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.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, shareWith); + return channel -> client.executeLocally(ShareResourceAction.INSTANCE, shareResourceRequest, new RestToXContentListener<>(channel)); + } +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/transport/CreateResourceTransportAction.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/transport/CreateResourceTransportAction.java new file mode 100644 index 0000000000..d6d12022d5 --- /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.Resource; +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) { + Resource sample = request.getResource(); + try (XContentBuilder builder = jsonBuilder()) { + IndexRequest ir = nodeClient.prepareIndex(RESOURCE_INDEX_NAME) + .setWaitForActiveShards(1) + .setRefreshPolicy(WriteRequest.RefreshPolicy.IMMEDIATE) + .setSource(sample.toXContent(builder, ToXContent.EMPTY_PARAMS)) + .request(); + + log.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..77455a3e73 --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/transport/DeleteResourceTransportAction.java @@ -0,0 +1,117 @@ +/* + * 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.Set; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import org.opensearch.ResourceNotFoundException; +import org.opensearch.action.DocWriteResponse; +import org.opensearch.action.delete.DeleteRequest; +import org.opensearch.action.delete.DeleteResponse; +import org.opensearch.action.support.ActionFilters; +import org.opensearch.action.support.HandledTransportAction; +import org.opensearch.action.support.WriteRequest; +import org.opensearch.common.inject.Inject; +import org.opensearch.common.settings.Settings; +import org.opensearch.common.util.concurrent.ThreadContext; +import org.opensearch.core.action.ActionListener; +import org.opensearch.sample.SampleResourceScope; +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.client.resources.ResourceSharingClient; +import org.opensearch.security.spi.resources.exceptions.ResourceSharingException; +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; + private final Settings settings; + + @Inject + public DeleteResourceTransportAction( + Settings settings, + TransportService transportService, + ActionFilters actionFilters, + NodeClient nodeClient + ) { + super(DeleteResourceAction.NAME, transportService, actionFilters, DeleteResourceRequest::new); + this.transportService = transportService; + this.nodeClient = nodeClient; + this.settings = settings; + } + + @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(nodeClient, settings); + resourceSharingClient.verifyResourceAccess( + resourceId, + RESOURCE_INDEX_NAME, + Set.of( + SampleResourceScope.SAMPLE_DELETE_ACCESS.value(), + SampleResourceScope.SAMPLE_FULL_ACCESS.value(), + SampleResourceScope.PUBLIC.value() + ), + ActionListener.wrap(isAuthorized -> { + if (!isAuthorized) { + listener.onFailure(new ResourceSharingException("Current user is not authorized to delete resource: " + resourceId)); + 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..27df11c26d --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/transport/GetResourceTransportAction.java @@ -0,0 +1,186 @@ +/* + * 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.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.SampleResourceScope; +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.client.resources.ResourceSharingClient; +import org.opensearch.security.common.support.ConfigConstants; +import org.opensearch.security.spi.resources.exceptions.ResourceSharingException; +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 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(nodeClient, settings); + if (request.getResourceId() == null || request.getResourceId().isEmpty()) { + // get all request + if (this.settings.getAsBoolean( + ConfigConstants.OPENSEARCH_RESOURCE_SHARING_ENABLED, + ConfigConstants.OPENSEARCH_RESOURCE_SHARING_ENABLED_DEFAULT + )) { + resourceSharingClient.listAllAccessibleResources(RESOURCE_INDEX_NAME, ActionListener.wrap(resources -> { + listener.onResponse(new GetResourceResponse((Set) resources)); + }, failure -> { + if (failure instanceof ResourceSharingException) { + if (((ResourceSharingException) 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, + Set.of( + SampleResourceScope.SAMPLE_READ_ACCESS.value(), + SampleResourceScope.SAMPLE_FULL_ACCESS.value(), + SampleResourceScope.PUBLIC.value() + ), + ActionListener.wrap(isAuthorized -> { + if (!isAuthorized) { + listener.onFailure( + new ResourceSharingException("Current user is not authorized to access resource: " + request.getResourceId()) + ); + 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()) + ) { + listener.onResponse(new GetResourceResponse(Set.of(SampleResource.fromXContent(parser)))); + } + } + }, 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)); + } + } + listener.onResponse(new GetResourceResponse(resources)); + } catch (Exception e) { + listener.onFailure(new ResourceSharingException("Failed to parse resources: " + e.getMessage(), e)); + } + }, 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..10aa6e837a --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/transport/RevokeResourceAccessTransportAction.java @@ -0,0 +1,67 @@ +/* + * 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.common.settings.Settings; +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.client.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 revoking resource access. + */ +public class RevokeResourceAccessTransportAction extends HandledTransportAction { + private static final Logger log = LogManager.getLogger(RevokeResourceAccessTransportAction.class); + + private final NodeClient nodeClient; + private final Settings settings; + + @Inject + public RevokeResourceAccessTransportAction( + Settings settings, + TransportService transportService, + ActionFilters actionFilters, + NodeClient nodeClient + ) { + super(RevokeResourceAccessAction.NAME, transportService, actionFilters, RevokeResourceAccessRequest::new); + this.nodeClient = nodeClient; + this.settings = settings; + } + + @Override + protected void doExecute(Task task, RevokeResourceAccessRequest request, ActionListener listener) { + ResourceSharingClient resourceSharingClient = ResourceSharingClientAccessor.getResourceSharingClient(nodeClient, settings); + resourceSharingClient.revokeResourceAccess( + request.getResourceId(), + RESOURCE_INDEX_NAME, + request.getEntitiesToRevoke(), + request.getScopes(), + ActionListener.wrap(success -> { + + RevokeResourceAccessResponse response = new RevokeResourceAccessResponse(success.getShareWith()); + 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..e30bde7cb8 --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/transport/ShareResourceTransportAction.java @@ -0,0 +1,70 @@ +/* + * 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.common.settings.Settings; +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.client.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 implementation for sharing a resource. + */ +public class ShareResourceTransportAction extends HandledTransportAction { + private static final Logger log = LogManager.getLogger(ShareResourceTransportAction.class); + + private final NodeClient nodeClient; + private final Settings settings; + + @Inject + public ShareResourceTransportAction( + Settings settings, + TransportService transportService, + ActionFilters actionFilters, + NodeClient nodeClient + ) { + super(ShareResourceAction.NAME, transportService, actionFilters, ShareResourceRequest::new); + this.nodeClient = nodeClient; + this.settings = settings; + } + + @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(nodeClient, settings); + resourceSharingClient.shareResource( + request.getResourceId(), + RESOURCE_INDEX_NAME, + request.getShareWith(), + ActionListener.wrap(sharing -> { + ShareResourceResponse response = new ShareResourceResponse(sharing.getShareWith()); + 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..6128c9134b --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/transport/UpdateResourceTransportAction.java @@ -0,0 +1,124 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.sample.resource.actions.transport; + +import java.io.IOException; +import java.util.Set; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import org.opensearch.action.support.ActionFilters; +import org.opensearch.action.support.HandledTransportAction; +import org.opensearch.action.support.WriteRequest; +import org.opensearch.action.update.UpdateRequest; +import org.opensearch.common.inject.Inject; +import org.opensearch.common.settings.Settings; +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.SampleResourceScope; +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.client.resources.ResourceSharingClient; +import org.opensearch.security.spi.resources.Resource; +import org.opensearch.security.spi.resources.exceptions.ResourceSharingException; +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; + private final Settings settings; + + @Inject + public UpdateResourceTransportAction( + Settings settings, + TransportService transportService, + ActionFilters actionFilters, + NodeClient nodeClient + ) { + super(UpdateResourceAction.NAME, transportService, actionFilters, UpdateResourceRequest::new); + this.transportService = transportService; + this.nodeClient = nodeClient; + this.settings = settings; + } + + @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(nodeClient, settings); + resourceSharingClient.verifyResourceAccess( + request.getResourceId(), + RESOURCE_INDEX_NAME, + Set.of( + SampleResourceScope.SAMPLE_WRITE_ACCESS.value(), + SampleResourceScope.SAMPLE_FULL_ACCESS.value(), + SampleResourceScope.PUBLIC.value() + ), + ActionListener.wrap(isAuthorized -> { + if (!isAuthorized) { + listener.onFailure( + new ResourceSharingException("Current user is not authorized to access resource: " + request.getResourceId()) + ); + 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(); + Resource sample = request.getResource(); + try (XContentBuilder builder = jsonBuilder()) { + UpdateRequest ur = new UpdateRequest(RESOURCE_INDEX_NAME, resourceId).setRefreshPolicy(WriteRequest.RefreshPolicy.IMMEDIATE) + .doc(sample.toXContent(builder, ToXContent.EMPTY_PARAMS)); + + log.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().getResourceName() + " 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..c8cacc49fd --- /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.common.settings.Settings; +import org.opensearch.security.client.resources.ResourceSharingNodeClient; +import org.opensearch.transport.client.node.NodeClient; + +/** + * Accessor for resource sharing node client. + */ +public class ResourceSharingClientAccessor { + private static ResourceSharingNodeClient INSTANCE; + + private ResourceSharingClientAccessor() {} + + /** + * Get resource sharing client + * + * @param nodeClient node client + * @return resource sharing client + */ + public static ResourceSharingNodeClient getResourceSharingClient(NodeClient nodeClient, Settings settings) { + if (INSTANCE == null) { + INSTANCE = new ResourceSharingNodeClient(nodeClient, settings); + } + return INSTANCE; + } +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/utils/Constants.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/utils/Constants.java new file mode 100644 index 0000000000..8cccb7e178 --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/utils/Constants.java @@ -0,0 +1,19 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.sample.utils; + +/** + * Constants for Sample Resource Sharing Plugin + */ +public class Constants { + public static final String RESOURCE_INDEX_NAME = ".sample_resource_sharing_plugin"; + + public static final String SAMPLE_RESOURCE_PLUGIN_PREFIX = "_plugins/sample_resource_sharing"; + public static final String SAMPLE_RESOURCE_PLUGIN_API_PREFIX = "/" + SAMPLE_RESOURCE_PLUGIN_PREFIX; +} diff --git a/sample-resource-plugin/src/main/plugin-metadata/plugin-security.policy b/sample-resource-plugin/src/main/plugin-metadata/plugin-security.policy new file mode 100644 index 0000000000..a5dfc33a87 --- /dev/null +++ b/sample-resource-plugin/src/main/plugin-metadata/plugin-security.policy @@ -0,0 +1,3 @@ +grant { + permission java.lang.RuntimePermission "getClassLoader"; +}; \ No newline at end of file diff --git a/sample-resource-plugin/src/main/resources/META-INF/services/org.opensearch.security.spi.resources.ResourceSharingExtension b/sample-resource-plugin/src/main/resources/META-INF/services/org.opensearch.security.spi.resources.ResourceSharingExtension new file mode 100644 index 0000000000..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/scripts/build.sh b/scripts/build.sh index 4b2893f304..e0fa495845 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -77,6 +77,12 @@ echo "COPY ${distributions}/*.zip" mkdir -p $OUTPUT/plugins cp ${distributions}/*.zip ./$OUTPUT/plugins +# Publish jars +./gradlew :opensearch-resource-sharing-spi:publishToMavenLocal -Dopensearch.version=$VERSION -Dbuild.snapshot=$SNAPSHOT -Dbuild.version_qualifier=$QUALIFIER +./gradlew :opensearch-security-common:publishToMavenLocal -Dopensearch.version=$VERSION -Dbuild.snapshot=$SNAPSHOT -Dbuild.version_qualifier=$QUALIFIER +./gradlew :opensearch-security-client:publishToMavenLocal -Dopensearch.version=$VERSION -Dbuild.snapshot=$SNAPSHOT -Dbuild.version_qualifier=$QUALIFIER +./gradlew publishAllPublicationsToStagingRepository -Dopensearch.version=$VERSION -Dbuild.snapshot=$SNAPSHOT -Dbuild.version_qualifier=$QUALIFIER + ./gradlew publishPluginZipPublicationToZipStagingRepository -Dopensearch.version=$VERSION -Dbuild.snapshot=$SNAPSHOT -Dbuild.version_qualifier=$QUALIFIER mkdir -p $OUTPUT/maven/org/opensearch cp -r ./build/local-staging-repo/org/opensearch/. $OUTPUT/maven/org/opensearch diff --git a/settings.gradle b/settings.gradle index 1c3e7ff5aa..02aa91f8ee 100644 --- a/settings.gradle +++ b/settings.gradle @@ -5,3 +5,15 @@ */ rootProject.name = 'opensearch-security' + +include "spi" +project(":spi").name = "opensearch-resource-sharing-spi" + +include 'common' +project(":common").name = rootProject.name + "-common" + +include 'client' +project(":client").name = rootProject.name + "-client" + +include "sample-resource-plugin" +project(":sample-resource-plugin").name = "opensearch-sample-resource-plugin" diff --git a/spi/README.md b/spi/README.md new file mode 100644 index 0000000000..2d4d13f989 --- /dev/null +++ b/spi/README.md @@ -0,0 +1,167 @@ +# **Resource Sharing and Access Control SPI** + +This **Service Provider Interface (SPI)** provides the necessary **interfaces and mechanisms** to implement **Resource Sharing and Access Control** in OpenSearch. + +--- + +## **Usage** + +A plugin that **defines a resource** and aims to implement **access control** over that resource must **extend** the `ResourceSharingExtension` class to register itself as a **Resource Plugin**. + +### **Example: Implementing a Resource Plugin** +```java +public class SampleResourcePlugin extends Plugin implements SystemIndexPlugin, ResourceSharingExtension { + + // Override required methods + + @Override + public Collection getSystemIndexDescriptors(Settings settings) { + final SystemIndexDescriptor systemIndexDescriptor = + new SystemIndexDescriptor(RESOURCE_INDEX_NAME, "Sample index with resources"); + return Collections.singletonList(systemIndexDescriptor); + } + + @Override + public String getResourceType() { + return SampleResource.class.getCanonicalName(); + } + + @Override + public String getResourceIndex() { + return RESOURCE_INDEX_NAME; + } + + @Override + public ResourceParser getResourceParser() { + return new SampleResourceParser(); + } +} +``` + +--- + +## **Checklist for Implementing a Resource Plugin** + +To properly integrate with the **Resource Sharing and Access Control SPI**, follow these steps: + +### **1. Add Required Dependencies** +Include **`opensearch-security-client`** and **`opensearch-resource-sharing-spi`** in your **`build.gradle`** file. +Example: +```gradle +dependencies { + implementation 'org.opensearch:opensearch-security-client:VERSION' + implementation 'org.opensearch:opensearch-resource-sharing-spi:VERSION' +} +``` + +--- + +### **2. Register the Plugin Using the Java SPI Mechanism** +- Navigate to your plugin's `src/main/resources` folder. +- Locate or create the `META-INF/services` directory. +- Inside `META-INF/services`, create a file named: + ``` + org.opensearch.security.spi.resources.ResourceSharingExtension + ``` +- Edit the file and add a **single line** containing the **fully qualified class name** of your plugin implementation. + Example: + ``` + org.opensearch.sample.SampleResourcePlugin + ``` + > This step ensures that OpenSearch **dynamically loads your plugin** as a resource-sharing extension. + +--- + +### **3. Declare a Resource Class** +Each plugin must define a **resource class** that implements the `Resource` interface. +Example: +```java +public class SampleResource implements Resource { + private String id; + private String owner; + + // Constructor, getters, setters, etc. + + @Override + public String getResourceId() { + return id; + } +} +``` + +--- + +### **4. Implement a Resource Parser** +A **`ResourceParser`** is required to convert **resource data** from OpenSearch indices. +Example: +```java +public class SampleResourceParser implements ResourceParser { + @Override + public SampleResource parseXContent(XContentParser parser) throws IOException { + return SampleResource.fromXContent(parser); + } +} +``` + +--- + +### **5. Implement the `ResourceSharingExtension` Interface** +Ensure that your **plugin declaration class** implements `ResourceSharingExtension` and provides **all required methods**. + +**Important:** Mark the resource **index as a system index** to enforce security protections. + +--- + +### **6. Create a Client Accessor** +A **singleton accessor** should be created to manage the `ResourceSharingNodeClient`. +Example: +```java +public class ResourceSharingClientAccessor { + private static ResourceSharingNodeClient INSTANCE; + + private ResourceSharingClientAccessor() {} + + public static ResourceSharingNodeClient getResourceSharingClient(NodeClient nodeClient, Settings settings) { + if (INSTANCE == null) { + INSTANCE = new ResourceSharingNodeClient(nodeClient, settings); + } + return INSTANCE; + } +} +``` + +--- + +### **7. Utilize `ResourceSharingNodeClient` for Access Control** +Use the **client API methods** to manage resource sharing. + +#### **Example: Verifying Resource Access** +```java +Set scopes = Set.of("READ_ONLY"); +resourceSharingClient.verifyResourceAccess( + "resource-123", + "resource_index", + scopes, + ActionListener.wrap(isAuthorized -> { + if (isAuthorized) { + System.out.println("User has access to the resource."); + } else { + System.out.println("Access denied."); + } + }, e -> { + System.err.println("Failed to verify access: " + e.getMessage()); + }) +); +``` + +--- + +## **License** +This project is licensed under the **Apache 2.0 License**. + +--- + +## **Copyright** +© OpenSearch Contributors. + +--- diff --git a/spi/build.gradle b/spi/build.gradle new file mode 100644 index 0000000000..b8f33319b3 --- /dev/null +++ b/spi/build.gradle @@ -0,0 +1,86 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +plugins { + id 'java' + id 'maven-publish' + id 'io.github.goooler.shadow' version "8.1.7" +} + +ext { + opensearch_version = System.getProperty("opensearch.version", "3.0.0-alpha1-SNAPSHOT") +} + +repositories { + mavenLocal() + mavenCentral() + maven { url "https://aws.oss.sonatype.org/content/repositories/snapshots" } +} + +dependencies { + compileOnly "org.opensearch:opensearch:${opensearch_version}" +} + +java { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 +} + +task sourcesJar(type: Jar) { + archiveClassifier.set 'sources' + from sourceSets.main.allJava +} + +task javadocJar(type: Jar) { + archiveClassifier.set 'javadoc' + from tasks.javadoc +} + +publishing { + publications { + shadow(MavenPublication) { publication -> + project.shadow.component(publication) + artifact sourcesJar + artifact javadocJar + pom { + name.set("OpenSearch Resource Sharing SPI") + packaging = "jar" + description.set("OpenSearch Security Resource Sharing") + url.set("https://github.com/opensearch-project/security") + licenses { + license { + name.set("The Apache License, Version 2.0") + url.set("http://www.apache.org/licenses/LICENSE-2.0.txt") + } + } + scm { + connection.set("scm:git@github.com:opensearch-project/security.git") + developerConnection.set("scm:git@github.com:opensearch-project/security.git") + url.set("https://github.com/opensearch-project/security.git") + } + developers { + developer { + name.set("OpenSearch Contributors") + url.set("https://github.com/opensearch-project") + } + } + } + } + } + repositories { + maven { + name = "Snapshots" + url = "https://aws.oss.sonatype.org/content/repositories/snapshots" + credentials { + username "$System.env.SONATYPE_USERNAME" + password "$System.env.SONATYPE_PASSWORD" + } + } + maven { + name = 'staging' + url = "${rootProject.buildDir}/local-staging-repo" + } + } +} diff --git a/spi/src/main/java/org/opensearch/security/spi/resources/Resource.java b/spi/src/main/java/org/opensearch/security/spi/resources/Resource.java new file mode 100644 index 0000000000..72e0b7b5d1 --- /dev/null +++ b/spi/src/main/java/org/opensearch/security/spi/resources/Resource.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. + */ + +package org.opensearch.security.spi.resources; + +import org.opensearch.core.common.io.stream.NamedWriteable; +import org.opensearch.core.xcontent.ToXContentFragment; + +/** + * Marker interface for all resources + * + * @opensearch.experimental + */ +public interface Resource extends NamedWriteable, ToXContentFragment { + /** + * Abstract method to get the resource name. + * Must be implemented by plugins defining resources. + * + * @return resource name + */ + String getResourceName(); +} diff --git a/spi/src/main/java/org/opensearch/security/spi/resources/ResourceAccessScope.java b/spi/src/main/java/org/opensearch/security/spi/resources/ResourceAccessScope.java new file mode 100644 index 0000000000..c3b54a8c23 --- /dev/null +++ b/spi/src/main/java/org/opensearch/security/spi/resources/ResourceAccessScope.java @@ -0,0 +1,38 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.security.spi.resources; + +import java.util.Arrays; + +/** + * This interface defines the two basic access scopes for resource-access. Plugins can decide whether to use these. + * Each plugin must implement their own scopes and manage them. + * These access scopes will then be used to verify the type of access being requested. + * + * @opensearch.experimental + */ +public interface ResourceAccessScope> { + String RESTRICTED = "restricted"; + String PUBLIC = "public"; + + static & ResourceAccessScope> E fromValue(Class enumClass, String value) { + for (E enumConstant : enumClass.getEnumConstants()) { + if (enumConstant.value().equalsIgnoreCase(value)) { + return enumConstant; + } + } + throw new IllegalArgumentException("Unknown value: " + value); + } + + String value(); + + static & ResourceAccessScope> String[] values(Class enumClass) { + return Arrays.stream(enumClass.getEnumConstants()).map(ResourceAccessScope::value).toArray(String[]::new); + } +} diff --git a/spi/src/main/java/org/opensearch/security/spi/resources/ResourceParser.java b/spi/src/main/java/org/opensearch/security/spi/resources/ResourceParser.java new file mode 100644 index 0000000000..b02269322e --- /dev/null +++ b/spi/src/main/java/org/opensearch/security/spi/resources/ResourceParser.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.security.spi.resources; + +import java.io.IOException; + +import org.opensearch.core.xcontent.XContentParser; + +/** + * Interface for parsing resources from XContentParser + * @param the type of resource to be parsed + * + * @opensearch.experimental + */ +public interface ResourceParser { + /** + * Parse source bytes supplied by the parser to a desired Resource type + * @param parser to parser bytes-ref json input + * @return the parsed object of Resource type + * @throws IOException if something went wrong while parsing + */ + T parseXContent(XContentParser parser) throws IOException; +} diff --git a/spi/src/main/java/org/opensearch/security/spi/resources/ResourceSharingExtension.java b/spi/src/main/java/org/opensearch/security/spi/resources/ResourceSharingExtension.java new file mode 100644 index 0000000000..bbfc802d82 --- /dev/null +++ b/spi/src/main/java/org/opensearch/security/spi/resources/ResourceSharingExtension.java @@ -0,0 +1,35 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.security.spi.resources; + +/** + * This interface should be implemented by all the plugins that define one or more resources and need access control over those resources. + * + * @opensearch.experimental + */ +public interface ResourceSharingExtension { + + /** + * Type of the resource + * @return a string containing the type of the resource. A qualified class name can be supplied here. + */ + String getResourceType(); + + /** + * The index where resource is stored + * @return the name of the parent index where resource is stored + */ + String getResourceIndex(); + + /** + * The parser for the resource, which will be used by security plugin to parse the resource + * @return the parser for the resource + */ + ResourceParser getResourceParser(); +} diff --git a/spi/src/main/java/org/opensearch/security/spi/resources/exceptions/ResourceSharingException.java b/spi/src/main/java/org/opensearch/security/spi/resources/exceptions/ResourceSharingException.java new file mode 100644 index 0000000000..560669112b --- /dev/null +++ b/spi/src/main/java/org/opensearch/security/spi/resources/exceptions/ResourceSharingException.java @@ -0,0 +1,60 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.spi.resources.exceptions; + +import java.io.IOException; + +import org.opensearch.OpenSearchException; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.rest.RestStatus; + +/** + * This class represents an exception that occurs during resource sharing operations. + * It extends the OpenSearchException class. + * + * @opensearch.experimental + */ +public class ResourceSharingException extends OpenSearchException { + public ResourceSharingException(Throwable cause) { + super(cause); + } + + public ResourceSharingException(String msg, Object... args) { + super(msg, args); + } + + public ResourceSharingException(String msg, Throwable cause, Object... args) { + super(msg, cause, args); + } + + public ResourceSharingException(StreamInput in) throws IOException { + super(in); + } + + @Override + public RestStatus status() { + String message = getMessage(); + if (message.contains("not authorized")) { + return RestStatus.FORBIDDEN; + } else if (message.startsWith("No authenticated")) { + return RestStatus.UNAUTHORIZED; + } else if (message.contains("not found")) { + return RestStatus.NOT_FOUND; + } else if (message.contains("not a system index")) { + return RestStatus.BAD_REQUEST; + } else if (message.contains("is disabled")) { + return RestStatus.NOT_IMPLEMENTED; + } + + return RestStatus.INTERNAL_SERVER_ERROR; + } +} diff --git a/spi/src/main/java/org/opensearch/security/spi/resources/package-info.java b/spi/src/main/java/org/opensearch/security/spi/resources/package-info.java new file mode 100644 index 0000000000..f2e210a5e5 --- /dev/null +++ b/spi/src/main/java/org/opensearch/security/spi/resources/package-info.java @@ -0,0 +1,15 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +/** + * This package defines classes required to implement resource access control in OpenSearch. + * This package will be added as a dependency by all OpenSearch plugins that require resource access control. + * + * @opensearch.experimental + */ +package org.opensearch.security.spi.resources; diff --git a/spi/src/main/java/org/opensearch/security/spi/resources/sharing/CreatedBy.java b/spi/src/main/java/org/opensearch/security/spi/resources/sharing/CreatedBy.java new file mode 100644 index 0000000000..50bdd1aea7 --- /dev/null +++ b/spi/src/main/java/org/opensearch/security/spi/resources/sharing/CreatedBy.java @@ -0,0 +1,88 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.security.spi.resources.sharing; + +import java.io.IOException; + +import org.opensearch.core.common.io.stream.NamedWriteable; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; +import org.opensearch.core.xcontent.ToXContentFragment; +import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.core.xcontent.XContentParser; + +/** + * This class is used to store information about the creator of a resource. + * + * @opensearch.experimental + */ +public class CreatedBy implements ToXContentFragment, NamedWriteable { + + private final Creator creatorType; + private final String creator; + + public CreatedBy(Creator creatorType, String creator) { + this.creatorType = creatorType; + this.creator = creator; + } + + public CreatedBy(StreamInput in) throws IOException { + this.creatorType = in.readEnum(Creator.class); + this.creator = in.readString(); + } + + public String getCreator() { + return creator; + } + + public Creator getCreatorType() { + return creatorType; + } + + @Override + public String toString() { + return "CreatedBy {" + this.creatorType.getName() + "='" + this.creator + '\'' + '}'; + } + + @Override + public String getWriteableName() { + return "created_by"; + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + out.writeEnum(Creator.valueOf(creatorType.name())); + out.writeString(creator); + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + return builder.startObject().field(creatorType.getName(), creator).endObject(); + } + + public static CreatedBy fromXContent(XContentParser parser) throws IOException { + String creator = null; + Creator creatorType = null; + XContentParser.Token token; + + while ((token = parser.nextToken()) != XContentParser.Token.END_OBJECT) { + if (token == XContentParser.Token.FIELD_NAME) { + creatorType = Creator.fromName(parser.currentName()); + } else if (token == XContentParser.Token.VALUE_STRING) { + creator = parser.text(); + } + } + + if (creator == null) { + throw new IllegalArgumentException(creatorType + " is required"); + } + + return new CreatedBy(creatorType, creator); + } +} diff --git a/spi/src/main/java/org/opensearch/security/spi/resources/sharing/Creator.java b/spi/src/main/java/org/opensearch/security/spi/resources/sharing/Creator.java new file mode 100644 index 0000000000..75e2415b93 --- /dev/null +++ b/spi/src/main/java/org/opensearch/security/spi/resources/sharing/Creator.java @@ -0,0 +1,37 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.security.spi.resources.sharing; + +/** + * This enum is used to store information about the creator of a resource. + * + * @opensearch.experimental + */ +public enum Creator { + USER("user"); + + private final String name; + + Creator(String name) { + this.name = name; + } + + public String getName() { + return name; + } + + public static Creator fromName(String name) { + for (Creator creator : values()) { + if (creator.name.equalsIgnoreCase(name)) { // Case-insensitive comparison + return creator; + } + } + throw new IllegalArgumentException("No enum constant for name: " + name); + } +} diff --git a/spi/src/main/java/org/opensearch/security/spi/resources/sharing/Recipient.java b/spi/src/main/java/org/opensearch/security/spi/resources/sharing/Recipient.java new file mode 100644 index 0000000000..77215071de --- /dev/null +++ b/spi/src/main/java/org/opensearch/security/spi/resources/sharing/Recipient.java @@ -0,0 +1,31 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.security.spi.resources.sharing; + +/** + * Enum representing the recipients of a shared resource. + * It includes USERS, ROLES, and BACKEND_ROLES. + * + * @opensearch.experimental + */ +public enum Recipient { + USERS("users"), + ROLES("roles"), + BACKEND_ROLES("backend_roles"); + + private final String name; + + Recipient(String name) { + this.name = name; + } + + public String getName() { + return name; + } +} diff --git a/spi/src/main/java/org/opensearch/security/spi/resources/sharing/RecipientType.java b/spi/src/main/java/org/opensearch/security/spi/resources/sharing/RecipientType.java new file mode 100644 index 0000000000..d3b916abc2 --- /dev/null +++ b/spi/src/main/java/org/opensearch/security/spi/resources/sharing/RecipientType.java @@ -0,0 +1,24 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.security.spi.resources.sharing; + +/** + * This class determines a type of recipient a resource can be shared with. + * An example type would be a user or a role. + * This class is used to determine the type of recipient a resource can be shared with. + * + * @opensearch.experimental + */ +public record RecipientType(String type) { + + @Override + public String toString() { + return type; + } +} diff --git a/spi/src/main/java/org/opensearch/security/spi/resources/sharing/RecipientTypeRegistry.java b/spi/src/main/java/org/opensearch/security/spi/resources/sharing/RecipientTypeRegistry.java new file mode 100644 index 0000000000..a1bdb89089 --- /dev/null +++ b/spi/src/main/java/org/opensearch/security/spi/resources/sharing/RecipientTypeRegistry.java @@ -0,0 +1,39 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.security.spi.resources.sharing; + +import java.util.HashMap; +import java.util.Map; + +/** + * This class determines a collection of recipient types a resource can be shared with. + * Allows addition of other recipient types in the future. + * + * @opensearch.experimental + */ +public final class RecipientTypeRegistry { + // TODO: Check what size should this be. A cap should be added to avoid infinite addition of objects + private static final Integer REGISTRY_MAX_SIZE = 20; + private static final Map REGISTRY = new HashMap<>(10); + + public static void registerRecipientType(String key, RecipientType recipientType) { + if (REGISTRY.size() == REGISTRY_MAX_SIZE) { + throw new IllegalArgumentException("RecipientTypeRegistry is full. Cannot register more recipient types."); + } + REGISTRY.put(key, recipientType); + } + + public static RecipientType fromValue(String value) { + RecipientType type = REGISTRY.get(value); + if (type == null) { + throw new IllegalArgumentException("Unknown RecipientType: " + value + ". Must be 1 of these: " + REGISTRY.values()); + } + return type; + } +} diff --git a/spi/src/main/java/org/opensearch/security/spi/resources/sharing/ResourceSharing.java b/spi/src/main/java/org/opensearch/security/spi/resources/sharing/ResourceSharing.java new file mode 100644 index 0000000000..1690213872 --- /dev/null +++ b/spi/src/main/java/org/opensearch/security/spi/resources/sharing/ResourceSharing.java @@ -0,0 +1,202 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.security.spi.resources.sharing; + +import java.io.IOException; +import java.util.Objects; + +import org.opensearch.core.common.io.stream.NamedWriteable; +import org.opensearch.core.common.io.stream.StreamOutput; +import org.opensearch.core.xcontent.ToXContentFragment; +import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.core.xcontent.XContentParser; + +/** + * Represents a resource sharing configuration that manages access control for OpenSearch resources. + * This class holds information about shared resources including their source, creator, and sharing permissions. + * The class maintains information about: + *
    + *
  • The source index where the resource is defined
  • + *
  • The unique identifier of the resource
  • + *
  • The creator's information
  • + *
  • The sharing permissions and recipients
  • + *
+ * + * @opensearch.experimental + * @see org.opensearch.security.spi.resources.sharing.CreatedBy + * @see org.opensearch.security.spi.resources.sharing.ShareWith + */ +public class ResourceSharing implements ToXContentFragment, NamedWriteable { + + /** + * The index where the resource is defined + */ + private String sourceIdx; + + /** + * The unique identifier of the resource + */ + private String resourceId; + + /** + * Information about who created the resource + */ + private CreatedBy createdBy; + + /** + * Information about with whom the resource is shared with + */ + private ShareWith shareWith; + + public ResourceSharing(String sourceIdx, String resourceId, CreatedBy createdBy, ShareWith shareWith) { + this.sourceIdx = sourceIdx; + this.resourceId = resourceId; + this.createdBy = createdBy; + this.shareWith = shareWith; + } + + public String getSourceIdx() { + return sourceIdx; + } + + public void setSourceIdx(String sourceIdx) { + this.sourceIdx = sourceIdx; + } + + public String getResourceId() { + return resourceId; + } + + public void setResourceId(String resourceId) { + this.resourceId = resourceId; + } + + public CreatedBy getCreatedBy() { + return createdBy; + } + + public void setCreatedBy(CreatedBy createdBy) { + this.createdBy = createdBy; + } + + public ShareWith getShareWith() { + return shareWith; + } + + public void setShareWith(ShareWith shareWith) { + this.shareWith = shareWith; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + ResourceSharing resourceSharing = (ResourceSharing) o; + return Objects.equals(getSourceIdx(), resourceSharing.getSourceIdx()) + && Objects.equals(getResourceId(), resourceSharing.getResourceId()) + && Objects.equals(getCreatedBy(), resourceSharing.getCreatedBy()) + && Objects.equals(getShareWith(), resourceSharing.getShareWith()); + } + + @Override + public int hashCode() { + return Objects.hash(getSourceIdx(), getResourceId(), getCreatedBy(), getShareWith()); + } + + @Override + public String toString() { + return "Resource {" + + "sourceIdx='" + + sourceIdx + + '\'' + + ", resourceId='" + + resourceId + + '\'' + + ", createdBy=" + + createdBy + + ", sharedWith=" + + shareWith + + '}'; + } + + @Override + public String getWriteableName() { + return "resource_sharing"; + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + out.writeString(sourceIdx); + out.writeString(resourceId); + createdBy.writeTo(out); + if (shareWith != null) { + out.writeBoolean(true); + shareWith.writeTo(out); + } else { + out.writeBoolean(false); + } + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + builder.startObject().field("source_idx", sourceIdx).field("resource_id", resourceId).field("created_by"); + createdBy.toXContent(builder, params); + if (shareWith != null && !shareWith.getSharedWithScopes().isEmpty()) { + builder.field("share_with"); + shareWith.toXContent(builder, params); + } + return builder.endObject(); + } + + public static ResourceSharing fromXContent(XContentParser parser) throws IOException { + String sourceIdx = null; + String resourceId = null; + CreatedBy createdBy = null; + ShareWith shareWith = null; + + String currentFieldName = null; + XContentParser.Token token; + + while ((token = parser.nextToken()) != XContentParser.Token.END_OBJECT) { + if (token == XContentParser.Token.FIELD_NAME) { + currentFieldName = parser.currentName(); + } else { + switch (Objects.requireNonNull(currentFieldName)) { + case "source_idx": + sourceIdx = parser.text(); + break; + case "resource_id": + resourceId = parser.text(); + break; + case "created_by": + createdBy = CreatedBy.fromXContent(parser); + break; + case "share_with": + shareWith = ShareWith.fromXContent(parser); + break; + default: + parser.skipChildren(); + break; + } + } + } + + validateRequiredField("source_idx", sourceIdx); + validateRequiredField("resource_id", resourceId); + validateRequiredField("created_by", createdBy); + + return new ResourceSharing(sourceIdx, resourceId, createdBy, shareWith); + } + + private static void validateRequiredField(String field, T value) { + if (value == null) { + throw new IllegalArgumentException(field + " is required"); + } + } +} diff --git a/spi/src/main/java/org/opensearch/security/spi/resources/sharing/ShareWith.java b/spi/src/main/java/org/opensearch/security/spi/resources/sharing/ShareWith.java new file mode 100644 index 0000000000..267bb7ece0 --- /dev/null +++ b/spi/src/main/java/org/opensearch/security/spi/resources/sharing/ShareWith.java @@ -0,0 +1,103 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.security.spi.resources.sharing; + +import java.io.IOException; +import java.util.HashSet; +import java.util.Set; + +import org.opensearch.core.common.io.stream.NamedWriteable; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; +import org.opensearch.core.xcontent.ToXContentFragment; +import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.core.xcontent.XContentParser; + +/** + * This class contains information about whom a resource is shared with and at what scope. + * Example: + * "share_with": { + * "read_only": { + * "users": [], + * "roles": [], + * "backend_roles": [] + * }, + * "read_write": { + * "users": [], + * "roles": [], + * "backend_roles": [] + * } + * } + * + * @opensearch.experimental + */ +public class ShareWith implements ToXContentFragment, NamedWriteable { + + /** + * A set of objects representing the scopes and their associated users, roles, and backend roles. + */ + private final Set sharedWithScopes; + + public ShareWith(Set sharedWithScopes) { + this.sharedWithScopes = sharedWithScopes; + } + + public ShareWith(StreamInput in) throws IOException { + this.sharedWithScopes = in.readSet(SharedWithScope::new); + } + + public Set getSharedWithScopes() { + return sharedWithScopes; + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + builder.startObject(); + + for (SharedWithScope scope : sharedWithScopes) { + scope.toXContent(builder, params); + } + + return builder.endObject(); + } + + public static ShareWith fromXContent(XContentParser parser) throws IOException { + Set sharedWithScopes = new HashSet<>(); + + if (parser.currentToken() != XContentParser.Token.START_OBJECT) { + parser.nextToken(); + } + + XContentParser.Token token; + while ((token = parser.nextToken()) != XContentParser.Token.END_OBJECT) { + // Each field in the object represents a SharedWithScope + if (token == XContentParser.Token.FIELD_NAME) { + SharedWithScope scope = SharedWithScope.fromXContent(parser); + sharedWithScopes.add(scope); + } + } + + return new ShareWith(sharedWithScopes); + } + + @Override + public String getWriteableName() { + return "share_with"; + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + out.writeCollection(sharedWithScopes); + } + + @Override + public String toString() { + return "ShareWith " + sharedWithScopes; + } +} diff --git a/spi/src/main/java/org/opensearch/security/spi/resources/sharing/SharedWithScope.java b/spi/src/main/java/org/opensearch/security/spi/resources/sharing/SharedWithScope.java new file mode 100644 index 0000000000..1dfca103a3 --- /dev/null +++ b/spi/src/main/java/org/opensearch/security/spi/resources/sharing/SharedWithScope.java @@ -0,0 +1,169 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.security.spi.resources.sharing; + +import java.io.IOException; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import org.opensearch.core.common.io.stream.NamedWriteable; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; +import org.opensearch.core.xcontent.ToXContentFragment; +import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.core.xcontent.XContentParser; + +/** + * This class represents the scope at which a resource is shared with for a particular scope. + * Example: + * "read_only": { + * "users": [], + * "roles": [], + * "backend_roles": [] + * } + * where "users", "roles" and "backend_roles" are the recipient entities + * + * @opensearch.experimental + */ +public class SharedWithScope implements ToXContentFragment, NamedWriteable { + + private final String scope; + + private final ScopeRecipients scopeRecipients; + + public SharedWithScope(String scope, ScopeRecipients scopeRecipients) { + this.scope = scope; + this.scopeRecipients = scopeRecipients; + } + + public SharedWithScope(StreamInput in) throws IOException { + this.scope = in.readString(); + this.scopeRecipients = new ScopeRecipients(in); + } + + public String getScope() { + return scope; + } + + public ScopeRecipients getSharedWithPerScope() { + return scopeRecipients; + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + builder.field(scope); + builder.startObject(); + + scopeRecipients.toXContent(builder, params); + + return builder.endObject(); + } + + public static SharedWithScope fromXContent(XContentParser parser) throws IOException { + String scope = parser.currentName(); + + parser.nextToken(); + + ScopeRecipients scopeRecipients = ScopeRecipients.fromXContent(parser); + + return new SharedWithScope(scope, scopeRecipients); + } + + @Override + public String toString() { + return "{" + scope + ": " + scopeRecipients + '}'; + } + + @Override + public String getWriteableName() { + return "shared_with_scope"; + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + out.writeString(scope); + out.writeNamedWriteable(scopeRecipients); + } + + /** + * This class represents the entities with whom a resource is shared with for a given scope. + * + * @opensearch.experimental + */ + public static class ScopeRecipients implements ToXContentFragment, NamedWriteable { + + private final Map> recipients; + + public ScopeRecipients(Map> recipients) { + if (recipients == null) { + throw new IllegalArgumentException("Recipients map cannot be null"); + } + this.recipients = recipients; + } + + public ScopeRecipients(StreamInput in) throws IOException { + this.recipients = in.readMap( + key -> RecipientTypeRegistry.fromValue(key.readString()), + input -> input.readSet(StreamInput::readString) + ); + } + + public Map> getRecipients() { + return recipients; + } + + @Override + public String getWriteableName() { + return "scope_recipients"; + } + + public static ScopeRecipients fromXContent(XContentParser parser) throws IOException { + Map> recipients = new HashMap<>(); + + XContentParser.Token token; + while ((token = parser.nextToken()) != XContentParser.Token.END_OBJECT) { + if (token == XContentParser.Token.FIELD_NAME) { + String fieldName = parser.currentName(); + RecipientType recipientType = RecipientTypeRegistry.fromValue(fieldName); + + parser.nextToken(); + Set values = new HashSet<>(); + while (parser.nextToken() != XContentParser.Token.END_ARRAY) { + values.add(parser.text()); + } + recipients.put(recipientType, values); + } + } + + return new ScopeRecipients(recipients); + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + out.writeMap( + recipients, + (streamOutput, recipientType) -> streamOutput.writeString(recipientType.type()), + (streamOutput, strings) -> streamOutput.writeCollection(strings, StreamOutput::writeString) + ); + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + if (recipients.isEmpty()) { + return builder; + } + for (Map.Entry> entry : recipients.entrySet()) { + builder.array(entry.getKey().type(), entry.getValue().toArray()); + } + return builder; + } + } +} diff --git a/spi/src/test/java/org/opensearch/security/spi/resources/CreatedByTests.java b/spi/src/test/java/org/opensearch/security/spi/resources/CreatedByTests.java new file mode 100644 index 0000000000..7d6eb5c61a --- /dev/null +++ b/spi/src/test/java/org/opensearch/security/spi/resources/CreatedByTests.java @@ -0,0 +1,320 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.security.spi.resources; + +import java.io.IOException; + +import org.hamcrest.MatcherAssert; +import org.junit.Test; + +import org.opensearch.common.io.stream.BytesStreamOutput; +import org.opensearch.common.xcontent.XContentFactory; +import org.opensearch.common.xcontent.json.JsonXContent; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; +import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.core.xcontent.XContentParser; +import org.opensearch.security.spi.resources.sharing.CreatedBy; +import org.opensearch.security.spi.resources.sharing.Creator; + +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.greaterThan; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.Matchers.nullValue; +import static org.junit.Assert.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Test class for CreatedBy class + * + * @opensearch.experimental + */ +public class CreatedByTests { + + private static final Creator CREATOR_TYPE = Creator.USER; + + @Test + public void testCreatedByConstructorWithValidUser() { + String expectedUser = "testUser"; + CreatedBy createdBy = new CreatedBy(CREATOR_TYPE, expectedUser); + + MatcherAssert.assertThat(expectedUser, is(equalTo(createdBy.getCreator()))); + } + + @Test + public void testCreatedByFromStreamInput() throws IOException { + String expectedUser = "testUser"; + + try (BytesStreamOutput out = new BytesStreamOutput()) { + out.writeEnum(Creator.valueOf(CREATOR_TYPE.name())); + out.writeString(expectedUser); + + StreamInput in = out.bytes().streamInput(); + + CreatedBy createdBy = new CreatedBy(in); + + MatcherAssert.assertThat(expectedUser, is(equalTo(createdBy.getCreator()))); + } + } + + @Test + public void testCreatedByWithEmptyStreamInput() throws IOException { + + try (StreamInput mockStreamInput = mock(StreamInput.class)) { + when(mockStreamInput.readString()).thenThrow(new IOException("EOF")); + + assertThrows(IOException.class, () -> new CreatedBy(mockStreamInput)); + } + } + + @Test + public void testCreatedByWithEmptyUser() { + + CreatedBy createdBy = new CreatedBy(CREATOR_TYPE, ""); + MatcherAssert.assertThat("", equalTo(createdBy.getCreator())); + } + + @Test + public void testCreatedByWithIOException() throws IOException { + + try (StreamInput mockStreamInput = mock(StreamInput.class)) { + when(mockStreamInput.readString()).thenThrow(new IOException("Test IOException")); + + assertThrows(IOException.class, () -> new CreatedBy(mockStreamInput)); + } + } + + @Test + public void testCreatedByWithLongUsername() { + String longUsername = "a".repeat(10000); + CreatedBy createdBy = new CreatedBy(CREATOR_TYPE, longUsername); + MatcherAssert.assertThat(longUsername, equalTo(createdBy.getCreator())); + } + + @Test + public void testCreatedByWithUnicodeCharacters() { + String unicodeUsername = "用户こんにちは"; + CreatedBy createdBy = new CreatedBy(CREATOR_TYPE, unicodeUsername); + MatcherAssert.assertThat(unicodeUsername, equalTo(createdBy.getCreator())); + } + + @Test + public void testFromXContentThrowsExceptionWhenUserFieldIsMissing() throws IOException { + String emptyJson = "{}"; + IllegalArgumentException exception; + try (XContentParser parser = JsonXContent.jsonXContent.createParser(null, null, emptyJson)) { + + exception = assertThrows(IllegalArgumentException.class, () -> CreatedBy.fromXContent(parser)); + } + + MatcherAssert.assertThat("null is required", equalTo(exception.getMessage())); + } + + @Test + public void testFromXContentWithEmptyInput() throws IOException { + String emptyJson = "{}"; + try (XContentParser parser = JsonXContent.jsonXContent.createParser(null, null, emptyJson)) { + + assertThrows(IllegalArgumentException.class, () -> CreatedBy.fromXContent(parser)); + } + } + + @Test + public void testFromXContentWithExtraFields() throws IOException { + String jsonWithExtraFields = "{\"user\": \"testUser\", \"extraField\": \"value\"}"; + XContentParser parser = JsonXContent.jsonXContent.createParser(null, null, jsonWithExtraFields); + + assertThrows(IllegalArgumentException.class, () -> CreatedBy.fromXContent(parser)); + } + + @Test + public void testFromXContentWithIncorrectFieldType() throws IOException { + String jsonWithIncorrectType = "{\"user\": 12345}"; + try (XContentParser parser = JsonXContent.jsonXContent.createParser(null, null, jsonWithIncorrectType)) { + + assertThrows(IllegalArgumentException.class, () -> CreatedBy.fromXContent(parser)); + } + } + + @Test + public void testFromXContentWithEmptyUser() throws IOException { + String emptyJson = "{\"" + CREATOR_TYPE + "\": \"\" }"; + CreatedBy createdBy; + try (XContentParser parser = JsonXContent.jsonXContent.createParser(null, null, emptyJson)) { + parser.nextToken(); + + createdBy = CreatedBy.fromXContent(parser); + } + + MatcherAssert.assertThat(CREATOR_TYPE, equalTo(createdBy.getCreatorType())); + MatcherAssert.assertThat("", equalTo(createdBy.getCreator())); + } + + @Test + public void testFromXContentWithNullUserValue() throws IOException { + String jsonWithNullUser = "{\"user\": null}"; + try (XContentParser parser = JsonXContent.jsonXContent.createParser(null, null, jsonWithNullUser)) { + + assertThrows(IllegalArgumentException.class, () -> CreatedBy.fromXContent(parser)); + } + } + + @Test + public void testFromXContentWithValidUser() throws IOException { + String json = "{\"user\":\"testUser\"}"; + XContentParser parser = JsonXContent.jsonXContent.createParser(null, null, json); + + CreatedBy createdBy = CreatedBy.fromXContent(parser); + + MatcherAssert.assertThat(createdBy, notNullValue()); + MatcherAssert.assertThat("testUser", equalTo(createdBy.getCreator())); + } + + @Test + public void testGetCreatorReturnsCorrectValue() { + String expectedUser = "testUser"; + CreatedBy createdBy = new CreatedBy(CREATOR_TYPE, expectedUser); + + String actualUser = createdBy.getCreator(); + + MatcherAssert.assertThat(expectedUser, equalTo(actualUser)); + } + + @Test + public void testGetCreatorWithNullString() { + + CreatedBy createdBy = new CreatedBy(CREATOR_TYPE, null); + MatcherAssert.assertThat(createdBy.getCreator(), nullValue()); + } + + @Test + public void testGetWriteableNameReturnsCorrectString() { + CreatedBy createdBy = new CreatedBy(CREATOR_TYPE, "testUser"); + MatcherAssert.assertThat("created_by", equalTo(createdBy.getWriteableName())); + } + + @Test + public void testToStringWithEmptyUser() { + CreatedBy createdBy = new CreatedBy(CREATOR_TYPE, ""); + String result = createdBy.toString(); + MatcherAssert.assertThat("CreatedBy {user=''}", equalTo(result)); + } + + @Test + public void testToStringWithNullUser() { + CreatedBy createdBy = new CreatedBy(CREATOR_TYPE, (String) null); + String result = createdBy.toString(); + MatcherAssert.assertThat("CreatedBy {user='null'}", equalTo(result)); + } + + @Test + public void testToStringWithLongUserName() { + + String longUserName = "a".repeat(1000); + CreatedBy createdBy = new CreatedBy(CREATOR_TYPE, longUserName); + String result = createdBy.toString(); + MatcherAssert.assertThat(result.startsWith("CreatedBy {user='"), is(true)); + MatcherAssert.assertThat(result.endsWith("'}"), is(true)); + MatcherAssert.assertThat(1019, equalTo(result.length())); + } + + @Test + public void testToXContentWithEmptyUser() throws IOException { + CreatedBy createdBy = new CreatedBy(CREATOR_TYPE, ""); + XContentBuilder builder = JsonXContent.contentBuilder(); + + createdBy.toXContent(builder, null); + String result = builder.toString(); + MatcherAssert.assertThat("{\"user\":\"\"}", equalTo(result)); + } + + @Test + public void testWriteToWithExceptionInStreamOutput() throws IOException { + CreatedBy createdBy = new CreatedBy(CREATOR_TYPE, "user1"); + try (StreamOutput failingOutput = new StreamOutput() { + @Override + public void writeByte(byte b) throws IOException { + throw new IOException("Simulated IO exception"); + } + + @Override + public void writeBytes(byte[] b, int offset, int length) throws IOException { + throw new IOException("Simulated IO exception"); + } + + @Override + public void flush() throws IOException { + + } + + @Override + public void close() throws IOException { + + } + + @Override + public void reset() throws IOException { + + } + }) { + + assertThrows(IOException.class, () -> createdBy.writeTo(failingOutput)); + } + } + + @Test + public void testWriteToWithLongUserName() throws IOException { + String longUserName = "a".repeat(65536); + CreatedBy createdBy = new CreatedBy(CREATOR_TYPE, longUserName); + BytesStreamOutput out = new BytesStreamOutput(); + createdBy.writeTo(out); + MatcherAssert.assertThat(out.size(), greaterThan(65536)); + } + + @Test + public void test_createdByToStringReturnsCorrectFormat() { + String testUser = "testUser"; + CreatedBy createdBy = new CreatedBy(CREATOR_TYPE, testUser); + + String expected = "CreatedBy {user='" + testUser + "'}"; + String actual = createdBy.toString(); + + MatcherAssert.assertThat(expected, equalTo(actual)); + } + + @Test + public void test_toXContent_serializesCorrectly() throws IOException { + String expectedUser = "testUser"; + CreatedBy createdBy = new CreatedBy(CREATOR_TYPE, expectedUser); + XContentBuilder builder = XContentFactory.jsonBuilder(); + + createdBy.toXContent(builder, null); + + String expectedJson = "{\"user\":\"testUser\"}"; + MatcherAssert.assertThat(expectedJson, equalTo(builder.toString())); + } + + @Test + public void test_writeTo_writesUserCorrectly() throws IOException { + String expectedUser = "testUser"; + CreatedBy createdBy = new CreatedBy(CREATOR_TYPE, expectedUser); + + BytesStreamOutput out = new BytesStreamOutput(); + createdBy.writeTo(out); + + StreamInput in = out.bytes().streamInput(); + in.readString(); + String actualUser = in.readString(); + + MatcherAssert.assertThat(expectedUser, equalTo(actualUser)); + } + +} diff --git a/spi/src/test/java/org/opensearch/security/spi/resources/RecipientTypeRegistryTests.java b/spi/src/test/java/org/opensearch/security/spi/resources/RecipientTypeRegistryTests.java new file mode 100644 index 0000000000..8b0bfa3297 --- /dev/null +++ b/spi/src/test/java/org/opensearch/security/spi/resources/RecipientTypeRegistryTests.java @@ -0,0 +1,43 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.security.spi.resources; + +import org.hamcrest.MatcherAssert; +import org.junit.Test; + +import org.opensearch.security.spi.resources.sharing.RecipientType; +import org.opensearch.security.spi.resources.sharing.RecipientTypeRegistry; + +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; +import static org.junit.Assert.assertThrows; + +/** + * Tests for {@link RecipientTypeRegistry}. + * + * @opensearch.experimental + */ +public class RecipientTypeRegistryTests { + + @Test + public void testFromValue() { + RecipientTypeRegistry.registerRecipientType("ble1", new RecipientType("ble1")); + RecipientTypeRegistry.registerRecipientType("ble2", new RecipientType("ble2")); + + // Valid Value + RecipientType type = RecipientTypeRegistry.fromValue("ble1"); + MatcherAssert.assertThat(type, notNullValue()); + MatcherAssert.assertThat(type.type(), is(equalTo("ble1"))); + + // Invalid Value + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> RecipientTypeRegistry.fromValue("bleble")); + MatcherAssert.assertThat("Unknown RecipientType: bleble. Must be 1 of these: [ble1, ble2]", is(equalTo(exception.getMessage()))); + } +} diff --git a/spi/src/test/java/org/opensearch/security/spi/resources/ShareWithTests.java b/spi/src/test/java/org/opensearch/security/spi/resources/ShareWithTests.java new file mode 100644 index 0000000000..d7ffa0ce5e --- /dev/null +++ b/spi/src/test/java/org/opensearch/security/spi/resources/ShareWithTests.java @@ -0,0 +1,284 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.security.spi.resources; + +import java.io.IOException; +import java.util.Collections; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import org.hamcrest.MatcherAssert; +import org.junit.Before; +import org.junit.Test; + +import org.opensearch.common.xcontent.XContentFactory; +import org.opensearch.common.xcontent.XContentType; +import org.opensearch.common.xcontent.json.JsonXContent; +import org.opensearch.core.common.io.stream.StreamOutput; +import org.opensearch.core.xcontent.NamedXContentRegistry; +import org.opensearch.core.xcontent.ToXContent; +import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.core.xcontent.XContentParser; +import org.opensearch.security.spi.resources.sharing.RecipientType; +import org.opensearch.security.spi.resources.sharing.RecipientTypeRegistry; +import org.opensearch.security.spi.resources.sharing.ShareWith; +import org.opensearch.security.spi.resources.sharing.SharedWithScope; + +import org.mockito.Mockito; + +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; +import static org.junit.Assert.assertThrows; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Test class for ShareWith class + * + * @opensearch.experimental + */ +public class ShareWithTests { + + @Before + public void setupResourceRecipientTypes() { + initializeRecipientTypes(); + } + + @Test + public void testFromXContentWhenCurrentTokenIsNotStartObject() throws IOException { + String json = "{\"read_only\": {\"users\": [\"user1\"], \"roles\": [], \"backend_roles\": []}}"; + XContentParser parser = JsonXContent.jsonXContent.createParser(null, null, json); + + parser.nextToken(); + + ShareWith shareWith = ShareWith.fromXContent(parser); + + MatcherAssert.assertThat(shareWith, notNullValue()); + Set sharedWithScopes = shareWith.getSharedWithScopes(); + MatcherAssert.assertThat(sharedWithScopes, notNullValue()); + MatcherAssert.assertThat(1, equalTo(sharedWithScopes.size())); + + SharedWithScope scope = sharedWithScopes.iterator().next(); + MatcherAssert.assertThat("read_only", equalTo(scope.getScope())); + + SharedWithScope.ScopeRecipients scopeRecipients = scope.getSharedWithPerScope(); + MatcherAssert.assertThat(scopeRecipients, notNullValue()); + Map> recipients = scopeRecipients.getRecipients(); + MatcherAssert.assertThat(recipients.get(RecipientTypeRegistry.fromValue(DefaultRecipientType.USERS.getName())).size(), is(1)); + MatcherAssert.assertThat(recipients.get(RecipientTypeRegistry.fromValue(DefaultRecipientType.USERS.getName())), contains("user1")); + MatcherAssert.assertThat(recipients.get(RecipientTypeRegistry.fromValue(DefaultRecipientType.ROLES.getName())).size(), is(0)); + MatcherAssert.assertThat( + recipients.get(RecipientTypeRegistry.fromValue(DefaultRecipientType.BACKEND_ROLES.getName())).size(), + is(0) + ); + } + + @Test + public void testFromXContentWithEmptyInput() throws IOException { + String emptyJson = "{}"; + XContentParser parser = XContentType.JSON.xContent().createParser(NamedXContentRegistry.EMPTY, null, emptyJson); + + ShareWith result = ShareWith.fromXContent(parser); + + MatcherAssert.assertThat(result, notNullValue()); + MatcherAssert.assertThat(result.getSharedWithScopes(), is(empty())); + } + + @Test + public void testFromXContentWithStartObject() throws IOException { + XContentParser parser; + try (XContentBuilder builder = XContentFactory.jsonBuilder()) { + builder.startObject() + .startObject(ResourceAccessScope.RESTRICTED) + .array("users", "user1", "user2") + .array("roles", "role1") + .array("backend_roles", "backend_role1") + .endObject() + .startObject(ResourceAccessScope.PUBLIC) + .array("users", "user3") + .array("roles", "role2", "role3") + .array("backend_roles") + .endObject() + .endObject(); + + parser = JsonXContent.jsonXContent.createParser(null, null, builder.toString()); + } + + parser.nextToken(); + + ShareWith shareWith = ShareWith.fromXContent(parser); + + MatcherAssert.assertThat(shareWith, notNullValue()); + Set scopes = shareWith.getSharedWithScopes(); + MatcherAssert.assertThat(scopes.size(), equalTo(2)); + + for (SharedWithScope scope : scopes) { + SharedWithScope.ScopeRecipients perScope = scope.getSharedWithPerScope(); + Map> recipients = perScope.getRecipients(); + if (scope.getScope().equals(ResourceAccessScope.RESTRICTED)) { + MatcherAssert.assertThat( + recipients.get(RecipientTypeRegistry.fromValue(DefaultRecipientType.USERS.getName())).size(), + is(2) + ); + MatcherAssert.assertThat( + recipients.get(RecipientTypeRegistry.fromValue(DefaultRecipientType.ROLES.getName())).size(), + is(1) + ); + MatcherAssert.assertThat( + recipients.get(RecipientTypeRegistry.fromValue(DefaultRecipientType.BACKEND_ROLES.getName())).size(), + is(1) + ); + } else if (scope.getScope().equals(ResourceAccessScope.PUBLIC)) { + MatcherAssert.assertThat( + recipients.get(RecipientTypeRegistry.fromValue(DefaultRecipientType.USERS.getName())).size(), + is(1) + ); + MatcherAssert.assertThat( + recipients.get(RecipientTypeRegistry.fromValue(DefaultRecipientType.ROLES.getName())).size(), + is(2) + ); + MatcherAssert.assertThat( + recipients.get(RecipientTypeRegistry.fromValue(DefaultRecipientType.BACKEND_ROLES.getName())).size(), + is(0) + ); + } + } + } + + @Test + public void testFromXContentWithUnexpectedEndOfInput() throws IOException { + XContentParser mockParser = mock(XContentParser.class); + when(mockParser.currentToken()).thenReturn(XContentParser.Token.START_OBJECT); + when(mockParser.nextToken()).thenReturn(XContentParser.Token.END_OBJECT, (XContentParser.Token) null); + + ShareWith result = ShareWith.fromXContent(mockParser); + + MatcherAssert.assertThat(result, notNullValue()); + MatcherAssert.assertThat(result.getSharedWithScopes(), is(empty())); + } + + @Test + public void testToXContentBuildsCorrectly() throws IOException { + SharedWithScope scope = new SharedWithScope( + "scope1", + new SharedWithScope.ScopeRecipients(Map.of(new RecipientType("users"), Set.of("bleh"))) + ); + + Set scopes = new HashSet<>(); + scopes.add(scope); + + ShareWith shareWith = new ShareWith(scopes); + + XContentBuilder builder = JsonXContent.contentBuilder(); + + shareWith.toXContent(builder, ToXContent.EMPTY_PARAMS); + + String result = builder.toString(); + + String expected = "{\"scope1\":{\"users\":[\"bleh\"]}}"; + + MatcherAssert.assertThat(expected.length(), equalTo(result.length())); + MatcherAssert.assertThat(expected, equalTo(result)); + } + + @Test + public void testWriteToWithEmptySet() throws IOException { + Set emptySet = Collections.emptySet(); + ShareWith shareWith = new ShareWith(emptySet); + StreamOutput mockOutput = Mockito.mock(StreamOutput.class); + + shareWith.writeTo(mockOutput); + + verify(mockOutput).writeCollection(emptySet); + } + + @Test + public void testWriteToWithIOException() throws IOException { + Set set = new HashSet<>(); + set.add(new SharedWithScope("test", new SharedWithScope.ScopeRecipients(Map.of()))); + ShareWith shareWith = new ShareWith(set); + StreamOutput mockOutput = Mockito.mock(StreamOutput.class); + + doThrow(new IOException("Simulated IO exception")).when(mockOutput).writeCollection(set); + + assertThrows(IOException.class, () -> shareWith.writeTo(mockOutput)); + } + + @Test + public void testWriteToWithLargeSet() throws IOException { + Set largeSet = new HashSet<>(); + for (int i = 0; i < 10000; i++) { + largeSet.add(new SharedWithScope("scope" + i, new SharedWithScope.ScopeRecipients(Map.of()))); + } + ShareWith shareWith = new ShareWith(largeSet); + StreamOutput mockOutput = Mockito.mock(StreamOutput.class); + + shareWith.writeTo(mockOutput); + + verify(mockOutput).writeCollection(largeSet); + } + + @Test + public void test_fromXContent_emptyObject() throws IOException { + XContentParser parser; + try (XContentBuilder builder = XContentFactory.jsonBuilder()) { + builder.startObject().endObject(); + parser = XContentType.JSON.xContent().createParser(null, null, builder.toString()); + } + + ShareWith shareWith = ShareWith.fromXContent(parser); + + MatcherAssert.assertThat(shareWith.getSharedWithScopes(), is(empty())); + } + + @Test + public void test_writeSharedWithScopesToStream() throws IOException { + StreamOutput mockStreamOutput = Mockito.mock(StreamOutput.class); + + Set sharedWithScopes = new HashSet<>(); + sharedWithScopes.add(new SharedWithScope(ResourceAccessScope.RESTRICTED, new SharedWithScope.ScopeRecipients(Map.of()))); + sharedWithScopes.add(new SharedWithScope(ResourceAccessScope.PUBLIC, new SharedWithScope.ScopeRecipients(Map.of()))); + + ShareWith shareWith = new ShareWith(sharedWithScopes); + + shareWith.writeTo(mockStreamOutput); + + verify(mockStreamOutput, times(1)).writeCollection(eq(sharedWithScopes)); + } + + private void initializeRecipientTypes() { + RecipientTypeRegistry.registerRecipientType("users", new RecipientType("users")); + RecipientTypeRegistry.registerRecipientType("roles", new RecipientType("roles")); + RecipientTypeRegistry.registerRecipientType("backend_roles", new RecipientType("backend_roles")); + } +} + +enum DefaultRecipientType { + USERS("users"), + ROLES("roles"), + BACKEND_ROLES("backend_roles"); + + private final String name; + + DefaultRecipientType(String name) { + this.name = name; + } + + public String getName() { + return name; + } +} 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..d89489a866 100644 --- a/src/integrationTest/java/org/opensearch/test/framework/cluster/OpenSearchClientProvider.java +++ b/src/integrationTest/java/org/opensearch/test/framework/cluster/OpenSearchClientProvider.java @@ -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 ) { - 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() { 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..f88b2f73ea 100644 --- a/src/integrationTest/java/org/opensearch/test/framework/cluster/TestRestClient.java +++ b/src/integrationTest/java/org/opensearch/test/framework/cluster/TestRestClient.java @@ -89,8 +89,8 @@ 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) { diff --git a/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java b/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java index 0802cb856c..56fda88a42 100644 --- a/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java +++ b/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java @@ -116,6 +116,7 @@ import org.opensearch.indices.IndicesService; import org.opensearch.indices.SystemIndexDescriptor; import org.opensearch.plugins.ClusterPlugin; +import org.opensearch.plugins.ExtensiblePlugin; import org.opensearch.plugins.ExtensionAwarePlugin; import org.opensearch.plugins.IdentityPlugin; import org.opensearch.plugins.MapperPlugin; @@ -143,6 +144,16 @@ import org.opensearch.security.auditlog.config.AuditConfig.Filter.FilterEntries; import org.opensearch.security.auditlog.impl.AuditLogImpl; import org.opensearch.security.auth.BackendRegistry; +import org.opensearch.security.common.resources.ResourceAccessHandler; +import org.opensearch.security.common.resources.ResourceIndexListener; +import org.opensearch.security.common.resources.ResourcePluginInfo; +import org.opensearch.security.common.resources.ResourceProvider; +import org.opensearch.security.common.resources.ResourceSharingConstants; +import org.opensearch.security.common.resources.ResourceSharingIndexHandler; +import org.opensearch.security.common.resources.ResourceSharingIndexManagementRepository; +import org.opensearch.security.common.resources.rest.ResourceAccessAction; +import org.opensearch.security.common.resources.rest.ResourceAccessRestAction; +import org.opensearch.security.common.resources.rest.ResourceAccessTransportAction; import org.opensearch.security.compliance.ComplianceIndexingOperationListener; import org.opensearch.security.compliance.ComplianceIndexingOperationListenerImpl; import org.opensearch.security.configuration.AdminDNs; @@ -183,6 +194,9 @@ import org.opensearch.security.securityconf.impl.CType; import org.opensearch.security.setting.OpensearchDynamicSetting; import org.opensearch.security.setting.TransportPassiveAuthSetting; +import org.opensearch.security.spi.resources.Resource; +import org.opensearch.security.spi.resources.ResourceParser; +import org.opensearch.security.spi.resources.ResourceSharingExtension; import org.opensearch.security.ssl.ExternalSecurityKeyStore; import org.opensearch.security.ssl.OpenSearchSecureSettingsFactory; import org.opensearch.security.ssl.OpenSearchSecuritySSLPlugin; @@ -236,9 +250,10 @@ public final class OpenSearchSecurityPlugin extends OpenSearchSecuritySSLPlugin implements ClusterPlugin, MapperPlugin, + IdentityPlugin, // CS-SUPPRESS-SINGLE: RegexpSingleline get Extensions Settings ExtensionAwarePlugin, - IdentityPlugin + ExtensiblePlugin // CS-ENFORCE-SINGLE { @@ -258,6 +273,7 @@ public final class OpenSearchSecurityPlugin extends OpenSearchSecuritySSLPlugin private volatile RestLayerPrivilegesEvaluator restLayerEvaluator; private volatile ConfigurationRepository cr; private volatile AdminDNs adminDns; + private volatile org.opensearch.security.common.configuration.AdminDNs adminDNsCommon; private volatile ClusterService cs; private volatile AtomicReference localNode = new AtomicReference<>(); private volatile AuditLog auditLog; @@ -275,6 +291,7 @@ public final class OpenSearchSecurityPlugin extends OpenSearchSecuritySSLPlugin private volatile OpensearchDynamicSetting transportPassiveAuthSetting; private volatile PasswordHasher passwordHasher; private volatile DlsFlsBaseContext dlsFlsBaseContext; + private ResourceSharingIndexManagementRepository rmr; public static boolean isActionTraceEnabled() { @@ -665,6 +682,14 @@ public List getRestHandlers( passwordHasher ) ); + + // Adds rest handlers for resource-access-control actions + if (settings.getAsBoolean( + ConfigConstants.OPENSEARCH_RESOURCE_SHARING_ENABLED, + ConfigConstants.OPENSEARCH_RESOURCE_SHARING_ENABLED_DEFAULT + )) { + handlers.add(new ResourceAccessRestAction()); + } log.debug("Added {} rest handler(s)", handlers.size()); } } @@ -692,6 +717,12 @@ public UnaryOperator getRestHandlerWrapper(final ThreadContext thre actions.add(new ActionHandler<>(CertificatesActionType.INSTANCE, TransportCertificatesInfoNodesAction.class)); } actions.add(new ActionHandler<>(WhoAmIAction.INSTANCE, TransportWhoAmIAction.class)); + if (settings.getAsBoolean( + ConfigConstants.OPENSEARCH_RESOURCE_SHARING_ENABLED, + ConfigConstants.OPENSEARCH_RESOURCE_SHARING_ENABLED_DEFAULT + )) { + actions.add(new ActionHandler<>(ResourceAccessAction.INSTANCE, ResourceAccessTransportAction.class)); + } } return actions; } @@ -719,6 +750,18 @@ public void onIndexModule(IndexModule indexModule) { dlsFlsBaseContext ) ); + + // Listening on POST and DELETE operations in resource indices + ResourceIndexListener resourceIndexListener = ResourceIndexListener.getInstance(); + resourceIndexListener.initialize(threadPool, localClient); + if (settings.getAsBoolean( + ConfigConstants.OPENSEARCH_RESOURCE_SHARING_ENABLED, + ConfigConstants.OPENSEARCH_RESOURCE_SHARING_ENABLED_DEFAULT + ) && ResourcePluginInfo.getInstance().getResourceIndices().contains(indexModule.getIndex().getName())) { + indexModule.addIndexOperationListener(resourceIndexListener); + log.info("Security plugin started listening to operations on resource-index {}", indexModule.getIndex().getName()); + } + indexModule.forceQueryCacheProvider((indexSettings, nodeCache) -> new QueryCache() { @Override @@ -1095,6 +1138,7 @@ public Collection createComponents( sslExceptionHandler = new AuditLogSslExceptionHandler(auditLog); adminDns = new AdminDNs(settings); + adminDNsCommon = new org.opensearch.security.common.configuration.AdminDNs(settings); cr = ConfigurationRepository.create(settings, this.configPath, threadPool, localClient, clusterService, auditLog); @@ -1123,6 +1167,17 @@ public Collection createComponents( namedXContentRegistry.get() ); + final var resourceSharingIndex = ResourceSharingConstants.OPENSEARCH_RESOURCE_SHARING_INDEX; + ResourceSharingIndexHandler rsIndexHandler = new ResourceSharingIndexHandler(resourceSharingIndex, localClient, threadPool); + ResourceAccessHandler resourceAccessHandler = new ResourceAccessHandler(threadPool, rsIndexHandler, adminDNsCommon); + resourceAccessHandler.initializeRecipientTypes(); + // Resource Sharing index is enabled by default + boolean isResourceSharingEnabled = settings.getAsBoolean( + ConfigConstants.OPENSEARCH_RESOURCE_SHARING_ENABLED, + ConfigConstants.OPENSEARCH_RESOURCE_SHARING_ENABLED_DEFAULT + ); + rmr = ResourceSharingIndexManagementRepository.create(rsIndexHandler, isResourceSharingEnabled); + dlsFlsBaseContext = new DlsFlsBaseContext(evaluator, threadPool.getThreadContext(), adminDns); if (SSLConfig.isSslOnlyMode()) { @@ -1194,7 +1249,7 @@ public Collection createComponents( // NOTE: We need to create DefaultInterClusterRequestEvaluator before creating ConfigurationRepository since the latter requires // security index to be accessible which means - // communciation with other nodes is already up. However for the communication to be up, there needs to be trusted nodes_dn. Hence + // communication with other nodes is already up. However for the communication to be up, there needs to be trusted nodes_dn. Hence // the base values from opensearch.yml // is used to first establish trust between same cluster nodes and there after dynamic config is loaded if enabled. if (DEFAULT_INTERCLUSTER_REQUEST_EVALUATOR_CLASS.equals(className)) { @@ -1203,6 +1258,7 @@ public Collection createComponents( } components.add(adminDns); + components.add(adminDNsCommon); components.add(cr); components.add(xffResolver); components.add(backendRegistry); @@ -1212,6 +1268,7 @@ public Collection createComponents( components.add(dcf); components.add(userService); components.add(passwordHasher); + components.add(resourceAccessHandler); components.add(sslSettingsManager); if (isSslCertReloadEnabled(settings) && sslCertificatesHotReloadEnabled(settings)) { @@ -2074,6 +2131,16 @@ public List> getSettings() { // Privileges evaluation settings.add(ActionPrivileges.PRECOMPUTED_PRIVILEGES_MAX_HEAP_SIZE); + + // Resource Sharing + settings.add( + Setting.boolSetting( + ConfigConstants.OPENSEARCH_RESOURCE_SHARING_ENABLED, + ConfigConstants.OPENSEARCH_RESOURCE_SHARING_ENABLED_DEFAULT, + Property.NodeScope, + Property.Filtered + ) + ); } return settings; @@ -2097,6 +2164,18 @@ public void onNodeStarted(DiscoveryNode localNode) { if (!SSLConfig.isSslOnlyMode() && !client && !disabled && !useClusterStateToInitSecurityConfig(settings)) { cr.initOnNodeStart(); } + + // rmr will be null when sec plugin is disabled or is in SSLOnly mode, hence rmr will not be instantiated + if (settings != null + && settings.getAsBoolean( + ConfigConstants.OPENSEARCH_RESOURCE_SHARING_ENABLED, + ConfigConstants.OPENSEARCH_RESOURCE_SHARING_ENABLED_DEFAULT + ) + && rmr != null) { + // create resource sharing index if absent + rmr.createResourceSharingIndexIfAbsent(); + } + final Set securityModules = ReflectionHelper.getModulesLoaded(); log.info("{} OpenSearch Security modules loaded so far: {}", securityModules.size(), securityModules); } @@ -2141,8 +2220,12 @@ public Collection getSystemIndexDescriptors(Settings sett ConfigConstants.SECURITY_CONFIG_INDEX_NAME, ConfigConstants.OPENDISTRO_SECURITY_DEFAULT_CONFIG_INDEX ); - final SystemIndexDescriptor systemIndexDescriptor = new SystemIndexDescriptor(indexPattern, "Security index"); - return Collections.singletonList(systemIndexDescriptor); + final SystemIndexDescriptor securityIndexDescriptor = new SystemIndexDescriptor(indexPattern, "Security index"); + final SystemIndexDescriptor resourceSharingIndexDescriptor = new SystemIndexDescriptor( + ResourceSharingConstants.OPENSEARCH_RESOURCE_SHARING_INDEX, + "Resource Sharing index" + ); + return List.of(securityIndexDescriptor, resourceSharingIndexDescriptor); } @Override @@ -2201,6 +2284,33 @@ private void tryAddSecurityProvider() { }); } + // CS-SUPPRESS-SINGLE: RegexpSingleline get Extensions Settings + @Override + public void loadExtensions(ExtensiblePlugin.ExtensionLoader loader) { + + if (settings.getAsBoolean( + ConfigConstants.OPENSEARCH_RESOURCE_SHARING_ENABLED, + ConfigConstants.OPENSEARCH_RESOURCE_SHARING_ENABLED_DEFAULT + )) { + Set resourceIndices = new HashSet<>(); + Map resourceProviders = new HashMap<>(); + for (ResourceSharingExtension extension : loader.loadExtensions(ResourceSharingExtension.class)) { + String resourceType = extension.getResourceType(); + String resourceIndexName = extension.getResourceIndex(); + ResourceParser resourceParser = extension.getResourceParser(); + + resourceIndices.add(resourceIndexName); + + ResourceProvider resourceProvider = new ResourceProvider(resourceType, resourceIndexName, resourceParser); + resourceProviders.put(resourceIndexName, resourceProvider); + log.info("Loaded resource sharing extension: {}, index: {}", resourceType, resourceIndexName); + } + ResourcePluginInfo.getInstance().setResourceIndices(resourceIndices); + ResourcePluginInfo.getInstance().setResourceProviders(resourceProviders); + } + } + // CS-ENFORCE-SINGLE + public static class GuiceHolder implements LifecycleComponent { private static RepositoriesService repositoriesService; diff --git a/src/main/java/org/opensearch/security/auth/BackendRegistry.java b/src/main/java/org/opensearch/security/auth/BackendRegistry.java index 8d47017664..1b84670b32 100644 --- a/src/main/java/org/opensearch/security/auth/BackendRegistry.java +++ b/src/main/java/org/opensearch/security/auth/BackendRegistry.java @@ -58,6 +58,7 @@ import org.opensearch.security.auditlog.AuditLog; import org.opensearch.security.auth.blocking.ClientBlockRegistry; import org.opensearch.security.auth.internal.NoOpAuthenticationBackend; +import org.opensearch.security.common.auth.UserSubjectImpl; import org.opensearch.security.configuration.AdminDNs; import org.opensearch.security.filter.SecurityRequest; import org.opensearch.security.filter.SecurityRequestChannel; @@ -197,7 +198,7 @@ public void onDynamicConfigModelChanged(DynamicConfigModel dcm) { * @param request * @return The authenticated user, null means another roundtrip * @throws OpenSearchSecurityException - */ + */ public boolean authenticate(final SecurityRequestChannel request) { final boolean isDebugEnabled = log.isDebugEnabled(); final boolean isBlockedBasedOnAddress = request.getRemoteAddress() @@ -224,7 +225,7 @@ public boolean authenticate(final SecurityRequestChannel request) { if (adminDns.isAdminDN(sslPrincipal)) { // PKI authenticated REST call User superuser = new User(sslPrincipal); - UserSubject subject = new UserSubjectImpl(threadPool, superuser); + UserSubject subject = new UserSubjectImpl(threadPool, new org.opensearch.security.common.user.User(sslPrincipal)); threadContext.putPersistent(ConfigConstants.OPENDISTRO_SECURITY_AUTHENTICATED_USER, subject); threadContext.putTransient(ConfigConstants.OPENDISTRO_SECURITY_USER, superuser); return true; @@ -392,7 +393,15 @@ public boolean authenticate(final SecurityRequestChannel request) { final User effectiveUser = impersonatedUser == null ? authenticatedUser : impersonatedUser; threadPool.getThreadContext().putTransient(ConfigConstants.OPENDISTRO_SECURITY_USER, effectiveUser); threadPool.getThreadContext().putTransient(ConfigConstants.OPENDISTRO_SECURITY_INITIATING_USER, authenticatedUser.getName()); - UserSubject subject = new UserSubjectImpl(threadPool, effectiveUser); + + // TODO: The following artistry must be reverted when User class is completely moved to :opensearch-security-common + org.opensearch.security.common.user.User effUser = new org.opensearch.security.common.user.User( + effectiveUser.getName(), + effectiveUser.getRoles(), + null + ); + effUser.setAttributes(effectiveUser.getCustomAttributesMap()); + UserSubject subject = new UserSubjectImpl(threadPool, effUser); threadPool.getThreadContext().putPersistent(ConfigConstants.OPENDISTRO_SECURITY_AUTHENTICATED_USER, subject); } else { if (isDebugEnabled) { @@ -420,7 +429,14 @@ public boolean authenticate(final SecurityRequestChannel request) { User anonymousUser = new User(User.ANONYMOUS.getName(), new HashSet(User.ANONYMOUS.getRoles()), null); anonymousUser.setRequestedTenant(tenant); - UserSubject subject = new UserSubjectImpl(threadPool, anonymousUser); + org.opensearch.security.common.user.User anonymousUserCommon = new org.opensearch.security.common.user.User( + User.ANONYMOUS.getName(), + new HashSet<>(User.ANONYMOUS.getRoles()), + null + ); + anonymousUserCommon.setRequestedTenant(tenant); + + UserSubject subject = new UserSubjectImpl(threadPool, anonymousUserCommon); threadPool.getThreadContext().putTransient(ConfigConstants.OPENDISTRO_SECURITY_USER, anonymousUser); threadPool.getThreadContext().putPersistent(ConfigConstants.OPENDISTRO_SECURITY_AUTHENTICATED_USER, subject); diff --git a/src/main/java/org/opensearch/security/dlic/rest/support/Utils.java b/src/main/java/org/opensearch/security/dlic/rest/support/Utils.java index 81b813e8b1..58d6f77d0b 100644 --- a/src/main/java/org/opensearch/security/dlic/rest/support/Utils.java +++ b/src/main/java/org/opensearch/security/dlic/rest/support/Utils.java @@ -69,6 +69,8 @@ public class Utils { public final static String OPENDISTRO_API_DEPRECATION_MESSAGE = "[_opendistro/_security] is a deprecated endpoint path. Please use _plugins/_security instead."; + public final static String PLUGIN_RESOURCE_ROUTE_PREFIX = PLUGIN_ROUTE_PREFIX + "/resources"; + private static final ObjectMapper internalMapper = new ObjectMapper(); public static Map convertJsonToxToStructuredMap(ToXContent jsonContent) { diff --git a/src/main/java/org/opensearch/security/support/ConfigConstants.java b/src/main/java/org/opensearch/security/support/ConfigConstants.java index 307db9cbcd..346437e775 100644 --- a/src/main/java/org/opensearch/security/support/ConfigConstants.java +++ b/src/main/java/org/opensearch/security/support/ConfigConstants.java @@ -43,6 +43,7 @@ public class ConfigConstants { public static final String OPENDISTRO_SECURITY_CONFIG_PREFIX = "_opendistro_security_"; + public static final String SECURITY_SETTINGS_PREFIX = "plugins.security."; public static final String OPENDISTRO_SECURITY_CHANNEL_TYPE = OPENDISTRO_SECURITY_CONFIG_PREFIX + "channel_type"; @@ -131,11 +132,11 @@ public class ConfigConstants { public static final String OPENDISTRO_SECURITY_DEFAULT_CONFIG_INDEX = ".opendistro_security"; - public static final String SECURITY_ENABLE_SNAPSHOT_RESTORE_PRIVILEGE = "plugins.security.enable_snapshot_restore_privilege"; + public static final String SECURITY_ENABLE_SNAPSHOT_RESTORE_PRIVILEGE = SECURITY_SETTINGS_PREFIX + "enable_snapshot_restore_privilege"; public static final boolean SECURITY_DEFAULT_ENABLE_SNAPSHOT_RESTORE_PRIVILEGE = true; - public static final String SECURITY_CHECK_SNAPSHOT_RESTORE_WRITE_PRIVILEGES = - "plugins.security.check_snapshot_restore_write_privileges"; + public static final String SECURITY_CHECK_SNAPSHOT_RESTORE_WRITE_PRIVILEGES = SECURITY_SETTINGS_PREFIX + + "check_snapshot_restore_write_privileges"; public static final boolean SECURITY_DEFAULT_CHECK_SNAPSHOT_RESTORE_WRITE_PRIVILEGES = true; public static final Set SECURITY_SNAPSHOT_RESTORE_NEEDED_WRITE_PRIVILEGES = Collections.unmodifiableSet( new HashSet(Arrays.asList("indices:admin/create", "indices:data/write/index" @@ -143,37 +144,39 @@ public class ConfigConstants { )) ); - public static final String SECURITY_INTERCLUSTER_REQUEST_EVALUATOR_CLASS = "plugins.security.cert.intercluster_request_evaluator_class"; + public static final String SECURITY_INTERCLUSTER_REQUEST_EVALUATOR_CLASS = SECURITY_SETTINGS_PREFIX + + "cert.intercluster_request_evaluator_class"; public static final String OPENDISTRO_SECURITY_ACTION_NAME = OPENDISTRO_SECURITY_CONFIG_PREFIX + "action_name"; - public static final String SECURITY_AUTHCZ_ADMIN_DN = "plugins.security.authcz.admin_dn"; - public static final String SECURITY_CONFIG_INDEX_NAME = "plugins.security.config_index_name"; - public static final String SECURITY_AUTHCZ_IMPERSONATION_DN = "plugins.security.authcz.impersonation_dn"; - public static final String SECURITY_AUTHCZ_REST_IMPERSONATION_USERS = "plugins.security.authcz.rest_impersonation_user"; + public static final String SECURITY_AUTHCZ_ADMIN_DN = SECURITY_SETTINGS_PREFIX + "authcz.admin_dn"; + public static final String SECURITY_CONFIG_INDEX_NAME = SECURITY_SETTINGS_PREFIX + "config_index_name"; + public static final String SECURITY_AUTHCZ_IMPERSONATION_DN = SECURITY_SETTINGS_PREFIX + "authcz.impersonation_dn"; + public static final String SECURITY_AUTHCZ_REST_IMPERSONATION_USERS = SECURITY_SETTINGS_PREFIX + "authcz.rest_impersonation_user"; public static final String BCRYPT = "bcrypt"; public static final String PBKDF2 = "pbkdf2"; - public static final String SECURITY_PASSWORD_HASHING_BCRYPT_ROUNDS = "plugins.security.password.hashing.bcrypt.rounds"; + public static final String SECURITY_PASSWORD_HASHING_BCRYPT_ROUNDS = SECURITY_SETTINGS_PREFIX + "password.hashing.bcrypt.rounds"; public static final int SECURITY_PASSWORD_HASHING_BCRYPT_ROUNDS_DEFAULT = 12; - public static final String SECURITY_PASSWORD_HASHING_BCRYPT_MINOR = "plugins.security.password.hashing.bcrypt.minor"; + public static final String SECURITY_PASSWORD_HASHING_BCRYPT_MINOR = SECURITY_SETTINGS_PREFIX + "password.hashing.bcrypt.minor"; public static final String SECURITY_PASSWORD_HASHING_BCRYPT_MINOR_DEFAULT = "Y"; - public static final String SECURITY_PASSWORD_HASHING_ALGORITHM = "plugins.security.password.hashing.algorithm"; + public static final String SECURITY_PASSWORD_HASHING_ALGORITHM = SECURITY_SETTINGS_PREFIX + "password.hashing.algorithm"; public static final String SECURITY_PASSWORD_HASHING_ALGORITHM_DEFAULT = BCRYPT; - public static final String SECURITY_PASSWORD_HASHING_PBKDF2_ITERATIONS = "plugins.security.password.hashing.pbkdf2.iterations"; + public static final String SECURITY_PASSWORD_HASHING_PBKDF2_ITERATIONS = SECURITY_SETTINGS_PREFIX + + "password.hashing.pbkdf2.iterations"; public static final int SECURITY_PASSWORD_HASHING_PBKDF2_ITERATIONS_DEFAULT = 600_000; - public static final String SECURITY_PASSWORD_HASHING_PBKDF2_LENGTH = "plugins.security.password.hashing.pbkdf2.length"; + public static final String SECURITY_PASSWORD_HASHING_PBKDF2_LENGTH = SECURITY_SETTINGS_PREFIX + "password.hashing.pbkdf2.length"; public static final int SECURITY_PASSWORD_HASHING_PBKDF2_LENGTH_DEFAULT = 256; - public static final String SECURITY_PASSWORD_HASHING_PBKDF2_FUNCTION = "plugins.security.password.hashing.pbkdf2.function"; + public static final String SECURITY_PASSWORD_HASHING_PBKDF2_FUNCTION = SECURITY_SETTINGS_PREFIX + "password.hashing.pbkdf2.function"; public static final String SECURITY_PASSWORD_HASHING_PBKDF2_FUNCTION_DEFAULT = Hmac.SHA256.name(); - public static final String SECURITY_AUDIT_TYPE_DEFAULT = "plugins.security.audit.type"; - public static final String SECURITY_AUDIT_CONFIG_DEFAULT = "plugins.security.audit.config"; - public static final String SECURITY_AUDIT_CONFIG_ROUTES = "plugins.security.audit.routes"; - public static final String SECURITY_AUDIT_CONFIG_ENDPOINTS = "plugins.security.audit.endpoints"; - public static final String SECURITY_AUDIT_THREADPOOL_SIZE = "plugins.security.audit.threadpool.size"; - public static final String SECURITY_AUDIT_THREADPOOL_MAX_QUEUE_LEN = "plugins.security.audit.threadpool.max_queue_len"; + public static final String SECURITY_AUDIT_TYPE_DEFAULT = SECURITY_SETTINGS_PREFIX + "audit.type"; + public static final String SECURITY_AUDIT_CONFIG_DEFAULT = SECURITY_SETTINGS_PREFIX + "audit.config"; + public static final String SECURITY_AUDIT_CONFIG_ROUTES = SECURITY_SETTINGS_PREFIX + "audit.routes"; + public static final String SECURITY_AUDIT_CONFIG_ENDPOINTS = SECURITY_SETTINGS_PREFIX + "audit.endpoints"; + public static final String SECURITY_AUDIT_THREADPOOL_SIZE = SECURITY_SETTINGS_PREFIX + "audit.threadpool.size"; + public static final String SECURITY_AUDIT_THREADPOOL_MAX_QUEUE_LEN = SECURITY_SETTINGS_PREFIX + "audit.threadpool.max_queue_len"; public static final String OPENDISTRO_SECURITY_AUDIT_LOG_REQUEST_BODY = "opendistro_security.audit.log_request_body"; public static final String OPENDISTRO_SECURITY_AUDIT_RESOLVE_INDICES = "opendistro_security.audit.resolve_indices"; public static final String OPENDISTRO_SECURITY_AUDIT_ENABLE_REST = "opendistro_security.audit.enable_rest"; @@ -188,13 +191,13 @@ public class ConfigConstants { ); public static final String OPENDISTRO_SECURITY_AUDIT_IGNORE_USERS = "opendistro_security.audit.ignore_users"; public static final String OPENDISTRO_SECURITY_AUDIT_IGNORE_REQUESTS = "opendistro_security.audit.ignore_requests"; - public static final String SECURITY_AUDIT_IGNORE_HEADERS = "plugins.security.audit.ignore_headers"; + public static final String SECURITY_AUDIT_IGNORE_HEADERS = SECURITY_SETTINGS_PREFIX + "audit.ignore_headers"; public static final String OPENDISTRO_SECURITY_AUDIT_RESOLVE_BULK_REQUESTS = "opendistro_security.audit.resolve_bulk_requests"; public static final boolean OPENDISTRO_SECURITY_AUDIT_SSL_VERIFY_HOSTNAMES_DEFAULT = true; public static final boolean OPENDISTRO_SECURITY_AUDIT_SSL_ENABLE_SSL_CLIENT_AUTH_DEFAULT = false; public static final String OPENDISTRO_SECURITY_AUDIT_EXCLUDE_SENSITIVE_HEADERS = "opendistro_security.audit.exclude_sensitive_headers"; - public static final String SECURITY_AUDIT_CONFIG_DEFAULT_PREFIX = "plugins.security.audit.config."; + public static final String SECURITY_AUDIT_CONFIG_DEFAULT_PREFIX = SECURITY_SETTINGS_PREFIX + "audit.config."; // Internal Opensearch data_stream public static final String SECURITY_AUDIT_OPENSEARCH_DATASTREAM_NAME = "data_stream.name"; @@ -237,31 +240,31 @@ public class ConfigConstants { public static final String SECURITY_AUDIT_LOG4J_LEVEL = "log4j.level"; // retry - public static final String SECURITY_AUDIT_RETRY_COUNT = "plugins.security.audit.config.retry_count"; - public static final String SECURITY_AUDIT_RETRY_DELAY_MS = "plugins.security.audit.config.retry_delay_ms"; + public static final String SECURITY_AUDIT_RETRY_COUNT = SECURITY_SETTINGS_PREFIX + "audit.config.retry_count"; + public static final String SECURITY_AUDIT_RETRY_DELAY_MS = SECURITY_SETTINGS_PREFIX + "audit.config.retry_delay_ms"; - public static final String SECURITY_KERBEROS_KRB5_FILEPATH = "plugins.security.kerberos.krb5_filepath"; - public static final String SECURITY_KERBEROS_ACCEPTOR_KEYTAB_FILEPATH = "plugins.security.kerberos.acceptor_keytab_filepath"; - public static final String SECURITY_KERBEROS_ACCEPTOR_PRINCIPAL = "plugins.security.kerberos.acceptor_principal"; - public static final String SECURITY_CERT_OID = "plugins.security.cert.oid"; - public static final String SECURITY_CERT_INTERCLUSTER_REQUEST_EVALUATOR_CLASS = - "plugins.security.cert.intercluster_request_evaluator_class"; - public static final String SECURITY_ADVANCED_MODULES_ENABLED = "plugins.security.advanced_modules_enabled"; - public static final String SECURITY_NODES_DN = "plugins.security.nodes_dn"; - public static final String SECURITY_NODES_DN_DYNAMIC_CONFIG_ENABLED = "plugins.security.nodes_dn_dynamic_config_enabled"; - public static final String SECURITY_DISABLED = "plugins.security.disabled"; + public static final String SECURITY_KERBEROS_KRB5_FILEPATH = SECURITY_SETTINGS_PREFIX + "kerberos.krb5_filepath"; + public static final String SECURITY_KERBEROS_ACCEPTOR_KEYTAB_FILEPATH = SECURITY_SETTINGS_PREFIX + "kerberos.acceptor_keytab_filepath"; + public static final String SECURITY_KERBEROS_ACCEPTOR_PRINCIPAL = SECURITY_SETTINGS_PREFIX + "kerberos.acceptor_principal"; + public static final String SECURITY_CERT_OID = SECURITY_SETTINGS_PREFIX + "cert.oid"; + public static final String SECURITY_CERT_INTERCLUSTER_REQUEST_EVALUATOR_CLASS = SECURITY_SETTINGS_PREFIX + + "cert.intercluster_request_evaluator_class"; + public static final String SECURITY_ADVANCED_MODULES_ENABLED = SECURITY_SETTINGS_PREFIX + "advanced_modules_enabled"; + public static final String SECURITY_NODES_DN = SECURITY_SETTINGS_PREFIX + "nodes_dn"; + public static final String SECURITY_NODES_DN_DYNAMIC_CONFIG_ENABLED = SECURITY_SETTINGS_PREFIX + "nodes_dn_dynamic_config_enabled"; + public static final String SECURITY_DISABLED = SECURITY_SETTINGS_PREFIX + "disabled"; - public static final String SECURITY_CACHE_TTL_MINUTES = "plugins.security.cache.ttl_minutes"; - public static final String SECURITY_ALLOW_UNSAFE_DEMOCERTIFICATES = "plugins.security.allow_unsafe_democertificates"; - public static final String SECURITY_ALLOW_DEFAULT_INIT_SECURITYINDEX = "plugins.security.allow_default_init_securityindex"; + public static final String SECURITY_CACHE_TTL_MINUTES = SECURITY_SETTINGS_PREFIX + "cache.ttl_minutes"; + public static final String SECURITY_ALLOW_UNSAFE_DEMOCERTIFICATES = SECURITY_SETTINGS_PREFIX + "allow_unsafe_democertificates"; + public static final String SECURITY_ALLOW_DEFAULT_INIT_SECURITYINDEX = SECURITY_SETTINGS_PREFIX + "allow_default_init_securityindex"; - public static final String SECURITY_ALLOW_DEFAULT_INIT_USE_CLUSTER_STATE = - "plugins.security.allow_default_init_securityindex.use_cluster_state"; + public static final String SECURITY_ALLOW_DEFAULT_INIT_USE_CLUSTER_STATE = SECURITY_SETTINGS_PREFIX + + "allow_default_init_securityindex.use_cluster_state"; - public static final String SECURITY_BACKGROUND_INIT_IF_SECURITYINDEX_NOT_EXIST = - "plugins.security.background_init_if_securityindex_not_exist"; + public static final String SECURITY_BACKGROUND_INIT_IF_SECURITYINDEX_NOT_EXIST = SECURITY_SETTINGS_PREFIX + + "background_init_if_securityindex_not_exist"; - public static final String SECURITY_ROLES_MAPPING_RESOLUTION = "plugins.security.roles_mapping_resolution"; + public static final String SECURITY_ROLES_MAPPING_RESOLUTION = SECURITY_SETTINGS_PREFIX + "roles_mapping_resolution"; public static final String OPENDISTRO_SECURITY_COMPLIANCE_HISTORY_WRITE_METADATA_ONLY = "opendistro_security.compliance.history.write.metadata_only"; @@ -280,21 +283,22 @@ public class ConfigConstants { public static final String OPENDISTRO_SECURITY_COMPLIANCE_HISTORY_EXTERNAL_CONFIG_ENABLED = "opendistro_security.compliance.history.external_config_enabled"; public static final String OPENDISTRO_SECURITY_SOURCE_FIELD_CONTEXT = OPENDISTRO_SECURITY_CONFIG_PREFIX + "source_field_context"; - public static final String SECURITY_COMPLIANCE_DISABLE_ANONYMOUS_AUTHENTICATION = - "plugins.security.compliance.disable_anonymous_authentication"; - public static final String SECURITY_COMPLIANCE_IMMUTABLE_INDICES = "plugins.security.compliance.immutable_indices"; - public static final String SECURITY_COMPLIANCE_SALT = "plugins.security.compliance.salt"; + public static final String SECURITY_COMPLIANCE_DISABLE_ANONYMOUS_AUTHENTICATION = SECURITY_SETTINGS_PREFIX + + "compliance.disable_anonymous_authentication"; + public static final String SECURITY_COMPLIANCE_IMMUTABLE_INDICES = SECURITY_SETTINGS_PREFIX + "compliance.immutable_indices"; + public static final String SECURITY_COMPLIANCE_SALT = SECURITY_SETTINGS_PREFIX + "compliance.salt"; public static final String SECURITY_COMPLIANCE_SALT_DEFAULT = "e1ukloTsQlOgPquJ";// 16 chars public static final String SECURITY_COMPLIANCE_HISTORY_INTERNAL_CONFIG_ENABLED = "opendistro_security.compliance.history.internal_config_enabled"; - public static final String SECURITY_SSL_ONLY = "plugins.security.ssl_only"; + public static final String SECURITY_SSL_ONLY = SECURITY_SETTINGS_PREFIX + "ssl_only"; public static final String SECURITY_CONFIG_SSL_DUAL_MODE_ENABLED = "plugins.security_config.ssl_dual_mode_enabled"; public static final String SECURITY_SSL_DUAL_MODE_SKIP_SECURITY = OPENDISTRO_SECURITY_CONFIG_PREFIX + "passive_security"; public static final String LEGACY_OPENDISTRO_SECURITY_CONFIG_SSL_DUAL_MODE_ENABLED = "opendistro_security_config.ssl_dual_mode_enabled"; - public static final String SECURITY_SSL_CERT_RELOAD_ENABLED = "plugins.security.ssl_cert_reload_enabled"; - public static final String SECURITY_SSL_CERTIFICATES_HOT_RELOAD_ENABLED = "plugins.security.ssl.certificates_hot_reload.enabled"; - public static final String SECURITY_DISABLE_ENVVAR_REPLACEMENT = "plugins.security.disable_envvar_replacement"; - public static final String SECURITY_DFM_EMPTY_OVERRIDES_ALL = "plugins.security.dfm_empty_overrides_all"; + public static final String SECURITY_SSL_CERT_RELOAD_ENABLED = SECURITY_SETTINGS_PREFIX + "ssl_cert_reload_enabled"; + public static final String SECURITY_SSL_CERTIFICATES_HOT_RELOAD_ENABLED = SECURITY_SETTINGS_PREFIX + + "ssl.certificates_hot_reload.enabled"; + public static final String SECURITY_DISABLE_ENVVAR_REPLACEMENT = SECURITY_SETTINGS_PREFIX + "disable_envvar_replacement"; + public static final String SECURITY_DFM_EMPTY_OVERRIDES_ALL = SECURITY_SETTINGS_PREFIX + "dfm_empty_overrides_all"; public enum RolesMappingResolution { MAPPING_ONLY, @@ -302,43 +306,45 @@ public enum RolesMappingResolution { BOTH } - public static final String SECURITY_FILTER_SECURITYINDEX_FROM_ALL_REQUESTS = "plugins.security.filter_securityindex_from_all_requests"; - public static final String SECURITY_DLS_MODE = "plugins.security.dls.mode"; + public static final String SECURITY_FILTER_SECURITYINDEX_FROM_ALL_REQUESTS = SECURITY_SETTINGS_PREFIX + + "filter_securityindex_from_all_requests"; + public static final String SECURITY_DLS_MODE = SECURITY_SETTINGS_PREFIX + "dls.mode"; // REST API - public static final String SECURITY_RESTAPI_ROLES_ENABLED = "plugins.security.restapi.roles_enabled"; - public static final String SECURITY_RESTAPI_ADMIN_ENABLED = "plugins.security.restapi.admin.enabled"; - public static final String SECURITY_RESTAPI_ENDPOINTS_DISABLED = "plugins.security.restapi.endpoints_disabled"; - public static final String SECURITY_RESTAPI_PASSWORD_VALIDATION_REGEX = "plugins.security.restapi.password_validation_regex"; - public static final String SECURITY_RESTAPI_PASSWORD_VALIDATION_ERROR_MESSAGE = - "plugins.security.restapi.password_validation_error_message"; - public static final String SECURITY_RESTAPI_PASSWORD_MIN_LENGTH = "plugins.security.restapi.password_min_length"; - public static final String SECURITY_RESTAPI_PASSWORD_SCORE_BASED_VALIDATION_STRENGTH = - "plugins.security.restapi.password_score_based_validation_strength"; + public static final String SECURITY_RESTAPI_ROLES_ENABLED = SECURITY_SETTINGS_PREFIX + "restapi.roles_enabled"; + public static final String SECURITY_RESTAPI_ADMIN_ENABLED = SECURITY_SETTINGS_PREFIX + "restapi.admin.enabled"; + public static final String SECURITY_RESTAPI_ENDPOINTS_DISABLED = SECURITY_SETTINGS_PREFIX + "restapi.endpoints_disabled"; + public static final String SECURITY_RESTAPI_PASSWORD_VALIDATION_REGEX = SECURITY_SETTINGS_PREFIX + "restapi.password_validation_regex"; + public static final String SECURITY_RESTAPI_PASSWORD_VALIDATION_ERROR_MESSAGE = SECURITY_SETTINGS_PREFIX + + "restapi.password_validation_error_message"; + public static final String SECURITY_RESTAPI_PASSWORD_MIN_LENGTH = SECURITY_SETTINGS_PREFIX + "restapi.password_min_length"; + public static final String SECURITY_RESTAPI_PASSWORD_SCORE_BASED_VALIDATION_STRENGTH = SECURITY_SETTINGS_PREFIX + + "restapi.password_score_based_validation_strength"; // Illegal Opcodes from here on - public static final String SECURITY_UNSUPPORTED_DISABLE_REST_AUTH_INITIALLY = - "plugins.security.unsupported.disable_rest_auth_initially"; - public static final String SECURITY_UNSUPPORTED_DELAY_INITIALIZATION_SECONDS = - "plugins.security.unsupported.delay_initialization_seconds"; - public static final String SECURITY_UNSUPPORTED_DISABLE_INTERTRANSPORT_AUTH_INITIALLY = - "plugins.security.unsupported.disable_intertransport_auth_initially"; - public static final String SECURITY_UNSUPPORTED_PASSIVE_INTERTRANSPORT_AUTH_INITIALLY = - "plugins.security.unsupported.passive_intertransport_auth_initially"; - public static final String SECURITY_UNSUPPORTED_RESTORE_SECURITYINDEX_ENABLED = - "plugins.security.unsupported.restore.securityindex.enabled"; - public static final String SECURITY_UNSUPPORTED_INJECT_USER_ENABLED = "plugins.security.unsupported.inject_user.enabled"; - public static final String SECURITY_UNSUPPORTED_INJECT_ADMIN_USER_ENABLED = "plugins.security.unsupported.inject_user.admin.enabled"; - public static final String SECURITY_UNSUPPORTED_ALLOW_NOW_IN_DLS = "plugins.security.unsupported.allow_now_in_dls"; - - public static final String SECURITY_UNSUPPORTED_RESTAPI_ALLOW_SECURITYCONFIG_MODIFICATION = - "plugins.security.unsupported.restapi.allow_securityconfig_modification"; - public static final String SECURITY_UNSUPPORTED_LOAD_STATIC_RESOURCES = "plugins.security.unsupported.load_static_resources"; - public static final String SECURITY_UNSUPPORTED_ACCEPT_INVALID_CONFIG = "plugins.security.unsupported.accept_invalid_config"; - - public static final String SECURITY_PROTECTED_INDICES_ENABLED_KEY = "plugins.security.protected_indices.enabled"; + public static final String SECURITY_UNSUPPORTED_DISABLE_REST_AUTH_INITIALLY = SECURITY_SETTINGS_PREFIX + + "unsupported.disable_rest_auth_initially"; + public static final String SECURITY_UNSUPPORTED_DELAY_INITIALIZATION_SECONDS = SECURITY_SETTINGS_PREFIX + + "unsupported.delay_initialization_seconds"; + public static final String SECURITY_UNSUPPORTED_DISABLE_INTERTRANSPORT_AUTH_INITIALLY = SECURITY_SETTINGS_PREFIX + + "unsupported.disable_intertransport_auth_initially"; + public static final String SECURITY_UNSUPPORTED_PASSIVE_INTERTRANSPORT_AUTH_INITIALLY = SECURITY_SETTINGS_PREFIX + + "unsupported.passive_intertransport_auth_initially"; + public static final String SECURITY_UNSUPPORTED_RESTORE_SECURITYINDEX_ENABLED = SECURITY_SETTINGS_PREFIX + + "unsupported.restore.securityindex.enabled"; + public static final String SECURITY_UNSUPPORTED_INJECT_USER_ENABLED = SECURITY_SETTINGS_PREFIX + "unsupported.inject_user.enabled"; + public static final String SECURITY_UNSUPPORTED_INJECT_ADMIN_USER_ENABLED = SECURITY_SETTINGS_PREFIX + + "unsupported.inject_user.admin.enabled"; + public static final String SECURITY_UNSUPPORTED_ALLOW_NOW_IN_DLS = SECURITY_SETTINGS_PREFIX + "unsupported.allow_now_in_dls"; + + public static final String SECURITY_UNSUPPORTED_RESTAPI_ALLOW_SECURITYCONFIG_MODIFICATION = SECURITY_SETTINGS_PREFIX + + "unsupported.restapi.allow_securityconfig_modification"; + public static final String SECURITY_UNSUPPORTED_LOAD_STATIC_RESOURCES = SECURITY_SETTINGS_PREFIX + "unsupported.load_static_resources"; + public static final String SECURITY_UNSUPPORTED_ACCEPT_INVALID_CONFIG = SECURITY_SETTINGS_PREFIX + "unsupported.accept_invalid_config"; + + public static final String SECURITY_PROTECTED_INDICES_ENABLED_KEY = SECURITY_SETTINGS_PREFIX + "protected_indices.enabled"; public static final Boolean SECURITY_PROTECTED_INDICES_ENABLED_DEFAULT = false; - public static final String SECURITY_PROTECTED_INDICES_KEY = "plugins.security.protected_indices.indices"; + public static final String SECURITY_PROTECTED_INDICES_KEY = SECURITY_SETTINGS_PREFIX + "protected_indices.indices"; public static final List SECURITY_PROTECTED_INDICES_DEFAULT = Collections.emptyList(); - public static final String SECURITY_PROTECTED_INDICES_ROLES_KEY = "plugins.security.protected_indices.roles"; + public static final String SECURITY_PROTECTED_INDICES_ROLES_KEY = SECURITY_SETTINGS_PREFIX + "protected_indices.roles"; public static final List SECURITY_PROTECTED_INDICES_ROLES_DEFAULT = Collections.emptyList(); // Roles injection for plugins @@ -352,19 +358,20 @@ public enum RolesMappingResolution { // System indices settings public static final String SYSTEM_INDEX_PERMISSION = "system:admin/system_index"; - public static final String SECURITY_SYSTEM_INDICES_ENABLED_KEY = "plugins.security.system_indices.enabled"; + public static final String SECURITY_SYSTEM_INDICES_ENABLED_KEY = SECURITY_SETTINGS_PREFIX + "system_indices.enabled"; public static final Boolean SECURITY_SYSTEM_INDICES_ENABLED_DEFAULT = false; - public static final String SECURITY_SYSTEM_INDICES_PERMISSIONS_ENABLED_KEY = "plugins.security.system_indices.permission.enabled"; + public static final String SECURITY_SYSTEM_INDICES_PERMISSIONS_ENABLED_KEY = SECURITY_SETTINGS_PREFIX + + "system_indices.permission.enabled"; public static final Boolean SECURITY_SYSTEM_INDICES_PERMISSIONS_DEFAULT = false; - public static final String SECURITY_SYSTEM_INDICES_KEY = "plugins.security.system_indices.indices"; + public static final String SECURITY_SYSTEM_INDICES_KEY = SECURITY_SETTINGS_PREFIX + "system_indices.indices"; public static final List SECURITY_SYSTEM_INDICES_DEFAULT = Collections.emptyList(); - public static final String SECURITY_MASKED_FIELDS_ALGORITHM_DEFAULT = "plugins.security.masked_fields.algorithm.default"; + public static final String SECURITY_MASKED_FIELDS_ALGORITHM_DEFAULT = SECURITY_SETTINGS_PREFIX + "masked_fields.algorithm.default"; public static final String TENANCY_PRIVATE_TENANT_NAME = "private"; public static final String TENANCY_GLOBAL_TENANT_NAME = "global"; public static final String TENANCY_GLOBAL_TENANT_DEFAULT_NAME = ""; - public static final String USE_JDK_SERIALIZATION = "plugins.security.use_jdk_serialization"; + public static final String USE_JDK_SERIALIZATION = SECURITY_SETTINGS_PREFIX + "use_jdk_serialization"; // On-behalf-of endpoints settings // CS-SUPPRESS-SINGLE: RegexpSingleline get Extensions Settings @@ -375,6 +382,10 @@ public enum RolesMappingResolution { // Variable for initial admin password support public static final String OPENSEARCH_INITIAL_ADMIN_PASSWORD = "OPENSEARCH_INITIAL_ADMIN_PASSWORD"; + // Resource sharing feature-flag + public static final String OPENSEARCH_RESOURCE_SHARING_ENABLED = SECURITY_SETTINGS_PREFIX + "resource_sharing.enabled"; + public static final boolean OPENSEARCH_RESOURCE_SHARING_ENABLED_DEFAULT = true; + public static Set getSettingAsSet( final Settings settings, final String key, diff --git a/src/test/java/org/opensearch/security/IndexIntegrationTests.java b/src/test/java/org/opensearch/security/IndexIntegrationTests.java index 31db353c63..91a92ab97d 100644 --- a/src/test/java/org/opensearch/security/IndexIntegrationTests.java +++ b/src/test/java/org/opensearch/security/IndexIntegrationTests.java @@ -846,19 +846,16 @@ public void testIndexResolveMinus() throws Exception { resc = rh.executeGetRequest("/*,-foo*/_search", encodeBasicHeader("foo_all", "nagilum")); assertThat(resc.getStatusCode(), is(HttpStatus.SC_FORBIDDEN)); - resc = rh.executeGetRequest("/*,-*security/_search", encodeBasicHeader("foo_all", "nagilum")); + resc = rh.executeGetRequest("/*,-*security,-*resource*/_search", encodeBasicHeader("foo_all", "nagilum")); assertThat(resc.getStatusCode(), is(HttpStatus.SC_OK)); - resc = rh.executeGetRequest("/*,-*security/_search", encodeBasicHeader("foo_all", "nagilum")); + resc = rh.executeGetRequest("/*,-*security,-foo*,-*resource*/_search", encodeBasicHeader("foo_all", "nagilum")); assertThat(resc.getStatusCode(), is(HttpStatus.SC_OK)); - resc = rh.executeGetRequest("/*,-*security,-foo*/_search", encodeBasicHeader("foo_all", "nagilum")); - assertThat(resc.getStatusCode(), is(HttpStatus.SC_OK)); - - resc = rh.executeGetRequest("/_all,-*security/_search", encodeBasicHeader("foo_all", "nagilum")); + resc = rh.executeGetRequest("/_all,-*security,-*resource*/_search", encodeBasicHeader("foo_all", "nagilum")); assertThat(resc.getStatusCode(), is(HttpStatus.SC_FORBIDDEN)); - resc = rh.executeGetRequest("/_all,-*security/_search", encodeBasicHeader("nagilum", "nagilum")); + resc = rh.executeGetRequest("/_all,-*security,-*resource*/_search", encodeBasicHeader("nagilum", "nagilum")); assertThat(resc.getStatusCode(), is(HttpStatus.SC_BAD_REQUEST)); } diff --git a/src/test/java/org/opensearch/security/SlowIntegrationTests.java b/src/test/java/org/opensearch/security/SlowIntegrationTests.java index 74e3bfa9e4..99ac9fb1b9 100644 --- a/src/test/java/org/opensearch/security/SlowIntegrationTests.java +++ b/src/test/java/org/opensearch/security/SlowIntegrationTests.java @@ -66,6 +66,7 @@ public void testCustomInterclusterRequestEvaluator() throws Exception { ConfigConstants.SECURITY_INTERCLUSTER_REQUEST_EVALUATOR_CLASS, "org.opensearch.security.AlwaysFalseInterClusterRequestEvaluator" ) + .put(ConfigConstants.OPENSEARCH_RESOURCE_SHARING_ENABLED, false) .put("discovery.initial_state_timeout", "8s") .build(); setup(Settings.EMPTY, null, settings, false, ClusterConfiguration.DEFAULT, 5, 1); diff --git a/src/test/java/org/opensearch/security/auth/UserSubjectImplTests.java b/src/test/java/org/opensearch/security/auth/UserSubjectImplTests.java index 9e630ef750..07bac9e349 100644 --- a/src/test/java/org/opensearch/security/auth/UserSubjectImplTests.java +++ b/src/test/java/org/opensearch/security/auth/UserSubjectImplTests.java @@ -15,7 +15,8 @@ import org.junit.Test; -import org.opensearch.security.user.User; +import org.opensearch.security.common.auth.UserSubjectImpl; +import org.opensearch.security.common.user.User; import org.opensearch.threadpool.TestThreadPool; import org.opensearch.threadpool.ThreadPool;