diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/RBACEngine.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/RBACEngine.java index 9d9ff5671d5..6867db69b55 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/RBACEngine.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/RBACEngine.java @@ -263,10 +263,15 @@ public class RBACEngine implements AuthorizationEngine { if (SearchScrollAction.NAME.equals(action)) { authorizeIndexActionName(action, authorizationInfo, null, listener); } else { - // we store the request as a transient in the ThreadContext in case of a authorization failure at the shard - // level. If authorization fails we will audit a access_denied message and will use the request to retrieve - // information such as the index and the incoming address of the request - listener.onResponse(new IndexAuthorizationResult(true, IndicesAccessControl.ALLOW_NO_INDICES)); + // RBACEngine simply authorizes scroll related actions without filling in any DLS/FLS permissions. + // Scroll related actions have special security logic, where the security context of the initial search + // request is attached to the scroll context upon creation in {@code SecuritySearchOperationListener#onNewScrollContext} + // and it is then verified, before every use of the scroll, in + // {@code SecuritySearchOperationListener#validateSearchContext}. + // The DLS/FLS permissions are used inside the {@code DirectoryReader} that {@code SecurityIndexReaderWrapper} + // built while handling the initial search request. In addition, for consistency, the DLS/FLS permissions from + // the originating search request are attached to the thread context upon validating the scroll. + listener.onResponse(new IndexAuthorizationResult(true, null)); } } else if (isAsyncRelatedAction(action)) { if (SubmitAsyncSearchAction.NAME.equals(action)) { diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/SecuritySearchOperationListener.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/SecuritySearchOperationListener.java index e4e792d60ac..0d9e2d55ceb 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/SecuritySearchOperationListener.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/SecuritySearchOperationListener.java @@ -5,6 +5,7 @@ */ package org.elasticsearch.xpack.security.authz; +import org.elasticsearch.ElasticsearchSecurityException; import org.elasticsearch.common.util.concurrent.ThreadContext; import org.elasticsearch.index.shard.SearchOperationListener; import org.elasticsearch.license.XPackLicenseState; @@ -17,6 +18,8 @@ import org.elasticsearch.xpack.core.security.SecurityContext; import org.elasticsearch.xpack.core.security.authc.Authentication; import org.elasticsearch.xpack.core.security.authc.AuthenticationField; import org.elasticsearch.xpack.core.security.authz.AuthorizationEngine.AuthorizationInfo; +import org.elasticsearch.xpack.core.security.authz.AuthorizationServiceField; +import org.elasticsearch.xpack.core.security.authz.accesscontrol.IndicesAccessControl; import org.elasticsearch.xpack.security.audit.AuditTrailService; import org.elasticsearch.xpack.security.audit.AuditUtil; @@ -51,6 +54,12 @@ public final class SecuritySearchOperationListener implements SearchOperationLis public void onNewScrollContext(SearchContext searchContext) { if (licenseState.isSecurityEnabled()) { searchContext.scrollContext().putInContext(AuthenticationField.AUTHENTICATION_KEY, securityContext.getAuthentication()); + // store the DLS and FLS permissions of the initial search request that created the scroll + // this is then used to assert the DLS/FLS permission for the scroll search action + IndicesAccessControl indicesAccessControl = + securityContext.getThreadContext().getTransient(AuthorizationServiceField.INDICES_PERMISSIONS_KEY); + assert indicesAccessControl != null : "thread context does not contain index access control"; + searchContext.scrollContext().putInContext(AuthorizationServiceField.INDICES_PERMISSIONS_KEY, indicesAccessControl); } } @@ -68,6 +77,39 @@ public final class SecuritySearchOperationListener implements SearchOperationLis final String action = threadContext.getTransient(ORIGINATING_ACTION_KEY); ensureAuthenticatedUserIsSame(originalAuth, current, auditTrailService, searchContext.id(), action, request, AuditUtil.extractRequestId(threadContext), threadContext.getTransient(AUTHORIZATION_INFO_KEY)); + // piggyback on context validation to assert the DLS/FLS permissions on the thread context of the scroll search handler + if (null == securityContext.getThreadContext().getTransient(AuthorizationServiceField.INDICES_PERMISSIONS_KEY)) { + // fill in the DLS and FLS permissions for the scroll search action from the scroll context + IndicesAccessControl scrollIndicesAccessControl = + searchContext.scrollContext().getFromContext(AuthorizationServiceField.INDICES_PERMISSIONS_KEY); + assert scrollIndicesAccessControl != null : "scroll does not contain index access control"; + securityContext.getThreadContext().putTransient(AuthorizationServiceField.INDICES_PERMISSIONS_KEY, + scrollIndicesAccessControl); + } + } + } + } + + @Override + public void onPreFetchPhase(SearchContext searchContext) { + ensureIndicesAccessControlForScrollThreadContext(searchContext); + } + + @Override + public void onPreQueryPhase(SearchContext searchContext) { + ensureIndicesAccessControlForScrollThreadContext(searchContext); + } + + void ensureIndicesAccessControlForScrollThreadContext(SearchContext searchContext) { + if (licenseState.isSecurityEnabled() && searchContext.scrollContext() != null) { + IndicesAccessControl scrollIndicesAccessControl = + searchContext.scrollContext().getFromContext(AuthorizationServiceField.INDICES_PERMISSIONS_KEY); + IndicesAccessControl threadIndicesAccessControl = + securityContext.getThreadContext().getTransient(AuthorizationServiceField.INDICES_PERMISSIONS_KEY); + if (scrollIndicesAccessControl != threadIndicesAccessControl) { + throw new ElasticsearchSecurityException("[" + searchContext.id() + "] expected scroll indices access control [" + + scrollIndicesAccessControl.toString() + "] but found [" + threadIndicesAccessControl.toString() + "] in thread " + + "context"); } } } diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/integration/FieldLevelSecurityTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/integration/FieldLevelSecurityTests.java index 3055d1b0f45..0e39bcaa3b9 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/integration/FieldLevelSecurityTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/integration/FieldLevelSecurityTests.java @@ -25,6 +25,7 @@ import org.elasticsearch.common.unit.TimeValue; import org.elasticsearch.common.xcontent.XContentBuilder; import org.elasticsearch.common.xcontent.XContentFactory; import org.elasticsearch.index.IndexModule; +import org.elasticsearch.index.query.QueryBuilder; import org.elasticsearch.index.query.QueryBuilders; import org.elasticsearch.indices.IndicesRequestCache; import org.elasticsearch.join.ParentJoinPlugin; @@ -768,6 +769,114 @@ public class FieldLevelSecurityTests extends SecurityIntegTestCase { } } + public void testScrollWithQueryCache() { + assertAcked(client().admin().indices().prepareCreate("test") + .setSettings(Settings.builder().put(IndexModule.INDEX_QUERY_CACHE_EVERYTHING_SETTING.getKey(), true)) + .addMapping("type1", "field1", "type=text", "field2", "type=text") + ); + + final int numDocs = scaledRandomIntBetween(2, 4); + for (int i = 0; i < numDocs; i++) { + client().prepareIndex("test", "type1", String.valueOf(i)) + .setSource("field1", "value1", "field2", "value2") + .get(); + } + refresh("test"); + + final QueryBuilder cacheableQueryBuilder = constantScoreQuery(termQuery("field1", "value1")); + + SearchResponse user1SearchResponse = null; + SearchResponse user2SearchResponse = null; + int scrolledDocsUser1 = 0; + final int numScrollSearch = scaledRandomIntBetween(20, 30); + + try { + for (int i = 0; i < numScrollSearch; i++) { + if (randomBoolean()) { + if (user2SearchResponse == null) { + user2SearchResponse = client().filterWithHeader(Collections.singletonMap(BASIC_AUTH_HEADER, basicAuthHeaderValue( + "user2", USERS_PASSWD))) + .prepareSearch("test") + .setQuery(cacheableQueryBuilder) + .setScroll(TimeValue.timeValueMinutes(10L)) + .setSize(1) + .setFetchSource(true) + .get(); + assertThat(user2SearchResponse.getHits().getTotalHits().value, is((long) 0)); + assertThat(user2SearchResponse.getHits().getHits().length, is(0)); + } else { + // make sure scroll is empty + user2SearchResponse = client() + .filterWithHeader(Collections.singletonMap(BASIC_AUTH_HEADER, basicAuthHeaderValue("user2", + USERS_PASSWD))) + .prepareSearchScroll(user2SearchResponse.getScrollId()) + .setScroll(TimeValue.timeValueMinutes(10L)) + .get(); + assertThat(user2SearchResponse.getHits().getTotalHits().value, is((long) 0)); + assertThat(user2SearchResponse.getHits().getHits().length, is(0)); + if (randomBoolean()) { + // maybe reuse the scroll even if empty + client().prepareClearScroll().addScrollId(user2SearchResponse.getScrollId()).get(); + user2SearchResponse = null; + } + } + } else { + if (user1SearchResponse == null) { + user1SearchResponse = client().filterWithHeader(Collections.singletonMap(BASIC_AUTH_HEADER, basicAuthHeaderValue( + "user1", USERS_PASSWD))) + .prepareSearch("test") + .setQuery(cacheableQueryBuilder) + .setScroll(TimeValue.timeValueMinutes(10L)) + .setSize(1) + .setFetchSource(true) + .get(); + assertThat(user1SearchResponse.getHits().getTotalHits().value, is((long) numDocs)); + assertThat(user1SearchResponse.getHits().getHits().length, is(1)); + assertThat(user1SearchResponse.getHits().getAt(0).getSourceAsMap().size(), is(1)); + assertThat(user1SearchResponse.getHits().getAt(0).getSourceAsMap().get("field1"), is("value1")); + scrolledDocsUser1++; + } else { + user1SearchResponse = client() + .filterWithHeader(Collections.singletonMap(BASIC_AUTH_HEADER, basicAuthHeaderValue("user1", USERS_PASSWD))) + .prepareSearchScroll(user1SearchResponse.getScrollId()) + .setScroll(TimeValue.timeValueMinutes(10L)) + .get(); + assertThat(user1SearchResponse.getHits().getTotalHits().value, is((long) numDocs)); + if (scrolledDocsUser1 < numDocs) { + assertThat(user1SearchResponse.getHits().getHits().length, is(1)); + assertThat(user1SearchResponse.getHits().getAt(0).getSourceAsMap().size(), is(1)); + assertThat(user1SearchResponse.getHits().getAt(0).getSourceAsMap().get("field1"), is("value1")); + scrolledDocsUser1++; + } else { + assertThat(user1SearchResponse.getHits().getHits().length, is(0)); + if (randomBoolean()) { + // maybe reuse the scroll even if empty + if (user1SearchResponse.getScrollId() != null) { + client().prepareClearScroll().addScrollId(user1SearchResponse.getScrollId()).get(); + } + user1SearchResponse = null; + scrolledDocsUser1 = 0; + } + } + } + } + } + } finally { + if (user1SearchResponse != null) { + String scrollId = user1SearchResponse.getScrollId(); + if (scrollId != null) { + client().prepareClearScroll().addScrollId(scrollId).get(); + } + } + if (user2SearchResponse != null) { + String scrollId = user2SearchResponse.getScrollId(); + if (scrollId != null) { + client().prepareClearScroll().addScrollId(scrollId).get(); + } + } + } + } + public void testRequestCache() throws Exception { assertAcked(client().admin().indices().prepareCreate("test") .setSettings(Settings.builder().put(IndicesRequestCache.INDEX_CACHE_REQUEST_ENABLED_SETTING.getKey(), true)) diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/SecuritySearchOperationListenerTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/SecuritySearchOperationListenerTests.java index 0620defb285..31cfc676fd1 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/SecuritySearchOperationListenerTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/SecuritySearchOperationListenerTests.java @@ -27,6 +27,8 @@ import org.elasticsearch.xpack.core.security.authc.Authentication; import org.elasticsearch.xpack.core.security.authc.Authentication.RealmRef; import org.elasticsearch.xpack.core.security.authc.AuthenticationField; import org.elasticsearch.xpack.core.security.authz.AuthorizationEngine.AuthorizationInfo; +import org.elasticsearch.xpack.core.security.authz.AuthorizationServiceField; +import org.elasticsearch.xpack.core.security.authz.accesscontrol.IndicesAccessControl; import org.elasticsearch.xpack.core.security.user.User; import org.elasticsearch.xpack.security.audit.AuditTrail; import org.elasticsearch.xpack.security.audit.AuditTrailService; @@ -39,6 +41,8 @@ import static org.elasticsearch.xpack.security.authz.AuthorizationService.AUTHOR import static org.elasticsearch.xpack.security.authz.AuthorizationService.ORIGINATING_ACTION_KEY; import static org.elasticsearch.xpack.security.authz.AuthorizationServiceTests.authzInfoRoles; import static org.elasticsearch.xpack.security.authz.SecuritySearchOperationListener.ensureAuthenticatedUserIsSame; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.nullValue; import static org.mockito.Matchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; @@ -77,6 +81,8 @@ public class SecuritySearchOperationListenerTests extends ESTestCase { AuditTrailService auditTrailService = mock(AuditTrailService.class); Authentication authentication = new Authentication(new User("test", "role"), new RealmRef("realm", "file", "node"), null); authentication.writeToContext(threadContext); + IndicesAccessControl indicesAccessControl = mock(IndicesAccessControl.class); + threadContext.putTransient(AuthorizationServiceField.INDICES_PERMISSIONS_KEY, indicesAccessControl); SecuritySearchOperationListener listener = new SecuritySearchOperationListener(securityContext, licenseState, auditTrailService); listener.onNewScrollContext(testSearchContext); @@ -85,6 +91,9 @@ public class SecuritySearchOperationListenerTests extends ESTestCase { assertEquals(authentication, contextAuth); assertEquals(scroll, testSearchContext.scrollContext().scroll); + assertThat(testSearchContext.scrollContext().getFromContext(AuthorizationServiceField.INDICES_PERMISSIONS_KEY), + is(indicesAccessControl)); + verify(licenseState).isSecurityEnabled(); verifyZeroInteractions(auditTrailService); } @@ -94,6 +103,8 @@ public class SecuritySearchOperationListenerTests extends ESTestCase { testSearchContext.scrollContext(new ScrollContext()); testSearchContext.scrollContext().putInContext(AuthenticationField.AUTHENTICATION_KEY, new Authentication(new User("test", "role"), new RealmRef("realm", "file", "node"), null)); + final IndicesAccessControl indicesAccessControl = mock(IndicesAccessControl.class); + testSearchContext.scrollContext().putInContext(AuthorizationServiceField.INDICES_PERMISSIONS_KEY, indicesAccessControl); testSearchContext.scrollContext().scroll = new Scroll(TimeValue.timeValueSeconds(2L)); XPackLicenseState licenseState = mock(XPackLicenseState.class); when(licenseState.isSecurityEnabled()).thenReturn(true); @@ -108,6 +119,7 @@ public class SecuritySearchOperationListenerTests extends ESTestCase { Authentication authentication = new Authentication(new User("test", "role"), new RealmRef("realm", "file", "node"), null); authentication.writeToContext(threadContext); listener.validateSearchContext(testSearchContext, Empty.INSTANCE); + assertThat(threadContext.getTransient(AuthorizationServiceField.INDICES_PERMISSIONS_KEY), is(indicesAccessControl)); verify(licenseState).isSecurityEnabled(); verifyZeroInteractions(auditTrail); } @@ -118,6 +130,7 @@ public class SecuritySearchOperationListenerTests extends ESTestCase { Authentication authentication = new Authentication(new User("test", "role"), new RealmRef(realmName, "file", nodeName), null); authentication.writeToContext(threadContext); listener.validateSearchContext(testSearchContext, Empty.INSTANCE); + assertThat(threadContext.getTransient(AuthorizationServiceField.INDICES_PERMISSIONS_KEY), is(indicesAccessControl)); verify(licenseState, times(2)).isSecurityEnabled(); verifyZeroInteractions(auditTrail); } @@ -134,6 +147,7 @@ public class SecuritySearchOperationListenerTests extends ESTestCase { final InternalScrollSearchRequest request = new InternalScrollSearchRequest(); SearchContextMissingException expected = expectThrows(SearchContextMissingException.class, () -> listener.validateSearchContext(testSearchContext, request)); + assertThat(threadContext.getTransient(AuthorizationServiceField.INDICES_PERMISSIONS_KEY), nullValue()); assertEquals(testSearchContext.id(), expected.contextId()); verify(licenseState, Mockito.atLeast(3)).isSecurityEnabled(); verify(auditTrail).accessDenied(eq(null), eq(authentication), eq("action"), eq(request), @@ -152,6 +166,7 @@ public class SecuritySearchOperationListenerTests extends ESTestCase { threadContext.putTransient(ORIGINATING_ACTION_KEY, "action"); final InternalScrollSearchRequest request = new InternalScrollSearchRequest(); listener.validateSearchContext(testSearchContext, request); + assertThat(threadContext.getTransient(AuthorizationServiceField.INDICES_PERMISSIONS_KEY), is(indicesAccessControl)); verify(licenseState, Mockito.atLeast(4)).isSecurityEnabled(); verifyNoMoreInteractions(auditTrail); } @@ -170,6 +185,7 @@ public class SecuritySearchOperationListenerTests extends ESTestCase { final InternalScrollSearchRequest request = new InternalScrollSearchRequest(); SearchContextMissingException expected = expectThrows(SearchContextMissingException.class, () -> listener.validateSearchContext(testSearchContext, request)); + assertThat(threadContext.getTransient(AuthorizationServiceField.INDICES_PERMISSIONS_KEY), nullValue()); assertEquals(testSearchContext.id(), expected.contextId()); verify(licenseState, Mockito.atLeast(5)).isSecurityEnabled(); verify(auditTrail).accessDenied(eq(null), eq(authentication), eq("action"), eq(request),