diff --git a/CHANGELOG.md b/CHANGELOG.md index f296c9610b..a9d515c52f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - Performance optimizations for building internal authorization data structures upon config updates ([#5988](https://github.com/opensearch-project/security/pull/5988)) - Make encryption_key optional for obo token authenticator ([#6017](https://github.com/opensearch-project/security/pull/6017) - Enable basic authentication for gRPC transport ([#6005](https://github.com/opensearch-project/security/pull/6005)) +- Allow specifying parentType and parentIdField in ResourceProvider ([#5735](https://github.com/opensearch-project/security/pull/5735)) ### Bug Fixes - Fix audit log writing errors for rollover-enabled alias indices ([#5878](https://github.com/opensearch-project/security/pull/5878) 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 242fd238ad..9c470a841b 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 @@ -243,6 +243,20 @@ public static String migrationPayload_missingDefaultOwner() { """.formatted(RESOURCE_INDEX_NAME, "user/name", "user/backend_roles", "sample_read_only"); } + public static String migrationPayload_valid_withParent(String groupId) { + return """ + { + "source_index": "%s", + "username_path": "%s", + "backend_roles_path": "%s", + "default_owner": "%s", + "default_access_level": { + "sample-resource": "%s" + } + } + """.formatted(RESOURCE_INDEX_NAME, "user/name", "user/backend_roles", "some_user", "sample_read_only"); + } + public static String putSharingInfoPayload( String resourceId, String resourceType, @@ -370,6 +384,15 @@ public String createSampleResourceAs(TestSecurityConfig.User user, Header... hea } } + public String createSampleResourceWithGroupAs(TestSecurityConfig.User user, String groupId, Header... headers) { + try (TestRestClient client = cluster.getRestClient(user)) { + String sample = "{\"group_id\":\"" + groupId + "\", \"name\":\"sample\",\"resource_type\":\"" + RESOURCE_TYPE + "\"}"; + TestRestClient.HttpResponse resp = client.putJson(SAMPLE_RESOURCE_CREATE_ENDPOINT, sample, headers); + resp.assertStatusCode(HttpStatus.SC_OK); + return resp.getTextFromJsonBody("/message").split(":")[1].trim(); + } + } + public String createSampleResourceGroupAs(TestSecurityConfig.User user, Header... headers) { try (TestRestClient client = cluster.getRestClient(user)) { String sample = "{\"name\":\"samplegroup\",\"resource_type\":\"" + RESOURCE_GROUP_TYPE + "\"}"; diff --git a/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/resource/securityapis/MigrateApiTests.java b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/resource/securityapis/MigrateApiTests.java index 063e97e6ef..657a538d9f 100644 --- a/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/resource/securityapis/MigrateApiTests.java +++ b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/resource/securityapis/MigrateApiTests.java @@ -42,13 +42,17 @@ import static org.opensearch.sample.resource.TestUtils.RESOURCE_SHARING_MIGRATION_ENDPOINT; import static org.opensearch.sample.resource.TestUtils.SAMPLE_RESOURCE_CREATE_ENDPOINT; import static org.opensearch.sample.resource.TestUtils.SAMPLE_RESOURCE_GET_ENDPOINT; +import static org.opensearch.sample.resource.TestUtils.SAMPLE_RESOURCE_GROUP_CREATE_ENDPOINT; +import static org.opensearch.sample.resource.TestUtils.SAMPLE_RESOURCE_GROUP_GET_ENDPOINT; import static org.opensearch.sample.resource.TestUtils.migrationPayload_missingBackendRoles; import static org.opensearch.sample.resource.TestUtils.migrationPayload_missingDefaultAccessLevel; import static org.opensearch.sample.resource.TestUtils.migrationPayload_missingDefaultOwner; import static org.opensearch.sample.resource.TestUtils.migrationPayload_missingSourceIndex; import static org.opensearch.sample.resource.TestUtils.migrationPayload_missingUserName; import static org.opensearch.sample.resource.TestUtils.migrationPayload_valid; +import static org.opensearch.sample.resource.TestUtils.migrationPayload_valid_withParent; import static org.opensearch.sample.resource.TestUtils.migrationPayload_valid_withSpecifiedAccessLevel; +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.security.resources.ResourceSharingIndexHandler.getSharingIndex; @@ -589,6 +593,82 @@ public void testMigrateAPI_inputValidation_invalidValues() { } } + @Test + public void testMigrateAPI_withParentHierarchy() { + // Create a resource group first, then a resource that belongs to it + String groupId = createSampleResourceGroup(); + String resourceId = createSampleResourceWithGroup(groupId); + clearResourceSharingEntries(); + + try (TestRestClient client = cluster.getRestClient(cluster.getAdminCertificate())) { + TestRestClient.HttpResponse migrateResponse = client.postJson( + RESOURCE_SHARING_MIGRATION_ENDPOINT, + migrationPayload_valid_withParent(groupId) + ); + migrateResponse.assertStatusCode(HttpStatus.SC_OK); + assertThat( + migrateResponse.bodyAsMap().get("summary"), + equalTo("Migration complete. migrated 2; skippedNoType 0; skippedExisting 0; failed 0") + ); + + // Verify the sharing record for the resource has parent_type and parent_id set + TestRestClient.HttpResponse sharingResponse = client.get(RESOURCE_SHARING_INDEX + "/_search"); + sharingResponse.assertStatusCode(HttpStatus.SC_OK); + ArrayNode hitsNode = (ArrayNode) sharingResponse.bodyAsJsonNode().get("hits").get("hits"); + assertThat(hitsNode.size(), equalTo(2)); + + // Find the resource hit (not the group) and verify parent fields + com.fasterxml.jackson.databind.JsonNode resourceSource = null; + for (com.fasterxml.jackson.databind.JsonNode hit : hitsNode) { + com.fasterxml.jackson.databind.JsonNode src = hit.get("_source"); + if (RESOURCE_TYPE.equals(src.get("resource_type").asText())) { + resourceSource = src; + break; + } + } + assertThat("Expected a sharing record for resource type " + RESOURCE_TYPE, resourceSource != null); + assertThat(resourceSource.get("resource_id").asText(), equalTo(resourceId)); + assertThat(resourceSource.get("parent_type").asText(), equalTo(RESOURCE_GROUP_TYPE)); + assertThat(resourceSource.get("parent_id").asText(), equalTo(groupId)); + } + } + + private String createSampleResourceGroup() { + try (TestRestClient client = cluster.getRestClient(MIGRATION_USER)) { + String sampleGroup = """ + { + "name":"sample_group" + } + """; + TestRestClient.HttpResponse response = client.putJson(SAMPLE_RESOURCE_GROUP_CREATE_ENDPOINT, sampleGroup); + response.assertStatusCode(HttpStatus.SC_OK); + String groupId = response.getTextFromJsonBody("/message").split(":")[1].trim(); + Awaitility.await() + .alias("Wait until group is populated") + .until(() -> client.get(SAMPLE_RESOURCE_GROUP_GET_ENDPOINT + "/" + groupId).getStatusCode(), equalTo(200)); + return groupId; + } + } + + private String createSampleResourceWithGroup(String groupId) { + try (TestRestClient client = cluster.getRestClient(MIGRATION_USER)) { + String sampleResource = """ + { + "name":"sample_with_group", + "group_id":"%s", + "store_user": true + } + """.formatted(groupId); + TestRestClient.HttpResponse response = client.putJson(SAMPLE_RESOURCE_CREATE_ENDPOINT, sampleResource); + response.assertStatusCode(HttpStatus.SC_OK); + String resourceId = response.getTextFromJsonBody("/message").split(":")[1].trim(); + Awaitility.await() + .alias("Wait until resource is populated") + .until(() -> client.get(SAMPLE_RESOURCE_GET_ENDPOINT + "/" + resourceId).getStatusCode(), equalTo(200)); + return resourceId; + } + } + private String createSampleResource() { try (TestRestClient client = cluster.getRestClient(MIGRATION_USER)) { String sampleResource = """ diff --git a/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/resource/securityapis/ParentHierarchyAccessTests.java b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/resource/securityapis/ParentHierarchyAccessTests.java new file mode 100644 index 0000000000..a4593bf94f --- /dev/null +++ b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/resource/securityapis/ParentHierarchyAccessTests.java @@ -0,0 +1,168 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.sample.resource.securityapis; + +import java.util.List; +import java.util.Map; + +import com.carrotsearch.randomizedtesting.annotations.ThreadLeakScope; +import org.apache.http.HttpStatus; +import org.junit.After; +import org.junit.ClassRule; +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.opensearch.sample.resource.TestUtils; +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.equalTo; +import static org.hamcrest.Matchers.hasItem; +import static org.opensearch.sample.resource.TestUtils.RESOURCE_SHARING_INDEX; +import static org.opensearch.sample.resource.TestUtils.SAMPLE_FULL_ACCESS; +import static org.opensearch.sample.resource.TestUtils.SAMPLE_GROUP_FULL_ACCESS; +import static org.opensearch.sample.resource.TestUtils.SECURITY_LIST_ENDPOINT; +import static org.opensearch.sample.utils.Constants.RESOURCE_INDEX_NAME; +import static org.opensearch.sample.utils.Constants.RESOURCE_TYPE; +import static org.opensearch.test.framework.TestSecurityConfig.User.USER_ADMIN; + +/** + * Tests that a user with access to a parent resource group can list/search child resources + * they were not directly shared with — i.e. parent-inherited access via the sharing index. + */ +@RunWith(com.carrotsearch.randomizedtesting.RandomizedRunner.class) +@ThreadLeakScope(ThreadLeakScope.Scope.NONE) +public class ParentHierarchyAccessTests { + + @ClassRule + public static final LocalCluster cluster = TestUtils.newCluster(true, true); + + private final TestUtils.ApiHelper api = new TestUtils.ApiHelper(cluster); + + @After + public void clearIndices() { + try (TestRestClient client = cluster.getRestClient(cluster.getAdminCertificate())) { + client.delete(RESOURCE_INDEX_NAME); + client.delete(RESOURCE_SHARING_INDEX); + } + } + + /** + * Scenario: + * 1. Admin creates a resource group and a child resource that references it (group_id = groupId) + * 2. Admin shares the resource group with NO_ACCESS_USER at full access + * 3. NO_ACCESS_USER was never directly shared the child resource + * 4. Calling list API for "sample-resource" should still return the child resource + * because the user has access to its parent group + */ + @Test + @SuppressWarnings("unchecked") + public void testListChildResources_viaParentGroupAccess() { + // 1. Create a resource group and a child resource + String groupId = api.createSampleResourceGroupAs(USER_ADMIN); + api.awaitSharingEntry(groupId); + + String childId = api.createSampleResourceWithGroupAs(USER_ADMIN, groupId); + api.awaitSharingEntry(childId); + + // 2. Share the group with NO_ACCESS_USER — child is NOT directly shared + TestRestClient.HttpResponse shareResp = api.shareResourceGroup( + groupId, + USER_ADMIN, + TestUtils.NO_ACCESS_USER, + SAMPLE_GROUP_FULL_ACCESS + ); + shareResp.assertStatusCode(HttpStatus.SC_OK); + + // 3. NO_ACCESS_USER lists child resources — should see the child via parent inheritance + try (TestRestClient client = cluster.getRestClient(TestUtils.NO_ACCESS_USER)) { + TestRestClient.HttpResponse response = client.get(SECURITY_LIST_ENDPOINT + "?resource_type=" + RESOURCE_TYPE); + response.assertStatusCode(HttpStatus.SC_OK); + + List resources = (List) response.bodyAsMap().get("resources"); + assertThat("Expected 1 child resource via parent inheritance", resources.size(), equalTo(1)); + + Map resource = (Map) resources.getFirst(); + assertThat(resource.get("resource_id"), equalTo(childId)); + } + } + + /** + * Scenario: + * 1. Admin creates a resource group and two child resources referencing it + * 2. Admin directly shares one child with NO_ACCESS_USER + * 3. Admin shares the group with NO_ACCESS_USER + * 4. List API should return both children (union of direct + inherited) + */ + @Test + @SuppressWarnings("unchecked") + public void testListChildResources_unionOfDirectAndInherited() { + // 1. Create group and two children + String groupId = api.createSampleResourceGroupAs(USER_ADMIN); + api.awaitSharingEntry(groupId); + + String child1Id = api.createSampleResourceWithGroupAs(USER_ADMIN, groupId); + api.awaitSharingEntry(child1Id); + + String child2Id = api.createSampleResourceWithGroupAs(USER_ADMIN, groupId); + api.awaitSharingEntry(child2Id); + + // 2. Directly share child1 with NO_ACCESS_USER + TestRestClient.HttpResponse directShare = api.shareResource(child1Id, USER_ADMIN, TestUtils.NO_ACCESS_USER, SAMPLE_FULL_ACCESS); + directShare.assertStatusCode(HttpStatus.SC_OK); + + // 3. Share the group with NO_ACCESS_USER (gives inherited access to both children) + TestRestClient.HttpResponse groupShare = api.shareResourceGroup( + groupId, + USER_ADMIN, + TestUtils.NO_ACCESS_USER, + SAMPLE_GROUP_FULL_ACCESS + ); + groupShare.assertStatusCode(HttpStatus.SC_OK); + + // 4. List should return both children + try (TestRestClient client = cluster.getRestClient(TestUtils.NO_ACCESS_USER)) { + TestRestClient.HttpResponse response = client.get(SECURITY_LIST_ENDPOINT + "?resource_type=" + RESOURCE_TYPE); + response.assertStatusCode(HttpStatus.SC_OK); + + List resources = (List) response.bodyAsMap().get("resources"); + assertThat("Expected 2 child resources (direct + inherited)", resources.size(), equalTo(2)); + + List ids = resources.stream().map(r -> (String) ((Map) r).get("resource_id")).toList(); + assertThat(ids, hasItem(child1Id)); + assertThat(ids, hasItem(child2Id)); + } + } + + /** + * Scenario: + * 1. Admin creates a resource group and a child resource + * 2. NO_ACCESS_USER has NO access to the group + * 3. List API for child resources should return empty + */ + @Test + @SuppressWarnings("unchecked") + public void testListChildResources_noAccessToParent_returnsEmpty() { + String groupId = api.createSampleResourceGroupAs(USER_ADMIN); + api.awaitSharingEntry(groupId); + + String childId = api.createSampleResourceWithGroupAs(USER_ADMIN, groupId); + api.awaitSharingEntry(childId); + + // NO_ACCESS_USER has no access to group or child + try (TestRestClient client = cluster.getRestClient(TestUtils.NO_ACCESS_USER)) { + TestRestClient.HttpResponse response = client.get(SECURITY_LIST_ENDPOINT + "?resource_type=" + RESOURCE_TYPE); + response.assertStatusCode(HttpStatus.SC_OK); + + List resources = (List) response.bodyAsMap().get("resources"); + assertThat("Expected 0 resources when user has no parent access", resources.size(), equalTo(0)); + } + } +} diff --git a/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/resourcegroup/ResourceHierarchyTests.java b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/resourcegroup/ResourceHierarchyTests.java new file mode 100644 index 0000000000..76cd740538 --- /dev/null +++ b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/resourcegroup/ResourceHierarchyTests.java @@ -0,0 +1,115 @@ +/* + * 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.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.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.SAMPLE_GROUP_READ_ONLY; +import static org.opensearch.sample.resource.TestUtils.newCluster; +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 ResourceHierarchyTests { + + @ClassRule + public static LocalCluster cluster = newCluster(true, true); + + private final TestUtils.ApiHelper api = new TestUtils.ApiHelper(cluster); + private String resourceGroupId; + private String resourceId; + + @Before + public void setup() { + resourceGroupId = api.createSampleResourceGroupAs(USER_ADMIN); + api.awaitSharingEntry(resourceGroupId); // wait until sharing entry is created + resourceId = api.createSampleResourceWithGroupAs(USER_ADMIN, resourceGroupId); + api.awaitSharingEntry(resourceId); // wait until sharing entry is created + } + + @After + public void cleanup() { + api.wipeOutResourceEntries(); + } + + @Test + public void testShouldHaveAccessToResourceWithGroupLevelAccess() throws Exception { + TestRestClient.HttpResponse response = ok(() -> api.getResource(resourceId, USER_ADMIN)); + assertThat(response.getBody(), containsString("sample")); + + forbidden(() -> api.getResourceGroup(resourceGroupId, FULL_ACCESS_USER)); + forbidden(() -> api.getResource(resourceGroupId, FULL_ACCESS_USER)); + + ok(() -> api.shareResourceGroup(resourceGroupId, USER_ADMIN, FULL_ACCESS_USER, SAMPLE_GROUP_READ_ONLY)); + + ok(() -> api.getResourceGroup(resourceGroupId, FULL_ACCESS_USER)); + ok(() -> api.getResource(resourceId, FULL_ACCESS_USER)); + } + + @Test + public void testGroupReadOnlyShouldNotGrantWriteOnChild() throws Exception { + ok(() -> api.shareResourceGroup(resourceGroupId, USER_ADMIN, FULL_ACCESS_USER, SAMPLE_GROUP_READ_ONLY)); + + // read is allowed via parent + ok(() -> api.getResource(resourceId, FULL_ACCESS_USER)); + + // write/delete on child should still be forbidden — read_only only maps to get actions + forbidden(() -> api.updateResource(resourceId, FULL_ACCESS_USER, "shouldFail")); + forbidden(() -> api.deleteResource(resourceId, FULL_ACCESS_USER)); + } + + @Test + public void testRevokingGroupAccessRemovesChildAccess() throws Exception { + ok(() -> api.shareResourceGroup(resourceGroupId, USER_ADMIN, FULL_ACCESS_USER, SAMPLE_GROUP_READ_ONLY)); + + ok(() -> api.getResource(resourceId, FULL_ACCESS_USER)); + + ok(() -> api.revokeResourceGroup(resourceGroupId, USER_ADMIN, FULL_ACCESS_USER, SAMPLE_GROUP_READ_ONLY)); + + forbidden(() -> api.getResourceGroup(resourceGroupId, FULL_ACCESS_USER)); + forbidden(() -> api.getResource(resourceId, FULL_ACCESS_USER)); + } + + @Test + public void testDirectChildShareGrantsAccessWithoutGroupShare() throws Exception { + // group is not shared with FULL_ACCESS_USER at all + forbidden(() -> api.getResourceGroup(resourceGroupId, FULL_ACCESS_USER)); + forbidden(() -> api.getResource(resourceId, FULL_ACCESS_USER)); + + // share the child directly at full_access + ok(() -> api.shareResource(resourceId, USER_ADMIN, FULL_ACCESS_USER, TestUtils.SAMPLE_FULL_ACCESS)); + + // child is now accessible + ok(() -> api.getResource(resourceId, FULL_ACCESS_USER)); + ok(() -> api.updateResource(resourceId, FULL_ACCESS_USER, "directShareUpdate")); + + // group itself remains inaccessible + forbidden(() -> api.getResourceGroup(resourceGroupId, FULL_ACCESS_USER)); + } + +} diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/SampleResource.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/SampleResource.java index 370b6acd0b..6f316e9617 100644 --- a/sample-resource-plugin/src/main/java/org/opensearch/sample/SampleResource.java +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/SampleResource.java @@ -36,6 +36,7 @@ public class SampleResource implements NamedWriteable, ToXContentObject { private String name; private String description; + private String groupId; private Map attributes; // NOTE: following field is added to specifically test migrate API, for newer resources this field must not be defined private User user; @@ -46,7 +47,8 @@ public SampleResource() throws IOException { public SampleResource(StreamInput in) throws IOException { this.name = in.readString(); - this.description = in.readString(); + this.description = in.readOptionalString(); + this.groupId = in.readOptionalString(); this.attributes = in.readMap(StreamInput::readString, StreamInput::readString); this.user = new User(in); } @@ -60,15 +62,17 @@ public SampleResource(StreamInput in) throws IOException { } s.setName((String) a[0]); s.setDescription((String) a[1]); - // ignore a[2] as we know the type - s.setAttributes((Map) a[3]); - s.setUser((User) a[4]); + s.setGroupId((String) a[2]); + // ignore a[3] as we know the type + s.setAttributes((Map) a[4]); + s.setUser((User) a[5]); return s; }); static { PARSER.declareString(constructorArg(), new ParseField("name")); PARSER.declareStringOrNull(optionalConstructorArg(), new ParseField("description")); + PARSER.declareStringOrNull(optionalConstructorArg(), new ParseField("group_id")); PARSER.declareStringOrNull(optionalConstructorArg(), new ParseField("resource_type")); PARSER.declareObjectOrNull(optionalConstructorArg(), (p, c) -> p.mapStrings(), null, new ParseField("attributes")); PARSER.declareObjectOrNull(optionalConstructorArg(), (p, c) -> User.parse(p), null, new ParseField("user")); @@ -82,6 +86,7 @@ public XContentBuilder toXContent(XContentBuilder builder, ToXContent.Params par return builder.startObject() .field("name", name) .field("description", description) + .field("group_id", groupId) .field("resource_type", RESOURCE_TYPE) .field("attributes", attributes) .field("user", user) @@ -90,7 +95,8 @@ public XContentBuilder toXContent(XContentBuilder builder, ToXContent.Params par public void writeTo(StreamOutput out) throws IOException { out.writeString(name); - out.writeString(description); + out.writeOptionalString(description); + out.writeOptionalString(groupId); out.writeMap(attributes, StreamOutput::writeString, StreamOutput::writeString); user.writeTo(out); } @@ -103,6 +109,10 @@ public void setDescription(String description) { this.description = description; } + public void setGroupId(String groupId) { + this.groupId = groupId; + } + public void setAttributes(Map attributes) { this.attributes = attributes; } diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/SampleResourceExtension.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/SampleResourceExtension.java index 6f47436787..7678589f90 100644 --- a/sample-resource-plugin/src/main/java/org/opensearch/sample/SampleResourceExtension.java +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/SampleResourceExtension.java @@ -18,6 +18,7 @@ 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; import static org.opensearch.sample.utils.Constants.RESOURCE_TYPE; @@ -43,6 +44,16 @@ public String resourceIndexName() { public String typeField() { return "resource_type"; } + + @Override + public String parentType() { + return RESOURCE_GROUP_TYPE; + } + + @Override + public String parentIdField() { + return "group_id"; + } }); } diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/create/CreateResourceRestAction.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/create/CreateResourceRestAction.java index 7ae7111931..401aaa9348 100644 --- a/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/create/CreateResourceRestAction.java +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/rest/create/CreateResourceRestAction.java @@ -60,10 +60,12 @@ protected RestChannelConsumer prepareRequest(RestRequest request, NodeClient cli 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; + String groupId = source.containsKey("group_id") ? (String) source.get("group_id") : null; Map attributes = getAttributes(source); SampleResource resource = new SampleResource(); resource.setName(name); resource.setDescription(description); + resource.setGroupId(groupId); resource.setAttributes(attributes); final UpdateResourceRequest updateResourceRequest = new UpdateResourceRequest(resourceId, resource); return channel -> client.executeLocally( @@ -75,10 +77,12 @@ private RestChannelConsumer updateResource(Map source, String re private RestChannelConsumer createResource(Map source, NodeClient client) throws IOException { String name = (String) source.get("name"); + String groupId = source.containsKey("group_id") ? (String) source.get("group_id") : null; String description = source.containsKey("description") ? (String) source.get("description") : null; Map attributes = getAttributes(source); boolean shouldStoreUser = source.containsKey("store_user") && (boolean) source.get("store_user"); SampleResource resource = new SampleResource(); + resource.setGroupId(groupId); resource.setName(name); resource.setDescription(description); resource.setAttributes(attributes); diff --git a/sample-resource-plugin/src/main/resources/mappings.json b/sample-resource-plugin/src/main/resources/mappings.json index b163ee8c11..aa6bfde7d1 100644 --- a/sample-resource-plugin/src/main/resources/mappings.json +++ b/sample-resource-plugin/src/main/resources/mappings.json @@ -7,6 +7,9 @@ "resource_type": { "type": "keyword" }, + "group_id": { + "type": "keyword" + }, "all_shared_principals": { "type": "keyword" } 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 c373634be9..3864586997 100644 --- a/sample-resource-plugin/src/main/resources/resource-action-groups.yml +++ b/sample-resource-plugin/src/main/resources/resource-action-groups.yml @@ -16,12 +16,17 @@ resource_types: sample_group_read_only: allowed_actions: - "cluster:admin/sample-resource-plugin/group/get" + # TODO: can sample-resource access levels be referenced here? i.e. sample_read_only + - "cluster:admin/sample-resource-plugin/get" sample_group_read_write: allowed_actions: - "cluster:admin/sample-resource-plugin/group/*" + - "cluster:admin/sample-resource-plugin/*" sample_group_full_access: allowed_actions: - "cluster:admin/sample-resource-plugin/group/*" + - "cluster:admin/sample-resource-plugin/*" + - "cluster:admin/security/resource/group/share" - "cluster:admin/security/resource/share" diff --git a/spi/src/main/java/org/opensearch/security/spi/resources/ResourceProvider.java b/spi/src/main/java/org/opensearch/security/spi/resources/ResourceProvider.java index e5b7fc39cd..f2b68376f4 100644 --- a/spi/src/main/java/org/opensearch/security/spi/resources/ResourceProvider.java +++ b/spi/src/main/java/org/opensearch/security/spi/resources/ResourceProvider.java @@ -29,4 +29,22 @@ default String typeField() { return null; } + /** + * Returns the type of the parent resource, if any, for hierarchical resources. + * + * @return the parent resource type + */ + default String parentType() { + return null; + } + + /** + * Returns the name of the field representing the parent resource ID in the child resource document. + * + * @return the field name containing the parent id + */ + default String parentIdField() { + return null; + } + } diff --git a/src/main/java/org/opensearch/security/resources/ResourceAccessHandler.java b/src/main/java/org/opensearch/security/resources/ResourceAccessHandler.java index ce4634e55e..e0f4e283e7 100644 --- a/src/main/java/org/opensearch/security/resources/ResourceAccessHandler.java +++ b/src/main/java/org/opensearch/security/resources/ResourceAccessHandler.java @@ -90,8 +90,31 @@ public void getOwnAndSharedResourceIdsForCurrentUser(@NonNull String resourceTyp } Set flatPrincipals = getFlatPrincipals(user); - // 3) Fetch all accessible resource IDs - resourceSharingIndexHandler.fetchAccessibleResourceIds(resourceIndex, flatPrincipals, listener); + // Phase 1: directly accessible resource IDs (via all_shared_principals on the resource index) + resourceSharingIndexHandler.fetchAccessibleResourceIds(resourceIndex, flatPrincipals, ActionListener.wrap(directIds -> { + String parentType = resourcePluginInfo.getParentType(resourceType); + if (parentType == null) { + // No parent hierarchy — return direct results as-is + listener.onResponse(directIds); + return; + } + + // Phase 2: resolve parent-inherited access + // 2a) get accessible parent resource IDs + String parentIndex = resourcePluginInfo.indexByType(parentType); + resourceSharingIndexHandler.fetchAccessibleResourceIds(parentIndex, flatPrincipals, ActionListener.wrap(parentIds -> { + if (parentIds.isEmpty()) { + listener.onResponse(directIds); + return; + } + // 2b) find child sharing records whose parent_id is in the accessible parent set + resourceSharingIndexHandler.fetchResourceIdsByParentIds(resourceIndex, parentIds, ActionListener.wrap(inheritedIds -> { + Set union = new HashSet<>(directIds); + union.addAll(inheritedIds); + listener.onResponse(union); + }, listener::onFailure)); + }, listener::onFailure)); + }, listener::onFailure)); } /** @@ -119,8 +142,29 @@ public void getResourceSharingInfoForCurrentUser(@NonNull String resourceType, A String resourceIndex = resourcePluginInfo.indexByType(resourceType); - // 3) Fetch all accessible resource sharing records - resourceSharingIndexHandler.fetchAccessibleResourceSharingRecords(resourceIndex, resourceType, user, flatPrincipals, listener); + // Phase 1: directly accessible resource IDs + resourceSharingIndexHandler.fetchAccessibleResourceIds(resourceIndex, flatPrincipals, ActionListener.wrap(directIds -> { + String parentType = resourcePluginInfo.getParentType(resourceType); + if (parentType == null) { + // No parent hierarchy — fetch sharing records for direct IDs only + resourceSharingIndexHandler.fetchResourceSharingRecordsByIds(resourceIndex, resourceType, user, directIds, listener); + return; + } + + // Phase 2: resolve parent-inherited access + String parentIndex = resourcePluginInfo.indexByType(parentType); + resourceSharingIndexHandler.fetchAccessibleResourceIds(parentIndex, flatPrincipals, ActionListener.wrap(parentIds -> { + if (parentIds.isEmpty()) { + resourceSharingIndexHandler.fetchResourceSharingRecordsByIds(resourceIndex, resourceType, user, directIds, listener); + return; + } + resourceSharingIndexHandler.fetchResourceIdsByParentIds(resourceIndex, parentIds, ActionListener.wrap(inheritedIds -> { + Set union = new HashSet<>(directIds); + union.addAll(inheritedIds); + resourceSharingIndexHandler.fetchResourceSharingRecordsByIds(resourceIndex, resourceType, user, union, listener); + }, listener::onFailure)); + }, listener::onFailure)); + }, listener::onFailure)); } /** @@ -180,8 +224,13 @@ public void hasPermission( Set accessLevels = sharingInfo.getAccessLevelsForUser(user); + // no matching access level, either recurse up or fail fast if (accessLevels.isEmpty()) { - listener.onResponse(false); + if (sharingInfo.getParentId() != null) { + hasPermission(sharingInfo.getParentId(), sharingInfo.getParentType(), action, listener); + } else { + listener.onResponse(false); + } return; } @@ -190,7 +239,16 @@ public void hasPermission( final Set allowedActions = agForType.resolve(accessLevels); final WildcardMatcher matcher = WildcardMatcher.from(allowedActions); - listener.onResponse(matcher.test(action)); + if (matcher.test(action)) { + listener.onResponse(true); + return; + } + + if (sharingInfo.getParentId() != null) { + hasPermission(sharingInfo.getParentId(), sharingInfo.getParentType(), action, listener); + } else { + listener.onResponse(false); + } }, e -> { LOGGER.error("Error while checking permission for user {} on resource {}: {}", user.getName(), resourceId, e.getMessage()); listener.onFailure(e); diff --git a/src/main/java/org/opensearch/security/resources/ResourceIndexListener.java b/src/main/java/org/opensearch/security/resources/ResourceIndexListener.java index e44cf48e42..248e95fde2 100644 --- a/src/main/java/org/opensearch/security/resources/ResourceIndexListener.java +++ b/src/main/java/org/opensearch/security/resources/ResourceIndexListener.java @@ -124,8 +124,13 @@ public void postIndex(ShardId shardId, Engine.Index index, Engine.IndexResult re .resourceId(resourceId) .resourceType(resourceType) .createdBy(new CreatedBy(user.getName(), user.getRequestedTenant())); + if (provider.parentType() != null) { + builder.parentType(provider.parentType()) + .parentId(ResourcePluginInfo.extractFieldFromIndexOp(provider.parentIdField(), index)); + } ResourceSharing sharingInfo = builder.build(); // User.getRequestedTenant() is null if multi-tenancy is disabled + this.resourceSharingIndexHandler.indexResourceSharing(resourceIndex, sharingInfo, listener); } catch (IOException e) { diff --git a/src/main/java/org/opensearch/security/resources/ResourcePluginInfo.java b/src/main/java/org/opensearch/security/resources/ResourcePluginInfo.java index 5c3deeae6a..e35e9414ce 100644 --- a/src/main/java/org/opensearch/security/resources/ResourcePluginInfo.java +++ b/src/main/java/org/opensearch/security/resources/ResourcePluginInfo.java @@ -251,6 +251,30 @@ public String indexByType(String type) { } } + public String getParentIdField(String resourceType) { + lock.readLock().lock(); + try { + if (!typeToProvider.containsKey(resourceType)) { + return null; + } + return typeToProvider.get(resourceType).parentIdField(); + } finally { + lock.readLock().unlock(); + } + } + + public String getParentType(String resourceType) { + lock.readLock().lock(); + try { + if (!typeToProvider.containsKey(resourceType)) { + return null; + } + return typeToProvider.get(resourceType).parentType(); + } finally { + lock.readLock().unlock(); + } + } + public Set getResourceTypes() { lock.readLock().lock(); try { diff --git a/src/main/java/org/opensearch/security/resources/ResourceSharingIndexHandler.java b/src/main/java/org/opensearch/security/resources/ResourceSharingIndexHandler.java index 77ea71eafc..d9a069b53c 100644 --- a/src/main/java/org/opensearch/security/resources/ResourceSharingIndexHandler.java +++ b/src/main/java/org/opensearch/security/resources/ResourceSharingIndexHandler.java @@ -397,6 +397,26 @@ public void fetchAllResourceSharingRecords(String resourceIndex, String resource * */ public void fetchAccessibleResourceIds(String resourceIndex, Set entities, ActionListener> listener) { + fetchAccessibleResourceIds(resourceIndex, null, entities, listener); + } + + /** + * Fetches accessible resource IDs from the resource index, optionally filtered by resource type. + * Queries the {@code all_shared_principals} denormalized field and, when {@code resourceType} is + * provided, also filters on the {@code resource_type} field so that only IDs belonging to that + * type are returned (important when multiple types share the same index). + * + * @param resourceIndex the resource index to search + * @param resourceType optional resource type filter; pass {@code null} to return all types + * @param entities flat principals to match against {@code all_shared_principals} + * @param listener listener to receive the set of matching resource IDs + */ + public void fetchAccessibleResourceIds( + String resourceIndex, + String resourceType, + Set entities, + ActionListener> listener + ) { final Scroll scroll = new Scroll(TimeValue.timeValueMinutes(1L)); try (ThreadContext.StoredContext ctx = this.threadPool.getThreadContext().stashContext()) { @@ -407,6 +427,9 @@ public void fetchAccessibleResourceIds(String resourceIndex, Set entitie // We match any doc whose "principals" contains at least one of the entities // e.g., "user:alice", "role:admin", "backend:ops" BoolQueryBuilder boolQuery = QueryBuilders.boolQuery().filter(QueryBuilders.termsQuery("all_shared_principals", entities)); + if (resourceType != null) { + boolQuery.filter(QueryBuilders.termQuery("resource_type", resourceType)); + } executeIdCollectingSearchRequest(scroll, searchRequest, boolQuery, ActionListener.wrap(resourceIds -> { ctx.restore(); @@ -424,6 +447,45 @@ public void fetchAccessibleResourceIds(String resourceIndex, Set entitie } } + /** + * Fetches child resource IDs from the sharing index whose {@code parent_id} field matches any of the given parent IDs. + * Used to resolve parent-inherited access: if a user has access to a parent resource, they implicitly have access + * to all child resources that reference that parent. + * + * @param resourceIndex the child resource index (sharing index is derived from this) + * @param parentIds the set of accessible parent resource IDs + * @param listener listener to receive the set of child resource IDs + */ + public void fetchResourceIdsByParentIds(String resourceIndex, Set parentIds, ActionListener> listener) { + if (parentIds == null || parentIds.isEmpty()) { + listener.onResponse(Collections.emptySet()); + return; + } + final String resourceSharingIndex = getSharingIndex(resourceIndex); + final Scroll scroll = new Scroll(TimeValue.timeValueMinutes(1L)); + + try (ThreadContext.StoredContext ctx = this.threadPool.getThreadContext().stashContext()) { + SearchRequest searchRequest = new SearchRequest(resourceSharingIndex); + searchRequest.scroll(scroll); + + BoolQueryBuilder query = QueryBuilders.boolQuery().filter(QueryBuilders.termsQuery("parent_id.keyword", parentIds)); + + executeSearchRequest(scroll, searchRequest, query, ActionListener.wrap(resourceIds -> { + ctx.restore(); + LOGGER.debug("Found {} child resources in {} with parent_ids {}", resourceIds.size(), resourceSharingIndex, parentIds); + listener.onResponse(resourceIds); + }, exception -> { + if (exception instanceof IndexNotFoundException) { + LOGGER.debug("Sharing index {} not found, returning empty set", resourceSharingIndex); + listener.onResponse(Collections.emptySet()); + return; + } + LOGGER.error("Search failed for child resources in {}, parentIds={}", resourceSharingIndex, parentIds, exception); + listener.onFailure(exception); + })); + } + } + /** * Executes a search request against the resource index and collects _id values (resource IDs) using scroll. * @@ -643,8 +705,15 @@ public void share(String resourceId, String resourceIndex, ShareWith shareWith, // build update script sharingInfoListener.whenComplete(sharingInfo -> { if (sharingInfo == null) { - LOGGER.debug("No sharing record found for resource {}", resourceId); - listener.onResponse(null); + LOGGER.warn("No sharing record found for resource {} in index {}", resourceId, resourceIndex); + listener.onFailure( + new OpenSearchStatusException( + "No sharing record found for resource [" + + resourceId + + "]. The resource may not exist or sharing has not been initialized yet.", + RestStatus.NOT_FOUND + ) + ); return; } for (String accessLevel : shareWith.accessLevels()) { @@ -718,6 +787,18 @@ public void patchSharingInfo( // Apply patch and update the document sharingInfoListener.whenComplete(sharingInfo -> { + if (sharingInfo == null) { + LOGGER.warn("No sharing record found for resource {} in index {}", resourceId, resourceIndex); + listener.onFailure( + new OpenSearchStatusException( + "No sharing record found for resource [" + + resourceId + + "]. The resource may not exist or sharing has not been initialized yet.", + RestStatus.NOT_FOUND + ) + ); + return; + } if (add != null) { sharingInfo.getShareWith().add(add); } @@ -922,6 +1003,91 @@ private void executeAllSearchRequest( }, listener::onFailure); } + /** + * Fetches resource-sharing records for a given set of resource IDs (pre-resolved). + * Used when the caller has already resolved the full set of accessible IDs (e.g. after parent-hierarchy union). + * + * @param resourceIndex the resource index + * @param resourceType the resource type + * @param user the requesting user (used for can_share check) + * @param resourceIds the pre-resolved set of accessible resource IDs + * @param listener listener to collect and return the sharing records + */ + public void fetchResourceSharingRecordsByIds( + String resourceIndex, + String resourceType, + User user, + Set resourceIds, + ActionListener> listener + ) { + if (resourceIds == null || resourceIds.isEmpty()) { + listener.onResponse(Collections.emptySet()); + return; + } + + final String resourceSharingIndex = getSharingIndex(resourceIndex); + final ThreadContext.StoredContext stored = this.threadPool.getThreadContext().stashContext(); + + final List idList = new ArrayList<>(resourceIds); + final int BATCH = 1000; + final Set out = ConcurrentHashMap.newKeySet(); + final AtomicInteger cursor = new AtomicInteger(0); + final String[] includes = { "resource_id", "resource_type", "created_by", "share_with" }; + + final AtomicReference submitNextRef = new AtomicReference<>(); + submitNextRef.set(() -> { + int start = cursor.getAndAdd(BATCH); + if (start >= idList.size()) { + stored.restore(); + listener.onResponse(out); + return; + } + int end = Math.min(start + BATCH, idList.size()); + + final MultiGetRequest mget = new MultiGetRequest(); + final FetchSourceContext fsc = new FetchSourceContext(true, includes, Strings.EMPTY_ARRAY); + for (int i = start; i < end; i++) { + mget.add(new MultiGetRequest.Item(resourceSharingIndex, idList.get(i)).fetchSourceContext(fsc)); + } + + client.multiGet(mget, ActionListener.wrap(mres -> { + for (MultiGetItemResponse item : mres.getResponses()) { + if (item == null || item.isFailed()) continue; + final GetResponse gr = item.getResponse(); + if (gr == null || !gr.isExists()) continue; + + try ( + XContentParser p = XContentHelper.createParser( + NamedXContentRegistry.EMPTY, + THROW_UNSUPPORTED_OPERATION, + gr.getSourceAsBytesRef(), + XContentType.JSON + ) + ) { + p.nextToken(); + ResourceSharing rs = ResourceSharing.fromXContent(p); + // skip records belonging to a different resource type (can happen when multiple + // types share the same resource index) + if (!resourceType.equals(rs.getResourceType())) continue; + 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); + } + } + submitNextRef.get().run(); + }, e -> { + try { + listener.onFailure(e); + } finally { + stored.restore(); + } + })); + }); + + submitNextRef.get().run(); + } + /** * Fetches resource-sharing records for this user for a given resource-index. * Executes in 2 steps: @@ -958,7 +1124,7 @@ public void fetchAccessibleResourceSharingRecords( final int BATCH = 1000; // tune if docs are large final Set out = ConcurrentHashMap.newKeySet(); final AtomicInteger cursor = new AtomicInteger(0); - final String[] includes = { "resource_id", "created_by", "share_with" }; + final String[] includes = { "resource_id", "resource_type", "created_by", "share_with" }; // self-referencing lambda for batch run final AtomicReference submitNextRef = new AtomicReference<>(); @@ -996,6 +1162,8 @@ public void fetchAccessibleResourceSharingRecords( ) { p.nextToken(); ResourceSharing rs = ResourceSharing.fromXContent(p); + // skip records belonging to a different resource type + if (!resourceType.equals(rs.getResourceType())) continue; boolean canShare = canUserShare(user, /* isAdmin */ false, rs, resourceType); out.add(new SharingRecord(rs, canShare)); } catch (Exception ex) { 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 d91e1aa78e..2c7d007ace 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 @@ -255,7 +255,19 @@ private ValidationResult loadCurrentSharingInfo(RestRequest type = typeToDefaultAccessLevel.keySet().iterator().next(); } - results.add(new SourceDoc(id, username, backendRoles, type)); + // Extract parent ID if the provider declares a parentIdField + String parentId = null; + if (type != null) { + ResourceProvider hitProvider = resourcePluginInfo.getResourceProvider(type); + if (hitProvider != null && hitProvider.parentIdField() != null) { + parentId = rec.at("/" + hitProvider.parentIdField().replace(".", "/")).asText(null); + if (parentId != null && parentId.isEmpty()) { + parentId = null; + } + } + } + + results.add(new SourceDoc(id, username, backendRoles, type, parentId)); } // 4) fetch next batch SearchScrollRequest scrollRequest = new SearchScrollRequest(scrollId).scroll(scroll); @@ -356,13 +368,16 @@ private ValidationResult createNewSharingRecords(ValidationResul failureCount.getAndIncrement(); migrationStatsLatch.countDown(); }); - - ResourceSharing sharingInfo = ResourceSharing.builder() + // Build the ResourceSharing record, including parent hierarchy if the provider declares one + ResourceSharing.Builder sharingBuilder = ResourceSharing.builder() .resourceId(resourceId) .createdBy(createdBy) .shareWith(shareWith) - .resourceType(provider.resourceType()) - .build(); + .resourceType(provider.resourceType()); + if (doc.parentId != null && provider.parentType() != null) { + sharingBuilder.parentId(doc.parentId).parentType(provider.parentType()); + } + ResourceSharing sharingInfo = sharingBuilder.build(); sharingIndexHandler.indexResourceSharing(sourceInfo.sourceIndex, sharingInfo, listener); } catch (Exception e) { @@ -507,7 +522,7 @@ public Map allowedKeysWithCo }; } - record SourceDoc(String resourceId, String username, List backendRoles, String type) { + record SourceDoc(String resourceId, String username, List backendRoles, String type, String parentId) { } record ValidationResultArg(String sourceIndex, String defaultOwnerName, Map typeToDefaultAccessLevel, List< diff --git a/src/main/java/org/opensearch/security/resources/sharing/ResourceSharing.java b/src/main/java/org/opensearch/security/resources/sharing/ResourceSharing.java index 4e58d784f5..6a93156301 100644 --- a/src/main/java/org/opensearch/security/resources/sharing/ResourceSharing.java +++ b/src/main/java/org/opensearch/security/resources/sharing/ResourceSharing.java @@ -56,6 +56,20 @@ public class ResourceSharing implements ToXContentFragment, NamedWriteable { */ private String resourceType; + /** + * The type of the parent resource + * + * Nullable + */ + private String parentType; + + /** + * The unique identifier of the parent resource + * + * Nullable + */ + private String parentId; + /** * Information about who created the resource */ @@ -69,6 +83,8 @@ public class ResourceSharing implements ToXContentFragment, NamedWriteable { private ResourceSharing(Builder b) { this.resourceId = b.resourceId; this.resourceType = b.resourceType; + this.parentType = b.parentType; + this.parentId = b.parentId; this.createdBy = b.createdBy; this.shareWith = b.shareWith; } @@ -97,6 +113,18 @@ public ShareWith getShareWith() { return shareWith; } + public String getResourceType() { + return resourceType; + } + + public String getParentType() { + return parentType; + } + + public String getParentId() { + return parentId; + } + public void share(String accessLevel, Recipients target) { if (shareWith == null) { Map recs = new HashMap<>(); @@ -141,13 +169,15 @@ public boolean equals(Object o) { ResourceSharing that = (ResourceSharing) o; return Objects.equals(resourceId, that.resourceId) && Objects.equals(resourceType, that.resourceType) + && Objects.equals(parentType, that.parentType) + && Objects.equals(parentId, that.parentId) && Objects.equals(createdBy, that.createdBy) && Objects.equals(shareWith, that.shareWith); } @Override public int hashCode() { - return Objects.hash(resourceId, resourceType, createdBy, shareWith); + return Objects.hash(resourceId, resourceType, parentType, parentId, createdBy, shareWith); } @Override @@ -159,6 +189,12 @@ public String toString() { + ", resourceType='" + resourceType + '\'' + + ", parentType='" + + parentType + + '\'' + + ", parentId='" + + parentId + + '\'' + ", createdBy=" + createdBy + ", shareWith=" @@ -175,6 +211,8 @@ public String getWriteableName() { public void writeTo(StreamOutput out) throws IOException { out.writeString(resourceId); out.writeString(resourceType); + out.writeOptionalString(parentType); + out.writeOptionalString(parentId); createdBy.writeTo(out); if (shareWith != null) { out.writeBoolean(true); @@ -188,6 +226,12 @@ public void writeTo(StreamOutput out) throws IOException { public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { builder.startObject().field("resource_id", resourceId).field("resource_type", resourceType).field("created_by"); createdBy.toXContent(builder, params); + if (parentType != null) { + builder.field("parent_type", parentType); + } + if (parentId != null) { + builder.field("parent_id", parentId); + } if (shareWith != null) { builder.field("share_with"); shareWith.toXContent(builder, params); @@ -200,7 +244,6 @@ public static ResourceSharing fromXContent(XContentParser parser) throws IOExcep String currentFieldName = null; XContentParser.Token token; - while ((token = parser.nextToken()) != XContentParser.Token.END_OBJECT) { if (token == XContentParser.Token.FIELD_NAME) { currentFieldName = parser.currentName(); @@ -216,6 +259,20 @@ public static ResourceSharing fromXContent(XContentParser parser) throws IOExcep b.resourceType(parser.text()); } break; + case "parent_type": + if (token == XContentParser.Token.VALUE_NULL) { + b.parentType(null); + } else { + b.parentType(parser.text()); + } + break; + case "parent_id": + if (token == XContentParser.Token.VALUE_NULL) { + b.parentId(null); + } else { + b.parentId(parser.text()); + } + break; case "created_by": b.createdBy(CreatedBy.fromXContent(parser)); break; @@ -380,6 +437,8 @@ public List getAllPrincipals() { public static final class Builder { private String resourceId; private String resourceType; + private String parentType; + private String parentId; private CreatedBy createdBy; private ShareWith shareWith; @@ -393,6 +452,16 @@ public Builder resourceType(String resourceType) { return this; } + public Builder parentType(String parentType) { + this.parentType = parentType; + return this; + } + + public Builder parentId(String parentId) { + this.parentId = parentId; + return this; + } + public Builder createdBy(CreatedBy createdBy) { this.createdBy = createdBy; return this;