Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 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
4a4f36b
Add plugin dev documentation
cwperks Sep 12, 2025
b2e5423
Merge branch 'main' into track-principals-dls
cwperks Sep 16, 2025
c19d4a1
Address PR feedback
cwperks Sep 22, 2025
4f82726
Merge branch 'main' into track-principals-dls
cwperks Sep 22, 2025
683f009
Address more feedback
cwperks Sep 22, 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 list of principals for which sharable resource is visible for searching ([#5596](https://github.com/opensearch-project/security/pull/5596))
- [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))
- [SecurityPlugin Health Check] Add AuthZ initialization completion check in health check API [(#5626)](https://github.com/opensearch-project/security/pull/5626)
Expand Down
51 changes: 50 additions & 1 deletion RESOURCE_SHARING_AND_ACCESS_CONTROL.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,56 @@ Each **action-group** entry contains the following access definitions:
}
```

## **4. Using the Client for Access Control**
## **4a. Filtering results based on authenticated user**

When performing a search on a resource index, Security will automatically filter the results based on the logged in user without
a plugin having to be conscious of who the logged in user is. One of the goals of Resource Sharing and Authorization is to remove
reasons for why plugins must rely on [common-utils](https://github.com/opensearch-project/common-utils/) today.

In order for this implicit filtering to work, plugins must declare themselves as an `IdentityAwarePlugin` and use their assigned
subject to run privileged operations against the resource index. See [Geospatial PR](https://github.com/opensearch-project/geospatial/pull/715) for an example
of how to make the switch for system index access. In future versions of OpenSearch, it will be required for plugins to replace usages of ThreadContext.stashContext to
access system indices.

Behind-the-scenes, Security will filter the resultset based on who the authenticated user is.

To accomplish this, Security will keep track of the list of principals that a particular resource is visible to as a new field on the resource
metadata itself in your plugin's resource index.

For example:

```
{
"name": "sharedDashboard",
"description": "A dashboard resource that is shared with multiple principals",
"type": "dashboard",
"created_at": "2025-09-02T14:30:00Z",
"attributes": {
"category": "analytics",
"sensitivity": "internal"
},
"all_shared_principals": [
"user:resource_sharing_test_user_alice",
"user:resource_sharing_test_user_bob",
"role:analytics_team",
"role:all_access",
"role:auditor"
]
}
```

For some high-level pseudo code for a plugin writing an API to search or list resources:

1. Plugin will expose an API to list resources. For example `/_plugins/_reports/definitions` is an API that reporting plugin exposes to list report definitons.
2. The plugin implementing search or list, will perform a plugin system-level request to search on the resource index. In the reporting plugin example, that would be a search on `.opendistro-reports-definitions`
3. Security will apply a DLS Filter behind-the-scenes to limit the result set based on the logged in user.

For the example above, imagine the authenticated user has `username: resource_sharing_test_user_alice` and `role: analytics_team`

Resource sharing will limit the resultset to documents that have either `user:resource_sharing_test_user_alice`, `role:analytics_team` or `user:*`
in the `all_shared_principals` section. Note that `user:*` is the convention used for publicly visible.

## **4b. Using the Client for Access Control**

[`opensearch-security-spi` README.md](./spi/README.md) is a great resource to learn more about the components of SPI and how to set up.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,13 +75,11 @@ public final class TestUtils {
public static final TestSecurityConfig.ActionGroup sampleReadOnlyAG = new TestSecurityConfig.ActionGroup(
"sample_plugin_index_read_access",
TestSecurityConfig.ActionGroup.Type.INDEX,
"indices:data/read*",
"cluster:admin/sample-resource-plugin/get"
);
public static final TestSecurityConfig.ActionGroup sampleAllAG = new TestSecurityConfig.ActionGroup(
"sample_plugin_index_all_access",
TestSecurityConfig.ActionGroup.Type.INDEX,
"indices:*",
"cluster:admin/sample-resource-plugin/*",
"cluster:admin/security/resource/share"
);
Expand Down Expand Up @@ -163,6 +161,18 @@ public static String revokeAccessPayload(String user, String accessLevel) {

}

public static String shareWithRolePayload(String role, String accessLevel) {
return """
{
"share_with": {
"%s" : {
"roles": ["%s"]
}
}
}
""".formatted(accessLevel, role);
}

static String migrationPayload_valid() {
return """
{
Expand Down Expand Up @@ -565,6 +575,22 @@ public void assertApiRevoke(
assertRevoke(SAMPLE_RESOURCE_REVOKE_ENDPOINT + "/" + resourceId, user, target, accessLevel, status);
}

public void assertApiShareByRole(
String resourceId,
TestSecurityConfig.User user,
String targetRole,
String accessLevel,
int status
) {
try (TestRestClient client = cluster.getRestClient(user)) {
TestRestClient.HttpResponse response = client.postJson(
SAMPLE_RESOURCE_SHARE_ENDPOINT + "/" + resourceId,
shareWithRolePayload(targetRole, accessLevel)
);
response.assertStatusCode(status);
}
}

private void assertRevoke(
String endpoint,
TestSecurityConfig.User user,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -134,4 +134,33 @@ public void sameUser_multipleLevels() {
assertFullAccess(LIMITED_ACCESS_USER);
}

private String getActualRoleName(TestSecurityConfig.User user, String baseRoleName) {
return "user_" + user.getName() + "__" + baseRoleName;
}

@Test
public void multipleRoles_multipleLevels() {
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
api.assertApiShareByRole(resourceId, USER_ADMIN, fullAccessUserRole, sampleReadOnlyAG.name(), HttpStatus.SC_OK);
api.assertApiShareByRole(resourceId, USER_ADMIN, limitedAccessUserRole, sampleAllAG.name(), HttpStatus.SC_OK);
api.awaitSharingEntry(resourceId, fullAccessUserRole);
api.awaitSharingEntry(resourceId, limitedAccessUserRole);

// 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
api.assertApiShareByRole(resourceId, LIMITED_ACCESS_USER, fullAccessUserRole, sampleAllAG.name(), HttpStatus.SC_OK);
api.awaitSharingEntry(resourceId, fullAccessUserRole);

// 4. FULL_ACCESS_USER now has full-access to admin's resource
assertFullAccess(FULL_ACCESS_USER);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,15 @@

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;

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.Nullable;
import org.opensearch.common.inject.Inject;
import org.opensearch.common.util.concurrent.ThreadContext;
import org.opensearch.common.xcontent.LoggingDeprecationHandler;
import org.opensearch.common.xcontent.XContentType;
import org.opensearch.core.action.ActionListener;
Expand All @@ -31,16 +27,14 @@
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.sample.utils.PluginClient;
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;

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

Expand All @@ -49,79 +43,56 @@
*/
public class GetResourceTransportAction extends HandledTransportAction<GetResourceRequest, GetResourceResponse> {

private final TransportService transportService;
private final NodeClient nodeClient;
private final PluginClient pluginClient;

@Inject
public GetResourceTransportAction(TransportService transportService, ActionFilters actionFilters, NodeClient nodeClient) {
public GetResourceTransportAction(TransportService transportService, ActionFilters actionFilters, PluginClient pluginClient) {
super(GetResourceAction.NAME, transportService, actionFilters, GetResourceRequest::new);
this.transportService = transportService;
this.nodeClient = nodeClient;
this.pluginClient = pluginClient;
}

@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;
}
private void fetchAllResources(ActionListener<GetResourceResponse> listener) {
SearchSourceBuilder ssb = new SearchSourceBuilder().size(1000).query(QueryBuilders.matchAllQuery());

client.getAccessibleResourceIds(RESOURCE_INDEX_NAME, ActionListener.wrap(ids -> {
if (ids.isEmpty()) {
listener.onResponse(new GetResourceResponse(Collections.emptySet()));
SearchRequest req = new SearchRequest(RESOURCE_INDEX_NAME).source(ssb);
pluginClient.search(req, ActionListener.wrap(searchResponse -> {
SearchHit[] hits = searchResponse.getHits().getHits();
if (hits.length == 0) {
listener.onFailure(new ResourceNotFoundException("No resources found in index: " + RESOURCE_INDEX_NAME));
} else {
fetchResourcesByIds(ids, listener);
Set<SampleResource> resources = Arrays.stream(hits).map(hit -> {
try {
return parseResource(hit.getSourceAsString());
} catch (IOException e) {
throw new RuntimeException(e);
}
}).collect(Collectors.toSet());
listener.onResponse(new GetResourceResponse(resources));
}
}, 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) {
withThreadContext(stashed -> {
SearchSourceBuilder ssb = new SearchSourceBuilder().size(1000)
.query(ids == null ? QueryBuilders.matchAllQuery() : QueryBuilders.idsQuery().addIds(ids.toArray(String[]::new)));

SearchRequest req = new SearchRequest(RESOURCE_INDEX_NAME).source(ssb);
nodeClient.search(req, ActionListener.wrap(searchResponse -> {
SearchHit[] hits = searchResponse.getHits().getHits();
if (hits.length == 0) {
listener.onFailure(new ResourceNotFoundException("No resources found in index: " + RESOURCE_INDEX_NAME));
} else {
Set<SampleResource> resources = Arrays.stream(hits).map(hit -> {
try {
return parseResource(hit.getSourceAsString());
} catch (IOException e) {
throw new RuntimeException(e);
}
}).collect(Collectors.toSet());
listener.onResponse(new GetResourceResponse(resources));
}
}, listener::onFailure));
});
GetRequest req = new GetRequest(RESOURCE_INDEX_NAME, resourceId);
pluginClient.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 {
Expand All @@ -133,11 +104,4 @@ private SampleResource parseResource(String json) throws IOException {
}
}

private void withThreadContext(Consumer<ThreadContext.StoredContext> action) {
ThreadContext tc = transportService.getThreadPool().getThreadContext();
try (ThreadContext.StoredContext st = tc.stashContext()) {
action.accept(st);
}
}

}
Loading
Loading