Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
dc06d08
WIP on Add Resource to Group
cwperks Oct 22, 2025
0d99679
WIP on Hierarchy
cwperks Oct 24, 2025
080fbe7
Store referential information in ResourceSharing document
cwperks Oct 24, 2025
2bdfacd
Show hierarchy working
cwperks Oct 25, 2025
11d3ea3
Use non-deprecated interface
cwperks Oct 25, 2025
33d09c8
Fix code hygiene issue
cwperks Oct 25, 2025
de14df5
Merge branch 'main' into multiple-resource-tests
cwperks Oct 27, 2025
ad45784
Fix few tests
cwperks Oct 27, 2025
2a1f18a
Fix more tests
cwperks Oct 27, 2025
87a93c0
Fix tests
cwperks Oct 28, 2025
e1241d8
Merge branch 'main' into multiple-resource-tests
cwperks Nov 4, 2025
ea30a61
Merge branch 'main' into multiple-resource-tests
cwperks Nov 19, 2025
7067c3d
Remove redundancy post merge conflict resolved
cwperks Nov 19, 2025
c59295f
Merge branch 'main' into multiple-resource-tests
cwperks Nov 20, 2025
2c8568b
Fix conflicts
cwperks Nov 20, 2025
a86433b
Fix conflicts
cwperks Nov 20, 2025
64a8670
Merge branch 'main' into multiple-resource-tests
cwperks Mar 17, 2026
01e5da1
Add more tests
cwperks Mar 17, 2026
a313524
Merge branch 'main' into multiple-resource-tests
cwperks Mar 21, 2026
b9a5842
Add to CHANGELOG
cwperks Mar 21, 2026
112fcd7
Add support for parent_id in resource sharing migrate API
cwperks Mar 21, 2026
7bb6cb4
Merge branch 'parent-type-migration' into multiple-resource-tests
cwperks Mar 21, 2026
714128f
Make child resources searchable if shared by a parent
cwperks Mar 21, 2026
24cc2d0
Handle missing sharing record
cwperks Mar 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 + "\"}";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 = """
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Object> resources = (List<Object>) response.bodyAsMap().get("resources");
assertThat("Expected 1 child resource via parent inheritance", resources.size(), equalTo(1));

Map<String, Object> resource = (Map<String, Object>) 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<Object> resources = (List<Object>) response.bodyAsMap().get("resources");
assertThat("Expected 2 child resources (direct + inherited)", resources.size(), equalTo(2));

List<String> ids = resources.stream().map(r -> (String) ((Map<String, Object>) 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<Object> resources = (List<Object>) response.bodyAsMap().get("resources");
assertThat("Expected 0 resources when user has no parent access", resources.size(), equalTo(0));
}
}
}
Loading
Loading