diff --git a/CHANGELOG.md b/CHANGELOG.md index 55e2cae4cd..7f6aa4997b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Enhancements - Makes resource settings dynamic ([#5677](https://github.com/opensearch-project/security/pull/5677)) +- [Resource Sharing] Allow multiple sharable resource types in single resource index ([#5713](https://github.com/opensearch-project/security/pull/5713)) ### Bug Fixes - Create a WildcardMatcher.NONE when creating a WildcardMatcher with an empty string ([#5694](https://github.com/opensearch-project/security/pull/5694)) @@ -33,6 +34,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - Bump `derek-ho/start-opensearch` from 7 to 9 ([#5630](https://github.com/opensearch-project/security/pull/5630), [#5679](https://github.com/opensearch-project/security/pull/5679)) - Bump `github/codeql-action` from 3 to 4 ([#5702](https://github.com/opensearch-project/security/pull/5702)) - Bump `com.github.spotbugs` from 6.4.2 to 6.4.4 ([#5727](https://github.com/opensearch-project/security/pull/5727)) +- Bump `com.autonomousapps.build-health` from 3.0.4 to 3.1.0 ([#5726](https://github.com/opensearch-project/security/pull/5726)) ### Documentation diff --git a/RESOURCE_SHARING_AND_ACCESS_CONTROL.md b/RESOURCE_SHARING_AND_ACCESS_CONTROL.md index 400002c185..ed41ac8f7b 100644 --- a/RESOURCE_SHARING_AND_ACCESS_CONTROL.md +++ b/RESOURCE_SHARING_AND_ACCESS_CONTROL.md @@ -605,12 +605,13 @@ Read documents from a plugin’s index and migrate ownership and backend role-ba **Request Body** -| Parameter | Type | Required | Description | -|------------------------|---------|----------|------------------------------------------------------------------------------------------------------------------------------------------------------| -| `source_index` | string | yes | Name of the plugin index containing the existing resource documents | -| `username_path` | string | yes | JSON Pointer to the username field inside each document | -| `backend_roles_path` | string | yes | JSON Pointer to the backend_roles field (must point to a JSON array) | -| `default_access_level` | string | yes | Default access level to assign migrated backend_roles. Must be one from the available action-groups for this type. See `resource-action-groups.yml`. | +| Parameter | Type | Required | Description | +|------------------------|--------|----------|------------------------------------------------------------------------------------------------------------------------------------------------------| +| `source_index` | string | yes | Name of the plugin index containing the existing resource documents | +| `username_path` | string | yes | JSON Pointer to the username field inside each document | +| `backend_roles_path` | string | yes | JSON Pointer to the backend_roles field (must point to a JSON array) | +| `type_path` | string | no | JSON Pointer to the resource type field inside each document (required if multiple resource types in same resource index) | +| `default_access_level` | object | yes | Default access level to assign migrated backend_roles. Must be one from the available action-groups for this type. See `resource-action-groups.yml`. | **Example Request** `POST /_plugins/_security/api/resources/migrate` @@ -619,8 +620,12 @@ Read documents from a plugin’s index and migrate ownership and backend role-ba { "source_index": ".sample_resource", "username_path": "/owner", - "backend_roles_path": "/access/backend_roles", - "default_access_level": "read_only" + "backend_roles_path": "/backend_roles", + "type_path": "/type", + "default_access_level": { + "sample-resource": "read_only", + "sample-resource-group": "read-only-group" + } } ``` diff --git a/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/resource/TestUtils.java b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/resource/TestUtils.java index 64acbf5770..f61440b288 100644 --- a/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/resource/TestUtils.java +++ b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/resource/TestUtils.java @@ -39,6 +39,7 @@ import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.is; +import static org.opensearch.sample.utils.Constants.RESOURCE_GROUP_TYPE; import static org.opensearch.sample.utils.Constants.RESOURCE_INDEX_NAME; import static org.opensearch.sample.utils.Constants.RESOURCE_TYPE; import static org.opensearch.sample.utils.Constants.SAMPLE_RESOURCE_PLUGIN_PREFIX; @@ -79,19 +80,29 @@ public final class TestUtils { public static final String SAMPLE_READ_WRITE = "sample_read_write"; public static final String SAMPLE_FULL_ACCESS = "sample_full_access"; + public static final String SAMPLE_GROUP_READ_ONLY = "sample_group_read_only"; + public static final String SAMPLE_GROUP_READ_WRITE = "sample_group_read_write"; + public static final String SAMPLE_GROUP_FULL_ACCESS = "sample_group_full_access"; + public static final String SAMPLE_RESOURCE_CREATE_ENDPOINT = SAMPLE_RESOURCE_PLUGIN_PREFIX + "/create"; public static final String SAMPLE_RESOURCE_GET_ENDPOINT = SAMPLE_RESOURCE_PLUGIN_PREFIX + "/get"; public static final String SAMPLE_RESOURCE_UPDATE_ENDPOINT = SAMPLE_RESOURCE_PLUGIN_PREFIX + "/update"; public static final String SAMPLE_RESOURCE_DELETE_ENDPOINT = SAMPLE_RESOURCE_PLUGIN_PREFIX + "/delete"; public static final String SAMPLE_RESOURCE_SEARCH_ENDPOINT = SAMPLE_RESOURCE_PLUGIN_PREFIX + "/search"; + public static final String SAMPLE_RESOURCE_GROUP_CREATE_ENDPOINT = SAMPLE_RESOURCE_PLUGIN_PREFIX + "/group/create"; + public static final String SAMPLE_RESOURCE_GROUP_GET_ENDPOINT = SAMPLE_RESOURCE_PLUGIN_PREFIX + "/group/get"; + public static final String SAMPLE_RESOURCE_GROUP_UPDATE_ENDPOINT = SAMPLE_RESOURCE_PLUGIN_PREFIX + "/group/update"; + public static final String SAMPLE_RESOURCE_GROUP_DELETE_ENDPOINT = SAMPLE_RESOURCE_PLUGIN_PREFIX + "/group/delete"; + public static final String SAMPLE_RESOURCE_GROUP_SEARCH_ENDPOINT = SAMPLE_RESOURCE_PLUGIN_PREFIX + "/group/search"; + public static final String RESOURCE_SHARING_MIGRATION_ENDPOINT = "_plugins/_security/api/resources/migrate"; public static final String SECURITY_SHARE_ENDPOINT = "_plugins/_security/api/resource/share"; public static final String SECURITY_TYPES_ENDPOINT = "_plugins/_security/api/resource/types"; public static final String SECURITY_LIST_ENDPOINT = "_plugins/_security/api/resource/list"; public static LocalCluster newCluster(boolean featureEnabled, boolean systemIndexEnabled) { - return newCluster(featureEnabled, systemIndexEnabled, List.of(RESOURCE_TYPE)); + return newCluster(featureEnabled, systemIndexEnabled, List.of(RESOURCE_TYPE, RESOURCE_GROUP_TYPE)); } public static LocalCluster newCluster(boolean featureEnabled, boolean systemIndexEnabled, List protectedResourceTypes) { @@ -144,10 +155,12 @@ public static String directSharePayload(String resourceId, String creator, Strin public static String migrationPayload_valid() { return """ { - "source_index": "%s", - "username_path": "%s", - "backend_roles_path": "%s", - "default_access_level": "%s" + "source_index": "%s", + "username_path": "%s", + "backend_roles_path": "%s", + "default_access_level": { + "sample-resource": "%s" + } } """.formatted(RESOURCE_INDEX_NAME, "user/name", "user/backend_roles", "sample_read_only"); } @@ -155,12 +168,14 @@ public static String migrationPayload_valid() { public static String migrationPayload_valid_withSpecifiedAccessLevel(String accessLevel) { return """ { - "source_index": "%s", - "username_path": "%s", - "backend_roles_path": "%s", - "default_access_level": "%s" + "source_index": "%s", + "username_path": "%s", + "backend_roles_path": "%s", + "default_access_level": { + "sample-resource": "%s" + } } - """.formatted(RESOURCE_INDEX_NAME, "user/name", "user/backend_roles", accessLevel); + """.formatted(RESOURCE_INDEX_NAME, "user/name", "user/backend_roles", accessLevel); } public static String migrationPayload_missingSourceIndex() { @@ -168,7 +183,9 @@ public static String migrationPayload_missingSourceIndex() { { "username_path": "%s", "backend_roles_path": "%s", - "default_access_level": "%s" + "default_access_level": { + "sample-resource": "%s" + } } """.formatted("user/name", "user/backend_roles", "sample_read_only"); } @@ -178,7 +195,9 @@ public static String migrationPayload_missingUserName() { { "source_index": "%s", "backend_roles_path": "%s", - "default_access_level": "%s" + "default_access_level": { + "sample-resource": "%s" + } } """.formatted(RESOURCE_INDEX_NAME, "user/backend_roles", "sample_read_only"); } @@ -188,7 +207,9 @@ public static String migrationPayload_missingBackendRoles() { { "source_index": "%s", "username_path": "%s", - "default_access_level": "%s" + "default_access_level": { + "sample-resource": "%s" + } } """.formatted(RESOURCE_INDEX_NAME, "user/name", "sample_read_only"); } @@ -330,6 +351,15 @@ public String createSampleResourceAs(TestSecurityConfig.User user, Header... hea } } + public String createSampleResourceGroupAs(TestSecurityConfig.User user, Header... headers) { + try (TestRestClient client = cluster.getRestClient(user)) { + String sample = "{\"name\":\"samplegroup\"}"; + TestRestClient.HttpResponse resp = client.putJson(SAMPLE_RESOURCE_GROUP_CREATE_ENDPOINT, sample, headers); + resp.assertStatusCode(HttpStatus.SC_OK); + return resp.getTextFromJsonBody("/message").split(":")[1].trim(); + } + } + public String createRawResourceAs(CertificateData adminCert) { try (TestRestClient client = cluster.getRestClient(adminCert)) { String sample = "{\"name\":\"sample\"}"; @@ -353,6 +383,12 @@ public TestRestClient.HttpResponse getResource(String resourceId, TestSecurityCo } } + public TestRestClient.HttpResponse getResourceGroup(String resourceGroupId, TestSecurityConfig.User user) { + try (TestRestClient client = cluster.getRestClient(user)) { + return client.get(SAMPLE_RESOURCE_GROUP_GET_ENDPOINT + "/" + resourceGroupId); + } + } + private void assertGet(String endpoint, TestSecurityConfig.User user, int status, String expectedString) { try (TestRestClient client = cluster.getRestClient(user)) { TestRestClient.HttpResponse response = client.get(endpoint); @@ -484,6 +520,13 @@ public TestRestClient.HttpResponse updateResource(String resourceId, TestSecurit } } + public TestRestClient.HttpResponse updateResourceGroup(String resourceGroupId, TestSecurityConfig.User user, String newName) { + try (TestRestClient client = cluster.getRestClient(user)) { + String updatePayload = "{" + "\"name\": \"" + newName + "\"}"; + return client.postJson(SAMPLE_RESOURCE_GROUP_UPDATE_ENDPOINT + "/" + resourceGroupId, updatePayload); + } + } + public void assertDirectUpdate(String resourceId, TestSecurityConfig.User user, String newName, int status) { assertUpdate(RESOURCE_INDEX_NAME + "/_doc/" + resourceId + "?refresh=true", newName, user, status); } @@ -526,6 +569,20 @@ public TestRestClient.HttpResponse shareResource( } } + public TestRestClient.HttpResponse shareResourceGroup( + String resourceId, + TestSecurityConfig.User user, + TestSecurityConfig.User target, + String accessLevel + ) { + try (TestRestClient client = cluster.getRestClient(user)) { + return client.putJson( + SECURITY_SHARE_ENDPOINT, + putSharingInfoPayload(resourceId, RESOURCE_GROUP_TYPE, accessLevel, Recipient.USERS, target.getName()) + ); + } + } + public TestRestClient.HttpResponse shareResourceByRole( String resourceId, TestSecurityConfig.User user, @@ -540,6 +597,20 @@ public TestRestClient.HttpResponse shareResourceByRole( } } + public TestRestClient.HttpResponse shareResourceGroupByRole( + String resourceId, + TestSecurityConfig.User user, + String targetRole, + String accessLevel + ) { + try (TestRestClient client = cluster.getRestClient(user)) { + return client.putJson( + SECURITY_SHARE_ENDPOINT, + putSharingInfoPayload(resourceId, RESOURCE_GROUP_TYPE, accessLevel, Recipient.ROLES, targetRole) + ); + } + } + public TestRestClient.HttpResponse revokeResource( String resourceId, TestSecurityConfig.User user, @@ -555,6 +626,21 @@ public TestRestClient.HttpResponse revokeResource( } } + public TestRestClient.HttpResponse revokeResourceGroup( + String resourceId, + TestSecurityConfig.User user, + TestSecurityConfig.User target, + String accessLevel + ) { + PatchSharingInfoPayloadBuilder patchBuilder = new PatchSharingInfoPayloadBuilder(); + patchBuilder.resourceType(RESOURCE_GROUP_TYPE); + patchBuilder.resourceId(resourceId); + patchBuilder.revoke(new Recipients(Map.of(Recipient.USERS, Set.of(target.getName()))), accessLevel); + try (TestRestClient client = cluster.getRestClient(user)) { + return client.patch(TestUtils.SECURITY_SHARE_ENDPOINT, patchBuilder.build()); + } + } + public void assertDirectDelete(String resourceId, TestSecurityConfig.User user, int status) { assertDelete(RESOURCE_INDEX_NAME + "/_doc/" + resourceId, user, status); } @@ -569,6 +655,12 @@ public TestRestClient.HttpResponse deleteResource(String resourceId, TestSecurit } } + public TestRestClient.HttpResponse deleteResourceGroup(String resourceGroupId, TestSecurityConfig.User user) { + try (TestRestClient client = cluster.getRestClient(user)) { + return client.delete(SAMPLE_RESOURCE_GROUP_DELETE_ENDPOINT + "/" + resourceGroupId); + } + } + private void assertDelete(String endpoint, TestSecurityConfig.User user, int status) { try (TestRestClient client = cluster.getRestClient(user)) { TestRestClient.HttpResponse response = client.delete(endpoint); diff --git a/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/resource/securityapis/ShareableResourceTypesInfoApiTests.java b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/resource/securityapis/ShareableResourceTypesInfoApiTests.java index bccb542133..770b588eb9 100644 --- a/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/resource/securityapis/ShareableResourceTypesInfoApiTests.java +++ b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/resource/securityapis/ShareableResourceTypesInfoApiTests.java @@ -22,6 +22,7 @@ import org.opensearch.test.framework.cluster.TestRestClient; import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsInAnyOrder; import static org.hamcrest.Matchers.equalTo; import static org.opensearch.sample.resource.TestUtils.NO_ACCESS_USER; import static org.opensearch.sample.resource.TestUtils.RESOURCE_SHARING_INDEX; @@ -54,10 +55,19 @@ public void testTypesApi_mustListSampleResourceAsAType() { TestRestClient.HttpResponse response = client.get(SECURITY_TYPES_ENDPOINT); response.assertStatusCode(HttpStatus.SC_OK); List types = (List) response.bodyAsMap().get("types"); - assertThat(types.size(), equalTo(1)); - Map responseBody = (Map) types.getFirst(); - assertThat(responseBody.get("type"), equalTo("sample-resource")); - assertThat(responseBody.get("action_groups"), equalTo(List.of("sample_read_only", "sample_read_write", "sample_full_access"))); + assertThat(types.size(), equalTo(2)); + Map firstType = (Map) types.get(0); + assertThat(firstType.get("type"), equalTo("sample-resource")); + assertThat( + (List) firstType.get("action_groups"), + containsInAnyOrder("sample_read_only", "sample_read_write", "sample_full_access") + ); + Map secondType = (Map) types.get(1); + assertThat(secondType.get("type"), equalTo("sample-resource-group")); + assertThat( + (List) secondType.get("action_groups"), + containsInAnyOrder("sample_group_read_only", "sample_group_read_write", "sample_group_full_access") + ); } } diff --git a/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/resourcegroup/SampleResourceGroupTests.java b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/resourcegroup/SampleResourceGroupTests.java new file mode 100644 index 0000000000..46b7fc93fa --- /dev/null +++ b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/resourcegroup/SampleResourceGroupTests.java @@ -0,0 +1,205 @@ +/* + * 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.resourcegroup; + +import com.carrotsearch.randomizedtesting.RandomizedRunner; +import com.carrotsearch.randomizedtesting.annotations.ThreadLeakScope; +import org.apache.http.HttpStatus; +import org.junit.After; +import org.junit.Before; +import org.junit.ClassRule; +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.opensearch.sample.resource.TestUtils; +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.opensearch.sample.resource.TestUtils.FULL_ACCESS_USER; +import static org.opensearch.sample.resource.TestUtils.LIMITED_ACCESS_USER; +import static org.opensearch.sample.resource.TestUtils.SAMPLE_GROUP_FULL_ACCESS; +import static org.opensearch.sample.resource.TestUtils.SAMPLE_GROUP_READ_ONLY; +import static org.opensearch.sample.resource.TestUtils.SECURITY_SHARE_ENDPOINT; +import static org.opensearch.sample.resource.TestUtils.newCluster; +import static org.opensearch.sample.utils.Constants.RESOURCE_GROUP_TYPE; +import static org.opensearch.security.api.AbstractApiIntegrationTest.forbidden; +import static org.opensearch.security.api.AbstractApiIntegrationTest.ok; +import static org.opensearch.test.framework.TestSecurityConfig.User.USER_ADMIN; + +/** + * Test resource access to a resource shared with mixed access-levels. Some users are shared at read_only, others at full_access. + * All tests are against USER_ADMIN's resource created during setup. + */ +@RunWith(RandomizedRunner.class) +@ThreadLeakScope(ThreadLeakScope.Scope.NONE) +public class SampleResourceGroupTests { + + @ClassRule + public static LocalCluster cluster = newCluster(true, true); + + private final TestUtils.ApiHelper api = new TestUtils.ApiHelper(cluster); + private String resourceGroupId; + + @Before + public void setup() { + resourceGroupId = api.createSampleResourceGroupAs(USER_ADMIN); + api.awaitSharingEntry(resourceGroupId); // wait until sharing entry is created + } + + @After + public void cleanup() { + api.wipeOutResourceEntries(); + } + + private void assertNoAccessBeforeSharing(TestSecurityConfig.User user) throws Exception { + forbidden(() -> api.getResourceGroup(resourceGroupId, user)); + forbidden(() -> api.updateResourceGroup(resourceGroupId, user, "sampleUpdateAdmin")); + forbidden(() -> api.deleteResourceGroup(resourceGroupId, user)); + + forbidden(() -> api.shareResourceGroup(resourceGroupId, user, user, SAMPLE_GROUP_FULL_ACCESS)); + forbidden(() -> api.revokeResourceGroup(resourceGroupId, user, user, SAMPLE_GROUP_FULL_ACCESS)); + } + + private void assertReadOnly(TestSecurityConfig.User user) throws Exception { + TestRestClient.HttpResponse response = ok(() -> api.getResourceGroup(resourceGroupId, user)); + assertThat(response.getBody(), containsString("sample")); + forbidden(() -> api.updateResourceGroup(resourceGroupId, user, "sampleUpdateAdmin")); + forbidden(() -> api.deleteResourceGroup(resourceGroupId, user)); + + forbidden(() -> api.shareResourceGroup(resourceGroupId, user, user, SAMPLE_GROUP_FULL_ACCESS)); + forbidden(() -> api.revokeResourceGroup(resourceGroupId, user, user, SAMPLE_GROUP_FULL_ACCESS)); + } + + private void assertFullAccess(TestSecurityConfig.User user) throws Exception { + TestRestClient.HttpResponse response = ok(() -> api.getResourceGroup(resourceGroupId, user)); + assertThat(response.getBody(), containsString("sample")); + ok(() -> api.updateResourceGroup(resourceGroupId, user, "sampleUpdateAdmin")); + ok(() -> api.shareResourceGroup(resourceGroupId, user, user, SAMPLE_GROUP_FULL_ACCESS)); + ok(() -> api.revokeResourceGroup(resourceGroupId, user, USER_ADMIN, SAMPLE_GROUP_FULL_ACCESS)); + ok(() -> api.deleteResourceGroup(resourceGroupId, user)); + } + + @Test + public void multipleUsers_multipleLevels() throws Exception { + assertNoAccessBeforeSharing(FULL_ACCESS_USER); + assertNoAccessBeforeSharing(LIMITED_ACCESS_USER); + // 1. share at read-only for full-access user and at full-access for limited-perms user + ok(() -> api.shareResourceGroup(resourceGroupId, USER_ADMIN, FULL_ACCESS_USER, SAMPLE_GROUP_READ_ONLY)); + ok(() -> api.shareResourceGroup(resourceGroupId, USER_ADMIN, LIMITED_ACCESS_USER, SAMPLE_GROUP_FULL_ACCESS)); + + // 2. check read-only access for full-access user + assertReadOnly(FULL_ACCESS_USER); + + // 3. limited access user shares with full-access user at sampleAllAG + ok(() -> api.shareResourceGroup(resourceGroupId, LIMITED_ACCESS_USER, FULL_ACCESS_USER, SAMPLE_GROUP_FULL_ACCESS)); + + // 4. full-access user now has full-access to admin's resource + assertFullAccess(FULL_ACCESS_USER); + } + + @Test + public void multipleUsers_sameLevel() throws Exception { + assertNoAccessBeforeSharing(FULL_ACCESS_USER); + assertNoAccessBeforeSharing(LIMITED_ACCESS_USER); + + // 1. share with both users at read-only level + ok(() -> api.shareResourceGroup(resourceGroupId, USER_ADMIN, FULL_ACCESS_USER, SAMPLE_GROUP_READ_ONLY)); + ok(() -> api.shareResourceGroup(resourceGroupId, USER_ADMIN, LIMITED_ACCESS_USER, SAMPLE_GROUP_READ_ONLY)); + + // 2. assert both now have read-only access + assertReadOnly(LIMITED_ACCESS_USER); + } + + @Test + public void sameUser_multipleLevels() throws Exception { + assertNoAccessBeforeSharing(LIMITED_ACCESS_USER); + + // 1. share with user at read-only level + ok(() -> api.shareResourceGroup(resourceGroupId, USER_ADMIN, LIMITED_ACCESS_USER, SAMPLE_GROUP_READ_ONLY)); + + // 2. assert user now has read-only access + assertReadOnly(LIMITED_ACCESS_USER); + + // 3. share with user at full-access level + ok(() -> api.shareResourceGroup(resourceGroupId, USER_ADMIN, LIMITED_ACCESS_USER, SAMPLE_GROUP_FULL_ACCESS)); + + // 4. assert user now has full access + assertFullAccess(LIMITED_ACCESS_USER); + } + + private String getActualRoleName(TestSecurityConfig.User user, String baseRoleName) { + return "user_" + user.getName() + "__" + baseRoleName; + } + + @Test + public void multipleRoles_multipleLevels() throws Exception { + assertNoAccessBeforeSharing(FULL_ACCESS_USER); + assertNoAccessBeforeSharing(LIMITED_ACCESS_USER); + + String fullAccessUserRole = getActualRoleName(FULL_ACCESS_USER, "shared_role"); + String limitedAccessUserRole = getActualRoleName(LIMITED_ACCESS_USER, "shared_role_limited_perms"); + + // 1. share at read-only for shared_role and at full-access for shared_role_limited_perms + ok(() -> api.shareResourceGroupByRole(resourceGroupId, USER_ADMIN, fullAccessUserRole, SAMPLE_GROUP_READ_ONLY)); + ok(() -> api.shareResourceGroupByRole(resourceGroupId, USER_ADMIN, limitedAccessUserRole, SAMPLE_GROUP_FULL_ACCESS)); + + // 2. check read-only access for FULL_ACCESS_USER (has shared_role) + assertReadOnly(FULL_ACCESS_USER); + + // 3. LIMITED_ACCESS_USER (has shared_role_limited_perms) shares with shared_role at sampleAllAG + ok(() -> api.shareResourceGroupByRole(resourceGroupId, LIMITED_ACCESS_USER, fullAccessUserRole, SAMPLE_GROUP_FULL_ACCESS)); + + // 4. FULL_ACCESS_USER now has full-access to admin's resource + assertFullAccess(FULL_ACCESS_USER); + } + + @Test + public void initialShare_multipleLevels() throws Exception { + assertNoAccessBeforeSharing(FULL_ACCESS_USER); + assertNoAccessBeforeSharing(LIMITED_ACCESS_USER); + + String shareWithPayload = """ + { + "resource_id": "%s", + "resource_type": "%s", + "share_with": { + "%s" : { + "users": ["%s"] + }, + "%s" : { + "users": ["%s"] + } + } + } + """.formatted( + resourceGroupId, + RESOURCE_GROUP_TYPE, + SAMPLE_GROUP_FULL_ACCESS, + LIMITED_ACCESS_USER.getName(), + SAMPLE_GROUP_READ_ONLY, + FULL_ACCESS_USER.getName() + ); + + try (TestRestClient client = cluster.getRestClient(USER_ADMIN)) { + TestRestClient.HttpResponse response = client.putJson(SECURITY_SHARE_ENDPOINT, shareWithPayload); + response.assertStatusCode(HttpStatus.SC_OK); + } + + // full-access user has read-only perm + assertReadOnly(FULL_ACCESS_USER); + + // limited access user has full-access + assertFullAccess(LIMITED_ACCESS_USER); + + } + +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/SampleResourceGroup.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/SampleResourceGroup.java new file mode 100644 index 0000000000..fd566af87e --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/SampleResourceGroup.java @@ -0,0 +1,97 @@ +/* + * 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.ParseField; +import org.opensearch.core.common.io.stream.NamedWriteable; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; +import org.opensearch.core.xcontent.ConstructingObjectParser; +import org.opensearch.core.xcontent.ToXContentObject; +import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.core.xcontent.XContentParser; + +import static org.opensearch.core.xcontent.ConstructingObjectParser.constructorArg; +import static org.opensearch.core.xcontent.ConstructingObjectParser.optionalConstructorArg; +import static org.opensearch.sample.utils.Constants.RESOURCE_GROUP_TYPE; +import static org.opensearch.sample.utils.Constants.RESOURCE_TYPE; + +/** + * Sample resource group declared by this plugin. + */ +public class SampleResourceGroup implements NamedWriteable, ToXContentObject { + + private String name; + private String description; + + public SampleResourceGroup() throws IOException { + super(); + } + + public SampleResourceGroup(StreamInput in) throws IOException { + this.name = in.readString(); + this.description = in.readString(); + } + + private static final ConstructingObjectParser PARSER = new ConstructingObjectParser<>( + RESOURCE_TYPE, + true, + a -> { + SampleResourceGroup s; + try { + s = new SampleResourceGroup(); + } catch (IOException e) { + throw new RuntimeException(e); + } + s.setName((String) a[0]); + s.setDescription((String) a[1]); + return s; + } + ); + + static { + PARSER.declareString(constructorArg(), new ParseField("name")); + PARSER.declareStringOrNull(optionalConstructorArg(), new ParseField("description")); + } + + public static SampleResourceGroup fromXContent(XContentParser parser) throws IOException { + return PARSER.parse(parser, null); + } + + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + return builder.startObject().field("name", name).field("description", description).endObject(); + } + + public void writeTo(StreamOutput out) throws IOException { + out.writeString(name); + out.writeString(description); + } + + public void setName(String name) { + this.name = name; + } + + public void setDescription(String description) { + this.description = description; + } + + public String getName() { + return name; + } + + @Override + public String getWriteableName() { + return RESOURCE_GROUP_TYPE; + } +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/SampleResourceGroupExtension.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/SampleResourceGroupExtension.java new file mode 100644 index 0000000000..822ae612e7 --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/SampleResourceGroupExtension.java @@ -0,0 +1,27 @@ +package org.opensearch.sample; + +import java.util.Set; + +import org.opensearch.sample.client.ResourceSharingClientAccessor; +import org.opensearch.security.spi.resources.ResourceProvider; +import org.opensearch.security.spi.resources.ResourceSharingExtension; +import org.opensearch.security.spi.resources.client.ResourceSharingClient; + +import static org.opensearch.sample.utils.Constants.RESOURCE_GROUP_TYPE; +import static org.opensearch.sample.utils.Constants.RESOURCE_INDEX_NAME; + +/** + * Responsible for parsing the XContent into a SampleResourceGroup object. + */ +public class SampleResourceGroupExtension implements ResourceSharingExtension { + + @Override + public Set getResourceProviders() { + return Set.of(new ResourceProvider(RESOURCE_GROUP_TYPE, RESOURCE_INDEX_NAME)); + } + + @Override + public void assignResourceSharingClient(ResourceSharingClient resourceSharingClient) { + ResourceSharingClientAccessor.getInstance().setResourceSharingClient(resourceSharingClient); + } +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/SampleResourcePlugin.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/SampleResourcePlugin.java index e1b63cadd1..3cddcaeefd 100644 --- a/sample-resource-plugin/src/main/java/org/opensearch/sample/SampleResourcePlugin.java +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/SampleResourcePlugin.java @@ -50,6 +50,20 @@ import org.opensearch.sample.resource.actions.transport.GetResourceTransportAction; import org.opensearch.sample.resource.actions.transport.SearchResourceTransportAction; import org.opensearch.sample.resource.actions.transport.UpdateResourceTransportAction; +import org.opensearch.sample.resourcegroup.actions.rest.create.CreateResourceGroupAction; +import org.opensearch.sample.resourcegroup.actions.rest.create.CreateResourceGroupRestAction; +import org.opensearch.sample.resourcegroup.actions.rest.create.UpdateResourceGroupAction; +import org.opensearch.sample.resourcegroup.actions.rest.delete.DeleteResourceGroupAction; +import org.opensearch.sample.resourcegroup.actions.rest.delete.DeleteResourceGroupRestAction; +import org.opensearch.sample.resourcegroup.actions.rest.get.GetResourceGroupAction; +import org.opensearch.sample.resourcegroup.actions.rest.get.GetResourceGroupRestAction; +import org.opensearch.sample.resourcegroup.actions.rest.search.SearchResourceGroupAction; +import org.opensearch.sample.resourcegroup.actions.rest.search.SearchResourceGroupRestAction; +import org.opensearch.sample.resourcegroup.actions.transport.CreateResourceGroupTransportAction; +import org.opensearch.sample.resourcegroup.actions.transport.DeleteResourceGroupTransportAction; +import org.opensearch.sample.resourcegroup.actions.transport.GetResourceGroupTransportAction; +import org.opensearch.sample.resourcegroup.actions.transport.SearchResourceGroupTransportAction; +import org.opensearch.sample.resourcegroup.actions.transport.UpdateResourceGroupTransportAction; import org.opensearch.sample.secure.actions.rest.create.SecurePluginAction; import org.opensearch.sample.secure.actions.rest.create.SecurePluginRestAction; import org.opensearch.sample.secure.actions.transport.SecurePluginTransportAction; @@ -105,6 +119,10 @@ public List getRestHandlers( handlers.add(new GetResourceRestAction()); handlers.add(new DeleteResourceRestAction()); handlers.add(new SearchResourceRestAction()); + handlers.add(new CreateResourceGroupRestAction()); + handlers.add(new GetResourceGroupRestAction()); + handlers.add(new DeleteResourceGroupRestAction()); + handlers.add(new SearchResourceGroupRestAction()); handlers.add(new SecurePluginRestAction()); return handlers; @@ -118,6 +136,11 @@ public List getRestHandlers( actions.add(new ActionHandler<>(UpdateResourceAction.INSTANCE, UpdateResourceTransportAction.class)); actions.add(new ActionHandler<>(DeleteResourceAction.INSTANCE, DeleteResourceTransportAction.class)); actions.add(new ActionHandler<>(SearchResourceAction.INSTANCE, SearchResourceTransportAction.class)); + actions.add(new ActionHandler<>(CreateResourceGroupAction.INSTANCE, CreateResourceGroupTransportAction.class)); + actions.add(new ActionHandler<>(GetResourceGroupAction.INSTANCE, GetResourceGroupTransportAction.class)); + actions.add(new ActionHandler<>(UpdateResourceGroupAction.INSTANCE, UpdateResourceGroupTransportAction.class)); + actions.add(new ActionHandler<>(DeleteResourceGroupAction.INSTANCE, DeleteResourceGroupTransportAction.class)); + actions.add(new ActionHandler<>(SearchResourceGroupAction.INSTANCE, SearchResourceGroupTransportAction.class)); actions.add(new ActionHandler<>(SecurePluginAction.INSTANCE, SecurePluginTransportAction.class)); return actions; } diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/resourcegroup/actions/rest/create/CreateResourceGroupAction.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/resourcegroup/actions/rest/create/CreateResourceGroupAction.java new file mode 100644 index 0000000000..a5eba1cea5 --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resourcegroup/actions/rest/create/CreateResourceGroupAction.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.resourcegroup.actions.rest.create; + +import org.opensearch.action.ActionType; + +/** + * Action to create a sample resource group + */ +public class CreateResourceGroupAction extends ActionType { + /** + * Create sample resource group action instance + */ + public static final CreateResourceGroupAction INSTANCE = new CreateResourceGroupAction(); + /** + * Create sample resource group action name + */ + public static final String NAME = "cluster:admin/sample-resource-plugin/group/create"; + + private CreateResourceGroupAction() { + super(NAME, CreateResourceGroupResponse::new); + } +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/resourcegroup/actions/rest/create/CreateResourceGroupRequest.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/resourcegroup/actions/rest/create/CreateResourceGroupRequest.java new file mode 100644 index 0000000000..0e7db2dc75 --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resourcegroup/actions/rest/create/CreateResourceGroupRequest.java @@ -0,0 +1,69 @@ +/* + * 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.resourcegroup.actions.rest.create; + +import java.io.IOException; + +import org.opensearch.action.ActionRequest; +import org.opensearch.action.ActionRequestValidationException; +import org.opensearch.action.DocRequest; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; +import org.opensearch.sample.SampleResource; + +import static org.opensearch.sample.utils.Constants.RESOURCE_GROUP_TYPE; +import static org.opensearch.sample.utils.Constants.RESOURCE_INDEX_NAME; + +/** + * Request object for CreateSampleResourceGroup transport action + */ +public class CreateResourceGroupRequest extends ActionRequest implements DocRequest { + + private final SampleResource resource; + + /** + * Default constructor + */ + public CreateResourceGroupRequest(SampleResource resource) { + this.resource = resource; + } + + public CreateResourceGroupRequest(StreamInput in) throws IOException { + this.resource = in.readNamedWriteable(SampleResource.class); + } + + @Override + public void writeTo(final StreamOutput out) throws IOException { + resource.writeTo(out); + } + + @Override + public ActionRequestValidationException validate() { + return null; + } + + public SampleResource getResource() { + return this.resource; + } + + @Override + public String type() { + return RESOURCE_GROUP_TYPE; + } + + @Override + public String index() { + return RESOURCE_INDEX_NAME; + } + + @Override + public String id() { + return null; + } +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/resourcegroup/actions/rest/create/CreateResourceGroupResponse.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/resourcegroup/actions/rest/create/CreateResourceGroupResponse.java new file mode 100644 index 0000000000..d0a44e15c1 --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resourcegroup/actions/rest/create/CreateResourceGroupResponse.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.resourcegroup.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 CreateSampleResourceGroupRequest + */ +public class CreateResourceGroupResponse extends ActionResponse implements ToXContentObject { + private final String message; + + /** + * Default constructor + * + * @param message The message + */ + public CreateResourceGroupResponse(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 CreateResourceGroupResponse(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/resourcegroup/actions/rest/create/CreateResourceGroupRestAction.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/resourcegroup/actions/rest/create/CreateResourceGroupRestAction.java new file mode 100644 index 0000000000..b3e5317966 --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resourcegroup/actions/rest/create/CreateResourceGroupRestAction.java @@ -0,0 +1,97 @@ +/* + * 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.resourcegroup.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 Group. Registers Create and Update REST APIs. + */ +public class CreateResourceGroupRestAction extends BaseRestHandler { + + public CreateResourceGroupRestAction() {} + + @Override + public List routes() { + return List.of( + new Route(PUT, SAMPLE_RESOURCE_PLUGIN_API_PREFIX + "/group/create"), + new Route(POST, SAMPLE_RESOURCE_PLUGIN_API_PREFIX + "/group/update/{resource_id}") + ); + } + + @Override + public String getName() { + return "create_update_sample_resource_group"; + } + + @Override + protected RestChannelConsumer prepareRequest(RestRequest request, NodeClient client) throws IOException { + Map source; + try (XContentParser parser = request.contentParser()) { + source = parser.map(); + } + + return switch (request.method()) { + case PUT -> createResource(source, client); + case POST -> updateResource(source, request.param("resource_id"), client); + default -> throw new IllegalArgumentException("Illegal method: " + request.method()); + }; + } + + private RestChannelConsumer updateResource(Map source, String resourceId, NodeClient client) throws IOException { + String name = (String) source.get("name"); + String description = source.containsKey("description") ? (String) source.get("description") : null; + Map attributes = getAttributes(source); + SampleResource resource = new SampleResource(); + resource.setName(name); + resource.setDescription(description); + resource.setAttributes(attributes); + final UpdateResourceGroupRequest updateResourceRequest = new UpdateResourceGroupRequest(resourceId, resource); + return channel -> client.executeLocally( + UpdateResourceGroupAction.INSTANCE, + updateResourceRequest, + new RestToXContentListener<>(channel) + ); + } + + private RestChannelConsumer createResource(Map source, NodeClient client) throws IOException { + String name = (String) source.get("name"); + String description = source.containsKey("description") ? (String) source.get("description") : null; + Map attributes = getAttributes(source); + SampleResource resource = new SampleResource(); + resource.setName(name); + resource.setDescription(description); + resource.setAttributes(attributes); + final CreateResourceGroupRequest createSampleResourceRequest = new CreateResourceGroupRequest(resource); + return channel -> client.executeLocally( + CreateResourceGroupAction.INSTANCE, + createSampleResourceRequest, + new RestToXContentListener<>(channel) + ); + } + + // NOTE: Do NOT use @SuppressWarnings("unchecked") on untrusted data in production code. This is used here only to keep the code simple + @SuppressWarnings("unchecked") + private Map getAttributes(Map source) { + return source.containsKey("attributes") ? (Map) source.get("attributes") : null; + } +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/resourcegroup/actions/rest/create/UpdateResourceGroupAction.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/resourcegroup/actions/rest/create/UpdateResourceGroupAction.java new file mode 100644 index 0000000000..956f6b4592 --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resourcegroup/actions/rest/create/UpdateResourceGroupAction.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.resourcegroup.actions.rest.create; + +import org.opensearch.action.ActionType; + +/** + * Action to update a sample resource group + */ +public class UpdateResourceGroupAction extends ActionType { + /** + * Update sample resource group action instance + */ + public static final UpdateResourceGroupAction INSTANCE = new UpdateResourceGroupAction(); + /** + * Update sample resource group action name + */ + public static final String NAME = "cluster:admin/sample-resource-plugin/group/update"; + + private UpdateResourceGroupAction() { + super(NAME, CreateResourceGroupResponse::new); + } +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/resourcegroup/actions/rest/create/UpdateResourceGroupRequest.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/resourcegroup/actions/rest/create/UpdateResourceGroupRequest.java new file mode 100644 index 0000000000..ad1d6eac9e --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resourcegroup/actions/rest/create/UpdateResourceGroupRequest.java @@ -0,0 +1,77 @@ +/* + * 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.resourcegroup.actions.rest.create; + +import java.io.IOException; + +import org.opensearch.action.ActionRequest; +import org.opensearch.action.ActionRequestValidationException; +import org.opensearch.action.DocRequest; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; +import org.opensearch.sample.SampleResource; + +import static org.opensearch.sample.utils.Constants.RESOURCE_GROUP_TYPE; +import static org.opensearch.sample.utils.Constants.RESOURCE_INDEX_NAME; + +/** + * Request object for UpdateResourceGroup transport action + */ +public class UpdateResourceGroupRequest extends ActionRequest implements DocRequest { + + private final String resourceId; + private final SampleResource resource; + + /** + * Default constructor + */ + public UpdateResourceGroupRequest(String resourceId, SampleResource resource) { + this.resourceId = resourceId; + this.resource = resource; + } + + public UpdateResourceGroupRequest(StreamInput in) throws IOException { + this.resourceId = in.readString(); + this.resource = in.readNamedWriteable(SampleResource.class); + } + + @Override + public void writeTo(final StreamOutput out) throws IOException { + out.writeString(resourceId); + resource.writeTo(out); + } + + @Override + public ActionRequestValidationException validate() { + return null; + } + + public SampleResource getResource() { + return this.resource; + } + + public String getResourceId() { + return this.resourceId; + } + + @Override + public String type() { + return RESOURCE_GROUP_TYPE; + } + + @Override + public String index() { + return RESOURCE_INDEX_NAME; + } + + @Override + public String id() { + return resourceId; + } +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/resourcegroup/actions/rest/delete/DeleteResourceGroupAction.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/resourcegroup/actions/rest/delete/DeleteResourceGroupAction.java new file mode 100644 index 0000000000..7b317fcf64 --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resourcegroup/actions/rest/delete/DeleteResourceGroupAction.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.resourcegroup.actions.rest.delete; + +import org.opensearch.action.ActionType; + +/** + * Action to delete a sample resource group + */ +public class DeleteResourceGroupAction extends ActionType { + /** + * Delete sample resource group action instance + */ + public static final DeleteResourceGroupAction INSTANCE = new DeleteResourceGroupAction(); + /** + * Delete sample resource group action name + */ + public static final String NAME = "cluster:admin/sample-resource-plugin/group/delete"; + + private DeleteResourceGroupAction() { + super(NAME, DeleteResourceGroupResponse::new); + } +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/resourcegroup/actions/rest/delete/DeleteResourceGroupRequest.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/resourcegroup/actions/rest/delete/DeleteResourceGroupRequest.java new file mode 100644 index 0000000000..f7994f3352 --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resourcegroup/actions/rest/delete/DeleteResourceGroupRequest.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.resourcegroup.actions.rest.delete; + +import java.io.IOException; + +import org.opensearch.action.ActionRequest; +import org.opensearch.action.ActionRequestValidationException; +import org.opensearch.action.DocRequest; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; + +import static org.opensearch.sample.utils.Constants.RESOURCE_GROUP_TYPE; +import static org.opensearch.sample.utils.Constants.RESOURCE_INDEX_NAME; + +/** + * Request object for DeleteSampleResourceGroup transport action + */ +public class DeleteResourceGroupRequest extends ActionRequest implements DocRequest { + + private final String resourceId; + + /** + * Default constructor + */ + public DeleteResourceGroupRequest(String resourceId) { + this.resourceId = resourceId; + } + + public DeleteResourceGroupRequest(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; + } + + @Override + public String type() { + return RESOURCE_GROUP_TYPE; + } + + @Override + public String index() { + return RESOURCE_INDEX_NAME; + } + + @Override + public String id() { + return resourceId; + } +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/resourcegroup/actions/rest/delete/DeleteResourceGroupResponse.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/resourcegroup/actions/rest/delete/DeleteResourceGroupResponse.java new file mode 100644 index 0000000000..1d1bb9b6af --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resourcegroup/actions/rest/delete/DeleteResourceGroupResponse.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.resourcegroup.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 DeleteSampleResourceGroupRequest + */ +public class DeleteResourceGroupResponse extends ActionResponse implements ToXContentObject { + private final String message; + + /** + * Default constructor + * + * @param message The message + */ + public DeleteResourceGroupResponse(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 DeleteResourceGroupResponse(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/resourcegroup/actions/rest/delete/DeleteResourceGroupRestAction.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/resourcegroup/actions/rest/delete/DeleteResourceGroupRestAction.java new file mode 100644 index 0000000000..9031258426 --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resourcegroup/actions/rest/delete/DeleteResourceGroupRestAction.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.resourcegroup.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 Group. + */ +public class DeleteResourceGroupRestAction extends BaseRestHandler { + + public DeleteResourceGroupRestAction() {} + + @Override + public List routes() { + return singletonList(new Route(DELETE, SAMPLE_RESOURCE_PLUGIN_API_PREFIX + "/group/delete/{resource_id}")); + } + + @Override + public String getName() { + return "delete_sample_resource_group"; + } + + @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 DeleteResourceGroupRequest createSampleResourceRequest = new DeleteResourceGroupRequest(resourceId); + return channel -> client.executeLocally( + DeleteResourceGroupAction.INSTANCE, + createSampleResourceRequest, + new RestToXContentListener<>(channel) + ); + } +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/resourcegroup/actions/rest/get/GetResourceGroupAction.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/resourcegroup/actions/rest/get/GetResourceGroupAction.java new file mode 100644 index 0000000000..40285009a8 --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resourcegroup/actions/rest/get/GetResourceGroupAction.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.resourcegroup.actions.rest.get; + +import org.opensearch.action.ActionType; + +/** + * Action to get a sample resource group + */ +public class GetResourceGroupAction extends ActionType { + /** + * Get sample resource group action instance + */ + public static final GetResourceGroupAction INSTANCE = new GetResourceGroupAction(); + /** + * Get sample resource group action name + */ + public static final String NAME = "cluster:admin/sample-resource-plugin/group/get"; + + private GetResourceGroupAction() { + super(NAME, GetResourceGroupResponse::new); + } +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/resourcegroup/actions/rest/get/GetResourceGroupRequest.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/resourcegroup/actions/rest/get/GetResourceGroupRequest.java new file mode 100644 index 0000000000..c10e9f25ae --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resourcegroup/actions/rest/get/GetResourceGroupRequest.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.resourcegroup.actions.rest.get; + +import java.io.IOException; + +import org.opensearch.action.ActionRequest; +import org.opensearch.action.ActionRequestValidationException; +import org.opensearch.action.DocRequest; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; + +import static org.opensearch.sample.utils.Constants.RESOURCE_GROUP_TYPE; +import static org.opensearch.sample.utils.Constants.RESOURCE_INDEX_NAME; + +/** + * Request object for GetSampleResourceGroup transport action + */ +public class GetResourceGroupRequest extends ActionRequest implements DocRequest { + + private final String resourceId; + + /** + * Default constructor + */ + public GetResourceGroupRequest(String resourceId) { + this.resourceId = resourceId; + } + + public GetResourceGroupRequest(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; + } + + @Override + public String type() { + return RESOURCE_GROUP_TYPE; + } + + @Override + public String index() { + return RESOURCE_INDEX_NAME; + } + + @Override + public String id() { + return resourceId; + } +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/resourcegroup/actions/rest/get/GetResourceGroupResponse.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/resourcegroup/actions/rest/get/GetResourceGroupResponse.java new file mode 100644 index 0000000000..8cfd10a05f --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resourcegroup/actions/rest/get/GetResourceGroupResponse.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.resourcegroup.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 GetResourceGroupResponse extends ActionResponse implements ToXContentObject { + private final Set resources; + + /** + * Default constructor + * + * @param resources The resources + */ + public GetResourceGroupResponse(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 GetResourceGroupResponse(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/resourcegroup/actions/rest/get/GetResourceGroupRestAction.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/resourcegroup/actions/rest/get/GetResourceGroupRestAction.java new file mode 100644 index 0000000000..fec6ccf942 --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resourcegroup/actions/rest/get/GetResourceGroupRestAction.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.resourcegroup.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 group + */ +public class GetResourceGroupRestAction extends BaseRestHandler { + + public GetResourceGroupRestAction() {} + + @Override + public List routes() { + return List.of( + new Route(GET, SAMPLE_RESOURCE_PLUGIN_API_PREFIX + "/group/get/{resource_id}"), + new Route(GET, SAMPLE_RESOURCE_PLUGIN_API_PREFIX + "/group/get") + ); + } + + @Override + public String getName() { + return "get_sample_resource_group"; + } + + @Override + protected RestChannelConsumer prepareRequest(RestRequest request, NodeClient client) { + String resourceId = request.param("resource_id"); + + final GetResourceGroupRequest getResourceRequest = new GetResourceGroupRequest(resourceId); + return channel -> client.executeLocally(GetResourceGroupAction.INSTANCE, getResourceRequest, new RestToXContentListener<>(channel)); + } +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/resourcegroup/actions/rest/search/SearchResourceGroupAction.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/resourcegroup/actions/rest/search/SearchResourceGroupAction.java new file mode 100644 index 0000000000..8b0a2c6b37 --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resourcegroup/actions/rest/search/SearchResourceGroupAction.java @@ -0,0 +1,26 @@ +/* + * 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.resourcegroup.actions.rest.search; + +import org.opensearch.action.ActionType; +import org.opensearch.action.search.SearchResponse; + +/** + * Action to search sample resource groups + */ +public class SearchResourceGroupAction extends ActionType { + + public static final SearchResourceGroupAction INSTANCE = new SearchResourceGroupAction(); + + public static final String NAME = "cluster:admin/sample-resource-plugin/group/search"; + + private SearchResourceGroupAction() { + super(NAME, SearchResponse::new); + } +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/resourcegroup/actions/rest/search/SearchResourceGroupRestAction.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/resourcegroup/actions/rest/search/SearchResourceGroupRestAction.java new file mode 100644 index 0000000000..2b463ddc6f --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resourcegroup/actions/rest/search/SearchResourceGroupRestAction.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.sample.resourcegroup.actions.rest.search; + +import java.io.IOException; +import java.util.List; + +import org.opensearch.action.search.SearchRequest; +import org.opensearch.core.xcontent.XContentParser; +import org.opensearch.index.query.QueryBuilders; +import org.opensearch.rest.BaseRestHandler; +import org.opensearch.rest.RestRequest; +import org.opensearch.rest.action.RestToXContentListener; +import org.opensearch.search.builder.SearchSourceBuilder; +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.sample.utils.Constants.RESOURCE_INDEX_NAME; +import static org.opensearch.sample.utils.Constants.SAMPLE_RESOURCE_PLUGIN_API_PREFIX; + +/** + * Rest action to search sample resource group(s) + */ +public class SearchResourceGroupRestAction extends BaseRestHandler { + + public SearchResourceGroupRestAction() {} + + @Override + public List routes() { + return List.of( + new Route(GET, SAMPLE_RESOURCE_PLUGIN_API_PREFIX + "/group/search"), + new Route(POST, SAMPLE_RESOURCE_PLUGIN_API_PREFIX + "/group/search") + ); + } + + @Override + public String getName() { + return "search_sample_resource_group"; + } + + @Override + protected RestChannelConsumer prepareRequest(RestRequest request, NodeClient client) throws IOException { + final SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder(); + + if (request.hasContentOrSourceParam()) { + try (XContentParser parser = request.contentOrSourceParamParser()) { + searchSourceBuilder.parseXContent(parser); + } + } else { + // Optional: default query if no body is provided + searchSourceBuilder.query(QueryBuilders.matchAllQuery()); + } + + SearchRequest searchRequest = new SearchRequest().indices(RESOURCE_INDEX_NAME).source(searchSourceBuilder); + + return channel -> client.executeLocally(SearchResourceGroupAction.INSTANCE, searchRequest, new RestToXContentListener<>(channel)); + } +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/resourcegroup/actions/transport/CreateResourceGroupTransportAction.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/resourcegroup/actions/transport/CreateResourceGroupTransportAction.java new file mode 100644 index 0000000000..6856417839 --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resourcegroup/actions/transport/CreateResourceGroupTransportAction.java @@ -0,0 +1,133 @@ +/* + * 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.resourcegroup.actions.transport; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.nio.charset.StandardCharsets; + +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.xcontent.XContentType; +import org.opensearch.core.action.ActionListener; +import org.opensearch.core.xcontent.ToXContent; +import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.sample.SampleResource; +import org.opensearch.sample.resourcegroup.actions.rest.create.CreateResourceGroupAction; +import org.opensearch.sample.resourcegroup.actions.rest.create.CreateResourceGroupRequest; +import org.opensearch.sample.resourcegroup.actions.rest.create.CreateResourceGroupResponse; +import org.opensearch.sample.utils.PluginClient; +import org.opensearch.tasks.Task; +import org.opensearch.transport.TransportService; + +import static org.opensearch.sample.utils.Constants.RESOURCE_INDEX_NAME; + +/** + * Transport action for creating a new resource. + */ +public class CreateResourceGroupTransportAction extends HandledTransportAction { + private static final Logger log = LogManager.getLogger(CreateResourceGroupTransportAction.class); + + private final TransportService transportService; + private final PluginClient pluginClient; + + @Inject + public CreateResourceGroupTransportAction(TransportService transportService, ActionFilters actionFilters, PluginClient pluginClient) { + super(CreateResourceGroupAction.NAME, transportService, actionFilters, CreateResourceGroupRequest::new); + this.transportService = transportService; + this.pluginClient = pluginClient; + } + + @Override + protected void doExecute(Task task, CreateResourceGroupRequest request, ActionListener listener) { + createResource(request, listener); + } + + private void createResource(CreateResourceGroupRequest request, ActionListener listener) { + SampleResource sample = request.getResource(); + + // 1. Read mapping JSON from the config file + final String mappingJson; + try { + URL url = CreateResourceGroupTransportAction.class.getClassLoader().getResource("mappings.json"); + if (url == null) { + listener.onFailure(new IllegalStateException("mappings.json not found on classpath")); + return; + } + try (InputStream is = url.openStream()) { + mappingJson = new String(is.readAllBytes(), StandardCharsets.UTF_8); + } + } catch (IOException e) { + listener.onFailure(new RuntimeException("Failed to read mappings.json from classpath", e)); + return; + } + + // 2. Ensure index exists with mapping, then index the doc + ensureIndexWithMapping(pluginClient, mappingJson, ActionListener.wrap(v -> { + try (XContentBuilder builder = org.opensearch.common.xcontent.XContentFactory.jsonBuilder()) { + IndexRequest ir = pluginClient.prepareIndex(RESOURCE_INDEX_NAME) + .setWaitForActiveShards(1) + .setRefreshPolicy(WriteRequest.RefreshPolicy.IMMEDIATE) + .setSource(sample.toXContent(builder, ToXContent.EMPTY_PARAMS)) + .request(); + + log.debug("Index Request: {}", ir); + + pluginClient.index(ir, ActionListener.wrap(idxResponse -> { + log.debug("Created resource: {}", idxResponse.getId()); + listener.onResponse(new CreateResourceGroupResponse("Created resource: " + idxResponse.getId())); + }, listener::onFailure)); + } catch (IOException e) { + listener.onFailure(new RuntimeException(e)); + } + }, listener::onFailure)); + } + + /** + * Ensures the index exists with the provided mapping. + * - If the index does not exist: creates it with the mapping. + * - If the index exists: updates (puts) the mapping. + */ + private void ensureIndexWithMapping(PluginClient pluginClient, String mappingJson, ActionListener listener) { + String indexName = RESOURCE_INDEX_NAME; + pluginClient.admin().indices().prepareExists(indexName).execute(ActionListener.wrap(existsResp -> { + if (!existsResp.isExists()) { + // Create index with mapping + pluginClient.admin().indices().prepareCreate(indexName).setMapping(mappingJson).execute(ActionListener.wrap(createResp -> { + if (!createResp.isAcknowledged()) { + listener.onFailure(new IllegalStateException("CreateIndex not acknowledged for " + indexName)); + return; + } + listener.onResponse(null); + }, listener::onFailure)); + } else { + // Update mapping on existing index + pluginClient.admin() + .indices() + .preparePutMapping(indexName) + .setSource(mappingJson, XContentType.JSON) + .execute(ActionListener.wrap(ack -> { + if (!ack.isAcknowledged()) { + listener.onFailure(new IllegalStateException("PutMapping not acknowledged for " + indexName)); + return; + } + listener.onResponse(null); + }, listener::onFailure)); + } + }, listener::onFailure)); + } + +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/resourcegroup/actions/transport/DeleteResourceGroupTransportAction.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/resourcegroup/actions/transport/DeleteResourceGroupTransportAction.java new file mode 100644 index 0000000000..31e9cdefff --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resourcegroup/actions/transport/DeleteResourceGroupTransportAction.java @@ -0,0 +1,77 @@ +/* + * 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.resourcegroup.actions.transport; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import org.opensearch.ResourceNotFoundException; +import org.opensearch.action.DocWriteResponse; +import org.opensearch.action.delete.DeleteRequest; +import org.opensearch.action.delete.DeleteResponse; +import org.opensearch.action.support.ActionFilters; +import org.opensearch.action.support.HandledTransportAction; +import org.opensearch.action.support.WriteRequest; +import org.opensearch.common.inject.Inject; +import org.opensearch.core.action.ActionListener; +import org.opensearch.sample.resourcegroup.actions.rest.delete.DeleteResourceGroupAction; +import org.opensearch.sample.resourcegroup.actions.rest.delete.DeleteResourceGroupRequest; +import org.opensearch.sample.resourcegroup.actions.rest.delete.DeleteResourceGroupResponse; +import org.opensearch.sample.utils.PluginClient; +import org.opensearch.tasks.Task; +import org.opensearch.transport.TransportService; + +import static org.opensearch.sample.utils.Constants.RESOURCE_INDEX_NAME; + +/** + * Transport action for deleting a resource + */ +public class DeleteResourceGroupTransportAction extends HandledTransportAction { + private static final Logger log = LogManager.getLogger(DeleteResourceGroupTransportAction.class); + + private final TransportService transportService; + private final PluginClient pluginClient; + + @Inject + public DeleteResourceGroupTransportAction(TransportService transportService, ActionFilters actionFilters, PluginClient pluginClient) { + super(DeleteResourceGroupAction.NAME, transportService, actionFilters, DeleteResourceGroupRequest::new); + this.transportService = transportService; + this.pluginClient = pluginClient; + } + + @Override + protected void doExecute(Task task, DeleteResourceGroupRequest request, ActionListener listener) { + String resourceId = request.getResourceId(); + if (resourceId == null || resourceId.isEmpty()) { + listener.onFailure(new IllegalArgumentException("Resource group ID cannot be null or empty")); + return; + } + ActionListener deleteResponseListener = ActionListener.wrap(deleteResponse -> { + if (deleteResponse.getResult() == DocWriteResponse.Result.NOT_FOUND) { + listener.onFailure(new ResourceNotFoundException("Resource group " + resourceId + " not found.")); + } else { + listener.onResponse(new DeleteResourceGroupResponse("Resource group " + resourceId + " deleted successfully.")); + } + }, exception -> { + log.error("Failed to delete resource group: " + resourceId, exception); + listener.onFailure(exception); + }); + + deleteResource(resourceId, deleteResponseListener); + } + + private void deleteResource(String resourceId, ActionListener listener) { + DeleteRequest deleteRequest = new DeleteRequest(RESOURCE_INDEX_NAME, resourceId).setRefreshPolicy( + WriteRequest.RefreshPolicy.IMMEDIATE + ); + + pluginClient.delete(deleteRequest, listener); + } + +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/resourcegroup/actions/transport/GetResourceGroupTransportAction.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/resourcegroup/actions/transport/GetResourceGroupTransportAction.java new file mode 100644 index 0000000000..e071bdb3ed --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resourcegroup/actions/transport/GetResourceGroupTransportAction.java @@ -0,0 +1,105 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.sample.resourcegroup.actions.transport; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Set; +import java.util.stream.Collectors; + +import org.opensearch.ResourceNotFoundException; +import org.opensearch.action.get.GetRequest; +import org.opensearch.action.search.SearchRequest; +import org.opensearch.action.support.ActionFilters; +import org.opensearch.action.support.HandledTransportAction; +import org.opensearch.common.inject.Inject; +import org.opensearch.common.xcontent.LoggingDeprecationHandler; +import org.opensearch.common.xcontent.XContentType; +import org.opensearch.core.action.ActionListener; +import org.opensearch.core.common.Strings; +import org.opensearch.core.xcontent.NamedXContentRegistry; +import org.opensearch.core.xcontent.XContentParser; +import org.opensearch.index.query.QueryBuilders; +import org.opensearch.sample.SampleResource; +import org.opensearch.sample.resourcegroup.actions.rest.get.GetResourceGroupAction; +import org.opensearch.sample.resourcegroup.actions.rest.get.GetResourceGroupRequest; +import org.opensearch.sample.resourcegroup.actions.rest.get.GetResourceGroupResponse; +import org.opensearch.sample.utils.PluginClient; +import org.opensearch.search.SearchHit; +import org.opensearch.search.builder.SearchSourceBuilder; +import org.opensearch.tasks.Task; +import org.opensearch.transport.TransportService; + +import static org.opensearch.sample.utils.Constants.RESOURCE_INDEX_NAME; + +/** + * Transport action for getting a resource + */ +public class GetResourceGroupTransportAction extends HandledTransportAction { + + private final PluginClient pluginClient; + + @Inject + public GetResourceGroupTransportAction(TransportService transportService, ActionFilters actionFilters, PluginClient pluginClient) { + super(GetResourceGroupAction.NAME, transportService, actionFilters, GetResourceGroupRequest::new); + this.pluginClient = pluginClient; + } + + @Override + protected void doExecute(Task task, GetResourceGroupRequest request, ActionListener listener) { + String resourceId = request.getResourceId(); + + if (Strings.isNullOrEmpty(resourceId)) { + fetchAllResources(listener); + } else { + fetchResourceById(resourceId, listener); + } + } + + private void fetchAllResources(ActionListener listener) { + SearchSourceBuilder ssb = new SearchSourceBuilder().size(1000).query(QueryBuilders.matchAllQuery()); + + SearchRequest req = new SearchRequest(RESOURCE_INDEX_NAME).source(ssb); + pluginClient.search(req, ActionListener.wrap(searchResponse -> { + SearchHit[] hits = searchResponse.getHits().getHits(); + + Set resources = Arrays.stream(hits).map(hit -> { + try { + return parseResource(hit.getSourceAsString()); + } catch (IOException e) { + throw new RuntimeException(e); + } + }).collect(Collectors.toSet()); + listener.onResponse(new GetResourceGroupResponse(resources)); + + }, listener::onFailure)); + } + + private void fetchResourceById(String resourceId, ActionListener listener) { + GetRequest req = new GetRequest(RESOURCE_INDEX_NAME, resourceId); + pluginClient.get(req, ActionListener.wrap(resp -> { + if (resp.isSourceEmpty()) { + listener.onFailure(new ResourceNotFoundException("Resource group " + resourceId + " not found.")); + } else { + SampleResource resource = parseResource(resp.getSourceAsString()); + listener.onResponse(new GetResourceGroupResponse(Set.of(resource))); + } + }, listener::onFailure)); + } + + private SampleResource parseResource(String json) throws IOException { + try ( + XContentParser parser = XContentType.JSON.xContent() + .createParser(NamedXContentRegistry.EMPTY, LoggingDeprecationHandler.INSTANCE, json) + ) { + return SampleResource.fromXContent(parser); + } + } + +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/resourcegroup/actions/transport/SearchResourceGroupTransportAction.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/resourcegroup/actions/transport/SearchResourceGroupTransportAction.java new file mode 100644 index 0000000000..ed4d359e97 --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resourcegroup/actions/transport/SearchResourceGroupTransportAction.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.sample.resourcegroup.actions.transport; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +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.core.action.ActionListener; +import org.opensearch.sample.resourcegroup.actions.rest.search.SearchResourceGroupAction; +import org.opensearch.sample.utils.PluginClient; +import org.opensearch.tasks.Task; +import org.opensearch.transport.TransportService; + +/** + * Transport action for searching sample resources + */ +public class SearchResourceGroupTransportAction extends HandledTransportAction { + private static final Logger log = LogManager.getLogger(SearchResourceGroupTransportAction.class); + + private final PluginClient pluginClient; + + @Inject + public SearchResourceGroupTransportAction(TransportService transportService, ActionFilters actionFilters, PluginClient pluginClient) { + super(SearchResourceGroupAction.NAME, transportService, actionFilters, SearchRequest::new); + this.pluginClient = pluginClient; + } + + @Override + protected void doExecute(Task task, SearchRequest request, ActionListener listener) { + pluginClient.search(request, listener); + } +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/resourcegroup/actions/transport/UpdateResourceGroupTransportAction.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/resourcegroup/actions/transport/UpdateResourceGroupTransportAction.java new file mode 100644 index 0000000000..b6fd7a67c0 --- /dev/null +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resourcegroup/actions/transport/UpdateResourceGroupTransportAction.java @@ -0,0 +1,86 @@ +/* + * 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.resourcegroup.actions.transport; + +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.core.action.ActionListener; +import org.opensearch.core.xcontent.ToXContent; +import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.sample.SampleResource; +import org.opensearch.sample.resourcegroup.actions.rest.create.CreateResourceGroupResponse; +import org.opensearch.sample.resourcegroup.actions.rest.create.UpdateResourceGroupAction; +import org.opensearch.sample.resourcegroup.actions.rest.create.UpdateResourceGroupRequest; +import org.opensearch.sample.utils.PluginClient; +import org.opensearch.tasks.Task; +import org.opensearch.transport.TransportService; + +import static org.opensearch.common.xcontent.XContentFactory.jsonBuilder; +import static org.opensearch.sample.utils.Constants.RESOURCE_INDEX_NAME; + +/** + * Transport action for updating a resource. + */ +public class UpdateResourceGroupTransportAction extends HandledTransportAction { + private static final Logger log = LogManager.getLogger(UpdateResourceGroupTransportAction.class); + + private final TransportService transportService; + private final PluginClient pluginClient; + + @Inject + public UpdateResourceGroupTransportAction(TransportService transportService, ActionFilters actionFilters, PluginClient pluginClient) { + super(UpdateResourceGroupAction.NAME, transportService, actionFilters, UpdateResourceGroupRequest::new); + this.transportService = transportService; + this.pluginClient = pluginClient; + } + + @Override + protected void doExecute(Task task, UpdateResourceGroupRequest request, ActionListener listener) { + if (request.getResourceId() == null || request.getResourceId().isEmpty()) { + listener.onFailure(new IllegalArgumentException("Resource Group ID cannot be null or empty")); + return; + } + // Check permission to resource + updateResource(request, listener); + } + + private void updateResource(UpdateResourceGroupRequest request, ActionListener listener) { + try { + String resourceId = request.getResourceId(); + SampleResource sample = request.getResource(); + try (XContentBuilder builder = jsonBuilder()) { + sample.toXContent(builder, ToXContent.EMPTY_PARAMS); + + // because some plugins seem to treat update API calls as index request + IndexRequest ir = new IndexRequest(RESOURCE_INDEX_NAME).id(resourceId) + .setRefreshPolicy(WriteRequest.RefreshPolicy.WAIT_UNTIL) // WAIT_UNTIL because we don't want tests to fail, as they + // execute search right after update + .source(builder); + + log.debug("Update Request: {}", ir.toString()); + + pluginClient.index(ir, ActionListener.wrap(updateResponse -> { + listener.onResponse( + new CreateResourceGroupResponse("Resource " + request.getResource().getName() + " updated successfully.") + ); + }, listener::onFailure)); + } + } 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/utils/Constants.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/utils/Constants.java index d2136b7ed7..876bdef2e0 100644 --- 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 @@ -14,6 +14,7 @@ public class Constants { public static final String RESOURCE_INDEX_NAME = ".sample_resource"; public static final String RESOURCE_TYPE = "sample-resource"; + public static final String RESOURCE_GROUP_TYPE = "sample-resource-group"; public static final String SAMPLE_RESOURCE_PLUGIN_PREFIX = "_plugins/sample_plugin"; public static final String SAMPLE_RESOURCE_PLUGIN_API_PREFIX = "/" + SAMPLE_RESOURCE_PLUGIN_PREFIX; 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 index d8a7415020..034acfc2a7 100644 --- 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 @@ -1 +1,2 @@ -org.opensearch.sample.SampleResourceExtension \ No newline at end of file +org.opensearch.sample.SampleResourceExtension +org.opensearch.sample.SampleResourceGroupExtension \ No newline at end of file diff --git a/sample-resource-plugin/src/main/resources/resource-action-groups.yml b/sample-resource-plugin/src/main/resources/resource-action-groups.yml index bcd0fcda1d..c373634be9 100644 --- a/sample-resource-plugin/src/main/resources/resource-action-groups.yml +++ b/sample-resource-plugin/src/main/resources/resource-action-groups.yml @@ -12,3 +12,16 @@ resource_types: allowed_actions: - "cluster:admin/sample-resource-plugin/*" - "cluster:admin/security/resource/share" + sample-resource-group: + sample_group_read_only: + allowed_actions: + - "cluster:admin/sample-resource-plugin/group/get" + + sample_group_read_write: + allowed_actions: + - "cluster:admin/sample-resource-plugin/group/*" + + sample_group_full_access: + allowed_actions: + - "cluster:admin/sample-resource-plugin/group/*" + - "cluster:admin/security/resource/share" diff --git a/settings.gradle b/settings.gradle index 2d9e2b9b4f..e309543ad6 100644 --- a/settings.gradle +++ b/settings.gradle @@ -5,7 +5,7 @@ */ plugins { - id("com.autonomousapps.build-health") version "3.0.4" + id("com.autonomousapps.build-health") version "3.1.0" } rootProject.name = 'opensearch-security' diff --git a/spi/src/main/java/org/opensearch/security/spi/resources/client/ResourceSharingClient.java b/spi/src/main/java/org/opensearch/security/spi/resources/client/ResourceSharingClient.java index 90572ee2ce..e2469b2e4a 100644 --- a/spi/src/main/java/org/opensearch/security/spi/resources/client/ResourceSharingClient.java +++ b/spi/src/main/java/org/opensearch/security/spi/resources/client/ResourceSharingClient.java @@ -22,18 +22,18 @@ 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 resourceType The resource type. * @param action The action to be verified * @param listener The listener to be notified with the access verification result. */ - void verifyAccess(String resourceId, String resourceIndex, String action, ActionListener listener); + void verifyAccess(String resourceId, String resourceType, String action, ActionListener listener); /** * Lists resourceIds of all shareable resources accessible by the current user. - * @param resourceIndex The index containing the resources. + * @param resourceType The resource type * @param listener The listener to be notified with the set of accessible resources. */ - void getAccessibleResourceIds(String resourceIndex, ActionListener> listener); + void getAccessibleResourceIds(String resourceType, ActionListener> listener); /** * Returns a flag to indicate whether resource-sharing is enabled for resource-type diff --git a/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java b/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java index e8e00145e7..475b225808 100644 --- a/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java +++ b/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java @@ -783,8 +783,7 @@ public void onIndexModule(IndexModule indexModule) { threadPool, localClient, resourcePluginInfo, - resourceSharingEnabledSetting, - resourceSharingProtectedResourceTypesSetting + resourceSharingEnabledSetting ); // CS-SUPPRESS-SINGLE: RegexpSingleline get Resource Sharing Extensions Set resourceIndices = resourcePluginInfo.getResourceIndices(); 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 0e99b6aece..5967f25e15 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 @@ -127,6 +127,11 @@ public static JsonNode toJsonNode(final String content) throws IOException { return DefaultObjectMapper.readTree(content); } + public static Map toMapOfStrings(final JsonNode jsonNode) { + return internalMapper.convertValue(jsonNode, new TypeReference>() { + }); + } + public static Object toConfigObject(final JsonNode content, final Class clazz) throws IOException { return DefaultObjectMapper.readTree(content, clazz); } diff --git a/src/main/java/org/opensearch/security/filter/SecurityFilter.java b/src/main/java/org/opensearch/security/filter/SecurityFilter.java index 8d8c6cbfe0..4e99f06a54 100644 --- a/src/main/java/org/opensearch/security/filter/SecurityFilter.java +++ b/src/main/java/org/opensearch/security/filter/SecurityFilter.java @@ -419,7 +419,7 @@ private void ap // require blocking transport threads leading to thread exhaustion and request timeouts // We perform the rest of the evaluation as normal if the request is not for resource-access or if the feature is disabled if (resourceAccessEvaluator.shouldEvaluate(request)) { - resourceAccessEvaluator.evaluateAsync(request, action, context, ActionListener.wrap(response -> { + resourceAccessEvaluator.evaluateAsync(request, action, ActionListener.wrap(response -> { if (handlePermissionCheckRequest(listener, response, action)) { return; } diff --git a/src/main/java/org/opensearch/security/privileges/ResourceAccessEvaluator.java b/src/main/java/org/opensearch/security/privileges/ResourceAccessEvaluator.java index 29119a127f..b83e174600 100644 --- a/src/main/java/org/opensearch/security/privileges/ResourceAccessEvaluator.java +++ b/src/main/java/org/opensearch/security/privileges/ResourceAccessEvaluator.java @@ -71,13 +71,11 @@ public ResourceAccessEvaluator( * * @param request may contain information about the index and the resource being requested * @param action the action being requested to be performed on the resource - * @param context the evaluation context to be used when performing authorization * @param pResponseListener the response listener which tells whether the action is allowed for user, or should the request be checked with another evaluator */ public void evaluateAsync( final ActionRequest request, final String action, - final PrivilegesEvaluationContext context, final ActionListener pResponseListener ) { PrivilegesEvaluatorResponse pResponse = new PrivilegesEvaluatorResponse(); @@ -87,7 +85,7 @@ public void evaluateAsync( // if it reached this evaluator, it is safe to assume that the request if of DocRequest type DocRequest req = (DocRequest) request; - resourceAccessHandler.hasPermission(req.id(), req.index(), action, context, ActionListener.wrap(hasAccess -> { + resourceAccessHandler.hasPermission(req.id(), req.type(), action, ActionListener.wrap(hasAccess -> { if (hasAccess) { pResponse.allowed = true; pResponseListener.onResponse(pResponse.markComplete()); diff --git a/src/main/java/org/opensearch/security/resources/ResourceAccessControlClient.java b/src/main/java/org/opensearch/security/resources/ResourceAccessControlClient.java index 4c06726131..f02c5b3d25 100644 --- a/src/main/java/org/opensearch/security/resources/ResourceAccessControlClient.java +++ b/src/main/java/org/opensearch/security/resources/ResourceAccessControlClient.java @@ -49,14 +49,14 @@ public ResourceAccessControlClient( * Verifies whether the current user has access to the specified resource. * * @param resourceId The ID of the resource to verify. - * @param resourceIndex The index in which the resource resides. + * @param resourceType The resource type. * @param action The action to be evaluated against * @param listener Callback that receives {@code true} if access is granted, {@code false} otherwise. */ @Override - public void verifyAccess(String resourceId, String resourceIndex, String action, ActionListener listener) { + public void verifyAccess(String resourceId, String resourceType, String action, ActionListener listener) { // following situation will arise when resource is onboarded to framework but not marked as protected - if (!resourcePluginInfo.getResourceIndicesForProtectedTypes().contains(resourceIndex)) { + if (!isFeatureEnabledForType(resourceType)) { LOGGER.warn( "Resource '{}' is onboarded to sharing framework but is not marked as protected. Action {} is allowed.", resourceId, @@ -65,18 +65,18 @@ public void verifyAccess(String resourceId, String resourceIndex, String action, listener.onResponse(true); return; } - resourceAccessHandler.hasPermission(resourceId, resourceIndex, action, null, listener); + resourceAccessHandler.hasPermission(resourceId, resourceType, action, listener); } /** * Lists all resources the current user has access to within the given index. * - * @param resourceIndex The index to search for accessible resources. + * @param resourceType The resource type. * @param listener Callback receiving a set of resource ids. */ @Override - public void getAccessibleResourceIds(String resourceIndex, ActionListener> listener) { - resourceAccessHandler.getOwnAndSharedResourceIdsForCurrentUser(resourceIndex, listener); + public void getAccessibleResourceIds(String resourceType, ActionListener> listener) { + resourceAccessHandler.getOwnAndSharedResourceIdsForCurrentUser(resourceType, listener); } /** diff --git a/src/main/java/org/opensearch/security/resources/ResourceAccessHandler.java b/src/main/java/org/opensearch/security/resources/ResourceAccessHandler.java index 4670153086..e547732a9f 100644 --- a/src/main/java/org/opensearch/security/resources/ResourceAccessHandler.java +++ b/src/main/java/org/opensearch/security/resources/ResourceAccessHandler.java @@ -28,9 +28,7 @@ import org.opensearch.core.rest.RestStatus; import org.opensearch.security.auth.UserSubjectImpl; import org.opensearch.security.configuration.AdminDNs; -import org.opensearch.security.privileges.PrivilegesEvaluationContext; import org.opensearch.security.privileges.PrivilegesEvaluator; -import org.opensearch.security.privileges.actionlevel.RoleBasedActionPrivileges; import org.opensearch.security.resources.sharing.Recipient; import org.opensearch.security.resources.sharing.ResourceSharing; import org.opensearch.security.resources.sharing.ShareWith; @@ -76,10 +74,10 @@ public ResourceAccessHandler( /** * 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 resourceType The resource type. * @param listener The listener to be notified with the set of accessible resource IDs. */ - public void getOwnAndSharedResourceIdsForCurrentUser(@NonNull String resourceIndex, ActionListener> listener) { + public void getOwnAndSharedResourceIdsForCurrentUser(@NonNull String resourceType, ActionListener> listener) { UserSubjectImpl userSub = (UserSubjectImpl) threadContext.getPersistent(ConfigConstants.OPENDISTRO_SECURITY_AUTHENTICATED_USER); User user = userSub == null ? null : userSub.getUser(); @@ -89,8 +87,10 @@ public void getOwnAndSharedResourceIdsForCurrentUser(@NonNull String resourceInd return; } + String resourceIndex = resourcePluginInfo.indexByType(resourceType); + if (adminDNs.isAdmin(user)) { - loadAllResourceIds(resourceIndex, ActionListener.wrap(listener::onResponse, listener::onFailure)); + loadAllResourceIds(resourceType, ActionListener.wrap(listener::onResponse, listener::onFailure)); return; } Set flatPrincipals = getFlatPrincipals(user); @@ -102,10 +102,10 @@ public void getOwnAndSharedResourceIdsForCurrentUser(@NonNull String resourceInd /** * Returns a set of resource sharing records for the current user within the specified resource index. * - * @param resourceIndex The resource index to check for accessible resources. + * @param resourceType The resource type. * @param listener The listener to be notified with the set of resource sharing records. */ - public void getResourceSharingInfoForCurrentUser(@NonNull String resourceIndex, ActionListener> listener) { + public void getResourceSharingInfoForCurrentUser(@NonNull String resourceType, ActionListener> listener) { UserSubjectImpl userSub = (UserSubjectImpl) threadContext.getPersistent(ConfigConstants.OPENDISTRO_SECURITY_AUTHENTICATED_USER); User user = userSub == null ? null : userSub.getUser(); @@ -116,30 +116,30 @@ public void getResourceSharingInfoForCurrentUser(@NonNull String resourceIndex, } if (adminDNs.isAdmin(user)) { - loadAllResourceSharingRecords(resourceIndex, ActionListener.wrap(listener::onResponse, listener::onFailure)); + loadAllResourceSharingRecords(resourceType, ActionListener.wrap(listener::onResponse, listener::onFailure)); return; } Set flatPrincipals = getFlatPrincipals(user); + String resourceIndex = resourcePluginInfo.indexByType(resourceType); + // 3) Fetch all accessible resource sharing records - resourceSharingIndexHandler.fetchAccessibleResourceSharingRecords(resourceIndex, user, flatPrincipals, listener); + resourceSharingIndexHandler.fetchAccessibleResourceSharingRecords(resourceIndex, resourceType, user, flatPrincipals, listener); } /** * Checks whether current user has permission to access given resource. * * @param resourceId The resource ID to check access for. - * @param resourceIndex The resource index containing the resource. + * @param resourceType The resource type. * @param action The action to check permission for - * @param context The evaluation context to be used. Will be null when used by {@link ResourceAccessControlClient}. * @param listener The listener to be notified with the permission check result. */ public void hasPermission( @NonNull String resourceId, - @NonNull String resourceIndex, + @NonNull String resourceType, @NonNull String action, - PrivilegesEvaluationContext context, ActionListener listener ) { final UserSubjectImpl userSubject = (UserSubjectImpl) threadContext.getPersistent( @@ -160,19 +160,12 @@ public void hasPermission( listener.onResponse(true); return; } - - PrivilegesEvaluationContext effectiveContext = context != null ? context : privilegesEvaluator.createContext(user, action); - Set userRoles = new HashSet<>(user.getSecurityRoles()); Set userBackendRoles = new HashSet<>(user.getRoles()); - // At present, plugins and tokens are not supported for access to resources - if (!(effectiveContext.getActionPrivileges() instanceof RoleBasedActionPrivileges)) { - LOGGER.debug( - "Plugin/Token access to resources is currently not supported. {} is not authorized to access resource {}.", - user.getName(), - resourceId - ); + String resourceIndex = resourcePluginInfo.indexByType(resourceType); + if (resourceIndex == null) { + LOGGER.debug("No resourceIndex mapping found for type '{}'; denying action {}", resourceType, action); listener.onResponse(false); return; } @@ -205,13 +198,6 @@ public void hasPermission( } // Fetch the static action-groups registered by plugins on bootstrap and check whether any match - final String resourceType = resourcePluginInfo.typeByIndex(resourceIndex); - if (resourceType == null) { - LOGGER.debug("No resourceType mapping found for index '{}'; denying action {}", resourceIndex, action); - listener.onResponse(false); - return; - } - final FlattenedActionGroups agForType = resourcePluginInfo.flattenedForType(resourceType); final Set allowedActions = agForType.resolve(accessLevels); final WildcardMatcher matcher = WildcardMatcher.from(allowedActions); @@ -230,14 +216,14 @@ public void hasPermission( * 3. Share with new entity - add op * A final resource-sharing object will be returned upon successful application of the patch to the index record * @param resourceId id of the resource whose sharing info is to be updated - * @param resourceIndex name of the resource index + * @param resourceType the resource type * @param add the recipients to be shared with * @param revoke the recipients to be revoked with * @param listener listener to be notified of final resource sharing record */ public void patchSharingInfo( @NonNull String resourceId, - @NonNull String resourceIndex, + @NonNull String resourceType, @Nullable ShareWith add, @Nullable ShareWith revoke, ActionListener listener @@ -258,6 +244,15 @@ public void patchSharingInfo( return; } + String resourceIndex = resourcePluginInfo.indexByType(resourceType); + if (resourceIndex == null) { + LOGGER.debug("No resourceIndex mapping found for type '{}';", resourceType); + listener.onFailure( + new OpenSearchStatusException("No resourceIndex mapping found for type '{}';" + resourceType, RestStatus.UNAUTHORIZED) + ); + return; + } + LOGGER.debug( "User {} is updating sharing info for resource {} in index {} with add: {}, revoke: {} ", user.getName(), @@ -286,10 +281,10 @@ public void patchSharingInfo( /** * Get sharing info for this record * @param resourceId id of the resource whose sharing info is to be fetched - * @param resourceIndex name of the resource index + * @param resourceType the resource type * @param listener listener to be notified of final resource sharing record */ - public void getSharingInfo(@NonNull String resourceId, @NonNull String resourceIndex, ActionListener listener) { + public void getSharingInfo(@NonNull String resourceId, @NonNull String resourceType, ActionListener listener) { final UserSubjectImpl userSubject = (UserSubjectImpl) threadContext.getPersistent( ConfigConstants.OPENDISTRO_SECURITY_AUTHENTICATED_USER ); @@ -306,13 +301,21 @@ public void getSharingInfo(@NonNull String resourceId, @NonNull String resourceI return; } - LOGGER.debug("User {} is fetching sharing info for resource {} in index {}", user.getName(), resourceId, resourceIndex); + LOGGER.debug("User {} is fetching sharing info for resource {} in index {}", user.getName(), resourceId, resourceType); + String resourceIndex = resourcePluginInfo.indexByType(resourceType); + if (resourceIndex == null) { + LOGGER.debug("No resourceIndex mapping found for type '{}';", resourceType); + listener.onFailure( + new OpenSearchStatusException("No resourceIndex mapping found for type '{}';" + resourceType, RestStatus.UNAUTHORIZED) + ); + return; + } this.resourceSharingIndexHandler.fetchSharingInfo(resourceIndex, resourceId, ActionListener.wrap(sharingInfo -> { - LOGGER.debug("Successfully fetched sharing info for resource {} in index {}", resourceId, resourceIndex); + LOGGER.debug("Successfully fetched sharing info for resource {} in index {}", resourceId, resourceType); listener.onResponse(sharingInfo); }, e -> { - LOGGER.error("Failed to fetched sharing info for resource {} in index {}: {}", resourceId, resourceIndex, e.getMessage()); + LOGGER.error("Failed to fetched sharing info for resource {} in index {}: {}", resourceId, resourceType, e.getMessage()); listener.onFailure(e); })); @@ -322,13 +325,13 @@ public void getSharingInfo(@NonNull String resourceId, @NonNull String resourceI * 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 resourceType The resource type * @param target The users, roles, and backend roles as well as the action group to share the resource with. * @param listener The listener to be notified with the updated ResourceSharing document. */ public void share( @NonNull String resourceId, - @NonNull String resourceIndex, + @NonNull String resourceType, @NonNull ShareWith target, ActionListener listener ) { @@ -350,6 +353,8 @@ public void share( LOGGER.debug("Sharing resource {} created by {} with {}", resourceId, user.getName(), target.toString()); + String resourceIndex = resourcePluginInfo.indexByType(resourceType); + this.resourceSharingIndexHandler.share(resourceId, resourceIndex, target, ActionListener.wrap(sharingInfo -> { LOGGER.debug("Successfully shared resource {} with {}", resourceId, target.toString()); listener.onResponse(sharingInfo); @@ -362,21 +367,23 @@ public void share( /** * Loads all resource-ids within the specified resource index. * - * @param resourceIndex The resource index to load resources from. + * @param resourceType The resource type. * @param listener The listener to be notified with the set of resource IDs. */ - private void loadAllResourceIds(String resourceIndex, ActionListener> listener) { + private void loadAllResourceIds(String resourceType, ActionListener> listener) { + String resourceIndex = resourcePluginInfo.indexByType(resourceType); this.resourceSharingIndexHandler.fetchAllResourceIds(resourceIndex, listener); } /** * Loads all resource-sharing records for the specified resource index. * - * @param resourceIndex The resource index to load records from. + * @param resourceType The resource type. * @param listener The listener to be notified with the set of resource-sharing records. */ - private void loadAllResourceSharingRecords(String resourceIndex, ActionListener> listener) { - this.resourceSharingIndexHandler.fetchAllResourceSharingRecords(resourceIndex, listener); + private void loadAllResourceSharingRecords(String resourceType, ActionListener> listener) { + String resourceIndex = resourcePluginInfo.indexByType(resourceType); + this.resourceSharingIndexHandler.fetchAllResourceSharingRecords(resourceIndex, resourceType, listener); } /** diff --git a/src/main/java/org/opensearch/security/resources/ResourceIndexListener.java b/src/main/java/org/opensearch/security/resources/ResourceIndexListener.java index fcda7008c6..b0c67de13d 100644 --- a/src/main/java/org/opensearch/security/resources/ResourceIndexListener.java +++ b/src/main/java/org/opensearch/security/resources/ResourceIndexListener.java @@ -9,7 +9,6 @@ package org.opensearch.security.resources; import java.io.IOException; -import java.util.List; import java.util.Objects; import org.apache.logging.log4j.LogManager; @@ -43,20 +42,16 @@ public class ResourceIndexListener implements IndexingOperationListener { private final OpensearchDynamicSetting resourceSharingEnabledSetting; - private final OpensearchDynamicSetting> protectedResourceTypesSetting; - public ResourceIndexListener( ThreadPool threadPool, Client client, ResourcePluginInfo resourcePluginInfo, - OpensearchDynamicSetting resourceSharingEnabledSetting, - OpensearchDynamicSetting> resourceSharingProtectedResourceTypesSetting + OpensearchDynamicSetting resourceSharingEnabledSetting ) { this.threadPool = threadPool; this.resourceSharingIndexHandler = new ResourceSharingIndexHandler(client, threadPool, resourcePluginInfo); this.resourcePluginInfo = resourcePluginInfo; this.resourceSharingEnabledSetting = resourceSharingEnabledSetting; - this.protectedResourceTypesSetting = resourceSharingProtectedResourceTypesSetting; } /** @@ -71,8 +66,7 @@ public void postIndex(ShardId shardId, Engine.Index index, Engine.IndexResult re } String resourceIndex = shardId.getIndexName(); - List protectedResourceTypes = protectedResourceTypesSetting.getDynamicSettingValue(); - if (!protectedResourceTypes.contains(resourcePluginInfo.typeByIndex(resourceIndex))) { + if (!resourcePluginInfo.getResourceIndicesForProtectedTypes().contains(resourceIndex)) { // type is marked as not protected return; } @@ -138,8 +132,7 @@ public void postDelete(ShardId shardId, Engine.Delete delete, Engine.DeleteResul } String resourceIndex = shardId.getIndexName(); - List protectedResourceTypes = protectedResourceTypesSetting.getDynamicSettingValue(); - if (!protectedResourceTypes.contains(resourcePluginInfo.typeByIndex(resourceIndex))) { + if (!resourcePluginInfo.getResourceIndicesForProtectedTypes().contains(resourceIndex)) { // type is marked as not protected return; } diff --git a/src/main/java/org/opensearch/security/resources/ResourcePluginInfo.java b/src/main/java/org/opensearch/security/resources/ResourcePluginInfo.java index ae8ac8cda2..a44287bbfe 100644 --- a/src/main/java/org/opensearch/security/resources/ResourcePluginInfo.java +++ b/src/main/java/org/opensearch/security/resources/ResourcePluginInfo.java @@ -48,7 +48,6 @@ public class ResourcePluginInfo { // type <-> index private final Map typeToIndex = new HashMap<>(); - private final Map indexToType = new HashMap<>(); // UI: action-group *names* per type private final Map> typeToGroupNames = new HashMap<>(); @@ -70,7 +69,6 @@ public void setResourceSharingExtensions(Set extension try { resourceSharingExtensions.clear(); typeToIndex.clear(); - indexToType.clear(); // Enforce resource-type unique-ness Set resourceTypes = new HashSet<>(); @@ -81,7 +79,6 @@ public void setResourceSharingExtensions(Set extension resourceTypes.add(rp.resourceType()); // also cache type->index and index->type mapping typeToIndex.put(rp.resourceType(), rp.resourceIndexName()); - indexToType.put(rp.resourceIndexName(), rp.resourceType()); } else { throw new OpenSearchSecurityException( String.format( @@ -108,7 +105,6 @@ public void updateProtectedTypes(List protectedTypes) { try { // Rebuild mappings based on the current allowlist typeToIndex.clear(); - indexToType.clear(); if (protectedTypes == null || protectedTypes.isEmpty()) { // No protected types -> leave maps empty @@ -127,7 +123,6 @@ public void updateProtectedTypes(List protectedTypes) { final String index = rp.resourceIndexName(); typeToIndex.put(type, index); - indexToType.put(index, type); } } @@ -192,19 +187,23 @@ public FlattenedActionGroups flattenedForType(String resourceType) { } } - public String typeByIndex(String index) { + public String indexByType(String type) { lock.readLock().lock(); try { - return indexToType.get(index); + return typeToIndex.get(type); } finally { lock.readLock().unlock(); } } - public String indexByType(String type) { + public Set typesByIndex(String index) { lock.readLock().lock(); try { - return typeToIndex.get(type); + return typeToIndex.entrySet() + .stream() + .filter(entry -> Objects.equals(entry.getValue(), index)) + .map(Map.Entry::getKey) + .collect(Collectors.toSet()); } finally { lock.readLock().unlock(); } @@ -227,7 +226,7 @@ public Set getResourceTypes() { public Set getResourceIndices() { lock.readLock().lock(); try { - return new LinkedHashSet<>(indexToType.keySet()); + return new HashSet<>(typeToIndex.values()); } finally { lock.readLock().unlock(); } @@ -246,10 +245,10 @@ public Set getResourceIndicesForProtectedTypes() { return cachedProtectedTypeIndices; } - return indexToType.entrySet() + return typeToIndex.entrySet() .stream() - .filter(e -> resourceTypes.contains(e.getValue())) - .map(Map.Entry::getKey) + .filter(e -> resourceTypes.contains(e.getKey())) + .map(Map.Entry::getValue) .collect(Collectors.toSet()); } finally { lock.readLock().unlock(); diff --git a/src/main/java/org/opensearch/security/resources/ResourceSharingIndexHandler.java b/src/main/java/org/opensearch/security/resources/ResourceSharingIndexHandler.java index b479af0ce4..179494a84e 100644 --- a/src/main/java/org/opensearch/security/resources/ResourceSharingIndexHandler.java +++ b/src/main/java/org/opensearch/security/resources/ResourceSharingIndexHandler.java @@ -11,6 +11,7 @@ import java.io.IOException; import java.util.ArrayList; +import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; @@ -124,7 +125,7 @@ public ResourceSharingIndexHandler(final Client client, final ThreadPool threadP * or communicating with the cluster */ - public void createResourceSharingIndicesIfAbsent(Set resourceIndices) { + public void createResourceSharingIndicesIfAbsent(Collection resourceIndices) { // TODO: Once stashContext is replaced with switchContext this call will have to be modified try (ThreadContext.StoredContext ctx = this.threadPool.getThreadContext().stashContext()) { for (String resourceIndex : resourceIndices) { @@ -359,9 +360,10 @@ public void fetchAllResourceIds(String resourceIndex, ActionListener /** * Fetches all resource-sharing records for a given resource-index * @param resourceIndex the index whose resource-sharing records are to be fetched + * @param resourceType the resource type * @param listener to collect and return the sharing records */ - public void fetchAllResourceSharingRecords(String resourceIndex, ActionListener> listener) { + public void fetchAllResourceSharingRecords(String resourceIndex, String resourceType, ActionListener> listener) { String resourceSharingIndex = getSharingIndex(resourceIndex); LOGGER.debug("Fetching all resource-sharing records asynchronously from {}", resourceSharingIndex); Scroll scroll = new Scroll(TimeValue.timeValueMinutes(1L)); @@ -372,7 +374,7 @@ public void fetchAllResourceSharingRecords(String resourceIndex, ActionListener< MatchAllQueryBuilder query = QueryBuilders.matchAllQuery(); - executeAllSearchRequest(resourceIndex, scroll, searchRequest, query, ActionListener.wrap(recs -> { + executeAllSearchRequest(resourceIndex, resourceType, scroll, searchRequest, query, ActionListener.wrap(recs -> { ctx.restore(); LOGGER.debug("Found {} resource-sharing records in {}", recs.size(), resourceSharingIndex); listener.onResponse(recs); @@ -901,6 +903,7 @@ private void executeSearchRequest( /** * Executes a search request and returns a set of collected resource-sharing documents using scroll. * @param resourceIndex the index whose records are to be searched + * @param resourceType the resource type * @param scroll Search scroll context * @param searchRequest Initial search request * @param query Query builder for the request @@ -908,6 +911,7 @@ private void executeSearchRequest( */ private void executeAllSearchRequest( String resourceIndex, + String resourceType, Scroll scroll, SearchRequest searchRequest, AbstractQueryBuilder> query, @@ -929,6 +933,7 @@ private void executeAllSearchRequest( null, true, resourceIndex, + resourceType, recs, scroll, scrollId, @@ -947,12 +952,14 @@ private void executeAllSearchRequest( * - Use mget in batches of 1000 to get the resource sharing records. * * @param resourceIndex the index for which records are to be searched + * @param resourceIndex the resource type * @param user the user that is requesting the records * @param flatPrincipals user's name, roles, backend_roles to be used for matching. * @param listener to collect and return accessible sharing records */ public void fetchAccessibleResourceSharingRecords( String resourceIndex, + String resourceType, User user, Set flatPrincipals, ActionListener> listener @@ -1010,7 +1017,7 @@ public void fetchAccessibleResourceSharingRecords( ) { p.nextToken(); ResourceSharing rs = ResourceSharing.fromXContent(p); - boolean canShare = canUserShare(user, /* isAdmin */ false, rs, resourceIndex); + boolean canShare = canUserShare(user, /* isAdmin */ false, rs, resourceType); out.add(new SharingRecord(rs, canShare)); } catch (Exception ex) { LOGGER.warn("Failed to parse resource-sharing doc id={}", gr.getId(), ex); @@ -1096,6 +1103,7 @@ private void processScrollResultsAndCollectSharingRecords( User user, boolean isAdmin, String resourceIndex, + String resourceType, Set resourceSharingRecords, Scroll scroll, String scrollId, @@ -1118,7 +1126,7 @@ private void processScrollResultsAndCollectSharingRecords( ) { parser.nextToken(); ResourceSharing rs = ResourceSharing.fromXContent(parser); - boolean canShare = canUserShare(user, isAdmin, rs, resourceIndex); + boolean canShare = canUserShare(user, isAdmin, rs, resourceType); resourceSharingRecords.add(new SharingRecord(rs, canShare)); } catch (Exception e) { // TODO: Decide how strict should this failure be: @@ -1137,6 +1145,7 @@ private void processScrollResultsAndCollectSharingRecords( user, isAdmin, resourceIndex, + resourceType, resourceSharingRecords, scroll, sr.getScrollId(), @@ -1173,9 +1182,7 @@ private void clearScroll(String scrollId, ActionListener listener) { // **** Check whether user can share this record further /** Resolve access-level for THIS resource type and check required action. */ - public boolean groupAllows(String resourceIndex, String accessLevel, String requiredAction) { - String resourceType = resourcePluginInfo.typeByIndex(resourceIndex); - if (resourceType == null || accessLevel == null || requiredAction == null) return false; + public boolean groupAllows(String resourceType, String accessLevel, String requiredAction) { return resourcePluginInfo.flattenedForType(resourceType).resolve(Set.of(accessLevel)).contains(requiredAction); } diff --git a/src/main/java/org/opensearch/security/resources/api/list/AccessibleResourcesRestAction.java b/src/main/java/org/opensearch/security/resources/api/list/AccessibleResourcesRestAction.java index d9c6975dfc..0337b095a5 100644 --- a/src/main/java/org/opensearch/security/resources/api/list/AccessibleResourcesRestAction.java +++ b/src/main/java/org/opensearch/security/resources/api/list/AccessibleResourcesRestAction.java @@ -80,7 +80,7 @@ protected RestChannelConsumer prepareRequest(RestRequest request, NodeClient cli if (resourceIndex == null) { return channel -> { handleResponse(channel, Set.of()); }; } - return channel -> resourceAccessHandler.getResourceSharingInfoForCurrentUser(resourceIndex, ActionListener.wrap(rows -> { + return channel -> resourceAccessHandler.getResourceSharingInfoForCurrentUser(resourceType, ActionListener.wrap(rows -> { handleResponse(channel, rows); }, e -> handleError(channel, e))); } diff --git a/src/main/java/org/opensearch/security/resources/api/migrate/MigrateResourceSharingInfoApiAction.java b/src/main/java/org/opensearch/security/resources/api/migrate/MigrateResourceSharingInfoApiAction.java index 56fd591dfa..70bdf61de3 100644 --- a/src/main/java/org/opensearch/security/resources/api/migrate/MigrateResourceSharingInfoApiAction.java +++ b/src/main/java/org/opensearch/security/resources/api/migrate/MigrateResourceSharingInfoApiAction.java @@ -139,35 +139,59 @@ private void handleMigrate(RestChannel channel, RestRequest request, Client clie * 3. Create a SourceDoc for each raw doc * 4. Returns a triple of the source index name, the default access level and the list of source docs. */ - private ValidationResult>> loadCurrentSharingInfo(RestRequest request, Client client) - throws IOException { + private ValidationResult, List>> loadCurrentSharingInfo( + RestRequest request, + Client client + ) throws IOException { JsonNode body = Utils.toJsonNode(request.content().utf8ToString()); String sourceIndex = body.get("source_index").asText(); String userNamePath = body.get("username_path").asText(); String backendRolesPath = body.get("backend_roles_path").asText(); - String defaultAccessLevel = body.get("default_access_level").asText(); - - // check that access level exists for given resource-index - String type = resourcePluginInfo.typeByIndex(sourceIndex); - var availableAGs = resourcePluginInfo.flattenedForType(type).actionGroups(); - if (!availableAGs.contains(defaultAccessLevel)) { - LOGGER.error( - "Invalid access level {} for resource sharing for resource type [{}]. Available access-levels are [{}]", - defaultAccessLevel, - sourceIndex, - availableAGs - ); - String badRequestMessage = "Invalid access level " - + defaultAccessLevel - + " for resource sharing for resource type [" - + type - + "]. Available access-levels are [" - + availableAGs - + "]"; + JsonNode node = body.get("default_access_level"); + Map typeToDefaultAccessLevel = Utils.toMapOfStrings(node); + String typePath = null; + if (body.has("type_path")) { + typePath = body.get("type_path").asText(); + } else { + LOGGER.info("No type_path provided, assuming single resource-type for all records."); + if (typeToDefaultAccessLevel.size() > 1) { + String badRequestMessage = "type_path must be provided when multiple resource types are specified in default_access_level."; + return ValidationResult.error(RestStatus.BAD_REQUEST, badRequestMessage(badRequestMessage)); + } + } + if (!resourcePluginInfo.getResourceIndicesForProtectedTypes().contains(sourceIndex)) { + String badRequestMessage = "Invalid resource index " + sourceIndex + "."; return ValidationResult.error(RestStatus.BAD_REQUEST, badRequestMessage(badRequestMessage)); } + for (String type : typeToDefaultAccessLevel.keySet()) { + String defaultAccessLevelForType = typeToDefaultAccessLevel.get(type); + LOGGER.info("Default access level for resource type [{}] is [{}]", type, typeToDefaultAccessLevel.get(type)); + // check that access level exists for given resource-index + if (resourcePluginInfo.indexByType(type) == null) { + String badRequestMessage = "Invalid resource type " + type + "."; + return ValidationResult.error(RestStatus.BAD_REQUEST, badRequestMessage(badRequestMessage)); + } + var accessLevels = resourcePluginInfo.flattenedForType(type).actionGroups(); + if (!accessLevels.contains(defaultAccessLevelForType)) { + LOGGER.error( + "Invalid access level {} for resource sharing for resource type [{}]. Available access-levels are [{}]", + defaultAccessLevelForType, + type, + accessLevels + ); + String badRequestMessage = "Invalid access level " + + defaultAccessLevelForType + + " for resource sharing for resource type [" + + type + + "]. Available access-levels are [" + + accessLevels + + "]"; + return ValidationResult.error(RestStatus.BAD_REQUEST, badRequestMessage(badRequestMessage)); + } + } + List results = new ArrayList<>(); // 1) configure a 1-minute scroll @@ -201,7 +225,14 @@ private ValidationResult>> loadCurrentSha } } - results.add(new SourceDoc(id, username, backendRoles)); + String type; + if (typePath != null) { + type = rec.at(typePath.startsWith("/") ? typePath : ("/" + typePath)).asText(null); + } else { + type = typeToDefaultAccessLevel.keySet().iterator().next(); + } + + results.add(new SourceDoc(id, username, backendRoles, type)); } // 4) fetch next batch SearchScrollRequest scrollRequest = new SearchScrollRequest(scrollId).scroll(scroll); @@ -214,7 +245,7 @@ private ValidationResult>> loadCurrentSha clear.addScrollId(scrollId); client.clearScroll(clear).actionGet(); - return ValidationResult.success(Triple.of(sourceIndex, defaultAccessLevel, results)); + return ValidationResult.success(Triple.of(sourceIndex, typeToDefaultAccessLevel, results)); } /** @@ -222,7 +253,7 @@ private ValidationResult>> loadCurrentSha * 1. Parses existing sharing info to a new ResourceSharing records * 2. Indexes the new record into corresponding resource-sharing index */ - private ValidationResult createNewSharingRecords(Triple> sourceInfo) + private ValidationResult createNewSharingRecords(Triple, List> sourceInfo) throws IOException { AtomicInteger migratedCount = new AtomicInteger(); AtomicReference> skippedNoUser = new AtomicReference<>(); @@ -243,7 +274,7 @@ private ValidationResult createNewSharingRecords(Triple createNewSharingRecords(Triple createNewSharingRecords(Triple(backendRoles))); - shareWith = new ShareWith(Map.of(sourceInfo.getMiddle(), recipients)); + shareWith = new ShareWith(Map.of(sourceInfo.getMiddle().get(doc.type), recipients)); } // 5) index the new record @@ -342,8 +382,8 @@ public Map allowedKeys() { .put("source_index", RequestContentValidator.DataType.STRING) // name of the resource plugin index .put("username_path", RequestContentValidator.DataType.STRING) // path to resource creator's name .put("backend_roles_path", RequestContentValidator.DataType.STRING) // path to backend_roles - .put("default_access_level", RequestContentValidator.DataType.STRING) // default access level for the new - // structure + .put("type_path", RequestContentValidator.DataType.STRING) // path to resource type + .put("default_access_level", RequestContentValidator.DataType.OBJECT) // default access level by type .build(); } }); @@ -355,11 +395,13 @@ static class SourceDoc { String resourceId; String username; List backendRoles; + String type; - public SourceDoc(String id, String username, List backendRoles) { + public SourceDoc(String id, String username, List backendRoles, String type) { this.resourceId = id; this.username = username; this.backendRoles = backendRoles; + this.type = type; } } diff --git a/src/main/java/org/opensearch/security/resources/api/share/ShareRequest.java b/src/main/java/org/opensearch/security/resources/api/share/ShareRequest.java index 4c9ed590f3..e4962282f4 100644 --- a/src/main/java/org/opensearch/security/resources/api/share/ShareRequest.java +++ b/src/main/java/org/opensearch/security/resources/api/share/ShareRequest.java @@ -32,6 +32,7 @@ public class ShareRequest extends ActionRequest implements DocRequest { @JsonProperty("resource_id") private final String resourceId; @JsonProperty("resource_type") + private final String resourceType; private final String resourceIndex; @JsonProperty("share_with") private final ShareWith shareWith; @@ -40,8 +41,6 @@ public class ShareRequest extends ActionRequest implements DocRequest { @JsonProperty("revoke") private final ShareWith revoke; - private final String resourceType; - private final RestRequest.Method method; /** @@ -49,12 +48,12 @@ public class ShareRequest extends ActionRequest implements DocRequest { */ private ShareRequest(Builder builder) { this.resourceId = builder.resourceId; + this.resourceType = builder.resourceType; this.resourceIndex = builder.resourceIndex; this.shareWith = builder.shareWith; this.add = builder.add; this.revoke = builder.revoke; this.method = builder.method; - this.resourceType = builder.resourceType; } public ShareRequest(StreamInput in) throws IOException { @@ -82,7 +81,7 @@ public void writeTo(StreamOutput out) throws IOException { @Override public ActionRequestValidationException validate() { var arv = new ActionRequestValidationException(); - if (Strings.isNullOrEmpty(resourceIndex) || Strings.isNullOrEmpty(resourceId)) { + if (Strings.isNullOrEmpty(resourceType) || Strings.isNullOrEmpty(resourceId)) { arv.addValidationError("resource_id and resource_type must be present"); throw arv; } @@ -148,13 +147,13 @@ public String id() { * Builder for ShareRequest */ public static class Builder { - private String resourceId; - private String resourceIndex; - private String resourceType; - private ShareWith shareWith; - private ShareWith add; - private ShareWith revoke; - private RestRequest.Method method; + String resourceId; + String resourceIndex; + String resourceType; + ShareWith shareWith; + ShareWith add; + ShareWith revoke; + RestRequest.Method method; public void resourceId(String resourceId) { this.resourceId = resourceId; diff --git a/src/main/java/org/opensearch/security/resources/api/share/ShareRestAction.java b/src/main/java/org/opensearch/security/resources/api/share/ShareRestAction.java index 16a37afd6f..39971d1eff 100644 --- a/src/main/java/org/opensearch/security/resources/api/share/ShareRestAction.java +++ b/src/main/java/org/opensearch/security/resources/api/share/ShareRestAction.java @@ -75,23 +75,34 @@ protected RestChannelConsumer prepareRequest(RestRequest request, NodeClient cli String resourceId = request.param("resource_id"); String resourceType = request.param("resource_type"); - String resourceIndex = resourcePluginInfo.indexByType(resourceType); - ShareRequest.Builder builder = new ShareRequest.Builder(); builder.method(request.method()); - - if (resourceIndex != null) { - builder.resourceIndex(resourceIndex); - builder.resourceType(resourceType); - } if (resourceId != null) { builder.resourceId(resourceId); } + builder.resourceType(resourceType); + if (request.hasContent()) { builder.parseContent(request.contentParser(), resourcePluginInfo); } + if (builder.resourceId == null || builder.resourceType == null) { + return channel -> { + channel.sendResponse(new BytesRestResponse(RestStatus.BAD_REQUEST, "Resource type and id are both required.")); + }; + } + + String resourceIndex = resourcePluginInfo.indexByType(builder.resourceType); + + if (resourceIndex == null) { + return channel -> { + channel.sendResponse(new BytesRestResponse(RestStatus.BAD_REQUEST, "Invalid resource type: " + resourceType)); + }; + } + + builder.resourceIndex(resourceIndex); + ShareRequest shareRequest = builder.build(); if (shareRequest.type() != null && !resourceSharingProtectedTypesSetting.getDynamicSettingValue().contains(shareRequest.type())) { diff --git a/src/main/java/org/opensearch/security/resources/api/share/ShareTransportAction.java b/src/main/java/org/opensearch/security/resources/api/share/ShareTransportAction.java index c25e74aa67..87c7515779 100644 --- a/src/main/java/org/opensearch/security/resources/api/share/ShareTransportAction.java +++ b/src/main/java/org/opensearch/security/resources/api/share/ShareTransportAction.java @@ -42,19 +42,19 @@ protected void doExecute(Task task, ShareRequest request, ActionListener { - ActionListener listener = inv.getArgument(4); + ActionListener listener = inv.getArgument(3); listener.onResponse(hasPermission); return null; - }).when(resourceAccessHandler).hasPermission(eq("anyId"), eq(IDX), eq("read"), any(), any()); + }).when(resourceAccessHandler).hasPermission(eq("anyId"), eq("indices"), eq("read"), any()); ActionListener callback = mock(ActionListener.class); - evaluator.evaluateAsync(req, "read", context, callback); + evaluator.evaluateAsync(req, "read", callback); ArgumentCaptor captor = ArgumentCaptor.forClass(PrivilegesEvaluatorResponse.class); verify(callback).onResponse(captor.capture()); diff --git a/src/test/java/org/opensearch/security/resources/ResourceAccessHandlerTest.java b/src/test/java/org/opensearch/security/resources/ResourceAccessHandlerTest.java index c4f155d6ba..1a972f842d 100644 --- a/src/test/java/org/opensearch/security/resources/ResourceAccessHandlerTest.java +++ b/src/test/java/org/opensearch/security/resources/ResourceAccessHandlerTest.java @@ -23,10 +23,7 @@ import org.opensearch.core.action.ActionListener; import org.opensearch.security.auth.UserSubjectImpl; import org.opensearch.security.configuration.AdminDNs; -import org.opensearch.security.privileges.PrivilegesEvaluationContext; import org.opensearch.security.privileges.PrivilegesEvaluator; -import org.opensearch.security.privileges.actionlevel.RoleBasedActionPrivileges; -import org.opensearch.security.privileges.actionlevel.SubjectBasedActionPrivileges; import org.opensearch.security.resources.sharing.Recipient; import org.opensearch.security.resources.sharing.ResourceSharing; import org.opensearch.security.resources.sharing.ShareWith; @@ -57,10 +54,6 @@ public class ResourceAccessHandlerTest { private AdminDNs adminDNs; @Mock private PrivilegesEvaluator privilegesEvaluator; - @Mock - private PrivilegesEvaluationContext context; - @Mock - private RoleBasedActionPrivileges roleBasedPrivileges; @Mock private ResourcePluginInfo resourcePluginInfo; @@ -69,6 +62,7 @@ public class ResourceAccessHandlerTest { private ResourceAccessHandler handler; private static final String INDEX = "test-index"; + private static final String TYPE = "test"; private static final String RESOURCE_ID = "res-1"; private static final String ACTION = "read"; @@ -79,8 +73,8 @@ public void setup() { handler = new ResourceAccessHandler(threadPool, sharingIndexHandler, adminDNs, privilegesEvaluator, resourcePluginInfo); // For tests that verify permission with action-group - when(resourcePluginInfo.typeByIndex(any())).thenReturn("org.example.Type"); when(resourcePluginInfo.flattenedForType(any())).thenReturn(mock(FlattenedActionGroups.class)); + when(resourcePluginInfo.indexByType(TYPE)).thenReturn(INDEX); } private void injectUser(User user) { @@ -96,7 +90,7 @@ public void testHasPermission_adminUserAllowed() { when(adminDNs.isAdmin(user)).thenReturn(true); ActionListener listener = mock(ActionListener.class); - handler.hasPermission(RESOURCE_ID, INDEX, ACTION, context, listener); + handler.hasPermission(RESOURCE_ID, TYPE, ACTION, listener); verify(listener).onResponse(true); } @@ -106,8 +100,6 @@ public void testHasPermission_ownerAllowed() { User user = new User("alice", ImmutableSet.of("r1"), ImmutableSet.of("b1"), null, ImmutableMap.of(), false); injectUser(user); when(adminDNs.isAdmin(user)).thenReturn(false); - when(privilegesEvaluator.createContext(user, ACTION)).thenReturn(context); - when(context.getActionPrivileges()).thenReturn(roleBasedPrivileges); ResourceSharing doc = mock(ResourceSharing.class); when(doc.isCreatedBy("alice")).thenReturn(true); @@ -119,7 +111,7 @@ public void testHasPermission_ownerAllowed() { }).when(sharingIndexHandler).fetchSharingInfo(eq(INDEX), eq(RESOURCE_ID), any()); ActionListener listener = mock(ActionListener.class); - handler.hasPermission(RESOURCE_ID, INDEX, ACTION, null, listener); + handler.hasPermission(RESOURCE_ID, TYPE, ACTION, listener); verify(listener).onResponse(true); } @@ -129,8 +121,6 @@ public void testHasPermission_sharedWithUserAllowed() { User user = new User("bob", ImmutableSet.of("role1"), ImmutableSet.of("backend1"), null, ImmutableMap.of(), false); injectUser(user); when(adminDNs.isAdmin(user)).thenReturn(false); - when(privilegesEvaluator.createContext(user, ACTION)).thenReturn(context); - when(context.getActionPrivileges()).thenReturn(roleBasedPrivileges); // Document setup: shared with the user at access-level "read" ResourceSharing doc = mock(ResourceSharing.class); @@ -139,11 +129,8 @@ public void testHasPermission_sharedWithUserAllowed() { when(doc.fetchAccessLevels(eq(Recipient.ROLES), any())).thenReturn(Set.of()); when(doc.fetchAccessLevels(eq(Recipient.BACKEND_ROLES), any())).thenReturn(Set.of()); - final String TYPE_FQN = "org.example.Type"; - when(resourcePluginInfo.typeByIndex(INDEX)).thenReturn(TYPE_FQN); - FlattenedActionGroups ag = mock(FlattenedActionGroups.class); - when(resourcePluginInfo.flattenedForType(TYPE_FQN)).thenReturn(ag); + when(resourcePluginInfo.flattenedForType(TYPE)).thenReturn(ag); // Resolve the access level "read" to the concrete allowed action "read" (could also be a wildcard) when(ag.resolve(any())).thenReturn(ImmutableSet.of("read")); @@ -155,7 +142,7 @@ public void testHasPermission_sharedWithUserAllowed() { }).when(sharingIndexHandler).fetchSharingInfo(eq(INDEX), eq(RESOURCE_ID), any()); ActionListener listener = mock(ActionListener.class); - handler.hasPermission(RESOURCE_ID, INDEX, ACTION, null, listener); + handler.hasPermission(RESOURCE_ID, TYPE, ACTION, listener); verify(listener).onResponse(true); } @@ -165,8 +152,6 @@ public void testHasPermission_noAccessLevelsDenied() { User user = new User("charlie", ImmutableSet.of("roleA"), ImmutableSet.of("backendA"), null, ImmutableMap.of(), false); injectUser(user); when(adminDNs.isAdmin(user)).thenReturn(false); - when(privilegesEvaluator.createContext(user, ACTION)).thenReturn(context); - when(context.getActionPrivileges()).thenReturn(roleBasedPrivileges); ResourceSharing doc = mock(ResourceSharing.class); when(doc.isCreatedBy("charlie")).thenReturn(false); @@ -179,7 +164,7 @@ public void testHasPermission_noAccessLevelsDenied() { }).when(sharingIndexHandler).fetchSharingInfo(eq(INDEX), eq(RESOURCE_ID), any()); ActionListener listener = mock(ActionListener.class); - handler.hasPermission(RESOURCE_ID, INDEX, ACTION, null, listener); + handler.hasPermission(RESOURCE_ID, TYPE, ACTION, listener); verify(listener).onResponse(false); } @@ -189,8 +174,6 @@ public void testHasPermission_nullDocumentDenied() { User user = new User("dave", ImmutableSet.of("x"), ImmutableSet.of("y"), null, ImmutableMap.of(), false); injectUser(user); when(adminDNs.isAdmin(user)).thenReturn(false); - when(privilegesEvaluator.createContext(user, ACTION)).thenReturn(context); - when(context.getActionPrivileges()).thenReturn(roleBasedPrivileges); doAnswer(inv -> { ActionListener l = inv.getArgument(2); @@ -199,21 +182,7 @@ public void testHasPermission_nullDocumentDenied() { }).when(sharingIndexHandler).fetchSharingInfo(eq(INDEX), eq(RESOURCE_ID), any()); ActionListener listener = mock(ActionListener.class); - handler.hasPermission(RESOURCE_ID, INDEX, ACTION, null, listener); - - verify(listener).onResponse(false); - } - - @Test - public void testHasPermission_pluginUserDenied() { - User user = new User("plugin_user", ImmutableSet.of(), ImmutableSet.of(), null, ImmutableMap.of(), false); - injectUser(user); - PrivilegesEvaluationContext subjectContext = mock(PrivilegesEvaluationContext.class); - when(subjectContext.getActionPrivileges()).thenReturn(mock(SubjectBasedActionPrivileges.class)); - when(privilegesEvaluator.createContext(user, ACTION)).thenReturn(subjectContext); - - ActionListener listener = mock(ActionListener.class); - handler.hasPermission(RESOURCE_ID, INDEX, ACTION, null, listener); + handler.hasPermission(RESOURCE_ID, TYPE, ACTION, listener); verify(listener).onResponse(false); } @@ -232,7 +201,7 @@ public void testGetOwnAndSharedResources_asAdmin() { return null; }).when(sharingIndexHandler).fetchAllResourceIds(eq(INDEX), any()); - handler.getOwnAndSharedResourceIdsForCurrentUser(INDEX, listener); + handler.getOwnAndSharedResourceIdsForCurrentUser(TYPE, listener); verify(listener).onResponse(Set.of("res1", "res2")); } @@ -250,7 +219,7 @@ public void testGetOwnAndSharedResources_asNormalUser() { return null; }).when(sharingIndexHandler).fetchAccessibleResourceIds(any(), any(), any()); - handler.getOwnAndSharedResourceIdsForCurrentUser(INDEX, listener); + handler.getOwnAndSharedResourceIdsForCurrentUser(TYPE, listener); verify(listener).onResponse(Set.of("res1")); } @@ -269,7 +238,7 @@ public void testShareSuccess() { }).when(sharingIndexHandler).share(eq(RESOURCE_ID), eq(INDEX), eq(shareWith), any()); ActionListener listener = mock(ActionListener.class); - handler.share(RESOURCE_ID, INDEX, shareWith, listener); + handler.share(RESOURCE_ID, TYPE, shareWith, listener); verify(listener).onResponse(doc); } @@ -280,7 +249,7 @@ public void testShareFailsIfNoUser() { ActionListener listener = mock(ActionListener.class); - handler.share(RESOURCE_ID, INDEX, shareWith, listener); + handler.share(RESOURCE_ID, TYPE, shareWith, listener); verify(listener).onFailure(any(OpenSearchStatusException.class)); } @@ -297,7 +266,7 @@ public void testGetSharingInfoSuccess() { }).when(sharingIndexHandler).fetchSharingInfo(eq(INDEX), eq(RESOURCE_ID), any()); ActionListener listener = mock(ActionListener.class); - handler.getSharingInfo(RESOURCE_ID, INDEX, listener); + handler.getSharingInfo(RESOURCE_ID, TYPE, listener); verify(listener).onResponse(doc); } @@ -305,7 +274,7 @@ public void testGetSharingInfoSuccess() { @Test public void testGetSharingInfoFailsIfNoUser() { ActionListener listener = mock(ActionListener.class); - handler.getSharingInfo(RESOURCE_ID, INDEX, listener); + handler.getSharingInfo(RESOURCE_ID, TYPE, listener); verify(listener).onFailure(any(OpenSearchStatusException.class)); } @@ -325,7 +294,7 @@ public void testPatchSharingInfoSuccess() { }).when(sharingIndexHandler).patchSharingInfo(eq(RESOURCE_ID), eq(INDEX), eq(add), eq(revoke), any()); ActionListener listener = mock(ActionListener.class); - handler.patchSharingInfo(RESOURCE_ID, INDEX, add, revoke, listener); + handler.patchSharingInfo(RESOURCE_ID, TYPE, add, revoke, listener); verify(listener).onResponse(doc); } @@ -334,7 +303,7 @@ public void testPatchSharingInfoSuccess() { public void testPatchSharingInfoFailsIfNoUser() { ShareWith x = new ShareWith(ImmutableMap.of()); ActionListener listener = mock(ActionListener.class); - handler.patchSharingInfo(RESOURCE_ID, INDEX, x, x, listener); + handler.patchSharingInfo(RESOURCE_ID, TYPE, x, x, listener); verify(listener).onFailure(any(OpenSearchStatusException.class)); }