diff --git a/CHANGELOG.md b/CHANGELOG.md index 30b0d492af..62826d165c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/RESOURCE_SHARING_AND_ACCESS_CONTROL.md b/RESOURCE_SHARING_AND_ACCESS_CONTROL.md index f715ebc37b..8e334ae403 100644 --- a/RESOURCE_SHARING_AND_ACCESS_CONTROL.md +++ b/RESOURCE_SHARING_AND_ACCESS_CONTROL.md @@ -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. 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 65ba2e9443..e328dee0b8 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 @@ -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" ); @@ -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 """ { @@ -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, diff --git a/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/resource/feature/enabled/multi_share/MixedAccessTests.java b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/resource/feature/enabled/multi_share/MixedAccessTests.java index 48417a70ec..e9ed20dd92 100644 --- a/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/resource/feature/enabled/multi_share/MixedAccessTests.java +++ b/sample-resource-plugin/src/integrationTest/java/org/opensearch/sample/resource/feature/enabled/multi_share/MixedAccessTests.java @@ -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); + } + } diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/transport/GetResourceTransportAction.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/transport/GetResourceTransportAction.java index 86931c3b88..3ab5e2fe59 100644 --- a/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/transport/GetResourceTransportAction.java +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/transport/GetResourceTransportAction.java @@ -10,9 +10,7 @@ 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; @@ -20,9 +18,7 @@ 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; @@ -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; @@ -49,79 +43,56 @@ */ public class GetResourceTransportAction extends HandledTransportAction { - 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 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 listener, ResourceSharingClient client) { - if (client == null) { - fetchResourcesByIds(null, listener); - return; - } + private void fetchAllResources(ActionListener 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 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 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 ids, ActionListener 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 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 { @@ -133,11 +104,4 @@ private SampleResource parseResource(String json) throws IOException { } } - private void withThreadContext(Consumer action) { - ThreadContext tc = transportService.getThreadPool().getThreadContext(); - try (ThreadContext.StoredContext st = tc.stashContext()) { - action.accept(st); - } - } - } diff --git a/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/transport/SearchResourceTransportAction.java b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/transport/SearchResourceTransportAction.java index 0add62ba8d..eb4ceea97b 100644 --- a/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/transport/SearchResourceTransportAction.java +++ b/sample-resource-plugin/src/main/java/org/opensearch/sample/resource/actions/transport/SearchResourceTransportAction.java @@ -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; @@ -18,20 +16,11 @@ 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 @@ -39,78 +28,18 @@ public class SearchResourceTransportAction extends HandledTransportAction { 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 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 listener) { - SearchSourceBuilder src = request.source() != null ? request.source() : new SearchSourceBuilder(); - ActionListener> 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 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); - 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); - } } } diff --git a/src/integrationTest/java/org/opensearch/security/support/WildcardMatcherTest.java b/src/integrationTest/java/org/opensearch/security/support/WildcardMatcherTest.java index 7b524cb5f2..e9227ea1f0 100644 --- a/src/integrationTest/java/org/opensearch/security/support/WildcardMatcherTest.java +++ b/src/integrationTest/java/org/opensearch/security/support/WildcardMatcherTest.java @@ -54,6 +54,28 @@ public void none() { assertEquals("", subject.toString()); } + @Test + public void all() { + WildcardMatcher any = applyCase(WildcardMatcher.ANY); + assertTrue(any.matchAll("any_string", "other_string")); + assertTrue(any.matchAll(Arrays.asList("any_string", "other_string"))); + assertTrue(any.matchAll(Stream.of("any_string", "other_string"))); + + WildcardMatcher none = applyCase(WildcardMatcher.NONE); + assertFalse(none.matchAll("any_string")); + assertFalse(none.matchAll(Arrays.asList("any_string"))); + assertFalse(none.matchAll(Stream.of("any_string"))); + assertTrue(none.matchAll()); + assertTrue(none.matchAll(Collections.emptyList())); + assertTrue(none.matchAll(Stream.empty())); + + WildcardMatcher prefix = applyCase(WildcardMatcher.from("test*")); + assertTrue(prefix.matchAll("test1", "test2", "testing")); + assertFalse(prefix.matchAll("test1", "other", "testing")); + assertTrue(prefix.matchAll(Arrays.asList("test1", "test2"))); + assertFalse(prefix.matchAll(Arrays.asList("test1", "other"))); + } + @Test public void anyFromStar() { assertSame(WildcardMatcher.ANY, applyCase(WildcardMatcher.from("*"))); diff --git a/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java b/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java index a052a81973..80e6441630 100644 --- a/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java +++ b/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java @@ -1202,7 +1202,9 @@ public Collection createComponents( resolver, xContentRegistry, threadPool, - dlsFlsBaseContext + dlsFlsBaseContext, + adminDns, + resourcePluginInfo.getResourceIndices() ); cr.subscribeOnChange(configMap -> { ((DlsFlsValveImpl) dlsFlsValve).updateConfiguration(cr.getConfiguration(CType.ROLES)); }); } diff --git a/src/main/java/org/opensearch/security/configuration/DlsFlsValveImpl.java b/src/main/java/org/opensearch/security/configuration/DlsFlsValveImpl.java index 9762bfdc64..f28129a980 100644 --- a/src/main/java/org/opensearch/security/configuration/DlsFlsValveImpl.java +++ b/src/main/java/org/opensearch/security/configuration/DlsFlsValveImpl.java @@ -17,6 +17,7 @@ import java.util.Comparator; import java.util.List; import java.util.Objects; +import java.util.Set; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; import java.util.stream.StreamSupport; @@ -67,6 +68,7 @@ import org.opensearch.search.internal.SearchContext; import org.opensearch.search.query.QuerySearchResult; import org.opensearch.security.OpenSearchSecurityPlugin; +import org.opensearch.security.auth.UserSubjectImpl; import org.opensearch.security.privileges.DocumentAllowList; import org.opensearch.security.privileges.PrivilegesEvaluationContext; import org.opensearch.security.privileges.PrivilegesEvaluationException; @@ -77,11 +79,13 @@ import org.opensearch.security.privileges.dlsfls.FieldMasking; import org.opensearch.security.privileges.dlsfls.IndexToRuleMap; import org.opensearch.security.resolver.IndexResolverReplacer; +import org.opensearch.security.resources.ResourceSharingDlsUtils; import org.opensearch.security.securityconf.DynamicConfigFactory; import org.opensearch.security.securityconf.impl.SecurityDynamicConfiguration; import org.opensearch.security.securityconf.impl.v7.RoleV7; import org.opensearch.security.support.ConfigConstants; import org.opensearch.security.support.HeaderHelper; +import org.opensearch.security.support.WildcardMatcher; import org.opensearch.threadpool.ThreadPool; import org.opensearch.transport.client.Client; @@ -102,6 +106,9 @@ public class DlsFlsValveImpl implements DlsFlsRequestValve { private final AtomicReference dlsFlsProcessedConfig = new AtomicReference<>(); private final FieldMasking.Config fieldMaskingConfig; private final Settings settings; + private final AdminDNs adminDNs; + private boolean isResourceSharingFeatureEnabled = false; + private final WildcardMatcher resourceIndicesMatcher; public DlsFlsValveImpl( Settings settings, @@ -110,7 +117,9 @@ public DlsFlsValveImpl( IndexNameExpressionResolver resolver, NamedXContentRegistry namedXContentRegistry, ThreadPool threadPool, - DlsFlsBaseContext dlsFlsBaseContext + DlsFlsBaseContext dlsFlsBaseContext, + AdminDNs adminDNs, + Set resourceIndices ) { super(); this.nodeClient = nodeClient; @@ -122,6 +131,8 @@ public DlsFlsValveImpl( this.fieldMaskingConfig = FieldMasking.Config.fromSettings(settings); this.dlsFlsBaseContext = dlsFlsBaseContext; this.settings = settings; + this.adminDNs = adminDNs; + this.resourceIndicesMatcher = WildcardMatcher.from(resourceIndices); clusterService.addListener(event -> { DlsFlsProcessedConfig config = dlsFlsProcessedConfig.get(); @@ -130,6 +141,11 @@ public DlsFlsValveImpl( config.updateClusterStateMetadataAsync(clusterService, threadPool); } }); + boolean isResourceSharingFeatureEnabled = settings.getAsBoolean( + ConfigConstants.OPENSEARCH_RESOURCE_SHARING_ENABLED, + ConfigConstants.OPENSEARCH_RESOURCE_SHARING_ENABLED_DEFAULT + ); + this.isResourceSharingFeatureEnabled = isResourceSharingFeatureEnabled; } /** @@ -139,12 +155,41 @@ public DlsFlsValveImpl( */ @Override public boolean invoke(PrivilegesEvaluationContext context, final ActionListener listener) { - if (HeaderHelper.isInternalOrPluginRequest(threadContext) - || (isClusterPerm(context.getAction()) && !MultiGetAction.NAME.equals(context.getAction()))) { + UserSubjectImpl userSubject = (UserSubjectImpl) threadContext.getPersistent(ConfigConstants.OPENDISTRO_SECURITY_AUTHENTICATED_USER); + if (isClusterPerm(context.getAction()) && !MultiGetAction.NAME.equals(context.getAction())) { + return true; + } + if (userSubject != null && adminDNs.isAdmin(userSubject.getUser())) { return true; } - DlsFlsProcessedConfig config = this.dlsFlsProcessedConfig.get(); ActionRequest request = context.getRequest(); + if (HeaderHelper.isInternalOrPluginRequest(threadContext)) { + IndexResolverReplacer.Resolved resolved = context.getResolvedRequest(); + if (isResourceSharingFeatureEnabled + && request instanceof SearchRequest + && resourceIndicesMatcher.matchAll(resolved.getAllIndices())) { + + IndexToRuleMap sharedResourceMap = ResourceSharingDlsUtils.resourceRestrictions( + namedXContentRegistry, + resolved, + userSubject.getUser() + ); + + return DlsFilterLevelActionHandler.handle( + context, + sharedResourceMap, + listener, + nodeClient, + clusterService, + OpenSearchSecurityPlugin.GuiceHolder.getIndicesService(), + resolver, + threadContext + ); + } else { + return true; + } + } + DlsFlsProcessedConfig config = this.dlsFlsProcessedConfig.get(); IndexResolverReplacer.Resolved resolved = context.getResolvedRequest(); try { diff --git a/src/main/java/org/opensearch/security/privileges/dlsfls/DlsRestriction.java b/src/main/java/org/opensearch/security/privileges/dlsfls/DlsRestriction.java index 242e0000a4..01fccb78e6 100644 --- a/src/main/java/org/opensearch/security/privileges/dlsfls/DlsRestriction.java +++ b/src/main/java/org/opensearch/security/privileges/dlsfls/DlsRestriction.java @@ -53,7 +53,7 @@ public class DlsRestriction extends AbstractRuleBasedPrivileges.Rule { private final ImmutableList queries; - DlsRestriction(List queries) { + public DlsRestriction(List queries) { this.queries = ImmutableList.copyOf(queries); } diff --git a/src/main/java/org/opensearch/security/privileges/dlsfls/DocumentPrivileges.java b/src/main/java/org/opensearch/security/privileges/dlsfls/DocumentPrivileges.java index 0252fb2772..bb4563517e 100644 --- a/src/main/java/org/opensearch/security/privileges/dlsfls/DocumentPrivileges.java +++ b/src/main/java/org/opensearch/security/privileges/dlsfls/DocumentPrivileges.java @@ -10,6 +10,7 @@ */ package org.opensearch.security.privileges.dlsfls; +import java.io.IOException; import java.util.ArrayList; import java.util.Collection; import java.util.List; @@ -213,4 +214,17 @@ public String getRenderedSource() { } } + public static RenderedDlsQuery getRenderedDlsQuery(NamedXContentRegistry xContentRegistry, String query) throws IOException { + return new RenderedDlsQuery(parseQuery(xContentRegistry, query), query); + } + + static QueryBuilder parseQuery(NamedXContentRegistry xContentRegistry, String queryString) throws IOException { + XContentParser parser = JsonXContent.jsonXContent.createParser( + xContentRegistry, + DeprecationHandler.THROW_UNSUPPORTED_OPERATION, + queryString + ); + return AbstractQueryBuilder.parseInnerQueryBuilder(parser); + } + } diff --git a/src/main/java/org/opensearch/security/privileges/dlsfls/IndexToRuleMap.java b/src/main/java/org/opensearch/security/privileges/dlsfls/IndexToRuleMap.java index 2c359af032..191b3810cc 100644 --- a/src/main/java/org/opensearch/security/privileges/dlsfls/IndexToRuleMap.java +++ b/src/main/java/org/opensearch/security/privileges/dlsfls/IndexToRuleMap.java @@ -28,7 +28,7 @@ public class IndexToRuleMap { private final ImmutableMap indexMap; - IndexToRuleMap(ImmutableMap indexMap) { + public IndexToRuleMap(ImmutableMap indexMap) { this.indexMap = indexMap; } diff --git a/src/main/java/org/opensearch/security/resources/ResourceAccessHandler.java b/src/main/java/org/opensearch/security/resources/ResourceAccessHandler.java index 9cf2c870a1..f69517e203 100644 --- a/src/main/java/org/opensearch/security/resources/ResourceAccessHandler.java +++ b/src/main/java/org/opensearch/security/resources/ResourceAccessHandler.java @@ -117,7 +117,7 @@ public void getOwnAndSharedResourceIdsForCurrentUser(@NonNull String resourceInd BoolQueryBuilder query = QueryBuilders.boolQuery() .should(QueryBuilders.termQuery("created_by.user.keyword", user.getName())) - .should(QueryBuilders.termsQuery("all_shared_principals", flatPrincipals)) + .should(QueryBuilders.termsQuery("all_shared_principals.keyword", flatPrincipals)) .minimumShouldMatch(1); // 3) Fetch all accessible resource IDs diff --git a/src/main/java/org/opensearch/security/resources/ResourceSharingDlsUtils.java b/src/main/java/org/opensearch/security/resources/ResourceSharingDlsUtils.java new file mode 100644 index 0000000000..474bbe5dc8 --- /dev/null +++ b/src/main/java/org/opensearch/security/resources/ResourceSharingDlsUtils.java @@ -0,0 +1,71 @@ +/* + * 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.security.resources; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import com.google.common.collect.ImmutableMap; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import org.opensearch.common.xcontent.XContentFactory; +import org.opensearch.core.xcontent.NamedXContentRegistry; +import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.security.privileges.dlsfls.DlsRestriction; +import org.opensearch.security.privileges.dlsfls.DocumentPrivileges; +import org.opensearch.security.privileges.dlsfls.IndexToRuleMap; +import org.opensearch.security.resolver.IndexResolverReplacer; +import org.opensearch.security.user.User; + +public class ResourceSharingDlsUtils { + private static final Logger LOGGER = LogManager.getLogger(ResourceSharingDlsUtils.class); + + public static IndexToRuleMap resourceRestrictions( + NamedXContentRegistry xContentRegistry, + IndexResolverReplacer.Resolved resolved, + User user + ) { + + List principals = new ArrayList<>(); + principals.add("user:*"); // Convention for publicly visible + principals.add("user:" + user.getName()); // owner + + // Security roles (OpenSearch Security roles) + if (user.getSecurityRoles() != null) { + user.getSecurityRoles().forEach(r -> principals.add("role:" + r)); + } + + // Backend roles (LDAP/SAML/etc) + if (user.getRoles() != null) { + user.getRoles().forEach(br -> principals.add("backend:" + br)); + } + + XContentBuilder builder = null; + DlsRestriction restriction; + try { + // Build a single `terms` query JSON + builder = XContentFactory.jsonBuilder(); + builder.startObject().startObject("terms").array("all_shared_principals.keyword", principals.toArray()).endObject().endObject(); + + String dlsJson = builder.toString(); + restriction = new DlsRestriction(List.of(DocumentPrivileges.getRenderedDlsQuery(xContentRegistry, dlsJson))); + } catch (IOException e) { + LOGGER.warn("Received error while applying resource restrictions.", e); + restriction = DlsRestriction.FULL; + } + + ImmutableMap.Builder mapBuilder = ImmutableMap.builder(); + for (String index : resolved.getAllIndices()) { + mapBuilder.put(index, restriction); + } + return new IndexToRuleMap<>(mapBuilder.build()); + } +} diff --git a/src/main/java/org/opensearch/security/support/WildcardMatcher.java b/src/main/java/org/opensearch/security/support/WildcardMatcher.java index dcf9917ebe..6f73f5dc49 100644 --- a/src/main/java/org/opensearch/security/support/WildcardMatcher.java +++ b/src/main/java/org/opensearch/security/support/WildcardMatcher.java @@ -62,6 +62,21 @@ public boolean matchAny(String... candidates) { return true; } + @Override + public boolean matchAll(Stream candidates) { + return true; + } + + @Override + public boolean matchAll(Collection candidates) { + return true; + } + + @Override + public boolean matchAll(String... candidates) { + return true; + } + @Override public > T getMatchAny(Stream candidates, Collector collector) { return candidates.collect(collector); @@ -100,6 +115,21 @@ public boolean matchAny(String... candidates) { return false; } + @Override + public boolean matchAll(Stream candidates) { + return candidates.findAny().isEmpty(); + } + + @Override + public boolean matchAll(Collection candidates) { + return candidates.isEmpty(); + } + + @Override + public boolean matchAll(String... candidates) { + return candidates.length == 0; + } + @Override public > T getMatchAny(Stream candidates, Collector collector) { return Stream.empty().collect(collector); @@ -233,6 +263,18 @@ public boolean matchAny(String... candidates) { return matchAny(Arrays.stream(candidates)); } + public boolean matchAll(Stream candidates) { + return candidates.allMatch(this); + } + + public boolean matchAll(Collection candidates) { + return matchAll(candidates.stream()); + } + + public boolean matchAll(String... candidates) { + return matchAll(Arrays.stream(candidates)); + } + public > T getMatchAny(Stream candidates, Collector collector) { return candidates.filter(this).collect(collector); }