Skip to content
Draft
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
f25ac34
WIP on tracking principals on the resource doc
cwperks Aug 26, 2025
746b239
Update resource visibility in indexResourceSharing
cwperks Aug 27, 2025
3b1f0fd
Update visibility when sharing
cwperks Aug 27, 2025
0bfc778
Update in revoke as well
cwperks Aug 27, 2025
4aa049b
Keep track of list of principals for which sharable resource is visib…
cwperks Aug 27, 2025
8bff6f3
Add to CHANGELOG
cwperks Aug 27, 2025
6ef6d1f
Merge branch 'main' into track-principals
cwperks Aug 27, 2025
3d90774
Merge branch 'main' into track-principals
cwperks Aug 28, 2025
5f6e254
Merge branch 'main' into track-principals
cwperks Aug 29, 2025
fee622b
Rename to all_shared_principals
cwperks Aug 29, 2025
373547e
WIP on RP DLS solution
cwperks Aug 29, 2025
9cae246
WIP on resource restrictions
cwperks Aug 29, 2025
2cba16e
Get all resource plugin tests working
cwperks Aug 30, 2025
21ae888
Fix tests
cwperks Aug 30, 2025
2613852
Only on SearchRequest
cwperks Aug 30, 2025
3c9ceec
Only apply DLS if all indices in the request are resource indices
cwperks Sep 2, 2025
f532d8e
Handle publicly visible
cwperks Sep 2, 2025
f539295
Fix CHANGELOG
cwperks Sep 2, 2025
ca58fa1
Merge branch 'main' into track-principals-dls
cwperks Sep 2, 2025
956487d
Merge branch 'main' into track-principals-dls
cwperks Sep 5, 2025
8c58c37
Remove sysouts
cwperks Sep 5, 2025
7ceafa8
Address review feedback
cwperks Sep 5, 2025
f6519f0
Re-add to client
cwperks Sep 5, 2025
ddb9a7c
Use pluginClient in get
cwperks Sep 5, 2025
7e10713
Change to SecurityRoles
cwperks Sep 5, 2025
a3b3511
Add new test for sharing with roles
cwperks Sep 5, 2025
b663a0e
Re-arrange
cwperks Sep 5, 2025
a86c2a8
Fix tests
cwperks Sep 6, 2025
7a1207c
Fix test
cwperks Sep 7, 2025
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 @@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),

### Enhancements

- [Resource Sharing] Use DLS to automatically filter sharable resources for authenticated user based on `all_shared_principals` ([#5600](https://github.com/opensearch-project/security/pull/5600))
- [Resource Sharing] Keep track of tenant for sharable resources by persisting user requested tenant with sharing info ([#5588](https://github.com/opensearch-project/security/pull/5588))

### Bug Fixes
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@
import org.junit.runner.RunWith;

import org.opensearch.Version;
import org.opensearch.painless.PainlessModulePlugin;
import org.opensearch.plugins.PluginInfo;
import org.opensearch.sample.SampleResourcePlugin;
import org.opensearch.security.OpenSearchSecurityPlugin;
Expand Down Expand Up @@ -64,7 +63,6 @@ public class MigrateApiTests {

@ClassRule
public static LocalCluster cluster = new LocalCluster.Builder().clusterManager(ClusterManager.DEFAULT)
.plugin(PainlessModulePlugin.class)
.plugin(
new PluginInfo(
SampleResourcePlugin.class.getName(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@
import org.junit.runner.RunWith;

import org.opensearch.Version;
import org.opensearch.painless.PainlessModulePlugin;
import org.opensearch.plugins.PluginInfo;
import org.opensearch.sample.SampleResourcePlugin;
import org.opensearch.security.OpenSearchSecurityPlugin;
Expand Down Expand Up @@ -65,7 +64,6 @@ public class SecurityDisabledTests {
false
)
)
.plugin(PainlessModulePlugin.class)
.loadConfigurationIntoIndex(false)
.nodeSettings(Map.of("plugins.security.disabled", true, "plugins.security.ssl.http.enabled", false))
.build();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@
import org.opensearch.common.xcontent.XContentFactory;
import org.opensearch.core.xcontent.ToXContent;
import org.opensearch.core.xcontent.XContentBuilder;
import org.opensearch.painless.PainlessModulePlugin;
import org.opensearch.plugins.PluginInfo;
import org.opensearch.sample.SampleResourcePlugin;
import org.opensearch.security.OpenSearchSecurityPlugin;
Expand Down Expand Up @@ -113,7 +112,6 @@ public static LocalCluster newCluster(boolean featureEnabled, boolean systemInde
false
)
)
.plugin(PainlessModulePlugin.class)
.anonymousAuth(true)
.authc(AUTHC_HTTPBASIC_INTERNAL)
.users(USER_ADMIN, FULL_ACCESS_USER, LIMITED_ACCESS_USER, NO_ACCESS_USER)
Expand Down Expand Up @@ -462,13 +460,21 @@ private void assertPostSearch(
) {
try (TestRestClient client = cluster.getRestClient(user)) {
TestRestClient.HttpResponse response = client.postJson(endpoint, searchPayload);
System.out.println("User: " + user);
System.out.println("Search response: " + response.getBody());
response.assertStatusCode(status);
if (status == HttpStatus.SC_OK) {
Map<String, Object> hits = (Map<String, Object>) response.bodyAsMap().get("hits");
assertThat(((List<String>) hits.get("hits")).size(), is(equalTo(expectedHits)));
assertThat(response.getBody(), containsString(expectedResourceName));
}
}

try (TestRestClient client = cluster.getRestClient(cluster.getAdminCertificate())) {
TestRestClient.HttpResponse response = client.postJson(endpoint, searchPayload);
System.out.println("SuperAdmin: " + user);
System.out.println("Search response: " + response.getBody());
}
}

public void assertDirectGetAll(TestSecurityConfig.User user, int status, String expectedResourceName) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@

import org.opensearch.Version;
import org.opensearch.core.rest.RestStatus;
import org.opensearch.painless.PainlessModulePlugin;
import org.opensearch.plugins.PluginInfo;
import org.opensearch.sample.SampleResourcePlugin;
import org.opensearch.security.OpenSearchSecurityPlugin;
Expand Down Expand Up @@ -47,7 +46,6 @@ public class SecurePluginTests {
.anonymousAuth(false)
.authc(AUTHC_DOMAIN)
.users(USER_ADMIN)
.plugin(PainlessModulePlugin.class)
.plugin(
new PluginInfo(
SampleResourcePlugin.class.getName(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@

import java.io.IOException;
import java.util.Arrays;
import java.util.Collections;
import java.util.Set;
import java.util.function.Consumer;
import java.util.stream.Collectors;
Expand All @@ -20,7 +19,6 @@
import org.opensearch.action.search.SearchRequest;
import org.opensearch.action.support.ActionFilters;
import org.opensearch.action.support.HandledTransportAction;
import org.opensearch.common.Nullable;
import org.opensearch.common.inject.Inject;
import org.opensearch.common.util.concurrent.ThreadContext;
import org.opensearch.common.xcontent.LoggingDeprecationHandler;
Expand All @@ -31,13 +29,11 @@
import org.opensearch.core.xcontent.XContentParser;
import org.opensearch.index.query.QueryBuilders;
import org.opensearch.sample.SampleResource;
import org.opensearch.sample.client.ResourceSharingClientAccessor;
import org.opensearch.sample.resource.actions.rest.get.GetResourceAction;
import org.opensearch.sample.resource.actions.rest.get.GetResourceRequest;
import org.opensearch.sample.resource.actions.rest.get.GetResourceResponse;
import org.opensearch.search.SearchHit;
import org.opensearch.search.builder.SearchSourceBuilder;
import org.opensearch.security.spi.resources.client.ResourceSharingClient;
import org.opensearch.tasks.Task;
import org.opensearch.transport.TransportService;
import org.opensearch.transport.client.node.NodeClient;
Expand All @@ -61,49 +57,18 @@ public GetResourceTransportAction(TransportService transportService, ActionFilte

@Override
protected void doExecute(Task task, GetResourceRequest request, ActionListener<GetResourceResponse> listener) {
ResourceSharingClient client = ResourceSharingClientAccessor.getInstance().getResourceSharingClient();
String resourceId = request.getResourceId();

if (Strings.isNullOrEmpty(resourceId)) {
fetchAllResources(listener, client);
fetchAllResources(listener);
} else {
fetchResourceById(resourceId, listener);
}
}

private void fetchAllResources(ActionListener<GetResourceResponse> listener, ResourceSharingClient client) {
if (client == null) {
fetchResourcesByIds(null, listener);
return;
}

client.getAccessibleResourceIds(RESOURCE_INDEX_NAME, ActionListener.wrap(ids -> {
if (ids.isEmpty()) {
listener.onResponse(new GetResourceResponse(Collections.emptySet()));
} else {
fetchResourcesByIds(ids, listener);
}
}, listener::onFailure));
}

private void fetchResourceById(String resourceId, ActionListener<GetResourceResponse> listener) {
withThreadContext(stashed -> {
GetRequest req = new GetRequest(RESOURCE_INDEX_NAME, resourceId);
nodeClient.get(req, ActionListener.wrap(resp -> {
if (resp.isSourceEmpty()) {
listener.onFailure(new ResourceNotFoundException("Resource " + resourceId + " not found."));
} else {
SampleResource resource = parseResource(resp.getSourceAsString());
listener.onResponse(new GetResourceResponse(Set.of(resource)));
}
}, listener::onFailure));
});
}

private void fetchResourcesByIds(@Nullable Set<String> ids, ActionListener<GetResourceResponse> listener) {
private void fetchAllResources(ActionListener<GetResourceResponse> listener) {
withThreadContext(stashed -> {
SearchSourceBuilder ssb = new SearchSourceBuilder().size(1000)
.query(ids == null ? QueryBuilders.matchAllQuery() : QueryBuilders.idsQuery().addIds(ids.toArray(String[]::new)));
SearchSourceBuilder ssb = new SearchSourceBuilder().size(1000).query(QueryBuilders.matchAllQuery());

SearchRequest req = new SearchRequest(RESOURCE_INDEX_NAME).source(ssb);
nodeClient.search(req, ActionListener.wrap(searchResponse -> {
Expand All @@ -124,6 +89,20 @@ private void fetchResourcesByIds(@Nullable Set<String> ids, ActionListener<GetRe
});
}

private void fetchResourceById(String resourceId, ActionListener<GetResourceResponse> listener) {
withThreadContext(stashed -> {
GetRequest req = new GetRequest(RESOURCE_INDEX_NAME, resourceId);
nodeClient.get(req, ActionListener.wrap(resp -> {
if (resp.isSourceEmpty()) {
listener.onFailure(new ResourceNotFoundException("Resource " + resourceId + " not found."));
} else {
SampleResource resource = parseResource(resp.getSourceAsString());
listener.onResponse(new GetResourceResponse(Set.of(resource)));
}
}, listener::onFailure));
});
}

private SampleResource parseResource(String json) throws IOException {
try (
XContentParser parser = XContentType.JSON.xContent()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@

package org.opensearch.sample.resource.actions.transport;

import java.util.Set;

import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;

Expand All @@ -18,99 +16,30 @@
import org.opensearch.action.support.ActionFilters;
import org.opensearch.action.support.HandledTransportAction;
import org.opensearch.common.inject.Inject;
import org.opensearch.common.util.concurrent.ThreadContext;
import org.opensearch.core.action.ActionListener;
import org.opensearch.index.query.BoolQueryBuilder;
import org.opensearch.index.query.QueryBuilder;
import org.opensearch.index.query.QueryBuilders;
import org.opensearch.sample.client.ResourceSharingClientAccessor;
import org.opensearch.sample.resource.actions.rest.search.SearchResourceAction;
import org.opensearch.search.builder.SearchSourceBuilder;
import org.opensearch.security.spi.resources.client.ResourceSharingClient;
import org.opensearch.sample.utils.PluginClient;
import org.opensearch.tasks.Task;
import org.opensearch.transport.TransportService;
import org.opensearch.transport.client.Client;

import static org.opensearch.sample.utils.Constants.RESOURCE_INDEX_NAME;

/**
* Transport action for searching sample resources
*/
public class SearchResourceTransportAction extends HandledTransportAction<SearchRequest, SearchResponse> {
private static final Logger log = LogManager.getLogger(SearchResourceTransportAction.class);

private final TransportService transportService;
private final Client nodeClient;
private final ResourceSharingClient resourceSharingClient;
private final PluginClient pluginClient;

@Inject
public SearchResourceTransportAction(TransportService transportService, ActionFilters actionFilters, Client nodeClient) {
public SearchResourceTransportAction(TransportService transportService, ActionFilters actionFilters, PluginClient pluginClient) {
super(SearchResourceAction.NAME, transportService, actionFilters, SearchRequest::new);
this.transportService = transportService;
this.nodeClient = nodeClient;
this.resourceSharingClient = ResourceSharingClientAccessor.getInstance().getResourceSharingClient();
this.pluginClient = pluginClient;
}

@Override
protected void doExecute(Task task, SearchRequest request, ActionListener<SearchResponse> listener) {
ThreadContext threadContext = transportService.getThreadPool().getThreadContext();
try (ThreadContext.StoredContext ignore = threadContext.stashContext()) {
if (resourceSharingClient == null) {
nodeClient.search(request, listener);
return;
}
// if the resource sharing feature is enabled, we only allow search from documents that requested user has access to
searchFilteredIds(request, listener);
} catch (Exception e) {
log.error("Failed to search resources", e);
listener.onFailure(e);
}
}

private void searchFilteredIds(SearchRequest request, ActionListener<SearchResponse> listener) {
SearchSourceBuilder src = request.source() != null ? request.source() : new SearchSourceBuilder();
ActionListener<Set<String>> idsListener = ActionListener.wrap(resourceIds -> {
mergeAccessibleFilter(src, resourceIds);
request.source(src);
nodeClient.search(request, listener);
}, e -> {
mergeAccessibleFilter(src, Set.of());
request.source(src);
nodeClient.search(request, listener);
});

resourceSharingClient.getAccessibleResourceIds(RESOURCE_INDEX_NAME, idsListener);
}

private void mergeAccessibleFilter(SearchSourceBuilder src, Set<String> resourceIds) {
QueryBuilder accessQB;

if (resourceIds == null || resourceIds.isEmpty()) {
// match nothing
accessQB = QueryBuilders.boolQuery().mustNot(QueryBuilders.matchAllQuery());
} else {
// match only from a provided set of resources
accessQB = QueryBuilders.idsQuery().addIds(resourceIds.toArray(new String[0]));
}

QueryBuilder existing = src.query();
if (existing == null) {
// No existing query → just the filter
src.query(QueryBuilders.boolQuery().filter(accessQB));
return;
}
pluginClient.search(request, listener);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we need to update plugin-dev documentation to declare plugin as IdentityAwarePlugin.


if (existing instanceof BoolQueryBuilder) {
// Reuse existing bool: just add a filter clause
((BoolQueryBuilder) existing).filter(accessQB);
src.query(existing);
} else {
// Preserve existing scoring by keeping it in MUST, add our filter
BoolQueryBuilder merged = QueryBuilders.boolQuery()
.must(existing) // keep original query semantics/scoring
.filter(accessQB); // filter results
src.query(merged);
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@

package org.opensearch.security.spi.resources.client;

import java.util.Set;

import org.opensearch.core.action.ActionListener;
import org.opensearch.security.spi.resources.sharing.ResourceSharing;
import org.opensearch.security.spi.resources.sharing.ShareWith;
Expand Down Expand Up @@ -47,11 +45,4 @@ public interface ResourceSharingClient {
* @param listener The listener to be notified with the updated ResourceSharing document.
*/
void revoke(String resourceId, String resourceIndex, ShareWith target, ActionListener<ResourceSharing> listener);

/**
* Lists resourceIds of all shareable resources accessible by the current user.
* @param resourceIndex The index containing the resources.
* @param listener The listener to be notified with the set of accessible resources.
*/
void getAccessibleResourceIds(String resourceIndex, ActionListener<Set<String>> listener);
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense. I'll add this back in until hierarchy is properly supported.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ML uses modelGroupIds to filter search for models. We need to keep this here until we support hierarchy.

}
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@
package org.opensearch.security.spi.resources.sharing;

import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
Expand Down Expand Up @@ -267,4 +269,47 @@ public Set<String> fetchAccessLevels(Recipient recipientType, Set<String> entiti
}
return matchingGroups;
}

/**
* Returns all principals (users, roles, backend_roles) that have access to this resource,
* including the creator and all shared recipients, formatted with appropriate prefixes.
*
* @return List of principals in format ["user:username", "role:rolename", "backend:backend_role"]
*/
public List<String> getAllPrincipals() {
List<String> principals = new ArrayList<>();

// Add creator
if (createdBy != null) {
principals.add("user:" + createdBy.getUsername());
}

// Add shared recipients
if (shareWith != null) {
// shared with at any access level
for (Recipients recipients : shareWith.getSharingInfo().values()) {
Map<Recipient, Set<String>> recipientMap = recipients.getRecipients();

// Add users
Set<String> users = recipientMap.getOrDefault(Recipient.USERS, Collections.emptySet());
for (String user : users) {
principals.add("user:" + user);
}

// Add roles
Set<String> roles = recipientMap.getOrDefault(Recipient.ROLES, Collections.emptySet());
for (String role : roles) {
principals.add("role:" + role);
}

// Add backend roles
Set<String> backendRoles = recipientMap.getOrDefault(Recipient.BACKEND_ROLES, Collections.emptySet());
for (String backendRole : backendRoles) {
principals.add("backend:" + backendRole);
}
}
}

return principals;
}
}
Loading
Loading