diff --git a/build.gradle b/build.gradle index 99a8b80b75..9814717169 100644 --- a/build.gradle +++ b/build.gradle @@ -573,6 +573,11 @@ allprojects { integrationTestImplementation 'org.slf4j:slf4j-api:2.0.12' integrationTestImplementation 'com.selectivem.collections:special-collections-complete:1.4.0' integrationTestImplementation "org.opensearch.plugin:lang-painless:${opensearch_version}" + + integrationTestImplementation ('com.jayway.jsonpath:json-path:2.9.0') { + exclude(group: 'net.minidev', module: 'json-smart') + } + integrationTestImplementation 'net.minidev:json-smart:2.6.0' } } diff --git a/src/integrationTest/java/org/opensearch/security/SearchOperationTest.java b/src/integrationTest/java/org/opensearch/security/SearchOperationTest.java index e8e15d1910..c1b3ce8927 100644 --- a/src/integrationTest/java/org/opensearch/security/SearchOperationTest.java +++ b/src/integrationTest/java/org/opensearch/security/SearchOperationTest.java @@ -67,6 +67,7 @@ import org.opensearch.action.search.SearchRequest; import org.opensearch.action.search.SearchResponse; import org.opensearch.action.search.SearchScrollRequest; +import org.opensearch.action.support.IndicesOptions; import org.opensearch.action.update.UpdateRequest; import org.opensearch.action.update.UpdateResponse; import org.opensearch.client.RestHighLevelClient; @@ -2281,7 +2282,11 @@ public void openIndex_negative() throws IOException { .open(new OpenIndexRequest(indexThatUserHasAccessTo, indexThatUserHasNoAccessTo), DEFAULT), statusException(FORBIDDEN) ); - assertThatThrownBy(() -> restHighLevelClient.indices().open(new OpenIndexRequest("*"), DEFAULT), statusException(FORBIDDEN)); + assertThatThrownBy( + () -> restHighLevelClient.indices() + .open(new OpenIndexRequest("*").indicesOptions(IndicesOptions.LENIENT_EXPAND_OPEN_CLOSED), DEFAULT), + statusException(FORBIDDEN) + ); } } diff --git a/src/integrationTest/java/org/opensearch/security/privileges/IndexPatternTest.java b/src/integrationTest/java/org/opensearch/security/privileges/IndexPatternTest.java index e2924bc45d..89d7e0788d 100644 --- a/src/integrationTest/java/org/opensearch/security/privileges/IndexPatternTest.java +++ b/src/integrationTest/java/org/opensearch/security/privileges/IndexPatternTest.java @@ -13,18 +13,12 @@ import java.time.ZonedDateTime; import java.time.temporal.ChronoField; -import com.google.common.collect.ImmutableMap; -import com.google.common.collect.ImmutableSet; import org.junit.Test; import org.opensearch.cluster.ClusterState; -import org.opensearch.cluster.metadata.IndexNameExpressionResolver; import org.opensearch.cluster.metadata.Metadata; -import org.opensearch.common.settings.Settings; -import org.opensearch.common.util.concurrent.ThreadContext; -import org.opensearch.security.resolver.IndexResolverReplacer; import org.opensearch.security.support.WildcardMatcher; -import org.opensearch.security.user.User; +import org.opensearch.security.util.MockPrivilegeEvaluationContextBuilder; import static org.opensearch.security.util.MockIndexMetadataBuilder.indices; import static org.junit.Assert.assertEquals; @@ -232,19 +226,10 @@ public void equals() { } private static PrivilegesEvaluationContext ctx() { - IndexNameExpressionResolver indexNameExpressionResolver = new IndexNameExpressionResolver(new ThreadContext(Settings.EMPTY)); - IndexResolverReplacer indexResolverReplacer = new IndexResolverReplacer(indexNameExpressionResolver, () -> CLUSTER_STATE, null); - User user = new User("test_user").withAttributes(ImmutableMap.of("attrs.a11", "a11", "attrs.year", "year")); - return new PrivilegesEvaluationContext( - user, - ImmutableSet.of(), - "indices:action/test", - null, - null, - indexResolverReplacer, - indexNameExpressionResolver, - () -> CLUSTER_STATE, - ActionPrivileges.EMPTY - ); + return MockPrivilegeEvaluationContextBuilder.ctx() + .action("indices:action/test") + .attr("attrs.a11", "a11") + .attr("attrs.year", "year") + .get(); } } diff --git a/src/integrationTest/java/org/opensearch/security/privileges/IndexRequestModifierTest.java b/src/integrationTest/java/org/opensearch/security/privileges/IndexRequestModifierTest.java new file mode 100644 index 0000000000..76be054389 --- /dev/null +++ b/src/integrationTest/java/org/opensearch/security/privileges/IndexRequestModifierTest.java @@ -0,0 +1,163 @@ +/* + * 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. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.privileges; + +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.Map; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Suite; + +import org.opensearch.action.ActionRequest; +import org.opensearch.action.IndicesRequest; +import org.opensearch.action.OriginalIndices; +import org.opensearch.action.index.IndexRequest; +import org.opensearch.action.search.SearchRequest; +import org.opensearch.action.support.IndicesOptions; +import org.opensearch.cluster.ClusterState; +import org.opensearch.cluster.metadata.IndexNameExpressionResolver; +import org.opensearch.cluster.metadata.Metadata; +import org.opensearch.cluster.metadata.ResolvedIndices; +import org.opensearch.common.settings.Settings; +import org.opensearch.common.util.concurrent.ThreadContext; +import org.opensearch.security.util.MockIndexMetadataBuilder; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +@RunWith(Suite.class) +@Suite.SuiteClasses({ IndexRequestModifierTest.SetLocalIndices.class, IndexRequestModifierTest.SetLocalIndicesToEmpty.class }) +public class IndexRequestModifierTest { + + static final IndexNameExpressionResolver indexNameExpressionResolver = new IndexNameExpressionResolver( + new ThreadContext(Settings.EMPTY) + ); + static final Metadata metadata = MockIndexMetadataBuilder.indices("index", "index1", "index2", "index3").build(); + final static ClusterState clusterState = ClusterState.builder(ClusterState.EMPTY_STATE).metadata(metadata).build(); + static final IndicesRequestModifier subject = new IndicesRequestModifier(); + + public static class SetLocalIndices { + @Test + public void basic() { + ResolvedIndices resolvedIndices = ResolvedIndices.of("index1"); + SearchRequest request = new SearchRequest("index1", "index2", "index3"); + + boolean success = subject.setLocalIndices(request, resolvedIndices, Collections.singletonList("index1")); + assertTrue(success); + assertArrayEquals(new String[] { "index1" }, request.indices()); + } + + @Test + public void withRemote() { + ResolvedIndices resolvedIndices = ResolvedIndices.of("index1") + .withRemoteIndices( + Map.of("remote", new OriginalIndices(new String[] { "index_remote" }, IndicesOptions.LENIENT_EXPAND_OPEN)) + ); + SearchRequest request = new SearchRequest("index1", "index2", "index3", "remote:index_remote"); + + boolean success = subject.setLocalIndices(request, resolvedIndices, Collections.singletonList("index1")); + assertTrue(success); + assertArrayEquals(new String[] { "index1", "remote:index_remote" }, request.indices()); + } + + @Test + public void empty() { + ResolvedIndices resolvedIndices = ResolvedIndices.of("index1"); + SearchRequest request = new SearchRequest("index1", "index2", "index3"); + + boolean success = subject.setLocalIndices(request, resolvedIndices, Collections.emptyList()); + assertTrue(success); + String[] finalResolvedIndices = indexNameExpressionResolver.concreteIndexNames(clusterState, request); + assertArrayEquals(new String[0], finalResolvedIndices); + } + + @Test + public void unsupportedType() { + ResolvedIndices resolvedIndices = ResolvedIndices.of("index1"); + IndexRequest request = new IndexRequest("index1"); + + boolean success = subject.setLocalIndices(request, resolvedIndices, Collections.singletonList("index1")); + assertFalse(success); + } + } + + @RunWith(Parameterized.class) + public static class SetLocalIndicesToEmpty { + + String description; + IndicesRequest request; + + @Test + public void setLocalIndicesToEmpty() { + + ResolvedIndices resolvedIndices = ResolvedIndices.of("index"); + + if (Arrays.asList(request.indices()).contains("remote:index")) { + resolvedIndices = resolvedIndices.withRemoteIndices( + Map.of("remote", new OriginalIndices(new String[] { "index" }, request.indicesOptions())) + ); + } + + boolean success = subject.setLocalIndicesToEmpty((ActionRequest) request, resolvedIndices); + + if (!(request instanceof IndicesRequest.Replaceable)) { + assertFalse(success); + } else if (!request.indicesOptions().allowNoIndices()) { + assertFalse(success); + } else { + assertTrue(success); + + String[] finalResolvedIndices = indexNameExpressionResolver.concreteIndexNames(clusterState, request); + + assertEquals("Resolved to empty indices: " + Arrays.asList(finalResolvedIndices), 0, finalResolvedIndices.length); + } + } + + @Parameterized.Parameters(name = "{0}") + public static Collection params() { + return Arrays.asList( + new Object[] { "lenient expand open", new SearchRequest("index").indicesOptions(IndicesOptions.LENIENT_EXPAND_OPEN) }, + new Object[] { + "lenient expand open/closed", + new SearchRequest("index").indicesOptions(IndicesOptions.LENIENT_EXPAND_OPEN_CLOSED) }, + new Object[] { + "lenient expand open/closed/hidden", + new SearchRequest("index").indicesOptions(IndicesOptions.LENIENT_EXPAND_OPEN_CLOSED_HIDDEN) }, + new Object[] { + "allow no indices", + new SearchRequest("index").indicesOptions(IndicesOptions.fromOptions(false, true, false, false)) }, + new Object[] { + "ignore unavailable", + new SearchRequest("index").indicesOptions(IndicesOptions.fromOptions(true, false, false, false)) }, + new Object[] { + "strict single index", + new SearchRequest("index").indicesOptions(IndicesOptions.STRICT_SINGLE_INDEX_NO_EXPAND_FORBID_CLOSED) }, + new Object[] { + "with remote index", + new SearchRequest("index", "remote:index").indicesOptions(IndicesOptions.LENIENT_EXPAND_OPEN) }, + new Object[] { "not implementing IndicesRequest.Replaceable", new IndexRequest("index") } + ); + + } + + public SetLocalIndicesToEmpty(String description, IndicesRequest request) { + this.description = description; + this.request = request; + } + } +} diff --git a/src/integrationTest/java/org/opensearch/security/privileges/IndicesRequestResolverTest.java b/src/integrationTest/java/org/opensearch/security/privileges/IndicesRequestResolverTest.java new file mode 100644 index 0000000000..22e70d2eea --- /dev/null +++ b/src/integrationTest/java/org/opensearch/security/privileges/IndicesRequestResolverTest.java @@ -0,0 +1,70 @@ +/* + * 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. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.privileges; + +import org.opensearch.cluster.ClusterState; +import org.opensearch.cluster.metadata.IndexNameExpressionResolver; +import org.opensearch.cluster.metadata.Metadata; +import org.opensearch.common.settings.Settings; +import org.opensearch.common.util.concurrent.ThreadContext; +import org.opensearch.security.util.MockIndexMetadataBuilder; + +public class IndicesRequestResolverTest { + + static final Metadata metadata = MockIndexMetadataBuilder.indices("index1", "index2", "index3").build(); + final static ClusterState clusterState = ClusterState.builder(ClusterState.EMPTY_STATE).metadata(metadata).build(); + static final IndicesRequestResolver subject = new IndicesRequestResolver( + new IndexNameExpressionResolver(new ThreadContext(Settings.EMPTY)) + ); + /* + @Test + public void resolve_normal() { + SearchRequest request = new SearchRequest("index1"); + ActionRequestMetadata actionRequestMetadata = mock(); + ResolvedIndices resolvedIndices = ResolvedIndices.of("index1"); + when(actionRequestMetadata.resolvedIndices()).thenReturn(Optional.of(resolvedIndices)); + + ResolvedIndices returnedResolvedIndices = subject.resolve(request, actionRequestMetadata, () -> clusterState); + assertEquals(resolvedIndices, returnedResolvedIndices); + } + + @Test + public void resolve_fallback() { + SearchRequest request = new SearchRequest("index1"); + ActionRequestMetadata actionRequestMetadata = mock(); + when(actionRequestMetadata.resolvedIndices()).thenReturn(Optional.empty()); + + ResolvedIndices returnedResolvedIndices = subject.resolve(request, actionRequestMetadata, () -> clusterState); + assertEquals(Set.of("index1"), returnedResolvedIndices.local().names()); + } + + @Test + public void resolve_fallbackUnsupported() { + ClusterStatsRequest request = new ClusterStatsRequest(); + ActionRequestMetadata actionRequestMetadata = mock(); + when(actionRequestMetadata.resolvedIndices()).thenReturn(Optional.empty()); + + ResolvedIndices returnedResolvedIndices = subject.resolve(request, actionRequestMetadata, () -> clusterState); + assertTrue("Expected isAll(), got: " + returnedResolvedIndices, returnedResolvedIndices.local().isAll()); + } + + @Test + public void resolve_withPrivilegesEvaluationContext() { + SearchRequest request = new SearchRequest("index*"); + ActionRequestMetadata actionRequestMetadata = mock(); + when(actionRequestMetadata.resolvedIndices()).thenReturn(Optional.empty()); + PrivilegesEvaluationContext context = MockPrivilegeEvaluationContextBuilder.ctx().clusterState(clusterState).get(); + + ResolvedIndices returnedResolvedIndices = subject.resolve(request, actionRequestMetadata, context); + assertEquals(Set.of("index1", "index2", "index3"), returnedResolvedIndices.local().names()); + }*/ +} diff --git a/src/integrationTest/java/org/opensearch/security/privileges/RestEndpointPermissionTests.java b/src/integrationTest/java/org/opensearch/security/privileges/RestEndpointPermissionTests.java index 09c122cc2b..7ad030d2f5 100644 --- a/src/integrationTest/java/org/opensearch/security/privileges/RestEndpointPermissionTests.java +++ b/src/integrationTest/java/org/opensearch/security/privileges/RestEndpointPermissionTests.java @@ -47,6 +47,7 @@ import org.opensearch.security.dlic.rest.api.Endpoint; import org.opensearch.security.dlic.rest.api.RestApiAdminPrivilegesEvaluator.PermissionBuilder; import org.opensearch.security.privileges.actionlevel.RoleBasedActionPrivileges; +import org.opensearch.security.privileges.actionlevel.RuntimeOptimizedActionPrivileges; import org.opensearch.security.securityconf.FlattenedActionGroups; import org.opensearch.security.securityconf.impl.CType; import org.opensearch.security.securityconf.impl.SecurityDynamicConfiguration; @@ -117,7 +118,13 @@ static String[] allRestApiPermissions() { final RoleBasedActionPrivileges actionPrivileges; public RestEndpointPermissionTests() throws IOException { - this.actionPrivileges = new RoleBasedActionPrivileges(createRolesConfig(), FlattenedActionGroups.EMPTY, Settings.EMPTY); + this.actionPrivileges = new RoleBasedActionPrivileges( + createRolesConfig(), + FlattenedActionGroups.EMPTY, + RuntimeOptimizedActionPrivileges.SpecialIndexProtection.NONE, + Settings.EMPTY, + false + ); } @Test diff --git a/src/integrationTest/java/org/opensearch/security/privileges/actionlevel/EmptyActionPrivilegesTest.java b/src/integrationTest/java/org/opensearch/security/privileges/actionlevel/EmptyActionPrivilegesTest.java index f7278325f2..f43bb9b55b 100644 --- a/src/integrationTest/java/org/opensearch/security/privileges/actionlevel/EmptyActionPrivilegesTest.java +++ b/src/integrationTest/java/org/opensearch/security/privileges/actionlevel/EmptyActionPrivilegesTest.java @@ -15,9 +15,9 @@ import org.junit.Test; +import org.opensearch.cluster.metadata.ResolvedIndices; import org.opensearch.security.privileges.ActionPrivileges; import org.opensearch.security.privileges.PrivilegesEvaluatorResponse; -import org.opensearch.security.resolver.IndexResolverReplacer; import static org.hamcrest.MatcherAssert.assertThat; import static org.opensearch.security.privileges.PrivilegeEvaluatorResponseMatcher.isForbidden; @@ -50,7 +50,7 @@ public void hasIndexPrivilege() { PrivilegesEvaluatorResponse result = subject.hasIndexPrivilege( ctx().get(), Set.of("indices:data/write/index"), - IndexResolverReplacer.Resolved.ofIndex("any_index") + ResolvedIndices.of("any_index") ); assertThat(result, isForbidden()); } @@ -60,7 +60,7 @@ public void hasExplicitIndexPrivilege() { PrivilegesEvaluatorResponse result = subject.hasExplicitIndexPrivilege( ctx().get(), Set.of("indices:data/write/index"), - IndexResolverReplacer.Resolved.ofIndex("any_index") + ResolvedIndices.of("any_index") ); assertThat(result, isForbidden()); } diff --git a/src/integrationTest/java/org/opensearch/security/privileges/actionlevel/RoleBasedActionPrivilegesTest.java b/src/integrationTest/java/org/opensearch/security/privileges/actionlevel/RoleBasedActionPrivilegesTest.java index 765cace0e2..a4c693ebea 100644 --- a/src/integrationTest/java/org/opensearch/security/privileges/actionlevel/RoleBasedActionPrivilegesTest.java +++ b/src/integrationTest/java/org/opensearch/security/privileges/actionlevel/RoleBasedActionPrivilegesTest.java @@ -13,6 +13,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -29,16 +30,17 @@ import org.junit.runners.Parameterized; import org.junit.runners.Suite; +import org.opensearch.action.OriginalIndices; import org.opensearch.action.support.IndicesOptions; import org.opensearch.cluster.metadata.IndexAbstraction; import org.opensearch.cluster.metadata.IndexMetadata; import org.opensearch.cluster.metadata.Metadata; +import org.opensearch.cluster.metadata.ResolvedIndices; import org.opensearch.common.settings.Settings; import org.opensearch.core.common.unit.ByteSizeUnit; import org.opensearch.core.common.unit.ByteSizeValue; import org.opensearch.security.privileges.PrivilegesEvaluationContext; import org.opensearch.security.privileges.PrivilegesEvaluatorResponse; -import org.opensearch.security.resolver.IndexResolverReplacer; import org.opensearch.security.securityconf.FlattenedActionGroups; import org.opensearch.security.securityconf.impl.CType; import org.opensearch.security.securityconf.impl.SecurityDynamicConfiguration; @@ -80,7 +82,13 @@ public void wellKnown() throws Exception { " cluster_permissions:\n" + // " - cluster:monitor/nodes/stats*", CType.ROLES); - RoleBasedActionPrivileges subject = new RoleBasedActionPrivileges(roles, FlattenedActionGroups.EMPTY, Settings.EMPTY); + RoleBasedActionPrivileges subject = new RoleBasedActionPrivileges( + roles, + FlattenedActionGroups.EMPTY, + RuntimeOptimizedActionPrivileges.SpecialIndexProtection.NONE, + Settings.EMPTY, + false + ); assertThat(subject.hasClusterPrivilege(ctx().roles("test_role").get(), "cluster:monitor/nodes/stats"), isAllowed()); assertThat( @@ -99,7 +107,13 @@ public void notWellKnown() throws Exception { " cluster_permissions:\n" + // " - cluster:monitor/nodes/stats*", CType.ROLES); - RoleBasedActionPrivileges subject = new RoleBasedActionPrivileges(roles, FlattenedActionGroups.EMPTY, Settings.EMPTY); + RoleBasedActionPrivileges subject = new RoleBasedActionPrivileges( + roles, + FlattenedActionGroups.EMPTY, + RuntimeOptimizedActionPrivileges.SpecialIndexProtection.NONE, + Settings.EMPTY, + false + ); assertThat( subject.hasClusterPrivilege(ctx().roles("test_role").get(), "cluster:monitor/nodes/stats/somethingnotwellknown"), @@ -121,7 +135,13 @@ public void wildcard() throws Exception { " cluster_permissions:\n" + // " - '*'", CType.ROLES); - RoleBasedActionPrivileges subject = new RoleBasedActionPrivileges(roles, FlattenedActionGroups.EMPTY, Settings.EMPTY); + RoleBasedActionPrivileges subject = new RoleBasedActionPrivileges( + roles, + FlattenedActionGroups.EMPTY, + RuntimeOptimizedActionPrivileges.SpecialIndexProtection.NONE, + Settings.EMPTY, + false + ); assertThat(subject.hasClusterPrivilege(ctx().roles("test_role").get(), "cluster:whatever"), isAllowed()); assertThat( @@ -144,7 +164,13 @@ public void explicit_wellKnown() throws Exception { CType.ROLES ); - RoleBasedActionPrivileges subject = new RoleBasedActionPrivileges(roles, FlattenedActionGroups.EMPTY, Settings.EMPTY); + RoleBasedActionPrivileges subject = new RoleBasedActionPrivileges( + roles, + FlattenedActionGroups.EMPTY, + RuntimeOptimizedActionPrivileges.SpecialIndexProtection.NONE, + Settings.EMPTY, + false + ); assertThat(subject.hasExplicitClusterPrivilege(ctx().roles("explicit_role").get(), "cluster:monitor/nodes/stats"), isAllowed()); assertThat( @@ -175,7 +201,13 @@ public void explicit_notWellKnown() throws Exception { CType.ROLES ); - RoleBasedActionPrivileges subject = new RoleBasedActionPrivileges(roles, FlattenedActionGroups.EMPTY, Settings.EMPTY); + RoleBasedActionPrivileges subject = new RoleBasedActionPrivileges( + roles, + FlattenedActionGroups.EMPTY, + RuntimeOptimizedActionPrivileges.SpecialIndexProtection.NONE, + Settings.EMPTY, + false + ); assertThat( subject.hasExplicitClusterPrivilege(ctx().roles("explicit_role").get(), "cluster:monitor/nodes/notwellknown"), @@ -201,7 +233,13 @@ public void hasAny_wellKnown() throws Exception { " cluster_permissions:\n" + // " - cluster:monitor/nodes/stats*", CType.ROLES); - RoleBasedActionPrivileges subject = new RoleBasedActionPrivileges(roles, FlattenedActionGroups.EMPTY, Settings.EMPTY); + RoleBasedActionPrivileges subject = new RoleBasedActionPrivileges( + roles, + FlattenedActionGroups.EMPTY, + RuntimeOptimizedActionPrivileges.SpecialIndexProtection.NONE, + Settings.EMPTY, + false + ); assertThat( subject.hasAnyClusterPrivilege(ctx().roles("test_role").get(), ImmutableSet.of("cluster:monitor/nodes/stats")), @@ -231,7 +269,13 @@ public void hasAny_notWellKnown() throws Exception { " cluster_permissions:\n" + // " - cluster:monitor/nodes/*", CType.ROLES); - RoleBasedActionPrivileges subject = new RoleBasedActionPrivileges(roles, FlattenedActionGroups.EMPTY, Settings.EMPTY); + RoleBasedActionPrivileges subject = new RoleBasedActionPrivileges( + roles, + FlattenedActionGroups.EMPTY, + RuntimeOptimizedActionPrivileges.SpecialIndexProtection.NONE, + Settings.EMPTY, + false + ); assertThat( subject.hasAnyClusterPrivilege(ctx().roles("test_role").get(), ImmutableSet.of("cluster:monitor/nodes/notwellknown")), @@ -268,7 +312,13 @@ public void hasAny_wildcard() throws Exception { " cluster_permissions:\n" + // " - '*'", CType.ROLES); - RoleBasedActionPrivileges subject = new RoleBasedActionPrivileges(roles, FlattenedActionGroups.EMPTY, Settings.EMPTY); + RoleBasedActionPrivileges subject = new RoleBasedActionPrivileges( + roles, + FlattenedActionGroups.EMPTY, + RuntimeOptimizedActionPrivileges.SpecialIndexProtection.NONE, + Settings.EMPTY, + false + ); assertThat(subject.hasAnyClusterPrivilege(ctx().roles("test_role").get(), ImmutableSet.of("cluster:whatever")), isAllowed()); @@ -352,13 +402,13 @@ public void positive_partial2() throws Exception { @Test public void positive_noLocal() throws Exception { - IndexResolverReplacer.Resolved resolved = new IndexResolverReplacer.Resolved( - ImmutableSet.of(), - ImmutableSet.of(), - ImmutableSet.of("remote:a"), - ImmutableSet.of("remote:a"), - IndicesOptions.LENIENT_EXPAND_OPEN - ); + ResolvedIndices resolved = ResolvedIndices.of(Collections.emptySet()) + .withRemoteIndices( + Map.of( + "remote", + new OriginalIndices(new String[] { "a" }, IndicesOptions.STRICT_SINGLE_INDEX_NO_EXPAND_FORBID_CLOSED) + ) + ); PrivilegesEvaluatorResponse result = subject.hasIndexPrivilege( ctx().roles("test_role").indexMetadata(INDEX_METADATA).get(), requiredActions, @@ -462,7 +512,13 @@ public IndicesAndAliases(IndexSpec indexSpec, ActionSpec actionSpec, Statefulnes .build(); } - this.subject = new RoleBasedActionPrivileges(roles, FlattenedActionGroups.EMPTY, settings); + this.subject = new RoleBasedActionPrivileges( + roles, + FlattenedActionGroups.EMPTY, + RuntimeOptimizedActionPrivileges.SpecialIndexProtection.NONE, + settings, + false + ); if (statefulness == Statefulness.STATEFUL || statefulness == Statefulness.STATEFUL_LIMITED) { this.subject.updateStatefulIndexPrivileges(INDEX_METADATA.getIndicesLookup(), 1); @@ -481,14 +537,8 @@ public IndicesAndAliases(IndexSpec indexSpec, ActionSpec actionSpec, Statefulnes .of("index_b1", "index_b2")// .build(); - static IndexResolverReplacer.Resolved resolved(String... indices) { - return new IndexResolverReplacer.Resolved( - ImmutableSet.of(), - ImmutableSet.copyOf(indices), - ImmutableSet.copyOf(indices), - ImmutableSet.of(), - IndicesOptions.LENIENT_EXPAND_OPEN - ); + static ResolvedIndices resolved(String... indices) { + return ResolvedIndices.of(indices); } } @@ -637,7 +687,13 @@ public DataStreams(IndexSpec indexSpec, ActionSpec actionSpec, Statefulness stat .build(); } - this.subject = new RoleBasedActionPrivileges(roles, FlattenedActionGroups.EMPTY, settings); + this.subject = new RoleBasedActionPrivileges( + roles, + FlattenedActionGroups.EMPTY, + RuntimeOptimizedActionPrivileges.SpecialIndexProtection.NONE, + settings, + false + ); if (statefulness == Statefulness.STATEFUL || statefulness == Statefulness.STATEFUL_LIMITED) { this.subject.updateStatefulIndexPrivileges(INDEX_METADATA.getIndicesLookup(), 1); @@ -648,28 +704,24 @@ public DataStreams(IndexSpec indexSpec, ActionSpec actionSpec, Statefulness stat dataStreams("data_stream_a11", "data_stream_a12", "data_stream_a21", "data_stream_a22", "data_stream_b1", "data_stream_b2") .build(); - static IndexResolverReplacer.Resolved resolved(String... indices) { - ImmutableSet.Builder allIndices = ImmutableSet.builder(); - - for (String index : indices) { - IndexAbstraction indexAbstraction = INDEX_METADATA.getIndicesLookup().get(index); + static ResolvedIndices resolved(String... indices) { + return ResolvedIndices.of(indices); + // TODO check + // ImmutableSet.Builder allIndices = ImmutableSet.builder(); + // + // + // for (String index : indices) { + // IndexAbstraction indexAbstraction = INDEX_METADATA.getIndicesLookup().get(index); + // + // if (indexAbstraction instanceof IndexAbstraction.DataStream) { + // allIndices.addAll( + // indexAbstraction.getIndices().stream().map(i -> i.getIndex().getName()).collect(Collectors.toList()) + // ); + // } + // + // allIndices.add(index); + // } - if (indexAbstraction instanceof IndexAbstraction.DataStream) { - allIndices.addAll( - indexAbstraction.getIndices().stream().map(i -> i.getIndex().getName()).collect(Collectors.toList()) - ); - } - - allIndices.add(index); - } - - return new IndexResolverReplacer.Resolved( - ImmutableSet.of(), - allIndices.build(), - ImmutableSet.copyOf(indices), - ImmutableSet.of(), - IndicesOptions.LENIENT_EXPAND_OPEN - ); } } @@ -808,7 +860,7 @@ public void relevantOnly_identity() throws Exception { assertTrue( "relevantOnly() returned identical object", - RoleBasedActionPrivileges.StatefulIndexPrivileges.relevantOnly(metadata) == metadata + RoleBasedActionPrivileges.StatefulIndexPrivileges.relevantOnly(metadata, i -> false) == metadata ); } @@ -822,7 +874,10 @@ public void relevantOnly_closed() throws Exception { assertNotNull("Original metadata contains index_open_1", metadata.get("index_open_1")); assertNotNull("Original metadata contains index_closed", metadata.get("index_closed")); - Map filteredMetadata = RoleBasedActionPrivileges.StatefulIndexPrivileges.relevantOnly(metadata); + Map filteredMetadata = RoleBasedActionPrivileges.StatefulIndexPrivileges.relevantOnly( + metadata, + i -> false + ); assertNotNull("Filtered metadata contains index_open_1", filteredMetadata.get("index_open_1")); assertNull("Filtered metadata does not contain index_closed", filteredMetadata.get("index_closed")); @@ -835,7 +890,10 @@ public void relevantOnly_dataStreamBackingIndices() throws Exception { assertNotNull("Original metadata contains backing index", metadata.get(".ds-data_stream_1-000001")); assertNotNull("Original metadata contains data stream", metadata.get("data_stream_1")); - Map filteredMetadata = RoleBasedActionPrivileges.StatefulIndexPrivileges.relevantOnly(metadata); + Map filteredMetadata = RoleBasedActionPrivileges.StatefulIndexPrivileges.relevantOnly( + metadata, + i -> false + ); assertNull("Filtered metadata does not contain backing index", filteredMetadata.get(".ds-data_stream_1-000001")); assertNotNull("Filtered metadata contains data stream", filteredMetadata.get("data_stream_1")); @@ -866,12 +924,18 @@ public void hasIndexPrivilege_errors() throws Exception { CType.ROLES ); - RoleBasedActionPrivileges subject = new RoleBasedActionPrivileges(roles, FlattenedActionGroups.EMPTY, Settings.EMPTY); + RoleBasedActionPrivileges subject = new RoleBasedActionPrivileges( + roles, + FlattenedActionGroups.EMPTY, + RuntimeOptimizedActionPrivileges.SpecialIndexProtection.NONE, + Settings.EMPTY, + false + ); PrivilegesEvaluatorResponse result = subject.hasIndexPrivilege( ctx().roles("role_with_errors").get(), Set.of("indices:some_action", "indices:data/write/index"), - IndexResolverReplacer.Resolved.ofIndex("any_index") + ResolvedIndices.of("any_index") ); assertThat(result, isForbidden()); assertTrue(result.hasEvaluationExceptions()); @@ -892,12 +956,18 @@ public void hasExplicitIndexPrivilege_positive() throws Exception { CType.ROLES ); - RoleBasedActionPrivileges subject = new RoleBasedActionPrivileges(roles, FlattenedActionGroups.EMPTY, Settings.EMPTY); + RoleBasedActionPrivileges subject = new RoleBasedActionPrivileges( + roles, + FlattenedActionGroups.EMPTY, + RuntimeOptimizedActionPrivileges.SpecialIndexProtection.NONE, + Settings.EMPTY, + false + ); PrivilegesEvaluatorResponse result = subject.hasExplicitIndexPrivilege( ctx().roles("test_role").get(), Set.of("system:admin/system_index"), - IndexResolverReplacer.Resolved.ofIndex("test_index") + ResolvedIndices.of("test_index") ); assertThat(result, isAllowed()); } @@ -912,12 +982,18 @@ public void hasExplicitIndexPrivilege_positive_wildcard() throws Exception { CType.ROLES ); - RoleBasedActionPrivileges subject = new RoleBasedActionPrivileges(roles, FlattenedActionGroups.EMPTY, Settings.EMPTY); + RoleBasedActionPrivileges subject = new RoleBasedActionPrivileges( + roles, + FlattenedActionGroups.EMPTY, + RuntimeOptimizedActionPrivileges.SpecialIndexProtection.NONE, + Settings.EMPTY, + false + ); PrivilegesEvaluatorResponse result = subject.hasExplicitIndexPrivilege( ctx().roles("test_role").get(), Set.of("system:admin/system_index"), - IndexResolverReplacer.Resolved.ofIndex("test_index") + ResolvedIndices.of("test_index") ); assertThat(result, isAllowed()); } @@ -929,12 +1005,18 @@ public void hasExplicitIndexPrivilege_noWildcard() throws Exception { CType.ROLES ); - RoleBasedActionPrivileges subject = new RoleBasedActionPrivileges(roles, FlattenedActionGroups.EMPTY, Settings.EMPTY); + RoleBasedActionPrivileges subject = new RoleBasedActionPrivileges( + roles, + FlattenedActionGroups.EMPTY, + RuntimeOptimizedActionPrivileges.SpecialIndexProtection.NONE, + Settings.EMPTY, + false + ); PrivilegesEvaluatorResponse result = subject.hasExplicitIndexPrivilege( ctx().roles("test_role").get(), Set.of("system:admin/system_index"), - IndexResolverReplacer.Resolved.ofIndex("test_index") + ResolvedIndices.of("test_index") ); assertThat(result, isForbidden()); } @@ -949,12 +1031,18 @@ public void hasExplicitIndexPrivilege_negative_wrongAction() throws Exception { CType.ROLES ); - RoleBasedActionPrivileges subject = new RoleBasedActionPrivileges(roles, FlattenedActionGroups.EMPTY, Settings.EMPTY); + RoleBasedActionPrivileges subject = new RoleBasedActionPrivileges( + roles, + FlattenedActionGroups.EMPTY, + RuntimeOptimizedActionPrivileges.SpecialIndexProtection.NONE, + Settings.EMPTY, + false + ); PrivilegesEvaluatorResponse result = subject.hasExplicitIndexPrivilege( ctx().roles("test_role").get(), Set.of("system:admin/system_foo"), - IndexResolverReplacer.Resolved.ofIndex("test_index") + ResolvedIndices.of("test_index") ); assertThat(result, isForbidden()); } @@ -969,12 +1057,18 @@ public void hasExplicitIndexPrivilege_errors() throws Exception { CType.ROLES ); - RoleBasedActionPrivileges subject = new RoleBasedActionPrivileges(roles, FlattenedActionGroups.EMPTY, Settings.EMPTY); + RoleBasedActionPrivileges subject = new RoleBasedActionPrivileges( + roles, + FlattenedActionGroups.EMPTY, + RuntimeOptimizedActionPrivileges.SpecialIndexProtection.NONE, + Settings.EMPTY, + false + ); PrivilegesEvaluatorResponse result = subject.hasExplicitIndexPrivilege( ctx().roles("role_with_errors").get(), Set.of("system:admin/system_index"), - IndexResolverReplacer.Resolved.ofIndex("any_index") + ResolvedIndices.of("any_index") ); assertThat(result, isForbidden()); assertTrue(result.hasEvaluationExceptions()); @@ -998,20 +1092,26 @@ public void aliasesOnDataStreamBackingIndices() throws Exception { + " allowed_actions: ['indices:data/write/index']", CType.ROLES ); - RoleBasedActionPrivileges subject = new RoleBasedActionPrivileges(roles, FlattenedActionGroups.EMPTY, Settings.EMPTY); + RoleBasedActionPrivileges subject = new RoleBasedActionPrivileges( + roles, + FlattenedActionGroups.EMPTY, + RuntimeOptimizedActionPrivileges.SpecialIndexProtection.NONE, + Settings.EMPTY, + false + ); subject.updateStatefulIndexPrivileges(metadata.getIndicesLookup(), 2); PrivilegesEvaluatorResponse resultForIndexCoveredByAlias = subject.hasIndexPrivilege( ctx().roles("role").indexMetadata(metadata).get(), Set.of("indices:data/write/index"), - IndexResolverReplacer.Resolved.ofIndex(".ds-ds_a-000001") + ResolvedIndices.of(".ds-ds_a-000001") ); assertThat(resultForIndexCoveredByAlias, isAllowed()); PrivilegesEvaluatorResponse resultForIndexNotCoveredByAlias = subject.hasIndexPrivilege( ctx().roles("role").indexMetadata(metadata).get(), Set.of("indices:data/write/index"), - IndexResolverReplacer.Resolved.ofIndex(".ds-ds_a-000002") + ResolvedIndices.of(".ds-ds_a-000002") ); assertThat(resultForIndexNotCoveredByAlias, isForbidden()); } @@ -1029,7 +1129,9 @@ public void statefulDisabled() throws Exception { RoleBasedActionPrivileges subject = new RoleBasedActionPrivileges( roles, FlattenedActionGroups.EMPTY, - Settings.builder().put(RoleBasedActionPrivileges.PRECOMPUTED_PRIVILEGES_ENABLED.getKey(), false).build() + RuntimeOptimizedActionPrivileges.SpecialIndexProtection.NONE, + Settings.builder().put(RoleBasedActionPrivileges.PRECOMPUTED_PRIVILEGES_ENABLED.getKey(), false).build(), + false ); subject.updateStatefulIndexPrivileges(metadata, 1); assertEquals(0, subject.getEstimatedStatefulIndexByteSize()); @@ -1048,7 +1150,13 @@ public static class StatefulIndexPrivilegesHeapSize { @Test public void estimatedSize() throws Exception { - RoleBasedActionPrivileges subject = new RoleBasedActionPrivileges(roles, FlattenedActionGroups.EMPTY, Settings.EMPTY); + RoleBasedActionPrivileges subject = new RoleBasedActionPrivileges( + roles, + FlattenedActionGroups.EMPTY, + RuntimeOptimizedActionPrivileges.SpecialIndexProtection.NONE, + Settings.EMPTY, + false + ); subject.updateStatefulIndexPrivileges(indices, 1); diff --git a/src/integrationTest/java/org/opensearch/security/privileges/actionlevel/SubjectBasedActionPrivilegesTest.java b/src/integrationTest/java/org/opensearch/security/privileges/actionlevel/SubjectBasedActionPrivilegesTest.java index 63d630b289..f1c01f8bb9 100644 --- a/src/integrationTest/java/org/opensearch/security/privileges/actionlevel/SubjectBasedActionPrivilegesTest.java +++ b/src/integrationTest/java/org/opensearch/security/privileges/actionlevel/SubjectBasedActionPrivilegesTest.java @@ -13,6 +13,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; +import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Set; @@ -27,12 +28,13 @@ import org.junit.runners.Parameterized; import org.junit.runners.Suite; +import org.opensearch.action.OriginalIndices; import org.opensearch.action.support.IndicesOptions; import org.opensearch.cluster.metadata.IndexAbstraction; import org.opensearch.cluster.metadata.Metadata; +import org.opensearch.cluster.metadata.ResolvedIndices; import org.opensearch.security.privileges.PrivilegesEvaluationContext; import org.opensearch.security.privileges.PrivilegesEvaluatorResponse; -import org.opensearch.security.resolver.IndexResolverReplacer; import org.opensearch.security.securityconf.FlattenedActionGroups; import org.opensearch.security.securityconf.impl.CType; import org.opensearch.security.securityconf.impl.SecurityDynamicConfiguration; @@ -70,7 +72,12 @@ public void wellKnown() throws Exception { - cluster:monitor/nodes/stats* """); - SubjectBasedActionPrivileges subject = new SubjectBasedActionPrivileges(config, FlattenedActionGroups.EMPTY); + SubjectBasedActionPrivileges subject = new SubjectBasedActionPrivileges( + config, + FlattenedActionGroups.EMPTY, + RuntimeOptimizedActionPrivileges.SpecialIndexProtection.NONE, + false + ); assertThat(subject.hasClusterPrivilege(ctx().get(), "cluster:monitor/nodes/stats"), isAllowed()); } @@ -81,7 +88,12 @@ public void notWellKnown() throws Exception { - cluster:monitor/nodes/stats* """); - SubjectBasedActionPrivileges subject = new SubjectBasedActionPrivileges(config, FlattenedActionGroups.EMPTY); + SubjectBasedActionPrivileges subject = new SubjectBasedActionPrivileges( + config, + FlattenedActionGroups.EMPTY, + RuntimeOptimizedActionPrivileges.SpecialIndexProtection.NONE, + false + ); assertThat(subject.hasClusterPrivilege(ctx().get(), "cluster:monitor/nodes/stats/somethingnotwellknown"), isAllowed()); } @@ -92,7 +104,12 @@ public void negative() throws Exception { - cluster:monitor/nodes/stats* """); - SubjectBasedActionPrivileges subject = new SubjectBasedActionPrivileges(config, FlattenedActionGroups.EMPTY); + SubjectBasedActionPrivileges subject = new SubjectBasedActionPrivileges( + config, + FlattenedActionGroups.EMPTY, + RuntimeOptimizedActionPrivileges.SpecialIndexProtection.NONE, + false + ); assertThat(subject.hasClusterPrivilege(ctx().get(), "cluster:monitor/nodes/foo"), isForbidden()); } @@ -103,7 +120,12 @@ public void wildcard() throws Exception { - '*' """); - SubjectBasedActionPrivileges subject = new SubjectBasedActionPrivileges(config, FlattenedActionGroups.EMPTY); + SubjectBasedActionPrivileges subject = new SubjectBasedActionPrivileges( + config, + FlattenedActionGroups.EMPTY, + RuntimeOptimizedActionPrivileges.SpecialIndexProtection.NONE, + false + ); assertThat(subject.hasClusterPrivilege(ctx().get(), "cluster:whatever"), isAllowed()); } @@ -114,7 +136,12 @@ public void explicit_wellKnown() throws Exception { - cluster:monitor/nodes/stats """); - SubjectBasedActionPrivileges subject = new SubjectBasedActionPrivileges(config, FlattenedActionGroups.EMPTY); + SubjectBasedActionPrivileges subject = new SubjectBasedActionPrivileges( + config, + FlattenedActionGroups.EMPTY, + RuntimeOptimizedActionPrivileges.SpecialIndexProtection.NONE, + false + ); assertThat(subject.hasExplicitClusterPrivilege(ctx().get(), "cluster:monitor/nodes/stats"), isAllowed()); } @@ -125,7 +152,12 @@ public void explicit_notWellKnown() throws Exception { - cluster:monitor/nodes/* """); - SubjectBasedActionPrivileges subject = new SubjectBasedActionPrivileges(config, FlattenedActionGroups.EMPTY); + SubjectBasedActionPrivileges subject = new SubjectBasedActionPrivileges( + config, + FlattenedActionGroups.EMPTY, + RuntimeOptimizedActionPrivileges.SpecialIndexProtection.NONE, + false + ); assertThat(subject.hasExplicitClusterPrivilege(ctx().get(), "cluster:monitor/nodes/notwellknown"), isAllowed()); } @@ -136,7 +168,12 @@ public void explicit_notExplicit() throws Exception { - '*' """); - SubjectBasedActionPrivileges subject = new SubjectBasedActionPrivileges(config, FlattenedActionGroups.EMPTY); + SubjectBasedActionPrivileges subject = new SubjectBasedActionPrivileges( + config, + FlattenedActionGroups.EMPTY, + RuntimeOptimizedActionPrivileges.SpecialIndexProtection.NONE, + false + ); assertThat( subject.hasExplicitClusterPrivilege(ctx().get(), "cluster:monitor/nodes/stats"), isForbidden(missingPrivileges("cluster:monitor/nodes/stats")) @@ -150,7 +187,12 @@ public void hasAny_wellKnown() throws Exception { - cluster:monitor/nodes/stats* """); - SubjectBasedActionPrivileges subject = new SubjectBasedActionPrivileges(config, FlattenedActionGroups.EMPTY); + SubjectBasedActionPrivileges subject = new SubjectBasedActionPrivileges( + config, + FlattenedActionGroups.EMPTY, + RuntimeOptimizedActionPrivileges.SpecialIndexProtection.NONE, + false + ); assertThat(subject.hasAnyClusterPrivilege(ctx().get(), ImmutableSet.of("cluster:monitor/nodes/stats")), isAllowed()); } @@ -161,7 +203,12 @@ public void hasAny_wildcard() throws Exception { - '*' """); - SubjectBasedActionPrivileges subject = new SubjectBasedActionPrivileges(config, FlattenedActionGroups.EMPTY); + SubjectBasedActionPrivileges subject = new SubjectBasedActionPrivileges( + config, + FlattenedActionGroups.EMPTY, + RuntimeOptimizedActionPrivileges.SpecialIndexProtection.NONE, + false + ); assertThat(subject.hasAnyClusterPrivilege(ctx().get(), ImmutableSet.of("cluster:monitor/nodes/stats")), isAllowed()); } } @@ -224,13 +271,13 @@ public void positive_partial2() throws Exception { @Test public void positive_noLocal() throws Exception { - IndexResolverReplacer.Resolved resolved = new IndexResolverReplacer.Resolved( - ImmutableSet.of(), - ImmutableSet.of(), - ImmutableSet.of("remote:a"), - ImmutableSet.of("remote:a"), - IndicesOptions.LENIENT_EXPAND_OPEN - ); + ResolvedIndices resolved = ResolvedIndices.of(Collections.emptySet()) + .withRemoteIndices( + Map.of( + "remote", + new OriginalIndices(new String[] { "a" }, IndicesOptions.STRICT_SINGLE_INDEX_NO_EXPAND_FORBID_CLOSED) + ) + ); PrivilegesEvaluatorResponse result = subject.hasIndexPrivilege( ctx().indexMetadata(INDEX_METADATA).get(), requiredActions, @@ -315,7 +362,12 @@ public IndicesAndAliases(IndexSpec indexSpec, ActionSpec actionSpec) throws Exce : ImmutableSet.of("indices:foobar/unknown"); this.indexSpec.indexMetadata = INDEX_METADATA.getIndicesLookup(); - this.subject = new SubjectBasedActionPrivileges(config, FlattenedActionGroups.EMPTY); + this.subject = new SubjectBasedActionPrivileges( + config, + FlattenedActionGroups.EMPTY, + RuntimeOptimizedActionPrivileges.SpecialIndexProtection.NONE, + false + ); } final static Metadata INDEX_METADATA = // @@ -330,14 +382,8 @@ public IndicesAndAliases(IndexSpec indexSpec, ActionSpec actionSpec) throws Exce .of("index_b1", "index_b2")// .build(); - static IndexResolverReplacer.Resolved resolved(String... indices) { - return new IndexResolverReplacer.Resolved( - ImmutableSet.of(), - ImmutableSet.copyOf(indices), - ImmutableSet.copyOf(indices), - ImmutableSet.of(), - IndicesOptions.LENIENT_EXPAND_OPEN - ); + static ResolvedIndices resolved(String... indices) { + return ResolvedIndices.of(indices); } } @@ -466,14 +512,21 @@ public DataStreams(IndexSpec indexSpec, ActionSpec actionSpec) throws Exception ? ImmutableSet.of("indices:data/write/update") : ImmutableSet.of("indices:foobar/unknown"); this.indexSpec.indexMetadata = INDEX_METADATA.getIndicesLookup(); - this.subject = new SubjectBasedActionPrivileges(config, FlattenedActionGroups.EMPTY); + this.subject = new SubjectBasedActionPrivileges( + config, + FlattenedActionGroups.EMPTY, + RuntimeOptimizedActionPrivileges.SpecialIndexProtection.NONE, + false + ); } final static Metadata INDEX_METADATA = // dataStreams("data_stream_a11", "data_stream_a12", "data_stream_a21", "data_stream_a22", "data_stream_b1", "data_stream_b2") .build(); - static IndexResolverReplacer.Resolved resolved(String... indices) { + static ResolvedIndices resolved(String... indices) { + return ResolvedIndices.of(indices); + /* TODO CHECK ImmutableSet.Builder allIndices = ImmutableSet.builder(); for (String index : indices) { @@ -495,6 +548,8 @@ static IndexResolverReplacer.Resolved resolved(String... indices) { ImmutableSet.of(), IndicesOptions.LENIENT_EXPAND_OPEN ); + + */ } } @@ -625,12 +680,17 @@ public void hasExplicitIndexPrivilege_positive() throws Exception { allowed_actions: ['system:admin/system_index'] """); - SubjectBasedActionPrivileges subject = new SubjectBasedActionPrivileges(config, FlattenedActionGroups.EMPTY); + SubjectBasedActionPrivileges subject = new SubjectBasedActionPrivileges( + config, + FlattenedActionGroups.EMPTY, + RuntimeOptimizedActionPrivileges.SpecialIndexProtection.NONE, + false + ); PrivilegesEvaluatorResponse result = subject.hasExplicitIndexPrivilege( ctx().get(), Set.of("system:admin/system_index"), - IndexResolverReplacer.Resolved.ofIndex("test_index") + ResolvedIndices.of("test_index") ); assertThat(result, isAllowed()); } @@ -643,12 +703,17 @@ public void hasExplicitIndexPrivilege_positive_pattern() throws Exception { allowed_actions: ['system:admin/system_index*'] """); - SubjectBasedActionPrivileges subject = new SubjectBasedActionPrivileges(config, FlattenedActionGroups.EMPTY); + SubjectBasedActionPrivileges subject = new SubjectBasedActionPrivileges( + config, + FlattenedActionGroups.EMPTY, + RuntimeOptimizedActionPrivileges.SpecialIndexProtection.NONE, + false + ); PrivilegesEvaluatorResponse result = subject.hasExplicitIndexPrivilege( ctx().get(), Set.of("system:admin/system_index"), - IndexResolverReplacer.Resolved.ofIndex("test_index") + ResolvedIndices.of("test_index") ); assertThat(result, isAllowed()); } @@ -660,12 +725,17 @@ public void hasExplicitIndexPrivilege_noWildcard() throws Exception { - index_patterns: ['test_index'] allowed_actions: ['*'] """); - SubjectBasedActionPrivileges subject = new SubjectBasedActionPrivileges(config, FlattenedActionGroups.EMPTY); + SubjectBasedActionPrivileges subject = new SubjectBasedActionPrivileges( + config, + FlattenedActionGroups.EMPTY, + RuntimeOptimizedActionPrivileges.SpecialIndexProtection.NONE, + false + ); PrivilegesEvaluatorResponse result = subject.hasExplicitIndexPrivilege( ctx().get(), Set.of("system:admin/system_index"), - IndexResolverReplacer.Resolved.ofIndex("test_index") + ResolvedIndices.of("test_index") ); assertThat(result, isForbidden()); } @@ -677,12 +747,17 @@ public void hasExplicitIndexPrivilege_negative_wrongAction() throws Exception { - index_patterns: ['test_index'] allowed_actions: ['system:admin/system*'] """); - SubjectBasedActionPrivileges subject = new SubjectBasedActionPrivileges(config, FlattenedActionGroups.EMPTY); + SubjectBasedActionPrivileges subject = new SubjectBasedActionPrivileges( + config, + FlattenedActionGroups.EMPTY, + RuntimeOptimizedActionPrivileges.SpecialIndexProtection.NONE, + false + ); PrivilegesEvaluatorResponse result = subject.hasExplicitIndexPrivilege( ctx().get(), Set.of("system:admin/system_foo"), - IndexResolverReplacer.Resolved.ofIndex("test_index") + ResolvedIndices.of("test_index") ); assertThat(result, isForbidden()); } @@ -694,12 +769,17 @@ public void hasExplicitIndexPrivilege_errors() throws Exception { - index_patterns: ['/invalid_regex${user.name}\\/'] allowed_actions: ['system:admin/system*'] """); - SubjectBasedActionPrivileges subject = new SubjectBasedActionPrivileges(config, FlattenedActionGroups.EMPTY); + SubjectBasedActionPrivileges subject = new SubjectBasedActionPrivileges( + config, + FlattenedActionGroups.EMPTY, + RuntimeOptimizedActionPrivileges.SpecialIndexProtection.NONE, + false + ); PrivilegesEvaluatorResponse result = subject.hasExplicitIndexPrivilege( ctx().get(), Set.of("system:admin/system_index"), - IndexResolverReplacer.Resolved.ofIndex("test_index") + ResolvedIndices.of("test_index") ); assertThat(result, isForbidden()); assertTrue(result.hasEvaluationExceptions()); diff --git a/src/integrationTest/java/org/opensearch/security/privileges/dlsfls/DlsFlsLegacyHeadersTest.java b/src/integrationTest/java/org/opensearch/security/privileges/dlsfls/DlsFlsLegacyHeadersTest.java index 5fe2837d19..8392d9fa1d 100644 --- a/src/integrationTest/java/org/opensearch/security/privileges/dlsfls/DlsFlsLegacyHeadersTest.java +++ b/src/integrationTest/java/org/opensearch/security/privileges/dlsfls/DlsFlsLegacyHeadersTest.java @@ -15,14 +15,12 @@ import java.util.Map; import com.google.common.collect.ImmutableList; -import com.google.common.collect.ImmutableSet; import org.junit.Test; import org.opensearch.Version; import org.opensearch.action.admin.cluster.shards.ClusterSearchShardsRequest; import org.opensearch.action.search.SearchRequest; import org.opensearch.cluster.ClusterState; -import org.opensearch.cluster.metadata.IndexNameExpressionResolver; import org.opensearch.cluster.metadata.Metadata; import org.opensearch.common.CheckedFunction; import org.opensearch.common.settings.Settings; @@ -35,14 +33,13 @@ import org.opensearch.index.query.RangeQueryBuilder; import org.opensearch.index.query.TermQueryBuilder; import org.opensearch.search.internal.ShardSearchRequest; -import org.opensearch.security.privileges.ActionPrivileges; import org.opensearch.security.privileges.PrivilegesEvaluationContext; import org.opensearch.security.securityconf.impl.SecurityDynamicConfiguration; import org.opensearch.security.securityconf.impl.v7.RoleV7; import org.opensearch.security.support.Base64Helper; import org.opensearch.security.support.ConfigConstants; -import org.opensearch.security.user.User; import org.opensearch.security.util.MockIndexMetadataBuilder; +import org.opensearch.security.util.MockPrivilegeEvaluationContextBuilder; import org.opensearch.test.framework.TestSecurityConfig; import org.opensearch.transport.Transport; @@ -335,40 +332,21 @@ public void prepare_ccs() throws Exception { ThreadContext threadContext = new ThreadContext(Settings.EMPTY); threadContext.putTransient(ConfigConstants.OPENDISTRO_SECURITY_SSL_TRANSPORT_TRUSTED_CLUSTER_REQUEST, true); - User user = new User("test_user"); ClusterState clusterState = ClusterState.builder(ClusterState.EMPTY_STATE).metadata(metadata).build(); - PrivilegesEvaluationContext ctx = new PrivilegesEvaluationContext( - user, - ImmutableSet.of("test_role"), - null, - new ClusterSearchShardsRequest(), - null, - null, - new IndexNameExpressionResolver(new ThreadContext(Settings.EMPTY)), - () -> clusterState, - ActionPrivileges.EMPTY - ); + PrivilegesEvaluationContext ctx = MockPrivilegeEvaluationContextBuilder.ctx() + .roles("test_role") + .request(new ClusterSearchShardsRequest()) + .clusterState(clusterState) + .get(); DlsFlsLegacyHeaders.prepare(threadContext, ctx, dlsFlsProcessedConfig(exampleRolesConfig(), metadata), metadata, false); assertTrue(threadContext.getResponseHeaders().containsKey(ConfigConstants.OPENDISTRO_SECURITY_DLS_QUERY_HEADER)); } static PrivilegesEvaluationContext ctx(Metadata metadata, String... roles) { - User user = new User("test_user"); ClusterState clusterState = ClusterState.builder(ClusterState.EMPTY_STATE).metadata(metadata).build(); - - return new PrivilegesEvaluationContext( - user, - ImmutableSet.copyOf(roles), - null, - null, - null, - null, - new IndexNameExpressionResolver(new ThreadContext(Settings.EMPTY)), - () -> clusterState, - ActionPrivileges.EMPTY - ); + return MockPrivilegeEvaluationContextBuilder.ctx().roles(roles).clusterState(clusterState).get(); } static DlsFlsProcessedConfig dlsFlsProcessedConfig(SecurityDynamicConfiguration rolesConfig, Metadata metadata) { diff --git a/src/integrationTest/java/org/opensearch/security/privileges/dlsfls/DocumentPrivilegesTest.java b/src/integrationTest/java/org/opensearch/security/privileges/dlsfls/DocumentPrivilegesTest.java index 7182b22ed6..68695db59a 100644 --- a/src/integrationTest/java/org/opensearch/security/privileges/dlsfls/DocumentPrivilegesTest.java +++ b/src/integrationTest/java/org/opensearch/security/privileges/dlsfls/DocumentPrivilegesTest.java @@ -32,12 +32,12 @@ import org.junit.runners.Parameterized; import org.junit.runners.Suite; -import org.opensearch.action.IndicesRequest; -import org.opensearch.action.support.IndicesOptions; +import org.opensearch.action.support.ActionRequestMetadata; import org.opensearch.cluster.ClusterState; import org.opensearch.cluster.metadata.IndexAbstraction; import org.opensearch.cluster.metadata.IndexNameExpressionResolver; import org.opensearch.cluster.metadata.Metadata; +import org.opensearch.cluster.metadata.ResolvedIndices; import org.opensearch.common.CheckedFunction; import org.opensearch.common.settings.Settings; import org.opensearch.common.util.concurrent.ThreadContext; @@ -56,7 +56,6 @@ import org.opensearch.security.privileges.PrivilegesEvaluationContext; import org.opensearch.security.privileges.PrivilegesEvaluationException; import org.opensearch.security.privileges.actionlevel.RoleBasedActionPrivileges; -import org.opensearch.security.resolver.IndexResolverReplacer; import org.opensearch.security.securityconf.impl.SecurityDynamicConfiguration; import org.opensearch.security.securityconf.impl.v7.RoleV7; import org.opensearch.security.user.User; @@ -531,6 +530,7 @@ public IndicesAndAliases_getRestriction( null, null, null, + null, () -> CLUSTER_STATE, ActionPrivileges.EMPTY ); @@ -567,17 +567,12 @@ public static class IndicesAndAliases_isUnrestricted { final static IndexNameExpressionResolver INDEX_NAME_EXPRESSION_RESOLVER = new IndexNameExpressionResolver( new ThreadContext(Settings.EMPTY) ); - final static IndexResolverReplacer RESOLVER_REPLACER = new IndexResolverReplacer( - INDEX_NAME_EXPRESSION_RESOLVER, - () -> CLUSTER_STATE, - null - ); final Statefulness statefulness; final UserSpec userSpec; final User user; final IndicesSpec indicesSpec; - final IndexResolverReplacer.Resolved resolvedIndices; + final ResolvedIndices resolvedIndices; final PrivilegesEvaluationContext context; final boolean dfmEmptyOverridesAll; @@ -685,7 +680,7 @@ public void alias_static() throws Exception { DocumentPrivileges subject = createSubject(roleConfig); boolean result = subject.isUnrestricted(context, resolvedIndices); - if (resolvedIndices.getAllIndices().contains("index_b1")) { + if (resolvedIndices.local().names().contains("index_b1")) { // index_b1 is not covered by any of the above roles, so there should be always a restriction assertFalse(result); } else if (dfmEmptyOverridesAll && userSpec.roles.contains("non_dls_role")) { @@ -741,7 +736,7 @@ public void alias_wildcard() throws Exception { DocumentPrivileges subject = createSubject(roleConfig); boolean result = subject.isUnrestricted(context, resolvedIndices); - if (resolvedIndices.getAllIndices().contains("index_b1")) { + if (resolvedIndices.local().names().contains("index_b1")) { // index_b1 is not covered by any of the above roles, so there should be always a restriction assertFalse(result); } else if (dfmEmptyOverridesAll && userSpec.roles.contains("non_dls_role")) { @@ -771,7 +766,7 @@ public void alias_template() throws Exception { if (userSpec.attributes.isEmpty()) { // All roles defined above use attributes. If there are no user attributes, we must get a restricted result. assertFalse(result); - } else if (resolvedIndices.getAllIndices().contains("index_b1")) { + } else if (resolvedIndices.local().names().contains("index_b1")) { // index_b1 is not covered by any of the above roles, so there should be always a restriction assertFalse(result); } else if (dfmEmptyOverridesAll && userSpec.roles.contains("non_dls_role")) { @@ -828,31 +823,16 @@ public IndicesAndAliases_isUnrestricted( this.userSpec = userSpec; this.indicesSpec = indicesSpec; this.user = userSpec.buildUser(); - this.resolvedIndices = RESOLVER_REPLACER.resolveRequest(new IndicesRequest.Replaceable() { - - @Override - public String[] indices() { - return indicesSpec.indices.toArray(new String[0]); - } - - @Override - public IndicesOptions indicesOptions() { - return IndicesOptions.LENIENT_EXPAND_OPEN_CLOSED; - } - - @Override - public IndicesRequest indices(String... strings) { - return this; - } - }); + this.resolvedIndices = ResolvedIndices.of(indicesSpec.indices); this.context = new PrivilegesEvaluationContext( this.user, ImmutableSet.copyOf(userSpec.roles), null, null, + ActionRequestMetadata.empty(), null, - RESOLVER_REPLACER, INDEX_NAME_EXPRESSION_RESOLVER, + null, () -> CLUSTER_STATE, ActionPrivileges.EMPTY ); @@ -1151,7 +1131,9 @@ public DataStreams_getRestriction( null, null, null, + null, () -> CLUSTER_STATE, + ActionPrivileges.EMPTY ); this.statefulness = statefulness; diff --git a/src/integrationTest/java/org/opensearch/security/privileges/dlsfls/FieldMaskingTest.java b/src/integrationTest/java/org/opensearch/security/privileges/dlsfls/FieldMaskingTest.java index 75d05e1fae..183bba5080 100644 --- a/src/integrationTest/java/org/opensearch/security/privileges/dlsfls/FieldMaskingTest.java +++ b/src/integrationTest/java/org/opensearch/security/privileges/dlsfls/FieldMaskingTest.java @@ -13,7 +13,6 @@ import java.nio.charset.StandardCharsets; import java.util.Arrays; -import com.google.common.collect.ImmutableSet; import org.apache.lucene.util.BytesRef; import org.junit.Test; import org.junit.runner.RunWith; @@ -22,13 +21,12 @@ import org.opensearch.cluster.ClusterState; import org.opensearch.cluster.metadata.Metadata; import org.opensearch.common.settings.Settings; -import org.opensearch.security.privileges.ActionPrivileges; import org.opensearch.security.privileges.PrivilegesConfigurationValidationException; import org.opensearch.security.privileges.PrivilegesEvaluationContext; import org.opensearch.security.securityconf.impl.SecurityDynamicConfiguration; import org.opensearch.security.securityconf.impl.v7.RoleV7; import org.opensearch.security.support.WildcardMatcher; -import org.opensearch.security.user.User; +import org.opensearch.security.util.MockPrivilegeEvaluationContextBuilder; import org.opensearch.test.framework.TestSecurityConfig; import static org.opensearch.security.privileges.dlsfls.FieldMasking.Config.BLAKE2B_LEGACY_DEFAULT; @@ -117,17 +115,7 @@ static FieldMasking createSubject(SecurityDynamicConfiguration roleConfi } static PrivilegesEvaluationContext ctx(String... roles) { - return new PrivilegesEvaluationContext( - new User("test_user"), - ImmutableSet.copyOf(roles), - null, - null, - null, - null, - null, - () -> CLUSTER_STATE, - ActionPrivileges.EMPTY - ); + return MockPrivilegeEvaluationContextBuilder.ctx().roles(roles).get(); } } diff --git a/src/integrationTest/java/org/opensearch/security/privileges/dlsfls/FieldPrivilegesTest.java b/src/integrationTest/java/org/opensearch/security/privileges/dlsfls/FieldPrivilegesTest.java index 3b08a9d427..ae1d988f48 100644 --- a/src/integrationTest/java/org/opensearch/security/privileges/dlsfls/FieldPrivilegesTest.java +++ b/src/integrationTest/java/org/opensearch/security/privileges/dlsfls/FieldPrivilegesTest.java @@ -13,7 +13,6 @@ import java.util.Arrays; import java.util.Collections; -import com.google.common.collect.ImmutableSet; import org.junit.Test; import org.junit.runner.RunWith; import org.junit.runners.Suite; @@ -21,13 +20,12 @@ import org.opensearch.cluster.ClusterState; import org.opensearch.cluster.metadata.Metadata; import org.opensearch.common.settings.Settings; -import org.opensearch.security.privileges.ActionPrivileges; import org.opensearch.security.privileges.PrivilegesConfigurationValidationException; import org.opensearch.security.privileges.PrivilegesEvaluationContext; import org.opensearch.security.securityconf.impl.SecurityDynamicConfiguration; import org.opensearch.security.securityconf.impl.v7.RoleV7; import org.opensearch.security.support.WildcardMatcher; -import org.opensearch.security.user.User; +import org.opensearch.security.util.MockPrivilegeEvaluationContextBuilder; import org.opensearch.test.framework.TestSecurityConfig; import static org.opensearch.security.util.MockIndexMetadataBuilder.indices; @@ -154,17 +152,7 @@ static FieldPrivileges createSubject(SecurityDynamicConfiguration roleCo } static PrivilegesEvaluationContext ctx(String... roles) { - return new PrivilegesEvaluationContext( - new User("test_user"), - ImmutableSet.copyOf(roles), - null, - null, - null, - null, - null, - () -> CLUSTER_STATE, - ActionPrivileges.EMPTY - ); + return MockPrivilegeEvaluationContextBuilder.ctx().roles(roles).get(); } } diff --git a/src/integrationTest/java/org/opensearch/security/privileges/int_tests/ClusterConfig.java b/src/integrationTest/java/org/opensearch/security/privileges/int_tests/ClusterConfig.java new file mode 100644 index 0000000000..ac3885e692 --- /dev/null +++ b/src/integrationTest/java/org/opensearch/security/privileges/int_tests/ClusterConfig.java @@ -0,0 +1,106 @@ +/* + * 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. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.privileges.int_tests; + +import java.util.Map; +import java.util.function.Function; +import java.util.function.Supplier; + +import org.opensearch.test.framework.cluster.LocalCluster; + +public enum ClusterConfig { + LEGACY_PRIVILEGES_EVALUATION( + "legacy", + c -> c.doNotFailOnForbidden(true).nodeSettings(Map.of("plugins.security.system_indices.enabled", true)), + true, + false, + false + ), + LEGACY_PRIVILEGES_EVALUATION_SYSTEM_INDEX_PERMISSION( + "legacy_system_index_perm", + c -> c.doNotFailOnForbidden(true) + .nodeSettings( + Map.of("plugins.security.system_indices.enabled", true, "plugins.security.system_indices.permission.enabled", true) + ), + true, + true, + false + ), + NEXT_GEN_PRIVILEGES_EVALUATION("next_gen", c -> c.privilegesEvaluationType("next_gen"), false, true, true); + + final String name; + final Function clusterConfiguration; + final boolean legacyPrivilegeEvaluation; + final boolean systemIndexPrivilegeEnabled; + final boolean allowsEmptyResultSets; + + private LocalCluster cluster; + + /** + * Optional: If we need to have a second remote cluster in our tests + */ + private LocalCluster remoteCluster; + + ClusterConfig( + String name, + Function clusterConfiguration, + boolean legacyPrivilegeEvaluation, + boolean systemIndexPrivilegeEnabled, + boolean allowsEmptyResultSets + ) { + this.name = name; + this.clusterConfiguration = clusterConfiguration; + this.legacyPrivilegeEvaluation = legacyPrivilegeEvaluation; + this.systemIndexPrivilegeEnabled = systemIndexPrivilegeEnabled; + this.allowsEmptyResultSets = allowsEmptyResultSets; + } + + LocalCluster cluster(Supplier clusterBuilder) { + if (cluster == null) { + cluster = this.clusterConfiguration.apply(clusterBuilder.get()).build(); + cluster.before(); + } + return cluster; + } + + LocalCluster remoteCluster(Supplier clusterBuilder) { + if (remoteCluster == null) { + remoteCluster = this.clusterConfiguration.apply(clusterBuilder.get()).build(); + remoteCluster.before(); + } + return remoteCluster; + } + + void shutdown() { + if (cluster != null) { + try { + cluster.close(); + } catch (Exception e) { + e.printStackTrace(); + } + cluster = null; + } + if (remoteCluster != null) { + try { + remoteCluster.close(); + } catch (Exception e) { + e.printStackTrace(); + } + remoteCluster = null; + } + } + + @Override + public String toString() { + return name; + } +} diff --git a/src/integrationTest/java/org/opensearch/security/privileges/int_tests/CrossClusterAuthorizationIntTests.java b/src/integrationTest/java/org/opensearch/security/privileges/int_tests/CrossClusterAuthorizationIntTests.java new file mode 100644 index 0000000000..696033d304 --- /dev/null +++ b/src/integrationTest/java/org/opensearch/security/privileges/int_tests/CrossClusterAuthorizationIntTests.java @@ -0,0 +1,484 @@ +/* + * 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. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.privileges.int_tests; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +import com.carrotsearch.randomizedtesting.annotations.ParametersFactory; +import com.carrotsearch.randomizedtesting.annotations.ThreadLeakScope; +import com.google.common.collect.ImmutableList; +import org.junit.AfterClass; +import org.junit.ClassRule; +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.opensearch.test.framework.TestIndex; +import org.opensearch.test.framework.TestSecurityConfig; +import org.opensearch.test.framework.certificate.TestCertificates; +import org.opensearch.test.framework.cluster.ClusterManager; +import org.opensearch.test.framework.cluster.LocalCluster; +import org.opensearch.test.framework.cluster.TestRestClient; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.opensearch.test.framework.TestSecurityConfig.AuthcDomain.AUTHC_HTTPBASIC_INTERNAL; +import static org.opensearch.test.framework.matcher.IndexApiResponseMatchers.OnResponseIndexMatcher.containsExactly; +import static org.opensearch.test.framework.matcher.IndexApiResponseMatchers.OnUserIndexMatcher.limitedTo; +import static org.opensearch.test.framework.matcher.IndexApiResponseMatchers.OnUserIndexMatcher.limitedToNone; +import static org.opensearch.test.framework.matcher.IndexApiResponseMatchers.OnUserIndexMatcher.unlimitedIncludingOpenSearchSecurityIndex; +import static org.opensearch.test.framework.matcher.RestMatchers.isForbidden; +import static org.opensearch.test.framework.matcher.RestMatchers.isOk; + +@RunWith(com.carrotsearch.randomizedtesting.RandomizedRunner.class) +@ThreadLeakScope(ThreadLeakScope.Scope.NONE) +public class CrossClusterAuthorizationIntTests { + + // ------------------------------------------------------------------------------------------------------- + // Test indices used by this test suite + // ------------------------------------------------------------------------------------------------------- + + interface LocalIndices { + TestIndex index_a1 = TestIndex.name("index_a1").documentCount(10).seed(1).build(); + TestIndex index_a2 = TestIndex.name("index_a2").documentCount(11).seed(2).build(); + } + + interface RemoteIndices { + TestIndex index_r1 = TestIndex.name("index_r1").documentCount(212).seed(11).build(); + TestIndex index_r2 = TestIndex.name("index_r2").documentCount(213).seed(12).build(); + TestIndex index_r3 = TestIndex.name("index_r3").documentCount(214).seed(13).build(); + } + + // ------------------------------------------------------------------------------------------------------- + // Test users with which the tests will be executed; the users need to be added to the list USERS below + // Each user comes with one or two additionally defined TestSecurityConfig.Role objects: + // - If it is two, one is meant for the local cluster, the other is meant for the remote cluster + // - If it is one, both local and remote cluster must get these roles. + // These roles must be passed to the test cluster builders via the roles() method + // ------------------------------------------------------------------------------------------------------- + + static final TestSecurityConfig.Role LIMITED_USER_ROLE_A_R = new TestSecurityConfig.Role("limited_user_A_R_role").clusterPermissions( + "cluster_composite_ops_ro", + "cluster_monitor" + ).indexPermissions("read", "indices_monitor").on("index_a*"); + static final TestSecurityConfig.Role LIMITED_USER_ROLE_A_R_REMOTE = new TestSecurityConfig.Role("limited_user_A_R_role") + .clusterPermissions("cluster_composite_ops_ro", "cluster_monitor") + .indexPermissions("read", "indices_monitor", "indices:admin/shards/search_shards") + .on("index_r*"); + static final TestSecurityConfig.User LIMITED_USER_A_R = new TestSecurityConfig.User("limited_user_A_R")// + .description("index_a*, index_r*")// + .roles(LIMITED_USER_ROLE_A_R)// + .indexMatcher( + "read", + limitedTo(LocalIndices.index_a1, LocalIndices.index_a2, RemoteIndices.index_r1, RemoteIndices.index_r2, RemoteIndices.index_r3) + ); + + static final TestSecurityConfig.Role LIMITED_USER_ROLE_R = new TestSecurityConfig.Role("limited_user_R_role").clusterPermissions( + "cluster_composite_ops_ro", + "cluster_monitor" + ); + static final TestSecurityConfig.Role LIMITED_USER_ROLE_R_REMOTE = new TestSecurityConfig.Role("limited_user_R_role").clusterPermissions( + "cluster_composite_ops_ro", + "cluster_monitor" + ).indexPermissions("read", "indices_monitor", "indices:admin/shards/search_shards").on("index_r*"); + static final TestSecurityConfig.User LIMITED_USER_R = new TestSecurityConfig.User("limited_user_R")// + .description("index_r*")// + .roles(LIMITED_USER_ROLE_R)// + .indexMatcher("read", limitedTo(RemoteIndices.index_r1, RemoteIndices.index_r2, RemoteIndices.index_r3)); + + static final TestSecurityConfig.Role LIMITED_USER_ROLE_R1 = new TestSecurityConfig.Role("limited_user_R1_role").clusterPermissions( + "cluster_composite_ops_ro", + "cluster_monitor" + ); + static final TestSecurityConfig.Role LIMITED_USER_ROLE_R1_REMOTE = new TestSecurityConfig.Role("limited_user_R1_role") + .clusterPermissions("cluster_composite_ops_ro", "cluster_monitor") + .indexPermissions("read", "indices_monitor", "indices:admin/shards/search_shards") + .on("index_${attr.internal.attr_r1}"); + static final TestSecurityConfig.User LIMITED_USER_R1 = new TestSecurityConfig.User("limited_user_R1")// + .description("index_r1, with user attribute")// + .roles(LIMITED_USER_ROLE_R1)// + .attr("attr_r1", "r1") + .indexMatcher("read", limitedTo(RemoteIndices.index_r1)); + + static final TestSecurityConfig.Role LIMITED_ROLE_NONE = new TestSecurityConfig.Role("limited_role_none").clusterPermissions( + "cluster_composite_ops_ro", + "cluster_monitor" + ).clusterPermissions("cluster_composite_ops_ro", "cluster_monitor"); + static final TestSecurityConfig.User LIMITED_USER_NONE = new TestSecurityConfig.User("limited_user_none")// + .description("no index privileges")// + .roles(LIMITED_ROLE_NONE)// + .indexMatcher("read", limitedToNone())// + .indexMatcher("read_nextgen", limitedToNone())// + .indexMatcher("get_alias", limitedToNone()); + + static final TestSecurityConfig.Role UNLIMITED_ROLE = new TestSecurityConfig.Role("unlimited_role")// + .clusterPermissions("*") + .indexPermissions("*") + .on("*"); + + static final TestSecurityConfig.User UNLIMITED_USER = new TestSecurityConfig.User("unlimited_user")// + .description("unlimited")// + .roles(UNLIMITED_ROLE)// + .indexMatcher( + "read", + limitedTo(LocalIndices.index_a1, LocalIndices.index_a2, RemoteIndices.index_r1, RemoteIndices.index_r2, RemoteIndices.index_r3) + )// + .indexMatcher("read_nextgen", limitedTo(LocalIndices.index_a1, LocalIndices.index_a2)); + + /** + * The SUPER_UNLIMITED_USER authenticates with an admin cert, which will cause all access control code to be skipped. + * This serves as a base for comparison with the default behavior. + */ + static final TestSecurityConfig.User SUPER_UNLIMITED_USER = new TestSecurityConfig.User("super_unlimited_user")// + .description("super unlimited (admin cert)")// + .adminCertUser()// + .indexMatcher("read", unlimitedIncludingOpenSearchSecurityIndex())// + .indexMatcher("read_nextgen", unlimitedIncludingOpenSearchSecurityIndex())// + .indexMatcher("get_alias", unlimitedIncludingOpenSearchSecurityIndex()); + + static final List USERS = ImmutableList.of( + LIMITED_USER_A_R, + LIMITED_USER_R, + LIMITED_USER_R1, + LIMITED_USER_NONE, + UNLIMITED_USER, + SUPER_UNLIMITED_USER + ); + + static final TestCertificates TEST_CERTIFICATES = new TestCertificates(); + + @ClassRule + public static final LocalCluster remoteCluster = new LocalCluster.Builder().certificates(TEST_CERTIFICATES) + .clusterManager(ClusterManager.SINGLENODE) + .clusterName("remote_1") + .authc(AUTHC_HTTPBASIC_INTERNAL) + .privilegesEvaluationType("next_gen") + .roles(LIMITED_USER_ROLE_A_R_REMOTE, LIMITED_USER_ROLE_R_REMOTE, LIMITED_USER_ROLE_R1_REMOTE, LIMITED_ROLE_NONE, UNLIMITED_ROLE) + .indices(RemoteIndices.index_r1, RemoteIndices.index_r2, RemoteIndices.index_r3) + .build(); + + static LocalCluster.Builder clusterBuilder() { + return new LocalCluster.Builder().clusterManager(ClusterManager.SINGLE_REMOTE_CLIENT) + .remote("remote_1", remoteCluster) + .certificates(TEST_CERTIFICATES) + .authc(AUTHC_HTTPBASIC_INTERNAL) + .users(USERS)// + .roles(LIMITED_USER_ROLE_A_R, LIMITED_USER_ROLE_R, LIMITED_USER_ROLE_R1, LIMITED_ROLE_NONE, UNLIMITED_ROLE) + .indices(LocalIndices.index_a1, LocalIndices.index_a2); + } + + @AfterClass + public static void stopClusters() { + for (ClusterConfig clusterConfig : ClusterConfig.values()) { + clusterConfig.shutdown(); + } + } + + final TestSecurityConfig.User user; + final LocalCluster cluster; + final ClusterConfig clusterConfig; + + @Test + public void search_wildcardWildcard() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("*:*/_search?size=1000"); + assertThat( + httpResponse, + containsExactly().andFromRemote("remote_1", RemoteIndices.index_r1, RemoteIndices.index_r2, RemoteIndices.index_r3) + .at("hits.hits[*]._index") + .reducedBy(user.indexMatcher("read")) + .whenEmpty(isForbidden()) + ); + } + } + + @Test + public void search_remoteWildcard() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("remote_1:*/_search?size=1000"); + assertThat( + httpResponse, + containsExactly().andFromRemote("remote_1", RemoteIndices.index_r1, RemoteIndices.index_r2, RemoteIndices.index_r3) + .at("hits.hits[*]._index") + .reducedBy(user.indexMatcher("read")) + .whenEmpty(isForbidden()) + ); + } + } + + @Test + public void search_remoteWildcard_minimizeRoundtrips() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("remote_1:*/_search?size=1000&ccs_minimize_roundtrips=true"); + assertThat( + httpResponse, + containsExactly().andFromRemote("remote_1", RemoteIndices.index_r1, RemoteIndices.index_r2, RemoteIndices.index_r3) + .at("hits.hits[*]._index") + .reducedBy(user.indexMatcher("read")) + .whenEmpty(isForbidden()) + ); + } + } + + @Test + public void search_remoteStaticIndices() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("remote_1:index_r1/_search?size=1000"); + assertThat( + httpResponse, + containsExactly().andFromRemote("remote_1", RemoteIndices.index_r1) + .at("hits.hits[*]._index") + .reducedBy(user.indexMatcher("read")) + .whenEmpty(isForbidden()) + ); + } + } + + @Test + public void search_remoteStaticIndices_minimizeRoundtrips() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("remote_1:index_r1/_search?size=1000&ccs_minimize_roundtrips=true"); + assertThat( + httpResponse, + containsExactly().andFromRemote("remote_1", RemoteIndices.index_r1) + .at("hits.hits[*]._index") + .reducedBy(user.indexMatcher("read")) + .whenEmpty(isForbidden()) + ); + } + } + + @Test + public void search_remoteIndexPattern() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("remote_1:index_*/_search?size=1000"); + assertThat( + httpResponse, + containsExactly().andFromRemote("remote_1", RemoteIndices.index_r1, RemoteIndices.index_r2, RemoteIndices.index_r3) + .at("hits.hits[*]._index") + .reducedBy(user.indexMatcher("read")) + .whenEmpty(isForbidden()) + ); + } + } + + @Test + public void search_remoteIndexPattern_minimizeRoundtrips() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("remote_1:index_*/_search?size=1000&ccs_minimize_roundtrips=true"); + assertThat( + httpResponse, + containsExactly().andFromRemote("remote_1", RemoteIndices.index_r1, RemoteIndices.index_r2, RemoteIndices.index_r3) + .at("hits.hits[*]._index") + .reducedBy(user.indexMatcher("read")) + .whenEmpty(isForbidden()) + ); + } + } + + @Test + public void search_localStaticIndex_remoteStaticIndices() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("index_a2,remote_1:index_r1/_search?size=1000"); + assertThat( + httpResponse, + containsExactly(LocalIndices.index_a2).andFromRemote("remote_1", RemoteIndices.index_r1) + .at("hits.hits[*]._index") + .butForbiddenIfIncomplete(user.indexMatcher("read")) + ); + } + } + + @Test + public void search_localIndexPattern_remoteIndexPattern() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("index_*,remote_1:index_*/_search?size=1000"); + + if (clusterConfig.legacyPrivilegeEvaluation) { + if (user.indexMatcher("read").covers(LocalIndices.index_a1) || user.indexMatcher("read").covers(LocalIndices.index_a2)) { + // Only if we have privileges for local indices, we also get through + assertThat( + httpResponse, + containsExactly(LocalIndices.index_a1, LocalIndices.index_a2).andFromRemote( + "remote_1", + RemoteIndices.index_r1, + RemoteIndices.index_r2, + RemoteIndices.index_r3 + ).at("hits.hits[*]._index").reducedBy(user.indexMatcher("read")).whenEmpty(isForbidden()) + ); + } else { + assertThat(httpResponse, isForbidden()); + } + } else { + if (user != LIMITED_USER_NONE) { + assertThat( + httpResponse, + containsExactly(LocalIndices.index_a1, LocalIndices.index_a2).andFromRemote( + "remote_1", + RemoteIndices.index_r1, + RemoteIndices.index_r2, + RemoteIndices.index_r3 + ).at("hits.hits[*]._index").reducedBy(user.indexMatcher("read")).whenEmpty(isOk()) + ); + } else { + // No search permissions anywhere will result in a 403 error + assertThat(httpResponse, isForbidden()); + } + } + } + } + + @Test + public void resolve_wildcardWildcard() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("_resolve/index/*:*"); + + assertThat( + httpResponse, + containsExactly().andFromRemote("remote_1", RemoteIndices.index_r1, RemoteIndices.index_r2, RemoteIndices.index_r3) + .at("$.*[*].name") + .reducedBy(user.indexMatcher("read")) + .whenEmpty(isOk()) + ); + } + } + + @Test + public void resolve_remoteWildcard() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("_resolve/index/remote_1:*"); + + assertThat( + httpResponse, + containsExactly().andFromRemote("remote_1", RemoteIndices.index_r1, RemoteIndices.index_r2, RemoteIndices.index_r3) + .at("$.*[*].name") + .reducedBy(user.indexMatcher("read")) + .whenEmpty(isOk()) + ); + } + } + + @Test + public void resolve_localWildcard_remoteWildcard() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("_resolve/index/*,remote_1:*"); + + assertThat( + httpResponse, + containsExactly(LocalIndices.index_a1, LocalIndices.index_a2).andFromRemote( + "remote_1", + RemoteIndices.index_r1, + RemoteIndices.index_r2, + RemoteIndices.index_r3 + ).at("$.*[*].name").reducedBy(user.indexMatcher("read")).whenEmpty(isOk()) + ); + } + } + + @Test + public void resolve_localIndexPattern_remoteIndexPattern() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("_resolve/index/index_a1*,remote_1:index_r1*"); + + if (clusterConfig.legacyPrivilegeEvaluation) { + if (user.indexMatcher("read").covers(LocalIndices.index_a1)) { + // Only if we have privileges for local indices, we also get through + assertThat( + httpResponse, + containsExactly(LocalIndices.index_a1).andFromRemote("remote_1", RemoteIndices.index_r1) + .at("$.*[*].name") + .reducedBy(user.indexMatcher("read")) + .whenEmpty(isOk()) + ); + } else { + assertThat(httpResponse, isForbidden()); + } + } else { + assertThat( + httpResponse, + containsExactly(LocalIndices.index_a1).andFromRemote("remote_1", RemoteIndices.index_r1) + .at("$.*[*].name") + .reducedBy(user.indexMatcher("read")) + .whenEmpty(isOk()) + ); + } + } + } + + @Test + public void field_caps_remoteWildcard() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("remote_1:*/_field_caps?fields=*"); + assertThat( + httpResponse, + containsExactly().andFromRemote("remote_1", RemoteIndices.index_r1, RemoteIndices.index_r2, RemoteIndices.index_r3) + .at("indices") + .reducedBy(user.indexMatcher("read")) + .whenEmpty(isOk()) + ); + } + } + + @Test + public void field_caps_localIndexPattern_remoteIndexPattern() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("index_*,remote_1:index_*/_field_caps?fields=*"); + if (clusterConfig.legacyPrivilegeEvaluation) { + if (user.indexMatcher("read").covers(LocalIndices.index_a1) || user.indexMatcher("read").covers(LocalIndices.index_a2)) { + // Only if we have privileges for local indices, we also get through + assertThat( + httpResponse, + containsExactly(LocalIndices.index_a1, LocalIndices.index_a2).andFromRemote( + "remote_1", + RemoteIndices.index_r1, + RemoteIndices.index_r2, + RemoteIndices.index_r3 + ).at("indices").reducedBy(user.indexMatcher("read")).whenEmpty(isOk()) + ); + } else { + assertThat(httpResponse, isForbidden()); + } + } else { + assertThat( + httpResponse, + containsExactly(LocalIndices.index_a1, LocalIndices.index_a2).andFromRemote( + "remote_1", + RemoteIndices.index_r1, + RemoteIndices.index_r2, + RemoteIndices.index_r3 + ).at("indices").reducedBy(user.indexMatcher("read")).whenEmpty(isOk()) + ); + } + } + } + + @ParametersFactory(shuffle = false, argumentFormatting = "%1$s, %3$s") + public static Collection params() { + List result = new ArrayList<>(); + + for (ClusterConfig clusterConfig : ClusterConfig.values()) { + for (TestSecurityConfig.User user : USERS) { + result.add(new Object[] { clusterConfig, user, user.getDescription() }); + + } + } + return result; + } + + public CrossClusterAuthorizationIntTests(ClusterConfig clusterConfig, TestSecurityConfig.User user, String description) + throws Exception { + this.user = user; + this.cluster = clusterConfig.cluster(CrossClusterAuthorizationIntTests::clusterBuilder); + this.clusterConfig = clusterConfig; + } + +} diff --git a/src/integrationTest/java/org/opensearch/security/privileges/int_tests/DataStreamAuthorizationReadOnlyIntTests.java b/src/integrationTest/java/org/opensearch/security/privileges/int_tests/DataStreamAuthorizationReadOnlyIntTests.java new file mode 100644 index 0000000000..d46735df11 --- /dev/null +++ b/src/integrationTest/java/org/opensearch/security/privileges/int_tests/DataStreamAuthorizationReadOnlyIntTests.java @@ -0,0 +1,847 @@ +/* + * 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. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.privileges.int_tests; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +import com.carrotsearch.randomizedtesting.annotations.ParametersFactory; +import com.carrotsearch.randomizedtesting.annotations.ThreadLeakScope; +import org.junit.AfterClass; +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.opensearch.test.framework.TestComponentTemplate; +import org.opensearch.test.framework.TestDataStream; +import org.opensearch.test.framework.TestIndex; +import org.opensearch.test.framework.TestIndexOrAliasOrDatastream; +import org.opensearch.test.framework.TestIndexTemplate; +import org.opensearch.test.framework.TestSecurityConfig; +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.is; +import static org.opensearch.test.framework.TestIndex.openSearchSecurityConfigIndex; +import static org.opensearch.test.framework.TestSecurityConfig.AuthcDomain.AUTHC_HTTPBASIC_INTERNAL; +import static org.opensearch.test.framework.matcher.IndexApiResponseMatchers.OnResponseIndexMatcher.containsExactly; +import static org.opensearch.test.framework.matcher.IndexApiResponseMatchers.OnUserIndexMatcher.limitedTo; +import static org.opensearch.test.framework.matcher.IndexApiResponseMatchers.OnUserIndexMatcher.limitedToNone; +import static org.opensearch.test.framework.matcher.IndexApiResponseMatchers.OnUserIndexMatcher.unlimited; +import static org.opensearch.test.framework.matcher.IndexApiResponseMatchers.OnUserIndexMatcher.unlimitedIncludingOpenSearchSecurityIndex; +import static org.opensearch.test.framework.matcher.RestMatchers.isForbidden; +import static org.opensearch.test.framework.matcher.RestMatchers.isNotFound; +import static org.opensearch.test.framework.matcher.RestMatchers.isOk; + +/** + * This class defines a huge test matrix for index related access controls. This class is especially for read-only operations on data streams. + * It uses the following dimensions: + *
    + *
  • ClusterConfig: At the moment, we test without and with system index permission enabled. New semantics will follow later.
  • + *
  • TestSecurityConfig.User: We have quite a few of different users with different privileges configurations.
  • + *
  • The test methods represent different operations with different options that are tested
  • + *
+ * To cope with the huge space of tests, this class uses test oracles to verify the result of the operations. + * These are defined with the "indexMatcher()" method of TestSecurityConfig.User. See there and the class IndexApiMatchers. + */ +@RunWith(com.carrotsearch.randomizedtesting.RandomizedRunner.class) +@ThreadLeakScope(ThreadLeakScope.Scope.NONE) +public class DataStreamAuthorizationReadOnlyIntTests { + + // ------------------------------------------------------------------------------------------------------- + // Test data streams and indices used by this test suite. Indices are usually initially created; the only + // exception is ds_ax, which is referred to in tests, but which does not exist on purpose. + // ------------------------------------------------------------------------------------------------------- + + static TestDataStream ds_a1 = TestDataStream.name("ds_a1").documentCount(100).rolloverAfter(10).seed(1).build(); + static TestDataStream ds_a2 = TestDataStream.name("ds_a2").documentCount(110).rolloverAfter(10).seed(2).build(); + static TestDataStream ds_a3 = TestDataStream.name("ds_a3").documentCount(120).rolloverAfter(10).seed(3).build(); + static TestDataStream ds_ax = TestDataStream.name("ds_ax").build(); // Not existing data stream + static TestDataStream ds_b1 = TestDataStream.name("ds_b1").documentCount(51).rolloverAfter(10).seed(4).build(); + static TestDataStream ds_b2 = TestDataStream.name("ds_b2").documentCount(52).rolloverAfter(10).seed(5).build(); + static TestDataStream ds_b3 = TestDataStream.name("ds_b3").documentCount(53).rolloverAfter(10).seed(6).build(); + static TestIndex index_c1 = TestIndex.name("index_c1").documentCount(5).seed(7).build(); + + static final List ALL_INDICES = List.of( + ds_a1, + ds_a2, + ds_a3, + ds_b1, + ds_b2, + ds_b3, + index_c1, + openSearchSecurityConfigIndex() + ); + + static final List ALL_DATA_STREAMS = List.of(ds_a1, ds_a2, ds_a3, ds_b1, ds_b2, ds_b3); + + // ------------------------------------------------------------------------------------------------------- + // Test users with which the tests will be executed; the users need to be added to the list USERS below + // The users have two redundant versions or privilege configuration, which needs to be kept in sync: + // - The standard role configuration defined with .roles() + // - IndexMatchers which act as test oracles, defined with the indexMatcher() methods + // ------------------------------------------------------------------------------------------------------- + + /** + * A simple user that can read from ds_a* + */ + static TestSecurityConfig.User LIMITED_USER_A = new TestSecurityConfig.User("limited_user_A")// + .description("ds_a*")// + .roles( + new TestSecurityConfig.Role("r1")// + .clusterPermissions("cluster_composite_ops_ro", "cluster_monitor")// + .indexPermissions( + "read", + "indices_monitor", + "indices:admin/analyze", + "indices:admin/data_stream/get", + "indices:monitor/data_stream/stats" + ) + .on("ds_a*") + )// + .indexMatcher("read", limitedTo(ds_a1, ds_a2, ds_a3, ds_ax)); + + /** + * A simple user that can read from ds_b* + */ + static TestSecurityConfig.User LIMITED_USER_B = new TestSecurityConfig.User("limited_user_B")// + .description("ds_b*")// + .roles( + new TestSecurityConfig.Role("r1")// + .clusterPermissions("cluster_composite_ops_ro", "cluster_monitor")// + .indexPermissions( + "read", + "indices_monitor", + "indices:admin/analyze", + "indices:admin/data_stream/get", + "indices:monitor/data_stream/stats" + ) + .on("ds_b*") + )// + .indexMatcher("read", limitedTo(ds_b1, ds_b2, ds_b3)); + + /** + * A simple user that can read from ds_b1 + */ + static TestSecurityConfig.User LIMITED_USER_B1 = new TestSecurityConfig.User("limited_user_B1")// + .description("ds_b1")// + .roles( + new TestSecurityConfig.Role("r1")// + .clusterPermissions("cluster_composite_ops_ro", "cluster_monitor")// + .indexPermissions( + "read", + "indices_monitor", + "indices:admin/analyze", + "indices:admin/data_stream/get", + "indices:monitor/data_stream/stats" + ) + .on("ds_b1") + )// + .indexMatcher("read", limitedTo(ds_b1)); + + /** + * This user has no privileges for indices that are used in this test. But they have privileges for other indices. + * This allows them to use actions like _search and receive empty result sets. + */ + static TestSecurityConfig.User LIMITED_USER_OTHER_PRIVILEGES = new TestSecurityConfig.User("limited_user_other_index_privileges")// + .description("no privileges for existing indices")// + .roles( + new TestSecurityConfig.Role("r1")// + .clusterPermissions("cluster_composite_ops_ro", "cluster_monitor")// + .indexPermissions( + "read", + "indices_monitor", + "indices:admin/analyze", + "indices:admin/data_stream/get", + "indices:monitor/data_stream/stats" + ) + .on("ds_does_not_exist_*") + )// + .indexMatcher("read", limitedToNone()); + + /** + * A user with "*" privileges on "*"; as it is a regular user, they are still subject to system index + * restrictions and similar things. + */ + static TestSecurityConfig.User UNLIMITED_USER = new TestSecurityConfig.User("unlimited_user")// + .description("unlimited")// + .roles( + new TestSecurityConfig.Role("r1")// + .clusterPermissions("cluster_composite_ops_ro", "cluster_monitor")// + .indexPermissions("*") + .on("*")// + )// + .indexMatcher("read", unlimited()); + + /** + * The SUPER_UNLIMITED_USER authenticates with an admin cert, which will cause all access control code to be skipped. + * This serves as a base for comparison with the default behavior. + */ + static TestSecurityConfig.User SUPER_UNLIMITED_USER = new TestSecurityConfig.User("super_unlimited_user")// + .description("super unlimited (admin cert)")// + .adminCertUser()// + .indexMatcher("read", unlimitedIncludingOpenSearchSecurityIndex()); + + static List USERS = List.of( + LIMITED_USER_A, + LIMITED_USER_B, + LIMITED_USER_B1, + LIMITED_USER_OTHER_PRIVILEGES, + UNLIMITED_USER, + SUPER_UNLIMITED_USER + ); + + static LocalCluster.Builder clusterBuilder() { + return new LocalCluster.Builder().singleNode() + .authc(AUTHC_HTTPBASIC_INTERNAL) + .users(USERS)// + .indexTemplates(new TestIndexTemplate("ds_test", "ds_*").dataStream().composedOf(TestComponentTemplate.DATA_STREAM_MINIMAL))// + .dataStreams(ds_a1, ds_a2, ds_a3, ds_b1, ds_b2, ds_b3)// + .indices(index_c1); + } + + @AfterClass + public static void stopClusters() { + for (ClusterConfig clusterConfig : ClusterConfig.values()) { + clusterConfig.shutdown(); + } + } + + final TestSecurityConfig.User user; + final LocalCluster cluster; + final ClusterConfig clusterConfig; + + @Test + public void search_noPattern() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("_search?size=1000"); + assertThat( + httpResponse, + containsExactly(ds_a1, ds_a2, ds_a3, ds_b1, ds_b2, ds_b3, index_c1).at("hits.hits[*]._index") + .reducedBy(user.indexMatcher("read")) + .whenEmpty(clusterConfig.allowsEmptyResultSets ? isOk() : isForbidden()) + ); + } + } + + @Test + public void search_noPattern_noWildcards() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("_search?size=1000&expand_wildcards=none"); + if (user == UNLIMITED_USER || user == SUPER_UNLIMITED_USER) { + assertThat(httpResponse, isOk()); + assertThat(httpResponse, containsExactly().at("hits.hits[*]._index")); + } else { + assertThat(httpResponse, isForbidden()); + } + } + } + + @Test + public void search_noPattern_allowNoIndicesFalse() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("_search?size=1000&allow_no_indices=false"); + if (user != LIMITED_USER_OTHER_PRIVILEGES) { + assertThat( + httpResponse, + containsExactly(ALL_INDICES).at("hits.hits[*]._index") + .reducedBy(user.indexMatcher("read")) + .whenEmpty(clusterConfig.allowsEmptyResultSets ? isNotFound() : isForbidden()) + ); + } else { + // Due to allow_no_indices=false, we cannot reduce to the empty set for the user without any privileges. Thus we get a 403 + assertThat(httpResponse, isForbidden()); + } + } + } + + @Test + public void search_all() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("_all/_search?size=1000"); + assertThat( + httpResponse, + containsExactly(ALL_INDICES).at("hits.hits[*]._index") + .reducedBy(user.indexMatcher("read")) + .whenEmpty(clusterConfig.allowsEmptyResultSets ? isOk() : isForbidden()) + ); + } + } + + @Test + public void search_all_noWildcards() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("_all/_search?size=1000&expand_wildcards=none"); + if (user == UNLIMITED_USER || user == SUPER_UNLIMITED_USER) { + assertThat(httpResponse, isOk()); + assertThat(httpResponse, containsExactly().at("hits.hits[*]._index")); + } else { + assertThat(httpResponse, isForbidden()); + } + } + } + + @Test + public void search_wildcard() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("*/_search?size=1000"); + assertThat( + httpResponse, + containsExactly(ALL_INDICES).at("hits.hits[*]._index") + .reducedBy(user.indexMatcher("read")) + .whenEmpty(clusterConfig.allowsEmptyResultSets ? isOk() : isForbidden()) + ); + } + } + + @Test + public void search_staticNames_noIgnoreUnavailable() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("ds_a1,ds_a2,ds_b1/_search?size=1000"); + if (clusterConfig.legacyPrivilegeEvaluation) { + // In the old privilege evaluation, data streams with incomplete privileges will be replaced by their member indices + assertThat( + httpResponse, + containsExactly(ds_a1, ds_a2, ds_b1).at("hits.hits[*]._index") + .reducedBy(user.indexMatcher("read")) + .whenEmpty(isForbidden()) + ); + } else { + // In the new privilege evaluation, data streams with incomplete privileges will lead to a 403 error + assertThat( + httpResponse, + containsExactly(ds_a1, ds_a2, ds_b1).at("hits.hits[*]._index").butForbiddenIfIncomplete(user.indexMatcher("read")) + ); + } + } + } + + @Test + public void search_staticNames_ignoreUnavailable() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("ds_a1,ds_a2,ds_b1/_search?size=1000&ignore_unavailable=true"); + assertThat( + httpResponse, + containsExactly(ds_a1, ds_a2, ds_b1).at("hits.hits[*]._index") + .reducedBy(user.indexMatcher("read")) + .whenEmpty(clusterConfig.allowsEmptyResultSets ? isOk() : isForbidden()) + ); + } + } + + @Test + public void search_staticIndicies_negation_backingIndices() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("ds_a1,ds_a2,ds_b1,-.ds-ds_b1*/_search?size=1000"); + assertThat( + httpResponse, + containsExactly(ds_a1, ds_a2, ds_b1).at("hits.hits[*]._index") + .reducedBy(user.indexMatcher("read")) + .whenEmpty(clusterConfig.allowsEmptyResultSets ? isOk() : isForbidden()) + ); + } + } + + @Test + public void search_indexPattern() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("ds_a*,ds_b*/_search?size=1000"); + assertThat( + httpResponse, + containsExactly(ds_a1, ds_a2, ds_a3, ds_b1, ds_b2, ds_b3).at("hits.hits[*]._index") + .reducedBy(user.indexMatcher("read")) + .whenEmpty(clusterConfig.allowsEmptyResultSets ? isOk() : isForbidden()) + ); + } + } + + @Test + public void search_indexPattern_minus() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("ds_a*,ds_b*,-ds_b2,-ds_b3/_search?size=1000"); + // does not handle the expression ds_a*,ds_b*,-ds_b2,-ds_b3 in a way that excludes the data streams. See + // search_indexPattern_minus_backingIndices for an alternative. + assertThat( + httpResponse, + containsExactly(ds_a1, ds_a2, ds_a3, ds_b1, ds_b2, ds_b3).at("hits.hits[*]._index") + .reducedBy(user.indexMatcher("read")) + .whenEmpty(clusterConfig.allowsEmptyResultSets ? isOk() : isForbidden()) + ); + } + } + + @Test + public void search_indexPattern_minus_backingIndices() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("ds_a*,ds_b*,-.ds-ds_b2*,-.ds-ds_b3*/_search?size=1000"); + assertThat( + httpResponse, + containsExactly(ds_a1, ds_a2, ds_a3, ds_b1).at("hits.hits[*]._index") + .reducedBy(user.indexMatcher("read")) + .whenEmpty(clusterConfig.allowsEmptyResultSets ? isOk() : isForbidden()) + ); + } + } + + @Test + public void search_indexPattern_nonExistingIndex_ignoreUnavailable() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get( + "ds_a*,ds_b*,xxx_non_existing/_search?size=1000&ignore_unavailable=true" + ); + assertThat( + httpResponse, + containsExactly(ds_a1, ds_a2, ds_a3, ds_b1, ds_b2, ds_b3).at("hits.hits[*]._index") + .reducedBy(user.indexMatcher("read")) + .whenEmpty(clusterConfig.allowsEmptyResultSets ? isOk() : isForbidden()) + ); + } + } + + @Test + public void search_indexPattern_noWildcards() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get( + "ds_a*,ds_b*/_search?size=1000&expand_wildcards=none&ignore_unavailable=true" + ); + if (clusterConfig.legacyPrivilegeEvaluation && (user == LIMITED_USER_B1 || user == LIMITED_USER_OTHER_PRIVILEGES)) { + assertThat(httpResponse, isForbidden("/error/root_cause/0/reason", "no permissions for [indices:data/read/search]")); + } else { + assertThat(httpResponse, isOk()); + assertThat(httpResponse, containsExactly().at("hits.hits[*]._index")); + } + } + } + + @Test + public void search_nonExisting_static() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("x_does_not_exist/_search?size=1000"); + + if (user == UNLIMITED_USER || user == SUPER_UNLIMITED_USER) { + assertThat(httpResponse, isNotFound()); + } else { + assertThat(httpResponse, isForbidden("/error/root_cause/0/reason", "no permissions for [indices:data/read/search]")); + } + } + } + + @Test + public void search_nonExisting_indexPattern() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("x_does_not_exist*/_search?size=1000"); + + assertThat(httpResponse, isOk()); + assertThat(httpResponse, containsExactly().at("hits.hits[*]._index")); + } + } + + @Test + public void search_termsAggregation_index() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.postJson("_search", """ + { + "size": 0, + "aggs": { + "indices": { + "terms": { + "field": "_index", + "size": 1000 + } + } + } + }"""); + + assertThat( + httpResponse, + containsExactly(ALL_INDICES).at("aggregations.indices.buckets[*].key") + .reducedBy(user.indexMatcher("read")) + .whenEmpty(isOk()) + ); + + } + } + + @Test + public void msearch_staticIndices() throws Exception { + String msearchBody = """ + {"index": "ds_b1"} + {"size": 10, "query": {"bool":{"must":{"match_all":{}}}}} + {"index": "ds_b2"} + {"size": 10, "query": {"bool":{"must":{"match_all":{}}}}} + """; + + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.postJson("_msearch", msearchBody); + assertThat( + httpResponse, + containsExactly(ds_b1, ds_b2).at("responses[*].hits.hits[*]._index").reducedBy(user.indexMatcher("read")).whenEmpty(isOk()) + ); + } + } + + @Test + public void index_stats_all() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("_stats"); + assertThat( + httpResponse, + containsExactly(ALL_INDICES).at("indices.keys()") + .reducedBy(user.indexMatcher("read")) + .whenEmpty(clusterConfig.allowsEmptyResultSets ? isOk() : isForbidden()) + ); + } + } + + @Test + public void index_stats_pattern() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("ds_b*/_stats"); + assertThat( + httpResponse, + containsExactly(ds_b1, ds_b2, ds_b3).at("indices.keys()") + .reducedBy(user.indexMatcher("read")) + .whenEmpty(clusterConfig.allowsEmptyResultSets ? isOk() : isForbidden()) + ); + } + } + + @Test + public void getDataStream_all() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("_data_stream"); + if (clusterConfig.legacyPrivilegeEvaluation) { + // The legacy mode does not support dnfof for indices:admin/data_stream/get + assertThat( + httpResponse, + containsExactly(ALL_DATA_STREAMS).at("$.data_streams[*].name").butForbiddenIfIncomplete(user.indexMatcher("read")) + ); + } else { + assertThat( + httpResponse, + containsExactly(ALL_DATA_STREAMS).at("$.data_streams[*].name").reducedBy(user.indexMatcher("read")).whenEmpty(isOk()) + ); + } + } + } + + @Test + public void getDataStream_wildcard() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("_data_stream/*"); + if (clusterConfig.legacyPrivilegeEvaluation) { + // The legacy mode does not support dnfof for indices:admin/data_stream/get + assertThat( + httpResponse, + containsExactly(ALL_DATA_STREAMS).at("$.data_streams[*].name").butForbiddenIfIncomplete(user.indexMatcher("read")) + ); + } else { + assertThat( + httpResponse, + containsExactly(ALL_DATA_STREAMS).at("$.data_streams[*].name").reducedBy(user.indexMatcher("read")).whenEmpty(isOk()) + ); + } + } + } + + @Test + public void getDataStream_pattern() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("_data_stream/ds_a*"); + if (clusterConfig.legacyPrivilegeEvaluation) { + // The legacy mode does not support dnfof for indices:admin/data_stream/get + assertThat( + httpResponse, + containsExactly(ds_a1, ds_a2, ds_a3).at("$.data_streams[*].name").butForbiddenIfIncomplete(user.indexMatcher("read")) + ); + } else { + assertThat( + httpResponse, + containsExactly(ds_a1, ds_a2, ds_a3).at("$.data_streams[*].name").reducedBy(user.indexMatcher("read")).whenEmpty(isOk()) + ); + } + } + } + + @Test + public void getDataStream_pattern_negation() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("_data_stream/ds_*,-ds_b*"); + if (clusterConfig.legacyPrivilegeEvaluation) { + // The legacy mode does not support dnfof for indices:admin/data_stream/get + assertThat( + httpResponse, + containsExactly(ds_a1, ds_a2, ds_a3).at("$.data_streams[*].name").butForbiddenIfIncomplete(user.indexMatcher("read")) + ); + } else { + assertThat( + httpResponse, + containsExactly(ds_a1, ds_a2, ds_a3).at("$.data_streams[*].name").reducedBy(user.indexMatcher("read")).whenEmpty(isOk()) + ); + } + } + } + + @Test + public void getDataStream_static() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("_data_stream/ds_a1,ds_a2"); + assertThat( + httpResponse, + containsExactly(ds_a1, ds_a2).at("$.data_streams[*].name").butForbiddenIfIncomplete(user.indexMatcher("read")) + ); + } + } + + @Test + public void getDataStreamStats_all() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("_data_stream/_stats"); + if (clusterConfig.legacyPrivilegeEvaluation) { + // The legacy mode does not support dnfof for indices:monitor/data_stream/stats + assertThat( + httpResponse, + containsExactly(ALL_DATA_STREAMS).at("$.data_streams[*].data_stream") + .butForbiddenIfIncomplete(user.indexMatcher("read")) + ); + } else { + assertThat( + httpResponse, + containsExactly(ALL_DATA_STREAMS).at("$.data_streams[*].data_stream") + .reducedBy(user.indexMatcher("read")) + .whenEmpty(isOk()) + ); + } + } + } + + @Test + public void getDataStreamStats_wildcard() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("_data_stream/*/_stats"); + if (clusterConfig.legacyPrivilegeEvaluation) { + // The legacy mode does not support dnfof for indices:monitor/data_stream/stats + assertThat( + httpResponse, + containsExactly(ALL_DATA_STREAMS).at("$.data_streams[*].data_stream") + .butForbiddenIfIncomplete(user.indexMatcher("read")) + ); + } else { + assertThat( + httpResponse, + containsExactly(ALL_DATA_STREAMS).at("$.data_streams[*].data_stream") + .reducedBy(user.indexMatcher("read")) + .whenEmpty(isOk()) + ); + } + } + } + + @Test + public void getDataStreamStats_pattern() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("_data_stream/ds_a*/_stats"); + if (clusterConfig.legacyPrivilegeEvaluation) { + // The legacy mode does not support dnfof for indices:monitor/data_stream/stats + assertThat( + httpResponse, + containsExactly(ds_a1, ds_a2, ds_a3).at("$.data_streams[*].data_stream") + .butForbiddenIfIncomplete(user.indexMatcher("read")) + ); + } else { + assertThat( + httpResponse, + containsExactly(ds_a1, ds_a2, ds_a3).at("$.data_streams[*].data_stream") + .reducedBy(user.indexMatcher("read")) + .whenEmpty(isOk()) + ); + } + } + } + + @Test + public void getDataStreamStats_static() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("_data_stream/ds_a1,ds_a2/_stats"); + assertThat( + httpResponse, + containsExactly(ds_a1, ds_a2).at("$.data_streams[*].data_stream").butForbiddenIfIncomplete(user.indexMatcher("read")) + ); + } + } + + @Test + public void resolve_wildcard() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("_resolve/index/*"); + assertThat( + httpResponse, + containsExactly(ALL_INDICES).at("$.*[*].name") + .reducedBy(user.indexMatcher("read")) + .whenEmpty(clusterConfig.allowsEmptyResultSets ? isOk() : isForbidden()) + ); + } + } + + @Test + public void resolve_indexPattern() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("_resolve/index/ds_a*,ds_b*"); + assertThat( + httpResponse, + containsExactly(ds_a1, ds_a2, ds_a3, ds_b1, ds_b2, ds_b3).at("$.*[*].name") + .reducedBy(user.indexMatcher("read")) + .whenEmpty(clusterConfig.allowsEmptyResultSets ? isOk() : isForbidden()) + ); + } + } + + @Test + public void field_caps_all() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("_field_caps?fields=*"); + assertThat( + httpResponse, + containsExactly(ALL_INDICES).at("indices") + .reducedBy(user.indexMatcher("read")) + .whenEmpty(clusterConfig.allowsEmptyResultSets ? isOk() : isForbidden()) + ); + } + } + + @Test + public void field_caps_indexPattern() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("ds_a*,ds_b*/_field_caps?fields=*"); + assertThat( + httpResponse, + containsExactly(ds_a1, ds_a2, ds_a3, ds_b1, ds_b2, ds_b3).at("indices") + .reducedBy(user.indexMatcher("read")) + .whenEmpty(clusterConfig.allowsEmptyResultSets ? isOk() : isForbidden()) + ); + } + } + + @Test + public void field_caps_staticIndices_noIgnoreUnavailable() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("ds_a1,ds_a2,ds_b1/_field_caps?fields=*"); + if (clusterConfig.legacyPrivilegeEvaluation) { + assertThat( + httpResponse, + containsExactly(ds_a1, ds_a2, ds_b1).at("indices").reducedBy(user.indexMatcher("read")).whenEmpty(isForbidden()) + ); + } else { + assertThat( + httpResponse, + containsExactly(ds_a1, ds_a2, ds_b1).at("indices").butForbiddenIfIncomplete(user.indexMatcher("read")) + ); + } + } + } + + @Test + public void field_caps_staticIndices_ignoreUnavailable() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("ds_a1,ds_a2,ds_b1/_field_caps?fields=*&ignore_unavailable=true"); + assertThat( + httpResponse, + containsExactly(ds_a1, ds_a2, ds_b1).at("indices") + .reducedBy(user.indexMatcher("read")) + .whenEmpty(clusterConfig.allowsEmptyResultSets ? isOk() : isForbidden()) + ); + } + } + + @Test + public void field_caps_nonExisting_static() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("x_does_not_exist/_field_caps?fields=*"); + + if (user == UNLIMITED_USER || user == SUPER_UNLIMITED_USER) { + assertThat(httpResponse, isNotFound()); + } else { + assertThat(httpResponse.getStatusCode(), is(403)); + } + } + } + + @Test + public void field_caps_nonExisting_indexPattern() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("x_does_not_exist*/_field_caps?fields=*"); + + assertThat(httpResponse, containsExactly().at("indices").whenEmpty(isOk())); + } + } + + @Test + public void field_caps_indexPattern_minus() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("ds_a*,ds_b*,-ds_b2,-ds_b3/_field_caps?fields=*"); + // does not handle the expression ds_a*,ds_b*,-ds_b2,-ds_b3 in a way that excludes the data streams. See + // field_caps_indexPattern_minus_backingIndices for an alternative. + assertThat( + httpResponse, + containsExactly(ds_a1, ds_a2, ds_a3, ds_b1, ds_b2, ds_b3).at("indices") + .reducedBy(user.indexMatcher("read")) + .whenEmpty(clusterConfig.allowsEmptyResultSets ? isOk() : isForbidden()) + ); + } + } + + @Test + public void field_caps_indexPattern_minus_backingIndices() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("ds_a*,ds_b*,-.ds-ds_b2*,-.ds-ds_b3*/_field_caps?fields=*"); + assertThat( + httpResponse, + containsExactly(ds_a1, ds_a2, ds_a3, ds_b1).at("indices") + .reducedBy(user.indexMatcher("read")) + .whenEmpty(clusterConfig.allowsEmptyResultSets ? isOk() : isForbidden()) + ); + } + } + + @Test + public void field_caps_staticIndices_negation_backingIndices() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("ds_a1,ds_a2,ds_b1,-.ds-ds_b1*/_field_caps?fields=*"); + assertThat( + httpResponse, + containsExactly(ds_a1, ds_a2, ds_b1).at("indices") + .reducedBy(user.indexMatcher("read")) + .whenEmpty(clusterConfig.allowsEmptyResultSets ? isOk() : isForbidden()) + ); + } + } + + @ParametersFactory(shuffle = false, argumentFormatting = "%1$s, %3$s") + public static Collection params() { + List result = new ArrayList<>(); + + for (ClusterConfig clusterConfig : ClusterConfig.values()) { + for (TestSecurityConfig.User user : USERS) { + result.add(new Object[] { clusterConfig, user, user.getDescription() }); + } + } + return result; + } + + public DataStreamAuthorizationReadOnlyIntTests(ClusterConfig clusterConfig, TestSecurityConfig.User user, String description) + throws Exception { + this.user = user; + this.cluster = clusterConfig.cluster(DataStreamAuthorizationReadOnlyIntTests::clusterBuilder); + this.clusterConfig = clusterConfig; + } +} diff --git a/src/integrationTest/java/org/opensearch/security/privileges/int_tests/DataStreamAuthorizationReadWriteIntTests.java b/src/integrationTest/java/org/opensearch/security/privileges/int_tests/DataStreamAuthorizationReadWriteIntTests.java new file mode 100644 index 0000000000..682f788237 --- /dev/null +++ b/src/integrationTest/java/org/opensearch/security/privileges/int_tests/DataStreamAuthorizationReadWriteIntTests.java @@ -0,0 +1,598 @@ +/* + * 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. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.privileges.int_tests; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import javax.annotation.concurrent.NotThreadSafe; + +import com.carrotsearch.randomizedtesting.annotations.ParametersFactory; +import com.carrotsearch.randomizedtesting.annotations.ThreadLeakScope; +import com.google.common.collect.ImmutableList; +import org.junit.After; +import org.junit.AfterClass; +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.opensearch.action.admin.indices.refresh.RefreshRequest; +import org.opensearch.test.framework.TestComponentTemplate; +import org.opensearch.test.framework.TestDataStream; +import org.opensearch.test.framework.TestIndex; +import org.opensearch.test.framework.TestIndexOrAliasOrDatastream; +import org.opensearch.test.framework.TestIndexTemplate; +import org.opensearch.test.framework.TestSecurityConfig; +import org.opensearch.test.framework.TestSecurityConfig.Role; +import org.opensearch.test.framework.cluster.LocalCluster; +import org.opensearch.test.framework.cluster.TestRestClient; +import org.opensearch.test.framework.cluster.TestRestClient.HttpResponse; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.opensearch.test.framework.TestSecurityConfig.AuthcDomain.AUTHC_HTTPBASIC_INTERNAL; +import static org.opensearch.test.framework.cluster.TestRestClient.json; +import static org.opensearch.test.framework.matcher.IndexApiResponseMatchers.OnResponseIndexMatcher.containsExactly; +import static org.opensearch.test.framework.matcher.IndexApiResponseMatchers.OnUserIndexMatcher.limitedTo; +import static org.opensearch.test.framework.matcher.IndexApiResponseMatchers.OnUserIndexMatcher.limitedToNone; +import static org.opensearch.test.framework.matcher.IndexApiResponseMatchers.OnUserIndexMatcher.unlimited; +import static org.opensearch.test.framework.matcher.IndexApiResponseMatchers.OnUserIndexMatcher.unlimitedIncludingOpenSearchSecurityIndex; +import static org.opensearch.test.framework.matcher.RestMatchers.isBadRequest; +import static org.opensearch.test.framework.matcher.RestMatchers.isCreated; +import static org.opensearch.test.framework.matcher.RestMatchers.isForbidden; +import static org.opensearch.test.framework.matcher.RestMatchers.isOk; +import static org.junit.Assert.assertEquals; + +/** + * This class defines a huge test matrix for index related access controls. This class is especially for read/write operations on data streams. + * It uses the following dimensions: + *
    + *
  • ClusterConfig: At the moment, we test without and with system index permission enabled. New semantics will follow later.
  • + *
  • TestSecurityConfig.User: We have quite a few of different users with different privileges configurations.
  • + *
  • The test methods represent different operations with different options that are tested
  • + *
+ * To cope with the huge space of tests, this class uses test oracles to verify the result of the operations. + * These are defined with the "indexMatcher()" method of TestSecurityConfig.User. See there and the class IndexApiMatchers. + */ +@RunWith(com.carrotsearch.randomizedtesting.RandomizedRunner.class) +@ThreadLeakScope(ThreadLeakScope.Scope.NONE) +@NotThreadSafe +public class DataStreamAuthorizationReadWriteIntTests { + + // ------------------------------------------------------------------------------------------------------- + // Test indices used by this test suite. We use the following naming scheme: + // - index_*r*, ds_*r*: This test will not write to this index or data stream + // - index_*w*, ds_*w*: This test can write to this index or data stream; the test won't delete and recreate it + // - index_*wx*, ds_*wx*: The index is not initially created; the test can create it on demand and delete it again + // ------------------------------------------------------------------------------------------------------- + + static TestDataStream ds_ar1 = TestDataStream.name("ds_ar1").documentCount(22).rolloverAfter(10).build(); + static TestDataStream ds_ar2 = TestDataStream.name("ds_ar2").documentCount(22).rolloverAfter(10).build(); + static TestDataStream ds_aw1 = TestDataStream.name("ds_aw1").documentCount(22).rolloverAfter(10).build(); + static TestDataStream ds_aw2 = TestDataStream.name("ds_aw2").documentCount(22).rolloverAfter(10).build(); + static TestDataStream ds_br1 = TestDataStream.name("ds_br1").documentCount(22).rolloverAfter(10).build(); + static TestDataStream ds_br2 = TestDataStream.name("ds_br2").documentCount(22).rolloverAfter(10).build(); + static TestDataStream ds_bw1 = TestDataStream.name("ds_bw1").documentCount(22).rolloverAfter(10).build(); + static TestDataStream ds_bw2 = TestDataStream.name("ds_bw2").documentCount(22).rolloverAfter(10).build(); + static TestIndex index_cr1 = TestIndex.name("index_cr1").documentCount(10).build(); + static TestIndex index_cw1 = TestIndex.name("index_cw1").documentCount(10).build(); + static TestDataStream ds_hidden = TestDataStream.name("ds_hidden").documentCount(10).rolloverAfter(3).seed(8).build(); + + static TestDataStream ds_bwx1 = TestDataStream.name("ds_bwx1").documentCount(0).build(); // not initially created + static TestDataStream ds_bwx2 = TestDataStream.name("ds_bwx2").documentCount(0).build(); // not initially created + + // ------------------------------------------------------------------------------------------------------- + // Test users with which the tests will be executed; the users need to be added to the list USERS below + // The users have two redundant versions or privilege configuration, which needs to be kept in sync: + // - The standard role configuration defined with .roles() + // - IndexMatchers which act as test oracles, defined with the indexMatcher() methods + // ------------------------------------------------------------------------------------------------------- + + /** + * A simple user that can read from ds_a* and write to ds_aw*; the user as no privileges to create or manage data streams + */ + static TestSecurityConfig.User LIMITED_USER_A = new TestSecurityConfig.User("limited_user_A")// + .description("ds_a*")// + .roles( + new Role("r1")// + .clusterPermissions("cluster_composite_ops", "cluster_monitor")// + .indexPermissions("read", "indices_monitor") + .on("ds_a*")// + .indexPermissions("write") + .on("ds_aw*") + )// + .indexMatcher("read", limitedTo(ds_ar1, ds_ar2, ds_aw1, ds_aw2))// + .indexMatcher("write", limitedTo(ds_aw1, ds_aw2))// + .indexMatcher("create_data_stream", limitedToNone())// + .indexMatcher("manage_data_stream", limitedToNone()); + + /** + * A simple user that can read from ds_b* and write to ds_bw*; the user as no privileges to create or manage data streams + */ + static TestSecurityConfig.User LIMITED_USER_B = new TestSecurityConfig.User("limited_user_B")// + .description("ds_b*")// + .roles( + new Role("r1")// + .clusterPermissions("cluster_composite_ops", "cluster_monitor")// + .indexPermissions("read", "indices_monitor") + .on("ds_b*")// + .indexPermissions("write") + .on("ds_bw*") + )// + .indexMatcher("read", limitedTo(ds_br1, ds_br2, ds_bw1, ds_bw2, ds_bwx1, ds_bwx2))// + .indexMatcher("write", limitedTo(ds_bw1, ds_bw2, ds_bwx1, ds_bwx2))// + .indexMatcher("create_data_stream", limitedToNone())// + .indexMatcher("manage_data_stream", limitedToNone()); + + /** + * A simple user that can read from ds_b* and write to ds_bw*; the user as no privileges to create or manage data streams. + * Additionally, they can read from ds_a* + */ + static TestSecurityConfig.User LIMITED_USER_B_READ_ONLY_A = new TestSecurityConfig.User("limited_user_B_read_only_A")// + .description("ds_b*; read only on ds_a*")// + .roles( + new Role("r1")// + .clusterPermissions("cluster_composite_ops", "cluster_monitor")// + .indexPermissions("read", "indices_monitor") + .on("ds_a*", "ds_b*")// + .indexPermissions("write") + .on("ds_bw*") + )// + .indexMatcher("read", limitedTo(ds_ar1, ds_ar2, ds_aw1, ds_aw2, ds_br1, ds_br2, ds_bw1, ds_bw2, ds_bwx1, ds_bwx2))// + .indexMatcher("write", limitedTo(ds_bw1, ds_bw2, ds_bwx1, ds_bwx2))// + .indexMatcher("create_data_stream", limitedToNone())// + .indexMatcher("manage_data_stream", limitedToNone()); + + /** + * This is an artificial user - in the sense that in real life it would likely not exist this way. + * It has privileges to write on ds_b*, but privileges for indices:admin/mapping/auto_put on all data streams. + * The reason is that some indexing operations are two phase - first auto put, then indexing. To be able to test both + * phases, we need which user which always allows the first phase to pass. + */ + static TestSecurityConfig.User LIMITED_USER_B_AUTO_PUT_ON_ALL = new TestSecurityConfig.User("limited_user_B_auto_put_on_all")// + .description("ds_b* with full auto put")// + .roles( + new Role("r1")// + .clusterPermissions("cluster_composite_ops", "cluster_monitor")// + .indexPermissions("read", "indices_monitor") + .on("ds_b*")// + .indexPermissions("write") + .on("ds_bw*")// + .indexPermissions("indices:admin/mapping/auto_put") + .on("*") + )// + .indexMatcher("read", limitedTo(ds_br1, ds_br2, ds_bw1, ds_bw2, ds_bwx1, ds_bwx2))// + .indexMatcher("write", limitedTo(ds_bw1, ds_bw2, ds_bwx1, ds_bwx2))// + .indexMatcher("create_data_stream", limitedToNone())// + .indexMatcher("manage_data_stream", limitedToNone()); + + /** + * A simple user that can read from ds_b* and write to ds_bw*; they can also create data streams with the name ds_bw* + */ + static TestSecurityConfig.User LIMITED_USER_B_CREATE_DS = new TestSecurityConfig.User("limited_user_B_create_ds")// + .description("ds_b* with create ds privs")// + .roles( + new Role("r1")// + .clusterPermissions("cluster_composite_ops", "cluster_monitor")// + .indexPermissions("read", "indices_monitor") + .on("ds_b*")// + .indexPermissions("write") + .on("ds_bw*")// + .indexPermissions("indices:admin/data_stream/create") + .on("ds_bw*") + )// + .indexMatcher("read", limitedTo(ds_br1, ds_br2, ds_bw1, ds_bw2, ds_bwx1, ds_bwx2))// + .indexMatcher("write", limitedTo(ds_bw1, ds_bw2, ds_bwx1, ds_bwx2))// + .indexMatcher("create_data_stream", limitedTo(ds_bw1, ds_bw2, ds_bwx1, ds_bwx2))// + .indexMatcher("manage_data_stream", limitedToNone()); + + /** + * A simple user that can read from ds_b* and write to ds_bw*; they can also create and manage data streams with the name ds_bw* + */ + static TestSecurityConfig.User LIMITED_USER_B_MANAGE_DS = new TestSecurityConfig.User("limited_user_B_manage_ds")// + .description("ds_b* with manage privs")// + .roles( + new Role("r1")// + .clusterPermissions("cluster_composite_ops", "cluster_monitor")// + .indexPermissions("read", "indices_monitor") + .on("ds_b*")// + .indexPermissions("write") + .on("ds_bw*")// + .indexPermissions("manage") + .on("ds_bw*") + )// + .indexMatcher("read", limitedTo(ds_br1, ds_br2, ds_bw1, ds_bw2, ds_bwx1, ds_bwx2))// + .indexMatcher("write", limitedTo(ds_bw1, ds_bw2, ds_bwx1, ds_bwx2))// + .indexMatcher("create_data_stream", limitedTo(ds_bw1, ds_bw2, ds_bwx1, ds_bwx2))// + .indexMatcher("manage_data_stream", limitedTo(ds_bw1, ds_bw2, ds_bwx1, ds_bwx2)); + + /** + * A user that can read from ds_a* and ds_b* and write/create/manage ds_aw*, ds_bw* + */ + static TestSecurityConfig.User LIMITED_USER_AB_MANAGE_INDEX = new TestSecurityConfig.User("limited_user_AB_manage_index")// + .description("ds_a*, ds_b* with manage index privs")// + .roles( + new Role("r1")// + .clusterPermissions("cluster_composite_ops", "cluster_monitor")// + .indexPermissions("read", "indices_monitor") + .on("ds_a*", "ds_b*")// + .indexPermissions("write") + .on("ds_aw*", "ds_bw*")// + .indexPermissions("manage") + .on("ds_aw*", "ds_bw*") + )// + .indexMatcher("read", limitedTo(ds_ar1, ds_ar2, ds_aw1, ds_aw2, ds_br1, ds_br2, ds_bw1, ds_bw2, ds_bwx1, ds_bwx2))// + .indexMatcher("write", limitedTo(ds_aw1, ds_aw2, ds_bw1, ds_bw2, ds_bwx1, ds_bwx2))// + .indexMatcher("create_data_stream", limitedTo(ds_aw1, ds_aw2, ds_bw1, ds_bw2, ds_bwx1, ds_bwx2))// + .indexMatcher("manage_data_stream", limitedTo(ds_aw1, ds_aw2, ds_bw1, ds_bw2, ds_bwx1, ds_bwx2)); + + /** + * A simple user that can read from index_c* + */ + static TestSecurityConfig.User LIMITED_USER_C = new TestSecurityConfig.User("limited_user_C")// + .description("index_c*")// + .roles( + new Role("r1")// + .clusterPermissions("cluster_composite_ops", "cluster_monitor")// + .indexPermissions("read", "indices_monitor") + .on("index_c*")// + .indexPermissions("write") + .on("index_cw*") + )// + .indexMatcher("read", limitedTo(index_cr1, index_cw1))// + .indexMatcher("write", limitedTo(index_cw1))// + .indexMatcher("create_data_stream", limitedToNone())// + .indexMatcher("manage_data_stream", limitedToNone()); + + /** + * A simple user that can read all indices and data streams, but cannot write anything + */ + static TestSecurityConfig.User LIMITED_READ_ONLY_ALL = new TestSecurityConfig.User("limited_read_only_all")// + .description("read/only on *")// + .roles( + new Role("r1")// + .clusterPermissions("cluster_composite_ops", "cluster_monitor")// + .indexPermissions("read") + .on("*") + )// + .indexMatcher("read", unlimited())// + .indexMatcher("write", limitedToNone())// + .indexMatcher("create_data_stream", limitedToNone())// + .indexMatcher("manage_data_stream", limitedToNone()); + + /** + * A simple user that can read from ds_a*, but cannot write anything + */ + static TestSecurityConfig.User LIMITED_READ_ONLY_A = new TestSecurityConfig.User("limited_read_only_A")// + .description("read/only on ds_a*")// + .roles( + new Role("r1")// + .clusterPermissions("cluster_composite_ops", "cluster_monitor")// + .indexPermissions("read") + .on("ds_a*") + )// + .indexMatcher("read", limitedTo(ds_ar1, ds_ar2, ds_aw1, ds_aw2))// + .indexMatcher("write", limitedToNone())// + .indexMatcher("create_data_stream", limitedToNone())// + .indexMatcher("manage_data_stream", limitedToNone()); + + /** + * A simple test user that only has index privileges for indices that are not used by this test. + */ + static TestSecurityConfig.User LIMITED_USER_OTHER_PRIVILEGES = new TestSecurityConfig.User("limited_user_other_privileges")// + .description("no privileges for existing indices")// + .roles( + new Role("r1")// + .clusterPermissions("cluster_composite_ops", "cluster_monitor")// + .indexPermissions("crud", "indices_monitor") + .on("ds_does_not_exist_*") + )// + .indexMatcher("read", limitedToNone())// + .indexMatcher("write", limitedToNone())// + .indexMatcher("create_data_stream", limitedToNone())// + .indexMatcher("manage_data_stream", limitedToNone()); + + /** + * A simple test user that has no index privileges at all. + */ + static final TestSecurityConfig.User LIMITED_USER_NONE = new TestSecurityConfig.User("limited_user_none")// + .description("no index privileges")// + .roles( + new TestSecurityConfig.Role("r1")// + .clusterPermissions("cluster_composite_ops_ro", "cluster_monitor") + )// + .indexMatcher("read", limitedToNone())// + .indexMatcher("write", limitedToNone())// + .indexMatcher("create_data_stream", limitedToNone())// + .indexMatcher("manage_data_stream", limitedToNone()); + + /** + * This user has only privileges on backing indices for data streams, but not on the data streams themselves + */ + static TestSecurityConfig.User LIMITED_USER_PERMISSIONS_ON_BACKING_INDICES = new TestSecurityConfig.User( + "limited_user_permissions_on_backing_indices" + )// + .description("ds_a* on backing indices")// + .roles( + new Role("r1")// + .clusterPermissions("cluster_composite_ops", "cluster_monitor")// + .indexPermissions("read", "indices_monitor") + .on(".ds-ds_a*")// + .indexPermissions("write") + .on(".ds-ds_aw*") + )// + .indexMatcher("read", limitedTo(ds_ar1, ds_ar2, ds_aw1, ds_aw2))// + .indexMatcher("write", limitedTo(ds_aw1, ds_aw2))// + .indexMatcher("create_data_stream", limitedToNone())// + .indexMatcher("manage_data_stream", limitedToNone()); + + /** + * A user with "*" privileges on "*"; as it is a regular user, they are still subject to system index + * restrictions and similar things. + */ + static TestSecurityConfig.User UNLIMITED_USER = new TestSecurityConfig.User("unlimited_user")// + .description("unlimited")// + .roles( + new Role("r1")// + .clusterPermissions("cluster_composite_ops", "cluster_monitor")// + .indexPermissions("*") + .on("*") + )// + .indexMatcher("read", unlimited())// + .indexMatcher("write", unlimited())// + .indexMatcher("create_data_stream", unlimited())// + .indexMatcher("manage_data_stream", unlimited()); + + /** + * The SUPER_UNLIMITED_USER authenticates with an admin cert, which will cause all access control code to be skipped. + * This serves as a base for comparison with the default behavior. + */ + static TestSecurityConfig.User SUPER_UNLIMITED_USER = new TestSecurityConfig.User("super_unlimited_user")// + .description("super unlimited (admin cert)")// + .adminCertUser()// + .indexMatcher("read", unlimitedIncludingOpenSearchSecurityIndex())// + .indexMatcher("write", unlimitedIncludingOpenSearchSecurityIndex())// + .indexMatcher("create_data_stream", unlimitedIncludingOpenSearchSecurityIndex())// + .indexMatcher("manage_data_stream", unlimitedIncludingOpenSearchSecurityIndex()); + + static List USERS = ImmutableList.of( + LIMITED_USER_A, + LIMITED_USER_B, + LIMITED_USER_B_READ_ONLY_A, + LIMITED_USER_B_AUTO_PUT_ON_ALL, + LIMITED_USER_B_CREATE_DS, + LIMITED_USER_B_MANAGE_DS, + LIMITED_USER_AB_MANAGE_INDEX, + LIMITED_USER_C, + LIMITED_READ_ONLY_ALL, + LIMITED_READ_ONLY_A, + LIMITED_USER_OTHER_PRIVILEGES, + LIMITED_USER_NONE, + LIMITED_USER_PERMISSIONS_ON_BACKING_INDICES, + UNLIMITED_USER, + SUPER_UNLIMITED_USER + ); + + static LocalCluster.Builder clusterBuilder() { + return new LocalCluster.Builder().singleNode() + .authc(AUTHC_HTTPBASIC_INTERNAL) + .users(USERS)// + .indexTemplates(new TestIndexTemplate("ds_test", "ds_*").dataStream().composedOf(TestComponentTemplate.DATA_STREAM_MINIMAL))// + .indices(index_cr1, index_cw1)// + .dataStreams(ds_ar1, ds_ar2, ds_aw1, ds_aw2, ds_br1, ds_br2, ds_bw1, ds_bw2, ds_hidden)// + .plugin(IndexAuthorizationReadOnlyIntTests.SystemIndexTestPlugin.class); + } + + @AfterClass + public static void stopClusters() { + for (ClusterConfig clusterConfig : ClusterConfig.values()) { + clusterConfig.shutdown(); + } + } + + final TestSecurityConfig.User user; + final LocalCluster cluster; + final ClusterConfig clusterConfig; + + @Test + public void createDocument() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + HttpResponse httpResponse = restClient.post("ds_bw1/_doc/", json("a", 1, "@timestamp", Instant.now().toString())); + assertThat(httpResponse, containsExactly(ds_bw1).at("_index").reducedBy(user.indexMatcher("write")).whenEmpty(isForbidden())); + } + } + + @Test + public void deleteByQuery_indexPattern() throws Exception { + String testName = "deleteByQuery_indexPattern"; + + try (TestRestClient restClient = cluster.getRestClient(user)) { + try (TestRestClient adminRestClient = cluster.getAdminCertRestClient()) { + // Init test data + HttpResponse httpResponse = adminRestClient.put( + "ds_bw1/_create/put_delete_delete_by_query_b1?refresh=true", + json("test", testName, "delete_by_query_test_delete", "yes", "@timestamp", Instant.now().toString()) + ); + assertThat(httpResponse, isCreated()); + httpResponse = adminRestClient.put( + "ds_bw1/_create/put_delete_delete_by_query_b2?refresh=true", + json("test", testName, "delete_by_query_test_delete", "no", "@timestamp", Instant.now().toString()) + ); + assertThat(httpResponse, isCreated()); + httpResponse = adminRestClient.put( + "ds_aw1/_create/put_delete_delete_by_query_a1?refresh=true", + json("test", testName, "delete_by_query_test_delete", "yes", "@timestamp", Instant.now().toString()) + ); + assertThat(httpResponse, isCreated()); + httpResponse = adminRestClient.put( + "ds_aw1/_create/put_delete_delete_by_query_a2?refresh=true", + json("test", testName, "delete_by_query_test_delete", "no", "@timestamp", Instant.now().toString()) + ); + assertThat(httpResponse, isCreated()); + } + + HttpResponse httpResponse = restClient.postJson("ds_aw*,ds_bw*/_delete_by_query?wait_for_completion=true", """ + { + "query": { + "term": { + "delete_by_query_test_delete": "yes" + } + } + } + """); + + if (clusterConfig.legacyPrivilegeEvaluation) { + // dnfof is not applicable to indices:data/write/delete/byquery, so we need privileges for all indices + if (user.indexMatcher("write").coversAll(ds_aw1, ds_aw2, ds_bw1, ds_bw2)) { + assertThat(httpResponse, isOk()); + } else { + assertThat(httpResponse, isForbidden()); + } + } else { + if (user != LIMITED_USER_NONE && user != LIMITED_READ_ONLY_ALL && user != LIMITED_READ_ONLY_A) { + assertThat(httpResponse, isOk()); + int expectedDeleteCount = containsExactly(ds_aw1, ds_bw1).at("_index").reducedBy(user.indexMatcher("write")).size(); + assertEquals(httpResponse.getBody(), expectedDeleteCount, httpResponse.bodyAsMap().get("deleted")); + } else { + assertThat(httpResponse, isForbidden()); + } + } + } finally { + deleteTestDocs(testName, "ds_aw*,ds_bw*"); + } + } + + @Test + public void putDocument_bulk() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + HttpResponse httpResponse = restClient.putJson("_bulk", """ + { "create": { "_index": "ds_aw1", "_id": "d1" } } + { "a": 1, "test": "putDocument_bulk", "@timestamp": "2025-09-15T12:00:00Z" } + { "create": { "_index": "ds_bw1", "_id": "d1" } } + { "b": 1, "test": "putDocument_bulk", "@timestamp": "2025-09-15T12:00:01Z" } + """); + + if (user != LIMITED_USER_NONE) { + assertThat( + httpResponse, + containsExactly(ds_aw1, ds_bw1).at("items[*].create[?(@.result == 'created')]._index") + .reducedBy(user.indexMatcher("write")) + .whenEmpty(isOk()) + ); + } else { + assertThat(httpResponse, isForbidden()); + } + } finally { + deleteTestDocs("putDocument_bulk", "ds_aw*,ds_bw*"); + } + } + + @Test + public void createDataStream() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + HttpResponse httpResponse = restClient.put("_data_stream/ds_bwx1"); + + if (containsExactly(ds_bwx1).reducedBy(user.indexMatcher("create_data_stream")).isEmpty()) { + assertThat(httpResponse, isForbidden()); + } else { + assertThat(httpResponse, isOk()); + } + } finally { + delete(ds_bwx1); + } + } + + @Test + public void putDataStream() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + HttpResponse httpResponse = restClient.putJson("ds_bwx1/", "{}"); + + if (user == UNLIMITED_USER + || user == SUPER_UNLIMITED_USER + || user == LIMITED_USER_B_MANAGE_DS + || user == LIMITED_USER_AB_MANAGE_INDEX) { + // This will fail because we try to create an index under a name of a data stream + assertThat(httpResponse, isBadRequest()); + } else { + assertThat(httpResponse, isForbidden()); + } + } finally { + delete(ds_bwx1); + } + } + + @Test + public void deleteDataStream() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + createInitialTestObjects(ds_bwx1); + + HttpResponse httpResponse = restClient.delete("_data_stream/ds_bwx1"); + + if (user.indexMatcher("manage_data_stream").isEmpty()) { + assertThat(httpResponse, isForbidden()); + } else { + assertThat(httpResponse, isOk()); + } + } finally { + delete(ds_bwx1); + } + } + + @After + public void refresh() { + cluster.getInternalNodeClient().admin().indices().refresh(new RefreshRequest("*")).actionGet(); + } + + @ParametersFactory(shuffle = false, argumentFormatting = "%1$s, %3$s") + public static Collection params() { + List result = new ArrayList<>(); + + for (ClusterConfig clusterConfig : ClusterConfig.values()) { + for (TestSecurityConfig.User user : USERS) { + result.add(new Object[] { clusterConfig, user, user.getDescription() }); + } + } + return result; + } + + public DataStreamAuthorizationReadWriteIntTests(ClusterConfig clusterConfig, TestSecurityConfig.User user, String description) + throws Exception { + this.user = user; + this.cluster = clusterConfig.cluster(DataStreamAuthorizationReadWriteIntTests::clusterBuilder); + this.clusterConfig = clusterConfig; + } + + private void createInitialTestObjects(TestIndexOrAliasOrDatastream... testIndexLikeArray) { + TestIndexOrAliasOrDatastream.createInitialTestObjects(cluster, testIndexLikeArray); + } + + private void delete(TestIndexOrAliasOrDatastream... testIndexLikeArray) { + TestIndexOrAliasOrDatastream.delete(cluster, testIndexLikeArray); + } + + private void deleteTestDocs(String testName, String indices) { + try (TestRestClient adminRestClient = cluster.getAdminCertRestClient()) { + adminRestClient.post(indices + "/_refresh"); + adminRestClient.postJson(indices + "/_delete_by_query?refresh=true&wait_for_completion=true", """ + { + "query": { + "term": { + "test.keyword": "%s" + } + } + } + """.formatted(testName)); + } catch (Exception e) { + throw new RuntimeException("Error while cleaning up test docs", e); + } + } + +} diff --git a/src/integrationTest/java/org/opensearch/security/privileges/int_tests/IndexAuthorizationReadOnlyIntTests.java b/src/integrationTest/java/org/opensearch/security/privileges/int_tests/IndexAuthorizationReadOnlyIntTests.java new file mode 100644 index 0000000000..7f7dd6977d --- /dev/null +++ b/src/integrationTest/java/org/opensearch/security/privileges/int_tests/IndexAuthorizationReadOnlyIntTests.java @@ -0,0 +1,1998 @@ +/* + * 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. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.privileges.int_tests; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.stream.Stream; + +import com.carrotsearch.randomizedtesting.annotations.ParametersFactory; +import com.carrotsearch.randomizedtesting.annotations.ThreadLeakScope; +import com.google.common.collect.ImmutableList; +import org.junit.AfterClass; +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.opensearch.common.settings.Settings; +import org.opensearch.indices.SystemIndexDescriptor; +import org.opensearch.plugins.Plugin; +import org.opensearch.plugins.SystemIndexPlugin; +import org.opensearch.script.mustache.MustacheModulePlugin; +import org.opensearch.test.framework.TestAlias; +import org.opensearch.test.framework.TestData; +import org.opensearch.test.framework.TestIndex; +import org.opensearch.test.framework.TestIndexOrAliasOrDatastream; +import org.opensearch.test.framework.TestSecurityConfig; +import org.opensearch.test.framework.cluster.LocalCluster; +import org.opensearch.test.framework.cluster.TestRestClient; +import org.opensearch.test.framework.matcher.IndexApiResponseMatchers; + +import static java.util.stream.Collectors.joining; +import static org.apache.commons.lang3.StringEscapeUtils.escapeJson; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.opensearch.test.framework.TestIndex.openSearchSecurityConfigIndex; +import static org.opensearch.test.framework.TestSecurityConfig.AuthcDomain.AUTHC_HTTPBASIC_INTERNAL; +import static org.opensearch.test.framework.cluster.TestRestClient.json; +import static org.opensearch.test.framework.matcher.IndexApiResponseMatchers.IndexMatcher; +import static org.opensearch.test.framework.matcher.IndexApiResponseMatchers.OnResponseIndexMatcher.containsExactly; +import static org.opensearch.test.framework.matcher.IndexApiResponseMatchers.OnUserIndexMatcher.limitedTo; +import static org.opensearch.test.framework.matcher.IndexApiResponseMatchers.OnUserIndexMatcher.limitedToNone; +import static org.opensearch.test.framework.matcher.IndexApiResponseMatchers.OnUserIndexMatcher.unlimitedIncludingOpenSearchSecurityIndex; +import static org.opensearch.test.framework.matcher.RestMatchers.isBadRequest; +import static org.opensearch.test.framework.matcher.RestMatchers.isForbidden; +import static org.opensearch.test.framework.matcher.RestMatchers.isNotFound; +import static org.opensearch.test.framework.matcher.RestMatchers.isOk; +import static org.junit.Assert.assertTrue; + +/** + * This class defines a huge test matrix for index related access controls. This class is especially for read-only operations on indices and aliases. + * It uses the following dimensions: + *
    + *
  • ClusterConfig: At the moment, we test without and with system index permission enabled. New semantics will follow later.
  • + *
  • TestSecurityConfig.User: We have quite a few of different users with different privileges configurations.
  • + *
  • The test methods represent different operations with different options that are tested
  • + *
+ * To cope with the huge space of tests, this class uses test oracles to verify the result of the operations. + * These are defined with the "indexMatcher()" method of TestSecurityConfig.User. See there and the class IndexApiMatchers. + */ +@RunWith(com.carrotsearch.randomizedtesting.RandomizedRunner.class) +@ThreadLeakScope(ThreadLeakScope.Scope.NONE) +public class IndexAuthorizationReadOnlyIntTests { + + // ------------------------------------------------------------------------------------------------------- + // Test indices used by this test suite. Indices are usually initially created; the only exception is + // index_ax, which is referred to in tests, but which does not exist on purpose. + // ------------------------------------------------------------------------------------------------------- + + static final TestIndex index_a1 = TestIndex.name("index_a1").documentCount(100).seed(1).build(); + static final TestIndex index_a2 = TestIndex.name("index_a2").documentCount(110).seed(2).build(); + static final TestIndex index_a3 = TestIndex.name("index_a3").documentCount(120).seed(3).build(); + static final TestIndex index_ax = TestIndex.name("index_ax").build(); // Not existing index + static final TestIndex index_b1 = TestIndex.name("index_b1").documentCount(51).seed(4).build(); + static final TestIndex index_b2 = TestIndex.name("index_b2").documentCount(52).seed(5).build(); + static final TestIndex index_b3 = TestIndex.name("index_b3").documentCount(53).seed(6).build(); + static final TestIndex index_c1 = TestIndex.name("index_c1").documentCount(5).seed(7).build(); + static final TestIndex index_hidden = TestIndex.name("index_hidden").hidden().documentCount(1).seed(8).build(); + static final TestIndex index_hidden_dot = TestIndex.name(".index_hidden_dot").hidden().documentCount(1).seed(8).build(); + static final TestIndex system_index_plugin = TestIndex.name(".system_index_plugin").hidden().documentCount(1).seed(8).build(); + + static final TestAlias alias_ab1 = new TestAlias("alias_ab1").on(index_a1, index_a2, index_a3, index_b1); + static final TestAlias alias_c1 = new TestAlias("alias_c1").on(index_c1); + static final TestAlias alias_with_system_index = new TestAlias(".alias_with_system_index").hidden().on(system_index_plugin); + + static final List ALL_INDICES_EXCEPT_SYSTEM_INDICES = List.of( + index_a1, + index_a2, + index_a3, + index_b1, + index_b2, + index_b3, + index_c1, + index_hidden, + index_hidden_dot + ); + + static final List ALL_INDICES = List.of( + index_a1, + index_a2, + index_a3, + index_b1, + index_b2, + index_b3, + index_c1, + index_hidden, + index_hidden_dot, + system_index_plugin, + openSearchSecurityConfigIndex() + ); + + static final List ALL_INDICES_AND_ALIASES = List.of( + index_a1, + index_a2, + index_a3, + index_b1, + index_b2, + index_b3, + index_c1, + alias_ab1, + alias_c1, + index_hidden, + index_hidden_dot, + system_index_plugin, + alias_with_system_index, + openSearchSecurityConfigIndex() + ); + + static final List ALL_INDICES_AND_ALIASES_EXCEPT_SYSTEM_INDICES = List.of( + index_a1, + index_a2, + index_a3, + index_b1, + index_b2, + index_b3, + index_c1, + alias_ab1, + alias_c1, + index_hidden, + index_hidden_dot + ); + + // ------------------------------------------------------------------------------------------------------- + // Test users with which the tests will be executed; the users need to be added to the list USERS below + // The users have two redundant versions or privilege configuration, which needs to be kept in sync: + // - The standard role configuration defined with .roles() + // - IndexMatchers which act as test oracles, defined with the indexMatcher() methods + // ------------------------------------------------------------------------------------------------------- + + /** + * A simple user that can read from index_a* + */ + static final TestSecurityConfig.User LIMITED_USER_A = new TestSecurityConfig.User("limited_user_A")// + .description("index_a*")// + .roles( + new TestSecurityConfig.Role("r1")// + .clusterPermissions("cluster_composite_ops_ro", "cluster_monitor") + .indexPermissions("read", "indices_monitor", "indices:admin/analyze") + .on("index_a*") + )// + .indexMatcher("read", limitedTo(index_a1, index_a2, index_a3, index_ax))// + .indexMatcher("read_nextgen", limitedTo(index_a1, index_a2, index_a3, index_ax))// + .indexMatcher("get_alias", limitedToNone()); + + /** + * A simple user that can read from index_b* + */ + static final TestSecurityConfig.User LIMITED_USER_B = new TestSecurityConfig.User("limited_user_B")// + .description("index_b*")// + .roles( + new TestSecurityConfig.Role("r1")// + .clusterPermissions("cluster_composite_ops_ro", "cluster_monitor") + .indexPermissions("read", "indices_monitor", "indices:admin/analyze") + .on("index_b*") + )// + .indexMatcher("read", limitedTo(index_b1, index_b2, index_b3))// + .indexMatcher("read_nextgen", limitedTo(index_b1, index_b2, index_b3))// + .indexMatcher("get_alias", limitedToNone()); + + /** + * A simple user that can read only from index_b1 + */ + static final TestSecurityConfig.User LIMITED_USER_B1 = new TestSecurityConfig.User("limited_user_B1")// + .description("index_b1")// + .roles( + new TestSecurityConfig.Role("r1")// + .clusterPermissions("cluster_composite_ops_ro", "cluster_monitor") + .indexPermissions("read", "indices_monitor", "indices:admin/analyze") + .on("index_b1") + )// + .indexMatcher("read", limitedTo(index_b1))// + .indexMatcher("read_nextgen", limitedTo(index_b1))// + .indexMatcher("get_alias", limitedToNone()); + + /** + * A simple user that can read from index_c* + */ + static final TestSecurityConfig.User LIMITED_USER_C = new TestSecurityConfig.User("limited_user_C")// + .description("index_c*")// + .roles( + new TestSecurityConfig.Role("r1")// + .clusterPermissions("cluster_composite_ops_ro", "cluster_monitor") + .indexPermissions("read", "indices_monitor", "indices:admin/analyze") + .on("index_c*") + )// + .indexMatcher("read", limitedTo(index_c1, alias_c1))// + .indexMatcher("read_nextgen", limitedTo(index_c1))// + .indexMatcher("get_alias", limitedToNone()); + + /** + * A user that has read privileges for alias_ab1*; these privileges are inherited to the member indices. + * The user has no directly defined privileges on indices. + */ + static final TestSecurityConfig.User LIMITED_USER_ALIAS_AB1 = new TestSecurityConfig.User("limited_user_alias_AB1")// + .description("alias_ab1")// + .roles( + new TestSecurityConfig.Role("r1")// + .clusterPermissions("cluster_composite_ops_ro", "cluster_monitor") + .indexPermissions("read", "indices_monitor", "indices:admin/analyze", "indices:admin/aliases/get") + .on("alias_ab1*") + )// + .indexMatcher("read", limitedTo(index_a1, index_a2, index_a3, index_b1, alias_ab1))// + .indexMatcher("read_nextgen", limitedTo(index_a1, index_a2, index_a3, index_b1, alias_ab1))// + .indexMatcher("get_alias", limitedTo(index_a1, index_a2, index_a3, index_b1, alias_ab1)); + + /** + * A user that has read privileges for alias_c1; these privileges are inherited to the member indices. + * The user has no directly defined privileges on indices. + */ + static final TestSecurityConfig.User LIMITED_USER_ALIAS_C1 = new TestSecurityConfig.User("limited_user_alias_C1")// + .description("alias_c1")// + .roles( + new TestSecurityConfig.Role("r1")// + .clusterPermissions("cluster_composite_ops_ro", "cluster_monitor") + .indexPermissions("read", "indices_monitor", "indices:admin/analyze", "indices:admin/aliases/get") + .on("alias_c1") + )// + .indexMatcher("read", limitedTo(index_c1, alias_c1))// + .indexMatcher("read_nextgen", limitedTo(index_c1, alias_c1))// + .indexMatcher("get_alias", limitedTo(index_c1, alias_c1)); + /** + * Same as LIMITED_USER_A with the addition of read privileges for index_hidden* and .index_hidden* + */ + static final TestSecurityConfig.User LIMITED_USER_A_HIDDEN = new TestSecurityConfig.User("limited_user_A_hidden")// + .description("index_a*, index_hidden*")// + .roles( + new TestSecurityConfig.Role("r1")// + .clusterPermissions("cluster_composite_ops_ro", "cluster_monitor") + .indexPermissions("read", "indices_monitor", "indices:admin/analyze") + .on("index_a*", "index_hidden*", ".index_hidden*") + )// + .indexMatcher("read", limitedTo(index_a1, index_a2, index_a3, index_ax, index_hidden, index_hidden_dot))// + .indexMatcher("read_nextgen", limitedTo(index_a1, index_a2, index_a3, index_ax, index_hidden, index_hidden_dot))// + .indexMatcher("get_alias", limitedToNone()); + + /** + * Same as LIMITED_USER_C with the addition of read privileges for ".system_index_plugin"; they also have the + * explicit privilege "system:admin/system_index" that allows them accessing this index. + */ + static final TestSecurityConfig.User LIMITED_USER_C_WITH_SYSTEM_INDICES = new TestSecurityConfig.User( + "limited_user_C_with_system_indices" + )// + .description("index_c*, .system_index_plugin")// + .roles( + new TestSecurityConfig.Role("r1")// + .clusterPermissions("cluster_composite_ops_ro", "cluster_monitor") + .indexPermissions("read", "indices_monitor", "indices:admin/analyze") + .on("index_c*")// + .indexPermissions("read", "indices_monitor", "indices:admin/analyze", "system:admin/system_index") + .on(".system_index_plugin", ".alias_with_system_index") + )// + .indexMatcher("read", limitedTo(index_c1, alias_c1, system_index_plugin, alias_with_system_index))// + .indexMatcher("read_nextgen", limitedTo(index_c1, system_index_plugin, alias_with_system_index))// + .indexMatcher("get_alias", limitedToNone()); + + /** + * This user has no privileges for indices that are used in this test. But they have privileges for other indices. + * This allows them to use actions like _search and receive empty result sets. + *

+ * Compare with LIMITED_USER_NONE, which has no search privileges and will only receive 403 errors. + */ + static final TestSecurityConfig.User LIMITED_USER_OTHER_PRIVILEGES = new TestSecurityConfig.User("limited_user_other_index_privileges")// + .description("no privileges for tested indices")// + .roles( + new TestSecurityConfig.Role("r1")// + .clusterPermissions("cluster_composite_ops_ro", "cluster_monitor") + .indexPermissions("crud", "indices_monitor", "indices:admin/analyze") + .on("index_does_not_exist_*") + )// + .indexMatcher("read", limitedToNone())// + .indexMatcher("read_nextgen", limitedToNone())// + .indexMatcher("get_alias", limitedToNone()); + + /** + * This user has no index read privileges at all. + */ + static final TestSecurityConfig.User LIMITED_USER_NONE = new TestSecurityConfig.User("limited_user_none")// + .description("no index privileges")// + .roles( + new TestSecurityConfig.Role("r1")// + .clusterPermissions("cluster_composite_ops_ro", "cluster_monitor") + )// + .indexMatcher("read", limitedToNone())// + .indexMatcher("read_nextgen", limitedToNone())// + .indexMatcher("get_alias", limitedToNone()); + + /** + * A user with "*" privileges on "*"; as it is a regular user, they are still subject to system index + * restrictions and similar things. + */ + static final TestSecurityConfig.User UNLIMITED_USER = new TestSecurityConfig.User("unlimited_user")// + .description("unlimited")// + .roles( + new TestSecurityConfig.Role("r1")// + .clusterPermissions("*") + .indexPermissions("*") + .on("*")// + + )// + .indexMatcher("read", limitedTo(ALL_INDICES_AND_ALIASES_EXCEPT_SYSTEM_INDICES).and(index_ax))// + .indexMatcher("read_nextgen", limitedTo(ALL_INDICES_AND_ALIASES_EXCEPT_SYSTEM_INDICES).and(index_ax))// + .indexMatcher("get_alias", limitedTo(ALL_INDICES_AND_ALIASES_EXCEPT_SYSTEM_INDICES).and(index_ax)); + + /** + * The SUPER_UNLIMITED_USER authenticates with an admin cert, which will cause all access control code to be skipped. + * This serves as a base for comparison with the default behavior. + */ + static final TestSecurityConfig.User SUPER_UNLIMITED_USER = new TestSecurityConfig.User("super_unlimited_user")// + .description("super unlimited (admin cert)")// + .adminCertUser()// + .indexMatcher("read", unlimitedIncludingOpenSearchSecurityIndex())// + .indexMatcher("read_nextgen", unlimitedIncludingOpenSearchSecurityIndex())// + .indexMatcher("get_alias", unlimitedIncludingOpenSearchSecurityIndex()); + + static final List USERS = ImmutableList.of( + LIMITED_USER_A, + LIMITED_USER_B, + LIMITED_USER_B1, + LIMITED_USER_C, + LIMITED_USER_ALIAS_AB1, + LIMITED_USER_ALIAS_C1, + LIMITED_USER_A_HIDDEN, + LIMITED_USER_C_WITH_SYSTEM_INDICES, + LIMITED_USER_OTHER_PRIVILEGES, + LIMITED_USER_NONE, + UNLIMITED_USER, + SUPER_UNLIMITED_USER + ); + + static LocalCluster.Builder clusterBuilder() { + return new LocalCluster.Builder().singleNode() + .authc(AUTHC_HTTPBASIC_INTERNAL) + .users(USERS)// + .indices( + index_a1, + index_a2, + index_a3, + index_b1, + index_b2, + index_b3, + index_c1, + index_hidden, + index_hidden_dot, + system_index_plugin + )// + .aliases(alias_ab1, alias_c1, alias_with_system_index)// + .plugin(SystemIndexTestPlugin.class, MustacheModulePlugin.class); + } + + @AfterClass + public static void stopClusters() { + for (ClusterConfig clusterConfig : ClusterConfig.values()) { + clusterConfig.shutdown(); + } + } + + final TestSecurityConfig.User user; + final LocalCluster cluster; + final ClusterConfig clusterConfig; + + @Test + public void search_noPattern() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("_search?size=1000"); + if (user != LIMITED_USER_NONE) { + // The main case: the requested indices are reduced according to privileges; depending on config even to an empty set of + // indices + assertThat( + httpResponse, + containsExactly(index_a1, index_a2, index_a3, index_b1, index_b2, index_b3, index_c1).at("hits.hits[*]._index") + .reducedBy(user.indexMatcher("read")) + .whenEmpty(clusterConfig.allowsEmptyResultSets ? isOk() : isForbidden()) + ); + } else { + // If a user has no search privileges at all, they will get a 403 error + assertThat(httpResponse, isForbidden()); + } + } + } + + @Test + public void search_noPattern_noWildcards() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("_search?size=1000&expand_wildcards=none"); + if (user == UNLIMITED_USER || user == SUPER_UNLIMITED_USER) { + // Users with full privileges get an empty result, like expected due to the expand_wildcards=none option + assertThat(httpResponse, isOk()); + assertThat(httpResponse, containsExactly().at("hits.hits[*]._index")); + } else { + assertThat(httpResponse, isForbidden()); + } + } + } + + @Test + public void search_noPattern_allowNoIndicesFalse() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("/_search?size=1000&allow_no_indices=false"); + assertThat( + httpResponse, + containsExactly(index_a1, index_a2, index_a3, index_b1, index_b2, index_b3, index_c1).at("hits.hits[*]._index") + .reducedBy(user.indexMatcher("read")) + .whenEmpty(isForbidden()) + ); + } + } + + @Test + public void search_all() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("_all/_search?size=1000"); + + if (user != LIMITED_USER_NONE) { + // The main case: the requested indices are reduced according to privileges; depending on config even to an empty set of + // indices + assertThat( + httpResponse, + containsExactly(index_a1, index_a2, index_a3, index_b1, index_b2, index_b3, index_c1).at("hits.hits[*]._index") + .reducedBy(user.indexMatcher("read")) + .whenEmpty(clusterConfig.allowsEmptyResultSets ? isOk() : isForbidden()) + ); + } else { + // If a user has no search privileges at all, they will get a 403 error + assertThat(httpResponse, isForbidden()); + } + } + } + + @Test + public void search_all_noWildcards() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("_all/_search?size=1000&expand_wildcards=none"); + + if (user == SUPER_UNLIMITED_USER || user == UNLIMITED_USER) { + // Users with full privileges get an empty result, like expected due to the expand_wildcards=none option + assertThat(httpResponse, isOk()); + assertThat(httpResponse, containsExactly().at("hits.hits[*]._index")); + } else { + assertThat(httpResponse, isForbidden()); + } + } + } + + @Test + public void search_all_includeHidden() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("_all/_search?size=1000&expand_wildcards=all"); + + if (clusterConfig.legacyPrivilegeEvaluation) { + assertThat( + httpResponse, + containsExactly( + clusterConfig.systemIndexPrivilegeEnabled || user == SUPER_UNLIMITED_USER + ? ALL_INDICES + : ALL_INDICES_EXCEPT_SYSTEM_INDICES + ).at("hits.hits[*]._index") + .reducedBy(user.indexMatcher("read")) + .whenEmpty(clusterConfig.allowsEmptyResultSets ? isOk() : isForbidden()) + ); + } else { // next gen privilege evaluation + if (user != LIMITED_USER_NONE) { + // In the new privilege evaluation, the system index privilege is observed and contributes to dnfof. + assertThat( + httpResponse, + containsExactly(ALL_INDICES).at("hits.hits[*]._index").reducedBy(user.indexMatcher("read")).whenEmpty(isOk()) + ); + } else { + assertThat(httpResponse, isForbidden()); + } + } + } + } + + @Test + public void search_wildcard() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("*/_search?size=1000"); + + if (user != LIMITED_USER_NONE) { + // The main case: the requested indices are reduced according to privileges; depending on config even to an empty set of + // indices + assertThat( + httpResponse, + containsExactly(index_a1, index_a2, index_a3, index_b1, index_b2, index_b3, index_c1).at("hits.hits[*]._index") + .reducedBy(user.indexMatcher("read")) + .whenEmpty(clusterConfig.allowsEmptyResultSets ? isOk() : isForbidden()) + ); + } else { + // If a user has no search privileges at all, they will get a 403 error + assertThat(httpResponse, isForbidden()); + } + } + } + + @Test + public void search_wildcard_noWildcards() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("*/_search?size=1000&expand_wildcards=none"); + + if (user == SUPER_UNLIMITED_USER || user == UNLIMITED_USER) { + // Users with full privileges get an empty result, like expected due to the expand_wildcards=none option + assertThat(httpResponse, isOk()); + assertThat(httpResponse, containsExactly().at("hits.hits[*]._index")); + } else { + assertThat(httpResponse, isForbidden()); + } + } + } + + @Test + public void search_wildcard_includeHidden() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("*/_search?size=1000&expand_wildcards=all"); + + if (user != LIMITED_USER_NONE) { + assertThat( + httpResponse, + containsExactly( + clusterConfig.systemIndexPrivilegeEnabled || user == SUPER_UNLIMITED_USER + ? ALL_INDICES + : ALL_INDICES_EXCEPT_SYSTEM_INDICES + ).at("hits.hits[*]._index") + .reducedBy(user.indexMatcher("read")) + .whenEmpty(clusterConfig.allowsEmptyResultSets ? isOk() : isForbidden()) + ); + } else { + // If a user has no search privileges at all, they will get a 403 error + assertThat(httpResponse, isForbidden()); + } + } + } + + @Test + public void search_staticIndices() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("index_a1/_search?size=1000"); + assertThat( + httpResponse, + containsExactly(index_a1).at("hits.hits[*]._index").reducedBy(user.indexMatcher("read")).whenEmpty(isForbidden()) + ); + } + } + + @Test + public void search_staticIndices_ignoreUnavailable() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("index_a1,index_b1/_search?size=1000&ignore_unavailable=true"); + + if (user != LIMITED_USER_NONE) { + assertThat( + httpResponse, + containsExactly(index_a1, index_b1).at("hits.hits[*]._index") + .reducedBy(user.indexMatcher("read")) + .whenEmpty(clusterConfig.allowsEmptyResultSets ? isOk() : isForbidden()) + ); + } else { + // If a user has no search privileges at all, they will get a 403 error + assertThat(httpResponse, isForbidden()); + } + } + } + + @Test + public void search_staticIndices_nonExisting() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("index_ax/_search?size=1000"); + + if (containsExactly(index_ax).reducedBy(user.indexMatcher("read")).isEmpty()) { + assertThat(httpResponse, isForbidden("/error/root_cause/0/reason", "no permissions for [indices:data/read/search]")); + } else { + assertThat(httpResponse, isNotFound()); + } + } + } + + @Test + public void search_staticIndices_hidden() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("index_hidden/_search?size=1000"); + assertThat( + httpResponse, + containsExactly(index_hidden).at("hits.hits[*]._index").butForbiddenIfIncomplete(user.indexMatcher("read")) + ); + } + } + + @Test + public void search_staticIndices_systemIndex() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get(".system_index_plugin/_search?size=1000"); + if (clusterConfig.systemIndexPrivilegeEnabled || user == SUPER_UNLIMITED_USER) { + assertThat( + httpResponse, + containsExactly(system_index_plugin).at("hits.hits[*]._index").butForbiddenIfIncomplete(user.indexMatcher("read")) + ); + } else { + // legacy privilege evaluation without system index privilege enabled + if (user == UNLIMITED_USER || user == LIMITED_USER_C_WITH_SYSTEM_INDICES) { + // The legacy evaluation grants access in SystemIndexAccessPrivilegesEvaluator for users with * privileges, + // but withholds documents on the DLS level + assertThat(httpResponse, isOk()); + assertThat(httpResponse, containsExactly().at("hits.hits[*]._index")); + } else { + assertThat(httpResponse, isForbidden("/error/root_cause/0/reason", "no permissions for [indices:data/read/search]")); + } + } + } + } + + @Test + public void search_staticIndices_systemIndex_alias() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get(".alias_with_system_index/_search?size=1000"); + + if (user == SUPER_UNLIMITED_USER) { + assertThat(httpResponse, isOk()); + assertThat(httpResponse, containsExactly(system_index_plugin).at("hits.hits[*]._index")); + } else if (clusterConfig == ClusterConfig.LEGACY_PRIVILEGES_EVALUATION) { + if (user == UNLIMITED_USER || user == LIMITED_USER_C_WITH_SYSTEM_INDICES) { + // The legacy evaluation grants access in SystemIndexAccessPrivilegesEvaluator for users with * privileges, + // but withholds documents on the DLS level + assertThat(httpResponse, isOk()); + assertThat(httpResponse, containsExactly().at("hits.hits[*]._index")); + } else { + assertThat(httpResponse, isForbidden()); + } + } else if (clusterConfig == ClusterConfig.LEGACY_PRIVILEGES_EVALUATION_SYSTEM_INDEX_PERMISSION) { + if (user == UNLIMITED_USER) { + // The legacy evaluation grants access in SystemIndexAccessPrivilegesEvaluator for users with * privileges, + // but withholds documents on the DLS level + assertThat(httpResponse, isOk()); + assertThat(httpResponse, containsExactly().at("hits.hits[*]._index")); + } else { + assertThat( + httpResponse, + containsExactly(system_index_plugin).at("hits.hits[*]._index") + .reducedBy(user.indexMatcher("read")) + .whenEmpty(isForbidden()) + ); + } + } else { + if (user.indexMatcher("read").covers(alias_with_system_index)) { + assertThat(httpResponse, isOk()); + assertThat(httpResponse, containsExactly(system_index_plugin).at("hits.hits[*]._index")); + } else { + assertThat(httpResponse, isForbidden()); + } + } + } + } + + @Test + public void search_indexPattern() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("index_a*,index_b*/_search?size=1000"); + + if (user != LIMITED_USER_NONE) { + assertThat( + httpResponse, + containsExactly(index_a1, index_a2, index_a3, index_b1, index_b2, index_b3).at("hits.hits[*]._index") + .reducedBy(user.indexMatcher("read")) + .whenEmpty(clusterConfig.allowsEmptyResultSets ? isOk() : isForbidden()) + ); + } else { + // If a user has no search privileges at all, they will get a 403 error + assertThat(httpResponse, isForbidden()); + } + } + } + + @Test + public void search_indexPattern_minus() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("index_a*,index_b*,-index_b2,-index_b3/_search?size=1000"); + + if (user != LIMITED_USER_NONE) { + assertThat( + httpResponse, + containsExactly(index_a1, index_a2, index_a3, index_b1).at("hits.hits[*]._index") + .reducedBy(user.indexMatcher("read")) + .whenEmpty(clusterConfig.allowsEmptyResultSets ? isOk() : isForbidden()) + ); + } else { + // If a user has no search privileges at all, they will get a 403 error + assertThat(httpResponse, isForbidden()); + } + } + } + + @Test + public void search_indexPattern_nonExistingIndex_ignoreUnavailable() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get( + "index_a*,index_b*,xxx_non_existing/_search?size=1000&ignore_unavailable=true" + ); + if (user != LIMITED_USER_NONE) { + assertThat( + httpResponse, + containsExactly(index_a1, index_a2, index_a3, index_b1, index_b2, index_b3).at("hits.hits[*]._index") + .reducedBy(user.indexMatcher("read")) + .whenEmpty(clusterConfig.allowsEmptyResultSets ? isOk() : isForbidden()) + ); + } else { + // If a user has no search privileges at all, they will get a 403 error + assertThat(httpResponse, isForbidden()); + } + } + } + + @Test + public void search_indexPattern_noWildcards() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("index_a*,index_b*/_search?size=1000&expand_wildcards=none"); + if (clusterConfig.legacyPrivilegeEvaluation) { + // We have to specify the users here explicitly because here we need to check privileges for the + // non-existing (and invalidly named) indices "index_a*" and "index_b*". Only users with privileges for "index_a*" + // and "index_b*" will get a ok response. + if (user == UNLIMITED_USER || user == SUPER_UNLIMITED_USER) { + assertThat(httpResponse, isNotFound()); + } else if (user == LIMITED_USER_A || user == LIMITED_USER_B || user == LIMITED_USER_A_HIDDEN) { + assertThat(httpResponse, isOk()); + assertThat(httpResponse, containsExactly().at("hits.hits[*]._index")); + } else { + assertThat(httpResponse, isForbidden("/error/root_cause/0/reason", "no permissions for [indices:data/read/search]")); + } + } else { + if (user == UNLIMITED_USER || user == SUPER_UNLIMITED_USER) { + // Only these users "get through". Because the indices does not exist, they get a 404 + assertThat(httpResponse, isNotFound()); + } else { + assertThat(httpResponse, isForbidden("/error/root_cause/0/reason", "no permissions for [indices:data/read/search]")); + } + } + } + } + + @Test + public void search_indexPatternAndStatic_negation() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + // If there is a wildcard, negation will also affect indices specified without a wildcard + TestRestClient.HttpResponse httpResponse = restClient.get("index_a*,index_b1,index_b2,-index_b2/_search?size=1000"); + if (user != LIMITED_USER_NONE) { + assertThat( + httpResponse, + containsExactly(index_a1, index_a2, index_a3, index_b1).at("hits.hits[*]._index") + .reducedBy(user.indexMatcher("read")) + .whenEmpty(clusterConfig.allowsEmptyResultSets ? isOk() : isForbidden()) + ); + } else { + assertThat(httpResponse, isForbidden()); + } + } + } + + @Test + public void search_indexPattern_includeHidden() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("*index*/_search?size=1000&expand_wildcards=all"); + + if (user == SUPER_UNLIMITED_USER) { + // The super admin sees everything + assertThat( + httpResponse, + containsExactly( + index_a1, + index_a2, + index_a3, + index_b1, + index_b2, + index_b3, + index_c1, + index_hidden, + index_hidden_dot, + system_index_plugin + ).at("hits.hits[*]._index") + .reducedBy(user.indexMatcher("read")) + .whenEmpty(clusterConfig.allowsEmptyResultSets ? isOk() : isForbidden()) + ); + } else if (!clusterConfig.systemIndexPrivilegeEnabled) { + // Without system index privileges, the system_index_plugin will be never included + assertThat( + httpResponse, + containsExactly(index_a1, index_a2, index_a3, index_b1, index_b2, index_b3, index_c1, index_hidden, index_hidden_dot) + .at("hits.hits[*]._index") + .reducedBy(user.indexMatcher("read")) + .whenEmpty(clusterConfig.allowsEmptyResultSets ? isOk() : isForbidden()) + ); + } else if (clusterConfig == ClusterConfig.LEGACY_PRIVILEGES_EVALUATION_SYSTEM_INDEX_PERMISSION) { + // Things get buggy here; basically all requests fail with a 403 + if (user == LIMITED_USER_C_WITH_SYSTEM_INDICES) { + // This user is supposed to have the system index privilege for the index .system_index_plugin + // However, the system index privilege evaluation code only works correct when the system index is the + // only requested index. If also non system indices are requested in the same request, it will require + // the presence of the system index privilege for all indices. As this is not the case, the request + // will be denied with a 403 error. + assertThat(httpResponse, isForbidden()); + } else { + // The other users do not have privileges for the system index. The dnfof feature promises to filter + // out indices without authorization from eligible requests. However, the SystemIndexAccessEvaluator + // is not aware of this and just denies all these requests + // See also https://github.com/opensearch-project/security/issues/5546 + assertThat(httpResponse, isForbidden()); + } + } else { + if (user != LIMITED_USER_NONE) { + // Without system index privileges, the system_index_plugin will be included if we have the privilege + assertThat( + httpResponse, + containsExactly( + index_a1, + index_a2, + index_a3, + index_b1, + index_b2, + index_b3, + index_c1, + index_hidden, + index_hidden_dot, + system_index_plugin + ).at("hits.hits[*]._index") + .reducedBy(user.indexMatcher("read")) + .whenEmpty(clusterConfig.allowsEmptyResultSets ? isOk() : isForbidden()) + ); + } else { + assertThat(httpResponse, isForbidden()); + } + } + } + } + + @Test + public void search_alias() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("alias_ab1/_search?size=1000"); + if (clusterConfig.legacyPrivilegeEvaluation) { + // The legacy privilege evaluation with dnfof enabled can replace aliases by a sub-set of its member indices + assertThat( + httpResponse, + containsExactly(index_a1, index_a2, index_a3, index_b1).at("hits.hits[*]._index") + .reducedBy(user.indexMatcher("read")) + .whenEmpty(isForbidden()) + ); + } else { + // The new privilege evaluation never replaces aliases + if (user.indexMatcher("read").covers(alias_ab1)) { + assertThat(httpResponse, containsExactly(index_a1, index_a2, index_a3, index_b1).at("hits.hits[*]._index")); + } else { + assertThat(httpResponse, isForbidden("/error/root_cause/0/reason", "no permissions for [indices:data/read/search]")); + } + } + } + } + + @Test + public void search_alias_pattern() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("alias_ab1*/_search?size=1000"); + + if (user != LIMITED_USER_NONE) { + assertThat( + httpResponse, + containsExactly(index_a1, index_a2, index_a3, index_b1).at("hits.hits[*]._index") + .reducedBy(user.indexMatcher("read")) + .whenEmpty(clusterConfig.allowsEmptyResultSets ? isOk() : isForbidden()) + ); + } else { + assertThat(httpResponse, isForbidden()); + } + } + } + + @Test + public void search_alias_pattern_negation() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("alias_*,-alias_ab1/_search?size=1000"); + + if (user != LIMITED_USER_NONE) { + if (clusterConfig.systemIndexPrivilegeEnabled) { + // If the system index privilege is enabled, we might also see the system_index_plugin index (being included via the + // alias) + assertThat( + httpResponse, + containsExactly(index_a1, index_a2, index_a3, index_b1, index_c1).at("hits.hits[*]._index") + .reducedBy(user.indexMatcher("read")) + .whenEmpty(clusterConfig.allowsEmptyResultSets ? isOk() : isForbidden()) + ); + } else { + assertThat( + httpResponse, + containsExactly(index_a1, index_a2, index_a3, index_b1, index_c1).at("hits.hits[*]._index") + .reducedBy(user.indexMatcher("read")) + .whenEmpty(clusterConfig.allowsEmptyResultSets ? isOk() : isForbidden()) + ); + } + } else { + assertThat(httpResponse, isForbidden()); + } + } + } + + @Test + public void search_alias_pattern_includeHidden() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("*alias*/_search?size=1000&expand_wildcards=all"); + + if (user == SUPER_UNLIMITED_USER) { + assertThat(httpResponse, isOk()); + assertThat( + httpResponse, + containsExactly(index_a1, index_a2, index_a3, index_b1, index_c1, system_index_plugin).at("hits.hits[*]._index") + ); + } else if (user != LIMITED_USER_NONE) { + if (clusterConfig == ClusterConfig.LEGACY_PRIVILEGES_EVALUATION) { + assertThat( + httpResponse, + containsExactly(index_a1, index_a2, index_a3, index_b1, index_c1).at("hits.hits[*]._index") + .reducedBy(user.indexMatcher("read")) + .whenEmpty(clusterConfig.allowsEmptyResultSets ? isOk() : isForbidden()) + ); + } else if (clusterConfig == ClusterConfig.LEGACY_PRIVILEGES_EVALUATION_SYSTEM_INDEX_PERMISSION) { + // For all users without the system index permission, SystemIndexAccessEvaluator shuts the door + // For the user with the system index permission, that happens as well, as SystemIndexAccessEvaluator expects the + // permission for all requested indices, even if they are not system indices + assertThat(httpResponse, isForbidden()); + } else { + assertThat( + httpResponse, + containsExactly(index_a1, index_a2, index_a3, index_b1, index_c1, system_index_plugin).at("hits.hits[*]._index") + .reducedBy(user.indexMatcher("read")) + .whenEmpty(isOk()) + ); + } + } else { + assertThat(httpResponse, isForbidden()); + } + } + } + + @Test + public void search_aliasAndIndex_ignoreUnavailable() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("alias_ab1,index_b1/_search?size=1000&ignore_unavailable=true"); + if (clusterConfig.legacyPrivilegeEvaluation) { + // The legacy privilege evaluation with dnfof enabled can replace aliases by a sub-set of its member indices + assertThat( + httpResponse, + containsExactly(index_a1, index_a2, index_a3, index_b1).at("hits.hits[*]._index") + .reducedBy(user.indexMatcher("read")) + .whenEmpty(isForbidden()) + ); + } else { + // The new privilege evaluation never replaces aliases + if (user == LIMITED_USER_NONE) { + assertThat(httpResponse, isForbidden("/error/root_cause/0/reason", "no permissions for [indices:data/read/search]")); + } else if (user.indexMatcher("read").covers(alias_ab1)) { + assertThat(httpResponse, containsExactly(index_a1, index_a2, index_a3, index_b1).at("hits.hits[*]._index")); + } else if (user.indexMatcher("read").covers(index_b1)) { + // Due to the "ignore_unavailable" request param, the alias_ab1 will be just silently ignored if we do not have + // privileges for it + assertThat(httpResponse, containsExactly(index_b1).at("hits.hits[*]._index")); + } else { + assertThat(httpResponse, containsExactly().at("hits.hits[*]._index")); + } + } + } + } + + @Test + public void search_nonExisting_static() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("x_does_not_exist/_search?size=1000"); + if (user == UNLIMITED_USER || user == SUPER_UNLIMITED_USER) { + assertThat(httpResponse, isNotFound()); + } else { + assertThat(httpResponse, isForbidden("/error/root_cause/0/reason", "no permissions for [indices:data/read/search]")); + } + } + } + + @Test + public void search_nonExisting_indexPattern() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("x_does_not_exist*/_search?size=1000"); + + if (user != LIMITED_USER_NONE) { + assertThat(httpResponse, containsExactly().at("hits.hits[*]._index").whenEmpty(isOk())); + } else { + assertThat(httpResponse, isForbidden("/error/root_cause/0/reason", "no permissions for [indices:data/read/search]")); + } + } + } + + @Test + public void search_termsAggregation_index() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.postJson("/_search", """ + { + "size": 0, + "aggs": { + "indices": { + "terms": { + "field": "_index", + "size": 1000 + } + } + } + }"""); + + if (clusterConfig == ClusterConfig.NEXT_GEN_PRIVILEGES_EVALUATION) { + if (user == LIMITED_USER_NONE) { + assertThat(httpResponse, isForbidden("/error/root_cause/0/reason", "no permissions for [indices:data/read/search]")); + } else if (user == LIMITED_USER_OTHER_PRIVILEGES) { + assertThat(httpResponse, isOk()); + assertTrue(httpResponse.getBody(), httpResponse.bodyAsMap().get("aggregations") == null); + } else { + assertThat( + httpResponse, + containsExactly(index_a1, index_a2, index_a3, index_b1, index_b2, index_b3, index_c1).at( + "aggregations.indices.buckets[*].key" + ).reducedBy(user.indexMatcher("read")).whenEmpty(isOk()) + ); + } + } else { + assertThat( + httpResponse, + containsExactly(index_a1, index_a2, index_a3, index_b1, index_b2, index_b3, index_c1).at( + "aggregations.indices.buckets[*].key" + ).reducedBy(user.indexMatcher("read")).whenEmpty(isOk()) + ); + } + } + } + + @Test + public void search_pit() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.post("index_a*,index_b*/_search/point_in_time?keep_alive=1m"); + + IndexApiResponseMatchers.OnResponseIndexMatcher indexMatcher = containsExactly( + index_a1, + index_a2, + index_a3, + index_b1, + index_b2, + index_b3 + ); + + if (indexMatcher.reducedBy(user.indexMatcher("read")).isEmpty()) { + assertThat( + httpResponse, + isForbidden("/error/root_cause/0/reason", "no permissions for [indices:data/read/point_in_time/create]") + ); + } else { + assertThat(httpResponse, isOk()); + String pitId = httpResponse.getTextFromJsonBody("/pit_id"); + httpResponse = restClient.postJson("/_search?size=1000", String.format(""" + { + "pit": { + "id": "%s" + } + } + """, pitId)); + assertThat(httpResponse, isOk()); + assertThat(httpResponse, indexMatcher.at("hits.hits[*]._index").reducedBy(user.indexMatcher("read"))); + } + } + } + + @Test + public void search_pit_all() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.post("_all/_search/point_in_time?keep_alive=1m"); + + IndexApiResponseMatchers.OnResponseIndexMatcher indexMatcher = containsExactly( + index_a1, + index_a2, + index_a3, + index_b1, + index_b2, + index_b3, + index_c1 + ); + + if (indexMatcher.reducedBy(user.indexMatcher("read")).isEmpty()) { + assertThat( + httpResponse, + isForbidden("/error/root_cause/0/reason", "no permissions for [indices:data/read/point_in_time/create]") + ); + } else { + assertThat(httpResponse, isOk()); + String pitId = httpResponse.getTextFromJsonBody("/pit_id"); + httpResponse = restClient.postJson("/_search?size=1000", String.format(""" + { + "pit": { + "id": "%s" + } + } + """, pitId)); + assertThat(httpResponse, isOk()); + assertThat(httpResponse, indexMatcher.at("hits.hits[*]._index").reducedBy(user.indexMatcher("read"))); + } + } + } + + @Test + public void search_pit_static() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.post("index_a1/_search/point_in_time?keep_alive=1m"); + + IndexApiResponseMatchers.OnResponseIndexMatcher indexMatcher = containsExactly(index_a1); + + if (indexMatcher.reducedBy(user.indexMatcher("read")).isEmpty()) { + assertThat( + httpResponse, + isForbidden("/error/root_cause/0/reason", "no permissions for [indices:data/read/point_in_time/create]") + ); + } else { + assertThat(httpResponse, isOk()); + String pitId = httpResponse.getTextFromJsonBody("/pit_id"); + httpResponse = restClient.postJson("/_search?size=1000", String.format(""" + { + "pit": { + "id": "%s" + } + } + """, pitId)); + assertThat(httpResponse, indexMatcher.at("hits.hits[*]._index").reducedBy(user.indexMatcher("read"))); + } + } + } + + @Test + public void search_pit_wrongIndex() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.post("index_a*/_search/point_in_time?keep_alive=1m"); + + IndexApiResponseMatchers.OnResponseIndexMatcher indexMatcher = containsExactly(index_a1, index_a2, index_a3); + + if (indexMatcher.reducedBy(user.indexMatcher("read")).isEmpty()) { + assertThat( + httpResponse, + isForbidden("/error/root_cause/0/reason", "no permissions for [indices:data/read/point_in_time/create]") + ); + } else { + assertThat(httpResponse, isOk()); + String pitId = httpResponse.getTextFromJsonBody("/pit_id"); + httpResponse = restClient.postJson("index_b*/_search?size=1000", String.format(""" + { + "pit": { + "id": "%s" + } + } + """, pitId)); + assertThat(httpResponse, isBadRequest("/error/root_cause/0/reason", "[indices] cannot be used with point in time")); + + } + } + } + + /** + * Moved from https://github.com/opensearch-project/security/blob/eb7153d772e9e00d49d9cb5ffafb33b5f02399fc/src/integrationTest/java/org/opensearch/security/privileges/PrivilegesEvaluatorTest.java#L103 + * See also https://github.com/opensearch-project/security/issues/1678 + */ + @Test + public void search_template_staticIndices() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + String params = """ + { + "department": [%s] + }""".formatted(TestData.DEPARTMENTS.stream().map(s -> '"' + s + '"').collect(joining(","))); + String query = """ + { + "query": { + "terms": { + "attr_text_1": [ + "{{#department}}", + "{{.}}", + "{{/department}}" + ] + } + } + } + """; + + TestRestClient.HttpResponse httpResponse = restClient.getWithJsonBody("index_a1/_search/template?size=1000", """ + { + "params": %s, + "source": "%s" + }""".formatted(params, escapeJson(query))); + + assertThat( + httpResponse, + containsExactly(index_a1).at("hits.hits[*]._index").reducedBy(user.indexMatcher("read")).whenEmpty(isForbidden()) + ); + } + } + + @Test + public void msearch_staticIndices() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.postJson("/_msearch", """ + {"index":"index_b1"} + {"size":10, "query":{"bool":{"must":{"match_all":{}}}}} + {"index":"index_b2"} + {"size":10, "query":{"bool":{"must":{"match_all":{}}}}} + """); + assertThat( + httpResponse, + containsExactly(index_b1, index_b2).at("responses[*].hits.hits[*]._index") + .reducedBy(user.indexMatcher("read")) + .whenEmpty(isOk()) + ); + } + } + + @Test + public void mget() throws Exception { + TestData.TestDocument testDocumentA1 = index_a1.anyDocument(); + TestData.TestDocument testDocumentB1 = index_b1.anyDocument(); + TestData.TestDocument testDocumentB2 = index_b2.anyDocument(); + + String mget = String.format(""" + { + "docs": [ + { "_index": "index_a1", "_id": "%s" }, + { "_index": "index_b1", "_id": "%s" }, + { "_index": "index_b2", "_id": "%s" } + ] + } + """, testDocumentA1.id(), testDocumentB1.id(), testDocumentB2.id()); + + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.postJson("/_mget", mget); + assertThat( + httpResponse, + containsExactly(index_a1, index_b1, index_b2).at("docs[?(@.found == true)]._index") + .reducedBy(user.indexMatcher("read")) + .whenEmpty(isOk()) + ); + } + } + + @Test + public void mget_alias() throws Exception { + TestData.TestDocument testDocumentC1a = index_c1.anyDocument(); + TestData.TestDocument testDocumentC1b = index_c1.anyDocument(); + + String mget = String.format(""" + { + "docs": [ + { "_index": "alias_c1", "_id": "%s" }, + { "_index": "alias_c1", "_id": "%s" } + ] + } + """, testDocumentC1a.id(), testDocumentC1b.id()); + + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.postJson("/_mget", mget); + assertThat( + httpResponse, + containsExactly(index_c1).at("docs[?(@.found == true)]._index").reducedBy(user.indexMatcher("read")).whenEmpty(isOk()) + ); + } + } + + @Test + public void get() throws Exception { + TestData.TestDocument testDocumentB1 = index_b1.anyDocument(); + + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("index_b1/_doc/" + testDocumentB1.id()); + assertThat(httpResponse, containsExactly(index_b1).at("_index").reducedBy(user.indexMatcher("read")).whenEmpty(isForbidden())); + } + } + + @Test + public void get_alias() throws Exception { + TestData.TestDocument testDocumentC1 = index_c1.anyDocument(); + + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("alias_c1/_doc/" + testDocumentC1.id()); + assertThat(httpResponse, containsExactly(index_c1).at("_index").reducedBy(user.indexMatcher("read")).whenEmpty(isForbidden())); + } + } + + @Test + public void get_systemIndex() throws Exception { + TestData.TestDocument testDocument = system_index_plugin.anyDocument(); + + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get(".system_index_plugin/_doc/" + testDocument.id()); + + if (clusterConfig == ClusterConfig.LEGACY_PRIVILEGES_EVALUATION) { + if (user == SUPER_UNLIMITED_USER) { + assertThat(httpResponse, isOk()); + assertThat(httpResponse, containsExactly(system_index_plugin).at("_index")); + } else if (user == LIMITED_USER_C_WITH_SYSTEM_INDICES || user == UNLIMITED_USER) { + // If the user has a role that grants access to the index, they can + // successfully access the index (i.e., they won't get a 403), but + // the index will appear empty (i.e., they will get a 404) + assertThat(httpResponse, isNotFound()); + } else { + assertThat(httpResponse, isForbidden()); + } + } else if ((clusterConfig.systemIndexPrivilegeEnabled && user == LIMITED_USER_C_WITH_SYSTEM_INDICES) + || user == SUPER_UNLIMITED_USER) { + assertThat(httpResponse, isOk()); + assertThat(httpResponse, containsExactly(system_index_plugin).at("_index")); + } else { + assertThat(httpResponse, isForbidden()); + } + } + } + + @Test + public void cat_indices_all() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("_cat/indices?format=json"); + + if (user != LIMITED_USER_NONE) { + assertThat( + httpResponse, + containsExactly(index_a1, index_a2, index_a3, index_b1, index_b2, index_b3, index_c1).at("$[*].index") + .reducedBy(user.indexMatcher("read")) + .whenEmpty(clusterConfig.allowsEmptyResultSets ? isOk() : isForbidden()) + ); + } else { + assertThat(httpResponse, isForbidden()); + } + } + } + + @Test + public void cat_indices_pattern() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("_cat/indices/index_a*?format=json"); + + if (user != LIMITED_USER_NONE) { + assertThat( + httpResponse, + containsExactly(index_a1, index_a2, index_a3).at("$[*].index") + .reducedBy(user.indexMatcher("read")) + .whenEmpty(clusterConfig.allowsEmptyResultSets ? isOk() : isForbidden()) + ); + } else { + assertThat(httpResponse, isForbidden()); + } + } + } + + @Test + public void cat_indices_all_includeHidden() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("_cat/indices?format=json&expand_wildcards=all"); + + if (clusterConfig.legacyPrivilegeEvaluation) { + if (user == UNLIMITED_USER) { + assertThat(httpResponse, containsExactly(ALL_INDICES).at("$[*].index")); + } else { + assertThat( + httpResponse, + containsExactly(ALL_INDICES).at("$[*].index").reducedBy(user.indexMatcher("read")).whenEmpty(isForbidden()) + ); + } + } else { + if (user != LIMITED_USER_NONE) { + assertThat( + httpResponse, + containsExactly(ALL_INDICES).at("$[*].index") + .reducedBy(user.indexMatcher("read")) + .whenEmpty(clusterConfig.allowsEmptyResultSets ? isOk() : isForbidden()) + ); + } else { + assertThat(httpResponse, isForbidden()); + } + } + } + } + + @Test + public void cat_aliases_all() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("_cat/aliases?format=json"); + + if (clusterConfig.legacyPrivilegeEvaluation && user == UNLIMITED_USER) { + assertThat(httpResponse, containsExactly(alias_ab1, alias_c1, alias_with_system_index).at("$[*].alias")); + } else { + if (!user.indexMatcher("get_alias").isEmpty()) { + assertThat( + httpResponse, + containsExactly(alias_ab1, alias_c1, alias_with_system_index).at("$[*].alias") + .reducedBy(user.indexMatcher("get_alias")) + .whenEmpty(isOk()) + ); + } else { + assertThat(httpResponse, isForbidden()); + } + } + } + } + + @Test + public void cat_aliases_pattern() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("_cat/aliases/alias_a*?format=json"); + + if (clusterConfig.legacyPrivilegeEvaluation) { + if (!user.indexMatcher("get_alias").isEmpty()) { + assertThat( + httpResponse, + containsExactly(alias_ab1).at("$[*].alias").reducedBy(user.indexMatcher("get_alias")).whenEmpty(isOk()) + ); + } else { + assertThat(httpResponse, isForbidden()); + } + } else { + if (!user.indexMatcher("get_alias").isEmpty()) { + assertThat( + httpResponse, + containsExactly(alias_ab1).at("$[*].alias").reducedBy(user.indexMatcher("get_alias")).whenEmpty(isForbidden()) + ); + } else { + assertThat(httpResponse, isForbidden()); + } + } + } + } + + @Test + public void index_stats_all() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("/_stats"); + + if (user != LIMITED_USER_NONE) { + assertThat( + httpResponse, + containsExactly(index_a1, index_a2, index_a3, index_b1, index_b2, index_b3, index_c1).at("indices.keys()") + .reducedBy(user.indexMatcher("read")) + .whenEmpty(clusterConfig.allowsEmptyResultSets ? isOk() : isForbidden()) + ); + } else { + assertThat(httpResponse, isForbidden()); + } + } + } + + @Test + public void index_stats_pattern() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("index_b*/_stats"); + + if (user != LIMITED_USER_NONE) { + assertThat( + httpResponse, + containsExactly(index_b1, index_b2, index_b3).at("indices.keys()") + .reducedBy(user.indexMatcher("read")) + .whenEmpty(clusterConfig.allowsEmptyResultSets ? isOk() : isForbidden()) + ); + } else { + assertThat(httpResponse, isForbidden()); + } + } + } + + @Test + public void getAlias_all() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("_alias"); + + if (clusterConfig.legacyPrivilegeEvaluation && user == UNLIMITED_USER) { + // The legacy privilege evaluation also allows regular users access to metadata of the security index + // This is not a security issue, as the metadata are not really security relevant + assertThat(httpResponse, containsExactly(ALL_INDICES).at("$.keys()")); + } else { + if (!user.indexMatcher("get_alias").isEmpty()) { + assertThat( + httpResponse, + containsExactly(alias_ab1, alias_c1, alias_with_system_index).at("$.*.aliases.keys()") + .reducedBy(user.indexMatcher("get_alias")) + .whenEmpty(clusterConfig.allowsEmptyResultSets ? isOk() : isForbidden()) + ); + assertThat( + httpResponse, + containsExactly(ALL_INDICES).at("$.keys()") + .reducedBy(user.indexMatcher("get_alias")) + .whenEmpty(clusterConfig.allowsEmptyResultSets ? isOk() : isForbidden()) + ); + } else { + assertThat(httpResponse, isForbidden()); + } + } + } + } + + @Test + public void getAlias_staticAlias() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("_alias/alias_c1"); + if (user == LIMITED_USER_ALIAS_AB1) { + if (clusterConfig.legacyPrivilegeEvaluation) { + // RestGetAliasesAction does some further post processing on the results, thus we get 404 errors in case a non wildcard + // alias was removed + assertThat(httpResponse, isNotFound()); + } else { + assertThat(httpResponse, isForbidden("/error/root_cause/0/reason", "no permissions for [indices:admin/aliases/get]")); + } + } else { + assertThat( + httpResponse, + containsExactly(alias_c1).at("$.*.aliases.keys()").reducedBy(user.indexMatcher("get_alias")).whenEmpty(isForbidden()) + ); + assertThat( + httpResponse, + containsExactly(index_c1).at("$.keys()").reducedBy(user.indexMatcher("get_alias")).whenEmpty(isForbidden()) + ); + } + } + } + + @Test + public void getAlias_aliasPattern() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("_alias/alias_ab*"); + + if (clusterConfig.legacyPrivilegeEvaluation) { + if (user == LIMITED_USER_ALIAS_AB1 || user == UNLIMITED_USER || user == SUPER_UNLIMITED_USER) { + assertThat(httpResponse, isOk()); + assertThat(httpResponse, containsExactly(alias_ab1).at("$.*.aliases.keys()").reducedBy(user.indexMatcher("get_alias"))); + assertThat(httpResponse, containsExactly(index_a1, index_a2, index_a3, index_b1).at("$.keys()")); + } else if (user == LIMITED_USER_ALIAS_C1) { + // This is also a kind of anomaly in the legacy privilege evaluation: Even though we do not have permissions + // we get a 200 response with an empty result + assertThat(httpResponse, isOk()); + assertTrue(httpResponse.getBody(), httpResponse.bodyAsMap().isEmpty()); + } else { + assertThat(httpResponse, isForbidden("/error/root_cause/0/reason", "no permissions for [indices:admin/aliases/get]")); + } + } else { + if (user.indexMatcher("get_alias").covers(alias_ab1)) { + assertThat(httpResponse, isOk()); + assertThat(httpResponse, containsExactly(alias_ab1).at("$.*.aliases.keys()")); + assertThat(httpResponse, containsExactly(index_a1, index_a2, index_a3, index_b1).at("$.keys()")); + } else { + assertThat(httpResponse, isForbidden("/error/root_cause/0/reason", "no permissions for [indices:admin/aliases/get]")); + } + } + } + } + + @Test + public void getAlias_indexPattern_includeHidden() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("*index*/_alias?expand_wildcards=all"); + if (user == SUPER_UNLIMITED_USER) { + // The super admin sees everything + assertThat(httpResponse, isOk()); + assertThat(httpResponse, containsExactly(alias_ab1, alias_c1, alias_with_system_index).at("$.*.aliases.keys()")); + assertThat( + httpResponse, + containsExactly( + index_a1, + index_a2, + index_a3, + index_b1, + index_b2, + index_b3, + index_c1, + index_hidden, + index_hidden_dot, + system_index_plugin + ).at("$.keys()") + ); + } else if (clusterConfig != ClusterConfig.LEGACY_PRIVILEGES_EVALUATION_SYSTEM_INDEX_PERMISSION) { + if (user == UNLIMITED_USER && clusterConfig == ClusterConfig.LEGACY_PRIVILEGES_EVALUATION) { + assertThat( + httpResponse, + containsExactly( + index_a1, + index_a2, + index_a3, + index_b1, + index_b2, + index_b3, + index_c1, + index_hidden, + index_hidden_dot, + system_index_plugin + ).at("$.keys()") + ); + } else if (!user.indexMatcher("get_alias").isEmpty()) { + assertThat( + httpResponse, + containsExactly(alias_ab1, alias_c1).at("$.*.aliases.keys()") + .reducedBy(user.indexMatcher("get_alias")) + .whenEmpty(clusterConfig.allowsEmptyResultSets ? isOk() : isForbidden()) + ); + assertThat( + httpResponse, + containsExactly(ALL_INDICES).at("$.keys()") + .reducedBy(user.indexMatcher("get_alias")) + .whenEmpty(clusterConfig.allowsEmptyResultSets ? isOk() : isForbidden()) + ); + } else { + assertThat(httpResponse, isForbidden()); + } + } else { // clusterConfig == ClusterConfig.LEGACY_PRIVILEGES_EVALUATION_SYSTEM_INDEX_PERMISSION + // If the system index privilege is enabled, we only get 403 errors, as + // the legacy SystemIndexPrivilegeEvaluator is not aware of dnfof + assertThat(httpResponse, isForbidden()); + } + } + } + + @Test + public void analyze_noIndex() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.postJson("_analyze", "{\"text\": \"sample text\"}"); + + if (user != LIMITED_USER_NONE) { + assertThat(httpResponse, isOk()); + } else { + // This is only forbidden if the user has no index privileges at all for indices:admin/analyze + assertThat(httpResponse, isForbidden()); + } + } + } + + @Test + public void analyze_staticIndex() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.postJson("index_a1/_analyze", "{\"text\": \"sample text\"}"); + IndexMatcher matcher = containsExactly(index_a1).reducedBy(user.indexMatcher("read")); + + if (matcher.isEmpty()) { + assertThat(httpResponse, isForbidden("/error/root_cause/0/reason", "no permissions for [indices:admin/analyze]")); + } else { + assertThat(httpResponse, isOk()); + } + } + } + + @Test + public void resolve_wildcard() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("_resolve/index/*"); + + if (user != LIMITED_USER_NONE) { + assertThat( + httpResponse, + containsExactly(index_a1, index_a2, index_a3, index_b1, index_b2, index_b3, index_c1, alias_ab1, alias_c1).at( + "$.*[*].name" + ) + .reducedBy(user.indexMatcher(clusterConfig.legacyPrivilegeEvaluation ? "read" : "read_nextgen")) + .whenEmpty(clusterConfig.allowsEmptyResultSets ? isOk() : isForbidden()) + ); + } else { + assertThat(httpResponse, isForbidden()); + } + } + } + + @Test + public void resolve_wildcard_includeHidden() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("_resolve/index/*?expand_wildcards=all"); + + if (clusterConfig.legacyPrivilegeEvaluation && (user == UNLIMITED_USER || user == SUPER_UNLIMITED_USER)) { + // The legacy privilege evaluation also allows regular users access to metadata of the security index + // This is not a security issue, as the metadata are not really security relevant + assertThat(httpResponse, containsExactly(ALL_INDICES_AND_ALIASES).at("$.*[*].name")); + } else if (user != LIMITED_USER_NONE) { + assertThat( + httpResponse, + containsExactly(ALL_INDICES_AND_ALIASES).at("$.*[*].name") + .reducedBy(user.indexMatcher(clusterConfig.legacyPrivilegeEvaluation ? "read" : "read_nextgen")) + .whenEmpty(clusterConfig.allowsEmptyResultSets ? isOk() : isForbidden()) + ); + } else { + assertThat(httpResponse, isForbidden()); + } + } + } + + @Test + public void resolve_indexPattern() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("_resolve/index/index_a*,index_b*"); + if (user != LIMITED_USER_NONE) { + assertThat( + httpResponse, + containsExactly(index_a1, index_a2, index_a3, index_b1, index_b2, index_b3).at("$.*[*].name") + .reducedBy(user.indexMatcher("read")) + .whenEmpty(clusterConfig.allowsEmptyResultSets ? isOk() : isForbidden()) + ); + } else { + assertThat(httpResponse, isForbidden()); + } + } + } + + @Test + public void field_caps_all() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("_field_caps?fields=*"); + if (user != LIMITED_USER_NONE) { + assertThat( + httpResponse, + containsExactly(index_a1, index_a2, index_a3, index_b1, index_b2, index_b3, index_c1).at("indices") + .reducedBy(user.indexMatcher("read")) + .whenEmpty(clusterConfig.allowsEmptyResultSets ? isOk() : isForbidden()) + ); + } else { + assertThat(httpResponse, isForbidden()); + } + } + } + + @Test + public void field_caps_indexPattern() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("index_b*/_field_caps?fields=*"); + if (user != LIMITED_USER_NONE) { + assertThat( + httpResponse, + containsExactly(index_b1, index_b2, index_b3).at("indices") + .reducedBy(user.indexMatcher("read")) + .whenEmpty(clusterConfig.allowsEmptyResultSets ? isOk() : isForbidden()) + ); + } else { + assertThat(httpResponse, isForbidden()); + } + } + } + + @Test + public void field_caps_staticIndices() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("index_a1/_field_caps?fields=*"); + assertThat(httpResponse, containsExactly(index_a1).at("indices").reducedBy(user.indexMatcher("read")).whenEmpty(isForbidden())); + } + } + + @Test + public void field_caps_staticIndices_hidden() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("index_hidden/_field_caps?fields=*"); + assertThat(httpResponse, containsExactly(index_hidden).at("indices").butForbiddenIfIncomplete(user.indexMatcher("read"))); + } + } + + @Test + public void field_caps_alias() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("alias_ab1/_field_caps?fields=*"); + if (clusterConfig.legacyPrivilegeEvaluation) { + assertThat( + httpResponse, + containsExactly(index_a1, index_a2, index_a3, index_b1).at("indices") + .reducedBy(user.indexMatcher("read")) + .whenEmpty(isForbidden()) + ); + } else { + if (user.indexMatcher("read").covers(alias_ab1)) { + assertThat(httpResponse, isOk()); + assertThat(httpResponse, containsExactly(index_a1, index_a2, index_a3, index_b1).at("indices")); + } else { + assertThat(httpResponse, isForbidden()); + } + } + } + } + + @Test + public void field_caps_aliasPattern() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("alias_ab*/_field_caps?fields=*"); + if (user != LIMITED_USER_NONE) { + assertThat( + httpResponse, + containsExactly(index_a1, index_a2, index_a3, index_b1).at("indices") + .reducedBy(user.indexMatcher("read")) + .whenEmpty(clusterConfig.allowsEmptyResultSets ? isOk() : isForbidden()) + ); + } else { + assertThat(httpResponse, isForbidden()); + } + } + } + + @Test + public void field_caps_nonExisting_static() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("index_ax/_field_caps?fields=*"); + + if (containsExactly(index_ax).reducedBy(user.indexMatcher("read")).isEmpty()) { + assertThat(httpResponse, isForbidden("/error/root_cause/0/reason", "no permissions for [indices:data/read/field_caps]")); + } else { + assertThat(httpResponse, isNotFound()); + } + } + } + + @Test + public void field_caps_nonExisting_indexPattern() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("x_does_not_exist*/_field_caps?fields=*"); + + if (user != LIMITED_USER_NONE) { + assertThat(httpResponse, containsExactly().at("indices").whenEmpty(isOk())); + } else { + assertThat(httpResponse, isForbidden()); + } + } + } + + @Test + public void field_caps_indexPattern_minus() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("index_a*,index_b*,-index_b2,-index_b3/_field_caps?fields=*"); + if (user != LIMITED_USER_NONE) { + assertThat( + httpResponse, + containsExactly(index_a1, index_a2, index_a3, index_b1).at("indices") + .reducedBy(user.indexMatcher("read")) + .whenEmpty(clusterConfig.allowsEmptyResultSets ? isOk() : isForbidden()) + ); + } else { + assertThat(httpResponse, isForbidden()); + } + } + } + + @Test + public void pit_list_all() throws Exception { + String indexA1pitId = createPit(index_a1); + + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("_search/point_in_time/_all"); + + if (clusterConfig.legacyPrivilegeEvaluation) { + // At the moment, it is sufficient to have any privileges for any existing index to use the _all API + // This is clearly a bug; yet, not a severe issue, as we do not have really sensitive things available here + if (user != LIMITED_USER_NONE && user != LIMITED_USER_OTHER_PRIVILEGES) { + assertThat(httpResponse, isOk()); + } else { + assertThat(httpResponse, isForbidden()); + } + } else { + // New privilege evaluation: this is now a cluster privilege, the users below are the users with full cluster privileges + if (user == UNLIMITED_USER || user == SUPER_UNLIMITED_USER) { + assertThat(httpResponse, isOk()); + } else { + assertThat(httpResponse, isForbidden()); + } + } + } finally { + deletePit(indexA1pitId); + } + } + + @Test + public void pit_delete() throws Exception { + String indexA1pitId = createPit(index_a1); + + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.delete("_search/point_in_time", json("pit_id", List.of(indexA1pitId))); + + if (user.indexMatcher("read").covers(index_a1)) { + assertThat(httpResponse, isOk()); + } else { + assertThat(httpResponse, isForbidden()); + } + } finally { + deletePit(indexA1pitId); + } + } + + @Test + public void pit_catSegments() throws Exception { + String indexA1pitId = createPit(index_a1); + + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("_cat/pit_segments", json("pit_id", List.of(indexA1pitId))); + + if (user.indexMatcher("read").covers(index_a1)) { + assertThat(httpResponse, isOk()); + } else { + assertThat(httpResponse, isForbidden()); + } + } finally { + deletePit(indexA1pitId); + } + } + + @Test + public void pit_catSegments_all() throws Exception { + String indexA1pitId = createPit(index_a1); + + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.get("_cat/pit_segments/_all"); + + if (clusterConfig.legacyPrivilegeEvaluation) { + // The user needs to have the privilege for all indices. If it is only granted for a subset of indices, this will be + // forbidden. + if (user == UNLIMITED_USER || user == SUPER_UNLIMITED_USER) { + assertThat(httpResponse, isOk()); + } else { + assertThat(httpResponse, isForbidden()); + } + } else { + // New privilege evaluation: this is now a separate cluster privilege, the users below are the users with full cluster + // privileges + if (user == UNLIMITED_USER || user == SUPER_UNLIMITED_USER) { + assertThat(httpResponse, isOk()); + } else { + assertThat(httpResponse, isForbidden()); + } + } + } finally { + deletePit(indexA1pitId); + } + } + + @ParametersFactory(shuffle = false, argumentFormatting = "%1$s, %3$s") + public static Collection params() { + List result = new ArrayList<>(); + + for (ClusterConfig clusterConfig : ClusterConfig.values()) { + for (TestSecurityConfig.User user : USERS) { + result.add(new Object[] { clusterConfig, user, user.getDescription() }); + } + } + return result; + } + + public IndexAuthorizationReadOnlyIntTests(ClusterConfig clusterConfig, TestSecurityConfig.User user, String description) + throws Exception { + this.user = user; + this.cluster = clusterConfig.cluster(IndexAuthorizationReadOnlyIntTests::clusterBuilder); + this.clusterConfig = clusterConfig; + } + + public static class SystemIndexTestPlugin extends Plugin implements SystemIndexPlugin { + @Override + public Collection getSystemIndexDescriptors(Settings settings) { + return List.of( + new SystemIndexDescriptor(".system_index_plugin", "for testing system index exclusion"), + new SystemIndexDescriptor(".system_index_plugin_not_existing", "for testing system index exclusion") + ); + } + } + + private String createPit(TestIndex... indices) throws IOException { + try (TestRestClient client = cluster.getAdminCertRestClient()) { + TestRestClient.HttpResponse response = client.post( + Stream.of(indices).map(TestIndex::name).collect(joining(",")) + "/_search/point_in_time?keep_alive=1m" + ); + assertThat(response, isOk()); + return response.getTextFromJsonBody("/pit_id"); + } + } + + private void deletePit(String... pitIds) { + try (TestRestClient client = cluster.getAdminCertRestClient()) { + TestRestClient.HttpResponse response = client.delete("_search/point_in_time", json("pit_id", Arrays.asList(pitIds))); + assertThat(response, isOk()); + } + } +} diff --git a/src/integrationTest/java/org/opensearch/security/privileges/int_tests/IndexAuthorizationReadWriteIntTests.java b/src/integrationTest/java/org/opensearch/security/privileges/int_tests/IndexAuthorizationReadWriteIntTests.java new file mode 100644 index 0000000000..a42c3a1457 --- /dev/null +++ b/src/integrationTest/java/org/opensearch/security/privileges/int_tests/IndexAuthorizationReadWriteIntTests.java @@ -0,0 +1,1258 @@ +/* + * 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. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ +package org.opensearch.security.privileges.int_tests; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import javax.annotation.concurrent.NotThreadSafe; + +import com.carrotsearch.randomizedtesting.annotations.ParametersFactory; +import com.carrotsearch.randomizedtesting.annotations.ThreadLeakScope; +import com.google.common.collect.ImmutableList; +import org.junit.After; +import org.junit.AfterClass; +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.opensearch.action.admin.indices.open.OpenIndexRequest; +import org.opensearch.action.admin.indices.refresh.RefreshRequest; +import org.opensearch.action.admin.indices.settings.put.UpdateSettingsRequest; +import org.opensearch.common.settings.Settings; +import org.opensearch.test.framework.TestAlias; +import org.opensearch.test.framework.TestIndex; +import org.opensearch.test.framework.TestIndexOrAliasOrDatastream; +import org.opensearch.test.framework.TestSecurityConfig; +import org.opensearch.test.framework.TestSecurityConfig.Role; +import org.opensearch.test.framework.cluster.LocalCluster; +import org.opensearch.test.framework.cluster.TestRestClient; +import org.opensearch.test.framework.cluster.TestRestClient.HttpResponse; +import org.opensearch.test.framework.matcher.IndexApiResponseMatchers; +import org.opensearch.transport.client.Client; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.opensearch.test.framework.TestSecurityConfig.AuthcDomain.AUTHC_HTTPBASIC_INTERNAL; +import static org.opensearch.test.framework.cluster.TestRestClient.json; +import static org.opensearch.test.framework.matcher.IndexApiResponseMatchers.OnResponseIndexMatcher.containsExactly; +import static org.opensearch.test.framework.matcher.IndexApiResponseMatchers.OnUserIndexMatcher.limitedTo; +import static org.opensearch.test.framework.matcher.IndexApiResponseMatchers.OnUserIndexMatcher.limitedToNone; +import static org.opensearch.test.framework.matcher.IndexApiResponseMatchers.OnUserIndexMatcher.unlimited; +import static org.opensearch.test.framework.matcher.IndexApiResponseMatchers.OnUserIndexMatcher.unlimitedIncludingOpenSearchSecurityIndex; +import static org.opensearch.test.framework.matcher.RestMatchers.isBadRequest; +import static org.opensearch.test.framework.matcher.RestMatchers.isCreated; +import static org.opensearch.test.framework.matcher.RestMatchers.isForbidden; +import static org.opensearch.test.framework.matcher.RestMatchers.isNotFound; +import static org.opensearch.test.framework.matcher.RestMatchers.isOk; +import static org.junit.Assert.assertEquals; + +/** + * This class defines a huge test matrix for index related access controls. This class is especially for read/write operations on indices and aliases. + * It uses the following dimensions: + *

    + *
  • ClusterConfig: At the moment, we test without and with system index permission enabled. New semantics will follow later.
  • + *
  • TestSecurityConfig.User: We have quite a few of different users with different privileges configurations.
  • + *
  • The test methods represent different operations with different options that are tested
  • + *
+ * To cope with the huge space of tests, this class uses test oracles to verify the result of the operations. + * These are defined with the "indexMatcher()" method of TestSecurityConfig.User. See there and the class IndexApiMatchers. + */ +@RunWith(com.carrotsearch.randomizedtesting.RandomizedRunner.class) +@ThreadLeakScope(ThreadLeakScope.Scope.NONE) +@NotThreadSafe +public class IndexAuthorizationReadWriteIntTests { + + // ------------------------------------------------------------------------------------------------------- + // Test indices used by this test suite. We use the following naming scheme: + // - index_*r*: This test will not write to this index + // - index_*w*: This test can write to this index; the test won't delete and recreate it + // - index_*wx*: The index is not initially created; the test can create it on demand and delete it again + // ------------------------------------------------------------------------------------------------------- + + static final TestIndex index_ar1 = TestIndex.name("index_ar1").documentCount(10).build(); + static final TestIndex index_ar2 = TestIndex.name("index_ar2").documentCount(10).build(); + static final TestIndex index_aw1 = TestIndex.name("index_aw1").documentCount(10).build(); + static final TestIndex index_aw2 = TestIndex.name("index_aw2").documentCount(10).build(); + static final TestIndex index_br1 = TestIndex.name("index_br1").documentCount(10).build(); + static final TestIndex index_br2 = TestIndex.name("index_br2").documentCount(10).build(); + static final TestIndex index_bw1 = TestIndex.name("index_bw1").documentCount(10).build(); + static final TestIndex index_bw2 = TestIndex.name("index_bw2").documentCount(10).build(); + static final TestIndex index_cr1 = TestIndex.name("index_cr1").documentCount(10).build(); + static final TestIndex index_cw1 = TestIndex.name("index_cw1").documentCount(10).build(); + static final TestIndex index_hidden = TestIndex.name("index_hidden").hidden().documentCount(1).seed(8).build(); + static final TestIndex system_index_plugin = TestIndex.name(".system_index_plugin").hidden().documentCount(1).seed(8).build(); + static final TestIndex system_index_plugin_not_existing = TestIndex.name(".system_index_plugin_not_existing") + .hidden() + .documentCount(0) + .build(); // not initially created + + static final TestAlias alias_ab1r = new TestAlias("alias_ab1r").on(index_ar1, index_ar2, index_aw1, index_aw2, index_br1, index_bw1); + static final TestAlias alias_ab1w = new TestAlias("alias_ab1w").on(index_aw1, index_aw2, index_bw1).writeIndex(index_aw1); + static final TestAlias alias_ab1w_nowriteindex = new TestAlias("alias_ab1w_nowriteindex").on(index_aw1, index_aw2, index_bw1); + + static final TestAlias alias_c1 = new TestAlias("alias_c1", index_cr1, index_cw1); + + static final TestIndex index_bwx1 = TestIndex.name("index_bwx1").documentCount(0).build(); // not initially created + static final TestIndex index_bwx2 = TestIndex.name("index_bwx2").documentCount(0).build(); // not initially created + + static final TestAlias alias_bwx = new TestAlias("alias_bwx"); // not initially created + + static final List ALL_NON_HIDDEN_INDICES = List.of( + index_ar1, + index_ar2, + index_aw1, + index_aw2, + index_br1, + index_br2, + index_bw1, + index_bw2, + index_cr1, + index_cw1 + ); + + static final List ALL_INDICES_AND_ALIASES_EXCEPT_SYSTEM_INDICES = List.of( + index_ar1, + index_ar2, + index_aw1, + index_aw2, + index_br1, + index_br2, + index_bw1, + index_bw2, + index_cr1, + index_cw1, + alias_ab1w, + alias_ab1r, + alias_c1, + alias_ab1w_nowriteindex, + index_hidden + ); + + // ------------------------------------------------------------------------------------------------------- + // Test users with which the tests will be executed; the users need to be added to the list USERS below + // The users have two redundant versions or privilege configuration, which needs to be kept in sync: + // - The standard role configuration defined with .roles() + // - IndexMatchers which act as test oracles, defined with the indexMatcher() methods + // ------------------------------------------------------------------------------------------------------- + + /** + * A simple user that can read from index_a* and write to index_aw*; the user as no privileges to create or manage indices + */ + static TestSecurityConfig.User LIMITED_USER_A = new TestSecurityConfig.User("limited_user_A")// + .description("index_a*")// + .roles( + new Role("r1")// + .clusterPermissions("cluster_composite_ops", "cluster_monitor")// + .indexPermissions("read", "indices_monitor") + .on("index_a*")// + .indexPermissions("write") + .on("index_aw*") + )// + .indexMatcher("read", limitedTo(index_ar1, index_ar2, index_aw1, index_aw2))// + .indexMatcher("write", limitedTo(index_aw1, index_aw2))// + .indexMatcher("create_index", limitedToNone())// + .indexMatcher("manage_index", limitedToNone())// + .indexMatcher("manage_alias", limitedToNone())// + .indexMatcher("get_alias", limitedToNone()); + + /** + * A simple user that can read from index_b* and write to index_bw*; the user as no privileges to create or manage indices + */ + static TestSecurityConfig.User LIMITED_USER_B = new TestSecurityConfig.User("limited_user_B")// + .description("index_b*")// + .roles( + new Role("r1")// + .clusterPermissions("cluster_composite_ops", "cluster_monitor")// + .indexPermissions("read", "indices_monitor") + .on("index_b*")// + .indexPermissions("write") + .on("index_bw*") + )// + .indexMatcher("read", limitedTo(index_br1, index_br2, index_bw1, index_bw2, index_bwx1, index_bwx2))// + .indexMatcher("write", limitedTo(index_bw1, index_bw2, index_bwx1, index_bwx2))// + .indexMatcher("create_index", limitedToNone())// + .indexMatcher("manage_index", limitedToNone())// + .indexMatcher("manage_alias", limitedToNone())// + .indexMatcher("get_alias", limitedToNone()); + + /** + * A simple user that can read from index_b* and write to index_bw*; additionally, they can create index_bw* indices + */ + static TestSecurityConfig.User LIMITED_USER_B_CREATE_INDEX = new TestSecurityConfig.User("limited_user_B_create_index")// + .description("index_b* with create index privs")// + .roles( + new Role("r1")// + .clusterPermissions("cluster_composite_ops", "cluster_monitor")// + .indexPermissions("read", "indices_monitor") + .on("index_b*")// + .indexPermissions("write") + .on("index_bw*")// + .indexPermissions("create_index") + .on("index_bw*") + )// + .indexMatcher("read", limitedTo(index_br1, index_br2, index_bw1, index_bw2, index_bwx1, index_bwx2))// + .indexMatcher("write", limitedTo(index_bw1, index_bw2, index_bwx1, index_bwx2))// + .indexMatcher("create_index", limitedTo(index_bw1, index_bw2, index_bwx1, index_bwx2))// + .indexMatcher("manage_index", limitedToNone())// + .indexMatcher("manage_alias", limitedToNone())// + .indexMatcher("get_alias", limitedToNone()); + + /** + * A simple user that can read from index_b* and write to index_bw*; additionally, they can create and manage index_bw* indices + */ + static TestSecurityConfig.User LIMITED_USER_B_MANAGE_INDEX = new TestSecurityConfig.User("limited_user_B_manage_index")// + .description("index_b* with manage privs")// + .roles( + new Role("r1")// + .clusterPermissions("cluster_composite_ops", "cluster_monitor")// + .indexPermissions("read", "indices_monitor") + .on("index_b*")// + .indexPermissions("write") + .on("index_bw*")// + .indexPermissions("manage") + .on("index_bw*") + )// + .indexMatcher("read", limitedTo(index_br1, index_br2, index_bw1, index_bw2, index_bwx1, index_bwx2))// + .indexMatcher("write", limitedTo(index_bw1, index_bw2, index_bwx1, index_bwx2))// + .indexMatcher("create_index", limitedTo(index_bw1, index_bw2, index_bwx1, index_bwx2))// + .indexMatcher("manage_index", limitedTo(index_bw1, index_bw2, index_bwx1, index_bwx2))// + .indexMatcher("manage_alias", limitedTo(index_bw1, index_bw2, index_bwx1, index_bwx2))// + .indexMatcher("get_alias", limitedTo()); + + /** + * A user that can read from index_b* and write to index_bw*; they can create and manage index_bw* indices and manage alias_bwx* aliases. + * For users with such alias permissions, keep in mind that alias permissions are inherited by the member indices. + * Thus, indices can gain or lose privileges when they are added/removed from the alias. + */ + static TestSecurityConfig.User LIMITED_USER_B_MANAGE_INDEX_ALIAS = new TestSecurityConfig.User("limited_user_B_manage_index_alias")// + .description("index_b*, alias_bwx* with manage privs")// + .roles( + new Role("r1")// + .clusterPermissions("cluster_composite_ops", "cluster_monitor")// + .indexPermissions("read", "indices_monitor") + .on("index_b*")// + .indexPermissions("write") + .on("index_bw*")// + .indexPermissions("manage") + .on("index_bw*")// + .indexPermissions("crud", "manage", "manage_aliases") + .on("alias_bwx*") + )// + .indexMatcher("read", limitedTo(index_br1, index_br2, index_bw1, index_bw2, index_bwx1, index_bwx2))// + .indexMatcher("write", limitedTo(index_bw1, index_bw2, index_bwx1, index_bwx2))// + .indexMatcher("create_index", limitedTo(index_bw1, index_bw2, index_bwx1, index_bwx2))// + .indexMatcher("manage_index", limitedTo(index_bw1, index_bw2, index_bwx1, index_bwx2, alias_bwx))// + .indexMatcher("manage_alias", limitedTo(index_bw1, index_bw2, index_bwx1, index_bwx2, alias_bwx))// + .indexMatcher("get_alias", limitedTo(alias_bwx)); + + /** + * This user differs from LIMITED_USER_B_MANAGE_INDEX_ALIAS the way that it does not give any direct + * write privileges to index_bw*; rather, it gives write privileges to alias_bxw. Any index which happens + * to be member of that alias then gains these write privileges. + */ + static TestSecurityConfig.User LIMITED_USER_B_READ_ONLY_MANAGE_INDEX_ALIAS = new TestSecurityConfig.User( + "limited_user_B_index_read_only_manage_index_alias" + )// + .description("index_b* r/o, alias_bwx* r/w with manage privs")// + .roles( + new Role("r1")// + .clusterPermissions("cluster_composite_ops", "cluster_monitor")// + .indexPermissions("read", "indices_monitor") + .on("index_b*")// + .indexPermissions("crud", "manage", "manage_aliases") + .on("alias_bwx*") + )// + .indexMatcher("read", limitedTo(index_br1, index_br2))// + .indexMatcher("write", limitedToNone())// + .indexMatcher("create_index", limitedToNone())// + .indexMatcher("manage_index", limitedTo(alias_bwx))// + .indexMatcher("manage_alias", limitedTo(alias_bwx))// + .indexMatcher("get_alias", limitedTo(alias_bwx)); + + /** + * Same as LIMITED_USER_B_MANAGE_INDEX_ALIAS with the addition of read/write/manage privileges on index_hidden* + */ + static TestSecurityConfig.User LIMITED_USER_B_HIDDEN_MANAGE_INDEX_ALIAS = new TestSecurityConfig.User( + "limited_user_B_hidden_manage_index_alias" + )// + .description("index_b*, index_hidden*, alias_bwx* with manage privs, index_a* read only")// + .roles( + new Role("r1")// + .clusterPermissions("cluster_composite_ops", "cluster_monitor")// + .indexPermissions("read", "indices_monitor") + .on("index_a*", "index_b*", "index_hidden*")// + .indexPermissions("write") + .on("index_bw*", "index_hidden*")// + .indexPermissions("manage") + .on("index_bw*", "index_hidden*")// + .indexPermissions("crud", "manage", "manage_aliases") + .on("alias_bwx*") + )// + .indexMatcher( + "read", + limitedTo( + index_ar1, + index_ar2, + index_aw1, + index_aw2, + index_br1, + index_br2, + index_bw1, + index_bw2, + index_bwx1, + index_bwx2, + index_hidden + ) + )// + .indexMatcher("write", limitedTo(index_bw1, index_bw2, index_bwx1, index_bwx2, index_hidden))// + .indexMatcher("create_index", limitedTo(index_bw1, index_bw2, index_bwx1, index_bwx2, index_hidden))// + .indexMatcher("manage_index", limitedTo(index_bw1, index_bw2, index_bwx1, index_bwx2, alias_bwx, index_hidden))// + .indexMatcher("manage_alias", limitedTo(index_bw1, index_bw2, index_bwx1, index_bwx2, alias_bwx, index_hidden))// + .indexMatcher("get_alias", limitedTo(alias_bwx)); + + /** + * Same as LIMITED_USER_B with the addition of read/write/manage privileges for ".system_index_plugin", ".system_index_plugin_*" + * including the explicit "system:admin/system_index" privilege. + */ + static TestSecurityConfig.User LIMITED_USER_B_SYSTEM_INDEX_MANAGE = new TestSecurityConfig.User("limited_user_B_system_index_manage")// + .description("index_b*, .system_index_plugin with manage privs")// + .roles( + new Role("r1")// + .clusterPermissions("cluster_composite_ops", "cluster_monitor")// + .indexPermissions("read", "indices_monitor", "system:admin/system_index") + .on("index_b*", "index_hidden*", ".system_index_plugin")// + .indexPermissions("write", "system:admin/system_index") + .on("index_bw*", ".system_index_plugin", ".system_index_plugin_*")// + .indexPermissions("manage", "system:admin/system_index") + .on("index_bw*", ".system_index_plugin", ".system_index_plugin_*") + )// + .indexMatcher( + "read", + limitedTo( + index_br1, + index_br2, + index_bw1, + index_bw2, + index_bwx1, + index_bwx2, + system_index_plugin, + system_index_plugin_not_existing + ) + )// + .indexMatcher( + "write", + limitedTo(index_bw1, index_bw2, index_bwx1, index_bwx2, system_index_plugin, system_index_plugin_not_existing) + )// + .indexMatcher( + "create_index", + limitedTo(index_bw1, index_bw2, index_bwx1, index_bwx2, system_index_plugin, system_index_plugin_not_existing) + )// + .indexMatcher( + "manage_index", + limitedTo(index_bw1, index_bw2, index_bwx1, index_bwx2, system_index_plugin, system_index_plugin_not_existing) + )// + .indexMatcher( + "manage_alias", + limitedTo(index_bw1, index_bw2, index_bwx1, index_bwx2, system_index_plugin, system_index_plugin_not_existing) + )// + .indexMatcher("get_alias", limitedToNone()); + + /** + * A simple test user that has read privileges on alias_ab1r and write privileges on alias_ab1w*. The user + * has no direct privileges on indices; all privileges are gained via the aliases. + */ + static TestSecurityConfig.User LIMITED_USER_AB1_ALIAS = new TestSecurityConfig.User("limited_user_alias_AB1")// + .description("alias_ab1")// + .roles( + new Role("r1")// + .clusterPermissions("cluster_composite_ops", "cluster_monitor")// + .indexPermissions("read", "indices_monitor", "indices:admin/aliases/get") + .on("alias_ab1r")// + .indexPermissions("read", "indices_monitor", "indices:admin/aliases/get", "write") + .on("alias_ab1w*") + )// + .indexMatcher( + "read", + limitedTo(index_ar1, index_ar2, index_aw1, index_aw2, index_br1, index_bw1, alias_ab1r, alias_ab1w, alias_ab1w_nowriteindex) + )// + .indexMatcher("write", limitedTo(index_aw1, index_aw2, index_bw1, alias_ab1w, alias_ab1w_nowriteindex))// + .indexMatcher("create_index", limitedTo(index_aw1, index_aw2, index_bw1))// + .indexMatcher("manage_index", limitedToNone())// + .indexMatcher("manage_alias", limitedToNone())// + .indexMatcher("get_alias", limitedTo(index_ar1, index_ar2, index_aw1, index_aw2, index_br1, index_bw1, alias_ab1r, alias_ab1w)); + + /** + * A simple test user that has read/only privileges on alias_ab1r and alias_ab1w*. However, they have write + * privileges for the member index index_aw1. + */ + static TestSecurityConfig.User LIMITED_USER_AB1_ALIAS_READ_ONLY = new TestSecurityConfig.User("limited_user_alias_AB1_read_only")// + .description("read/only on alias_ab1w, but with write privs in write index index_aw1")// + .roles( + new Role("r1")// + .clusterPermissions("cluster_composite_ops", "cluster_monitor")// + .indexPermissions("read", "write", "indices:admin/refresh") + .on("index_aw1")// + .indexPermissions("read") + .on("alias_ab1w") + )// + .indexMatcher("read", limitedTo(index_aw1, index_aw2, index_bw1, alias_ab1w))// + .indexMatcher("write", limitedTo(index_aw1))// + .indexMatcher("create_index", limitedToNone())// + .indexMatcher("manage_index", limitedToNone())// + .indexMatcher("manage_alias", limitedToNone()); + + /** + * A simple test user which has read/only privileges for "*" + */ + static TestSecurityConfig.User LIMITED_READ_ONLY_ALL = new TestSecurityConfig.User("limited_read_only_all")// + .description("read/only on *")// + .roles( + new Role("r1")// + .clusterPermissions("cluster_composite_ops", "cluster_monitor")// + .indexPermissions("read") + .on("*") + )// + .indexMatcher("read", unlimited())// + .indexMatcher("write", limitedToNone())// + .indexMatcher("create_index", limitedToNone())// + .indexMatcher("manage_index", limitedToNone())// + .indexMatcher("manage_alias", limitedToNone())// + .indexMatcher("get_alias", limitedToNone()); + + /** + * A simple test user which has read/only privileges for "index_a*" + */ + static TestSecurityConfig.User LIMITED_READ_ONLY_A = new TestSecurityConfig.User("limited_read_only_A")// + .description("read/only on index_a*")// + .roles( + new Role("r1")// + .clusterPermissions("cluster_composite_ops", "cluster_monitor")// + .indexPermissions("read") + .on("index_a*") + )// + .indexMatcher("read", limitedTo(index_ar1, index_ar2, index_aw1, index_aw2))// + .indexMatcher("write", limitedToNone())// + .indexMatcher("create_index", limitedToNone())// + .indexMatcher("manage_index", limitedToNone())// + .indexMatcher("manage_alias", limitedToNone())// + .indexMatcher("get_alias", limitedToNone()); + + /** + * A simple test user that only has index privileges for indices that are not used by this test. + */ + static TestSecurityConfig.User LIMITED_USER_OTHER_PRIVILEGES = new TestSecurityConfig.User("limited_user_other_privileges")// + .description("no privileges for existing indices")// + .roles( + new Role("r1")// + .clusterPermissions("cluster_composite_ops", "cluster_monitor")// + .indexPermissions("crud", "indices_monitor") + .on("index_does_not_exist_*") + )// + .indexMatcher("read", limitedToNone())// + .indexMatcher("write", limitedToNone())// + .indexMatcher("create_index", limitedToNone())// + .indexMatcher("manage_index", limitedToNone())// + .indexMatcher("manage_alias", limitedToNone())// + .indexMatcher("get_alias", limitedToNone()); + + /** + * A simple test user that has no index privileges at all. + */ + static final TestSecurityConfig.User LIMITED_USER_NONE = new TestSecurityConfig.User("limited_user_none")// + .description("no index privileges")// + .roles( + new TestSecurityConfig.Role("r1")// + .clusterPermissions("cluster_composite_ops_ro", "cluster_monitor") + )// + .indexMatcher("read", limitedToNone())// + .indexMatcher("write", limitedToNone())// + .indexMatcher("create_index", limitedToNone())// + .indexMatcher("manage_index", limitedToNone())// + .indexMatcher("manage_alias", limitedToNone())// + .indexMatcher("get_alias", limitedToNone()); + + /** + * A user with "*" privileges on "*"; as it is a regular user, they are still subject to system index + * restrictions and similar things. + */ + static TestSecurityConfig.User UNLIMITED_USER = new TestSecurityConfig.User("unlimited_user")// + .description("unlimited")// + .roles( + new Role("r1")// + .clusterPermissions("cluster_composite_ops", "cluster_monitor")// + .indexPermissions("*") + .on("*")// + .indexPermissions("*") + .on("*") + )// + .indexMatcher("read", limitedTo(ALL_INDICES_AND_ALIASES_EXCEPT_SYSTEM_INDICES).and(index_bwx1, index_bwx2, alias_bwx))// + .indexMatcher("write", limitedTo(ALL_INDICES_AND_ALIASES_EXCEPT_SYSTEM_INDICES).and(index_bwx1, index_bwx2, alias_bwx))// + .indexMatcher("create_index", limitedTo(ALL_INDICES_AND_ALIASES_EXCEPT_SYSTEM_INDICES).and(index_bwx1, index_bwx2, alias_bwx))// + .indexMatcher("manage_index", limitedTo(ALL_INDICES_AND_ALIASES_EXCEPT_SYSTEM_INDICES).and(index_bwx1, index_bwx2, alias_bwx))// + .indexMatcher("manage_alias", limitedTo(ALL_INDICES_AND_ALIASES_EXCEPT_SYSTEM_INDICES).and(index_bwx1, index_bwx2, alias_bwx))// + .indexMatcher("get_alias", limitedTo(ALL_INDICES_AND_ALIASES_EXCEPT_SYSTEM_INDICES).and(index_bwx1, index_bwx2, alias_bwx)); + + /** + * The SUPER_UNLIMITED_USER authenticates with an admin cert, which will cause all access control code to be skipped. + * This serves as a base for comparison with the default behavior. + */ + static TestSecurityConfig.User SUPER_UNLIMITED_USER = new TestSecurityConfig.User("super_unlimited_user")// + .description("super unlimited (admin cert)")// + .adminCertUser()// + .indexMatcher("read", unlimitedIncludingOpenSearchSecurityIndex())// + .indexMatcher("write", unlimitedIncludingOpenSearchSecurityIndex())// + .indexMatcher("create_index", unlimitedIncludingOpenSearchSecurityIndex())// + .indexMatcher("manage_index", unlimitedIncludingOpenSearchSecurityIndex())// + .indexMatcher("manage_alias", unlimitedIncludingOpenSearchSecurityIndex())// + .indexMatcher("get_alias", unlimitedIncludingOpenSearchSecurityIndex()); + + static List USERS = ImmutableList.of( + LIMITED_USER_A, + LIMITED_USER_B, + LIMITED_USER_B_CREATE_INDEX, + LIMITED_USER_B_MANAGE_INDEX, + LIMITED_USER_B_MANAGE_INDEX_ALIAS, + LIMITED_USER_B_READ_ONLY_MANAGE_INDEX_ALIAS, + LIMITED_USER_B_HIDDEN_MANAGE_INDEX_ALIAS, + LIMITED_USER_B_SYSTEM_INDEX_MANAGE, + LIMITED_USER_AB1_ALIAS, + LIMITED_USER_AB1_ALIAS_READ_ONLY, + LIMITED_READ_ONLY_ALL, + LIMITED_READ_ONLY_A, + LIMITED_USER_OTHER_PRIVILEGES, + LIMITED_USER_NONE, + UNLIMITED_USER, + SUPER_UNLIMITED_USER + ); + + static LocalCluster.Builder clusterBuilder() { + return new LocalCluster.Builder().singleNode() + .authc(AUTHC_HTTPBASIC_INTERNAL) + .users(USERS)// + .indices( + index_ar1, + index_ar2, + index_aw1, + index_aw2, + index_br1, + index_br2, + index_bw1, + index_bw2, + index_cr1, + index_cw1, + index_hidden, + system_index_plugin + )// + .aliases(alias_ab1r, alias_ab1w, alias_ab1w_nowriteindex, alias_c1)// + .nodeSettings(Map.of("action.destructive_requires_name", false)) + .plugin(IndexAuthorizationReadOnlyIntTests.SystemIndexTestPlugin.class); + } + + @AfterClass + public static void stopClusters() { + for (ClusterConfig clusterConfig : ClusterConfig.values()) { + clusterConfig.shutdown(); + } + } + + final TestSecurityConfig.User user; + final LocalCluster cluster; + final ClusterConfig clusterConfig; + + @Test + public void putDocument() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.put("index_bw1/_doc/put_test_1", json("a", 1)); + assertThat( + httpResponse, + containsExactly(index_bw1).at("_index").reducedBy(user.indexMatcher("write")).whenEmpty(isForbidden()) + ); + } finally { + delete("index_bw1/_doc/put_test_1"); + } + } + + @Test + public void putDocument_systemIndex() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse httpResponse = restClient.put(".system_index_plugin/_doc/put_test_1", json("a", 1)); + if (clusterConfig.systemIndexPrivilegeEnabled && user.indexMatcher("write").covers(system_index_plugin)) { + assertThat(httpResponse, isCreated()); + } else if (user == SUPER_UNLIMITED_USER) { + assertThat(httpResponse, isCreated()); + } else { + assertThat(httpResponse, isForbidden()); + } + } finally { + delete(".system_index_plugin/_doc/put_test_1"); + } + } + + @Test + public void deleteDocument() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user); TestRestClient adminRestClient = cluster.getAdminCertRestClient()) { + + // Initialization + { + HttpResponse httpResponse = adminRestClient.put("index_bw1/_doc/put_delete_test_1?refresh=true", json("a", 1)); + assertThat(httpResponse, isCreated()); + } + + HttpResponse httpResponse = restClient.delete("index_bw1/_doc/put_delete_test_1"); + assertThat( + httpResponse, + containsExactly(index_bw1).at("_index").reducedBy(user.indexMatcher("write")).whenEmpty(isForbidden()) + ); + } finally { + delete("index_bw1/_doc/put_delete_test_1"); + } + } + + @Test + public void deleteByQuery_indexPattern() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user); TestRestClient adminRestClient = cluster.getAdminCertRestClient()) { + + HttpResponse httpResponse = adminRestClient.put( + "index_bw1/_doc/put_delete_delete_by_query_b1?refresh=true", + json("delete_by_query_test", "yes") + ); + assertThat(httpResponse, isCreated()); + httpResponse = adminRestClient.put( + "index_bw1/_doc/put_delete_delete_by_query_b2?refresh=true", + json("delete_by_query_test", "no") + ); + assertThat(httpResponse, isCreated()); + httpResponse = adminRestClient.put( + "index_aw1/_doc/put_delete_delete_by_query_a1?refresh=true", + json("delete_by_query_test", "yes") + ); + assertThat(httpResponse, isCreated()); + httpResponse = adminRestClient.put( + "index_aw1/_doc/put_delete_delete_by_query_a2?refresh=true", + json("delete_by_query_test", "no") + ); + assertThat(httpResponse, isCreated()); + + httpResponse = restClient.postJson("index_aw*,index_bw*/_delete_by_query?wait_for_completion=true", """ + { + "query": { + "term": { + "delete_by_query_test": "yes" + } + } + }"""); + + if (clusterConfig.legacyPrivilegeEvaluation) { + // dnfof is not applicable to indices:data/write/delete/byquery, so we need privileges for all indices + if (user.indexMatcher("write").coversAll(index_aw1, index_aw2, index_bw1, index_bw2)) { + assertThat(httpResponse, isOk()); + } else { + assertThat(httpResponse, isForbidden()); + } + } else { + if (user != LIMITED_USER_NONE && user != LIMITED_READ_ONLY_ALL && user != LIMITED_READ_ONLY_A) { + assertThat(httpResponse, isOk()); + int expectedDeleteCount = containsExactly(index_aw1, index_bw1).at("_index") + .reducedBy(user.indexMatcher("write")) + .size(); + assertEquals(httpResponse.getBody(), expectedDeleteCount, httpResponse.bodyAsMap().get("deleted")); + } else { + assertThat(httpResponse, isForbidden()); + } + } + + } finally { + delete( + "index_bw1/_doc/put_delete_delete_by_query_b1", + "index_bw1/_doc/put_delete_delete_by_query_b2", + "index_aw1/_doc/put_delete_delete_by_query_a1", + "index_aw1/_doc/put_delete_delete_by_query_a2" + ); + } + } + + @Test + public void putDocument_bulk() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + IndexApiResponseMatchers.IndexMatcher writePrivileges = user.indexMatcher("write"); + + HttpResponse httpResponse = restClient.putJson("_bulk", """ + {"index": {"_index": "index_aw1", "_id": "new_doc_aw1"}} + {"a": 1} + {"index": {"_index": "index_bw1", "_id": "new_doc_bw1"}} + {"a": 1} + {"index": {"_index": "index_cw1", "_id": "new_doc_cw1"}} + {"a": 1} + """); + if (user != LIMITED_USER_NONE) { + assertThat( + httpResponse, + containsExactly(index_aw1, index_bw1, index_cw1).at("items[*].index[?(@.result == 'created')]._index") + .reducedBy(writePrivileges) + .whenEmpty(isOk()) + ); + } else { + assertThat(httpResponse, isForbidden()); + } + } finally { + delete("index_aw1/_doc/new_doc_aw1", "index_bw1/_doc/new_doc_bw1", "index_cw1/_doc/new_doc_cw1"); + } + } + + @Test + public void putDocument_alias() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + HttpResponse httpResponse = restClient.put("alias_ab1w/_doc/put_doc_alias_test_1", json("a, 1")); + if (clusterConfig.legacyPrivilegeEvaluation) { + if (user.indexMatcher("write").coversAll(index_aw1, index_aw2, index_bw1)) { + assertThat(httpResponse, isCreated()); + } else { + assertThat(httpResponse, isForbidden()); + } + } else { + if (user.indexMatcher("write").coversAll(alias_ab1w)) { + assertThat(httpResponse, isCreated()); + } else { + assertThat(httpResponse, isForbidden()); + } + } + } finally { + delete("alias_ab1w/_doc/put_doc_alias_test_1"); + } + } + + @Test + public void putDocument_alias_noWriteIndex() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + HttpResponse httpResponse = restClient.put("alias_ab1w_nowriteindex/_doc/put_doc_alias_test_1", json("a, 1")); + + if (containsExactly(alias_ab1w_nowriteindex).reducedBy(user.indexMatcher("write")).isEmpty()) { + assertThat(httpResponse, isForbidden()); + } else { + assertThat(httpResponse, isBadRequest()); + } + } + } + + @Test + public void putDocument_bulk_alias() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + HttpResponse httpResponse = restClient.putJson("_bulk", """ + {"index": {"_index": "alias_ab1w", "_id": "put_doc_alias_bulk_test_1"}} + {"a": 1} + """); + + if (user != LIMITED_USER_NONE) { + assertThat( + httpResponse, + containsExactly(index_aw1).at("items[*].index[?(@.result == 'created')]._index") + .reducedBy(user.indexMatcher("write")) + .whenEmpty(isOk()) + ); + } else { + assertThat(httpResponse, isForbidden()); + } + + } finally { + delete("index_aw1/_doc/put_doc_alias_bulk_test_1"); + } + } + + @Test + public void putDocument_noExistingIndex() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + HttpResponse httpResponse = restClient.put("index_bwx1/_doc/put_doc_non_existing_index_test_1", json("a, 1")); + assertThat( + httpResponse, + containsExactly(index_bwx1).at("_index").reducedBy(user.indexMatcher("create_index")).whenEmpty(isForbidden()) + ); + } finally { + delete(index_bwx1); + } + } + + @Test + public void createIndex() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + HttpResponse httpResponse = restClient.putJson("index_bwx1", "{}"); + assertThat( + httpResponse, + containsExactly(index_bwx1).at("index").reducedBy(user.indexMatcher("create_index")).whenEmpty(isForbidden()) + ); + } finally { + delete(index_bwx1); + } + } + + @Test + public void createIndex_systemIndex() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + HttpResponse httpResponse = restClient.putJson(".system_index_plugin_not_existing", "{}"); + + if (clusterConfig.systemIndexPrivilegeEnabled && user.indexMatcher("create_index").covers(system_index_plugin_not_existing)) { + assertThat(httpResponse, isOk()); + } else if (user == SUPER_UNLIMITED_USER + || (clusterConfig == ClusterConfig.LEGACY_PRIVILEGES_EVALUATION + && (user == UNLIMITED_USER || user == LIMITED_USER_B_SYSTEM_INDEX_MANAGE))) { + assertThat(httpResponse, isOk()); + } else { + assertThat(httpResponse, isForbidden()); + } + } finally { + delete(system_index_plugin_not_existing); + } + } + + @Test + public void deleteIndex() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + createInitialTestObjects(index_bwx1); + + HttpResponse httpResponse = restClient.delete("index_bwx1"); + if (user.indexMatcher("manage_index").covers(index_bwx1)) { + assertThat(httpResponse, isOk()); + } else { + assertThat(httpResponse, isForbidden()); + } + } finally { + delete(index_bwx1); + } + } + + @Test + public void deleteIndex_systemIndex() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + createInitialTestObjects(system_index_plugin_not_existing); + + HttpResponse httpResponse = restClient.delete(".system_index_plugin_not_existing"); + + if (clusterConfig.systemIndexPrivilegeEnabled && user.indexMatcher("manage_index").covers(system_index_plugin_not_existing)) { + assertThat(httpResponse, isOk()); + } else if (user == SUPER_UNLIMITED_USER) { + assertThat(httpResponse, isOk()); + } else { + assertThat(httpResponse, isForbidden()); + } + } finally { + delete(system_index_plugin_not_existing); + } + } + + @Test + public void createIndex_withAlias() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + HttpResponse httpResponse = restClient.putJson("index_bwx1", """ + { + "aliases": { + "alias_bwx": {} + } + }"""); + + if (clusterConfig.legacyPrivilegeEvaluation) { + if (user.indexMatcher("manage_alias").covers(index_bwx1)) { + assertThat(httpResponse, isOk()); + } else { + assertThat(httpResponse, isForbidden()); + } + } else { + if (user.indexMatcher("manage_alias").coversAll(alias_bwx, index_bwx1)) { + assertThat(httpResponse, isOk()); + assertThat( + httpResponse, + containsExactly(index_bwx1).at("index").reducedBy(user.indexMatcher("create_index")).whenEmpty(isForbidden()) + ); + } else { + assertThat(httpResponse, isForbidden()); + } + } + } finally { + delete(index_bwx1); + } + } + + @Test + public void deleteAlias_staticIndex() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + createInitialTestObjects(alias_bwx.on(index_bw1)); + + HttpResponse httpResponse = restClient.delete("index_bw1/_aliases/alias_bwx"); + + if (clusterConfig.legacyPrivilegeEvaluation) { + if (user.indexMatcher("manage_alias").covers(index_bw1) || user.indexMatcher("manage_alias").covers(alias_bwx)) { + assertThat(httpResponse, isOk()); + } else { + assertThat(httpResponse, isForbidden()); + } + } else { + if (user.indexMatcher("manage_alias").covers(alias_bwx)) { + assertThat(httpResponse, isOk()); + } else { + assertThat(httpResponse, isForbidden()); + } + } + + } finally { + delete(alias_bwx); + } + } + + @Test + public void deleteAlias_wildcard() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + createInitialTestObjects(alias_bwx.on(index_bw1)); + + HttpResponse httpResponse = restClient.delete("*/_aliases/alias_bwx"); + + if (clusterConfig.legacyPrivilegeEvaluation) { + // This is only allowed if we have privileges for all indices, even if not all indices are member of alias_bwx + if (user.indexMatcher("manage_alias").coversAll(ALL_NON_HIDDEN_INDICES)) { + assertThat(httpResponse, isOk()); + } else { + assertThat(httpResponse, isForbidden()); + } + } else { + if (user.indexMatcher("manage_alias").coversAll(alias_bwx)) { + assertThat(httpResponse, isOk()); + } else { + assertThat(httpResponse, isForbidden()); + } + } + } finally { + delete(alias_bwx); + } + } + + @Test + public void aliases_createAlias() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + + HttpResponse httpResponse = restClient.postJson("_aliases", """ + { + "actions": [ + { "add": { "index": "index_bw1", "alias": "alias_bwx" } } + ] + }"""); + + if (clusterConfig.legacyPrivilegeEvaluation) { + if (user.indexMatcher("manage_alias").covers(index_bw1)) { + assertThat(httpResponse, isOk()); + } else { + assertThat(httpResponse, isForbidden()); + } + } else { + if (user.indexMatcher("manage_alias").coversAll(alias_bwx, index_bw1)) { + assertThat(httpResponse, isOk()); + } else { + assertThat(httpResponse, isForbidden()); + } + } + + } finally { + delete(alias_bwx); + } + } + + @Test + public void aliases_createAlias_indexPattern() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + HttpResponse httpResponse = restClient.postJson("_aliases", """ + { + "actions": [ + { "add": { "indices": ["index_bw*"], "alias": "alias_bwx" } } + ] + }"""); + if (clusterConfig.legacyPrivilegeEvaluation) { + if (user.indexMatcher("manage_alias").coversAll(index_bw1, index_bw2)) { + assertThat(httpResponse, isOk()); + } else { + assertThat(httpResponse, isForbidden()); + } + } else { + if (user.indexMatcher("manage_alias").coversAll(alias_bwx, index_bw1, index_bw2)) { + assertThat(httpResponse, isOk()); + } else { + assertThat(httpResponse, isForbidden()); + } + } + } finally { + delete(alias_bwx); + } + } + + @Test + public void aliases_deleteAlias_staticIndex() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + createInitialTestObjects(alias_bwx.on(index_bw1)); + + HttpResponse httpResponse = restClient.postJson("_aliases", """ + { + "actions": [ + { "remove": { "index": "index_bw1", "alias": "alias_bwx" } } + ] + }"""); + + if (clusterConfig.legacyPrivilegeEvaluation) { + if (user.indexMatcher("manage_alias").covers(index_bw1) || user.indexMatcher("manage_alias").covers(alias_bwx)) { + assertThat(httpResponse, isOk()); + } else { + assertThat(httpResponse, isForbidden()); + } + } else { + if (user.indexMatcher("manage_alias").covers(alias_bwx)) { + assertThat(httpResponse, isOk()); + } else { + assertThat(httpResponse, isForbidden()); + } + } + } finally { + delete(alias_bwx); + } + } + + @Test + public void aliases_deleteAlias_wildcard() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + createInitialTestObjects(alias_bwx.on(index_bw1, index_bw2)); + + HttpResponse httpResponse = restClient.postJson("_aliases", """ + { + "actions": [ + { "remove": { "index": "*", "alias": "alias_bwx" } } + ] + }"""); + + if (clusterConfig.legacyPrivilegeEvaluation) { + // This is only allowed if we have privileges for all indices, even if not all indices are member of alias_bwx + if (user.indexMatcher("manage_alias").coversAll(ALL_NON_HIDDEN_INDICES)) { + assertThat(httpResponse, isOk()); + } else { + assertThat(httpResponse, isForbidden()); + } + } else { + if (user.indexMatcher("manage_alias").coversAll(alias_bwx)) { + assertThat(httpResponse, isOk()); + } else { + assertThat(httpResponse, isForbidden()); + } + } + + } finally { + delete(alias_bwx); + } + } + + @Test + public void aliases_removeIndex() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + createInitialTestObjects(index_bwx1); + + HttpResponse httpResponse = restClient.postJson("_aliases", """ + { + "actions": [ + { "remove_index": { "index": "index_bwx1" } } + ] + }"""); + + if (user.indexMatcher("manage_index").covers(index_bwx1)) { + assertThat(httpResponse, isOk()); + } else { + assertThat(httpResponse, isForbidden()); + } + } finally { + delete(index_bwx1); + } + } + + @Test + public void reindex() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + HttpResponse httpResponse = restClient.postJson("_reindex", """ + { + "source": { "index": "index_br1" }, + "dest": { "index": "index_bwx1" } + }"""); + if (containsExactly(index_bwx1).reducedBy(user.indexMatcher("create_index")).isEmpty()) { + assertThat(httpResponse, isForbidden()); + assertThat(cluster.getAdminCertRestClient().get("index_bwx1/_search"), isNotFound()); + } else { + assertThat(httpResponse, isOk()); + assertThat(cluster.getAdminCertRestClient().get("index_bwx1/_search"), isOk()); + } + } finally { + delete(index_bwx1); + } + } + + @Test + public void cloneIndex() throws Exception { + String sourceIndex = "index_bw1"; + String targetIndex = "index_bwx1"; + + Client client = cluster.getInternalNodeClient(); + client.admin() + .indices() + .updateSettings(new UpdateSettingsRequest(sourceIndex).settings(Settings.builder().put("index.blocks.write", true).build())) + .actionGet(); + + try (TestRestClient restClient = cluster.getRestClient(user)) { + HttpResponse httpResponse = restClient.post(sourceIndex + "/_clone/" + targetIndex); + assertThat( + httpResponse, + containsExactly(index_bwx1).at("index").reducedBy(user.indexMatcher("manage_index")).whenEmpty(isForbidden()) + ); + } finally { + cluster.getInternalNodeClient() + .admin() + .indices() + .updateSettings( + new UpdateSettingsRequest(sourceIndex).settings(Settings.builder().put("index.blocks.write", false).build()) + ) + .actionGet(); + delete(index_bwx1); + } + } + + @Test + public void closeIndex() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + HttpResponse httpResponse = restClient.post("index_bw1/_close"); + assertThat( + httpResponse, + containsExactly(index_bw1).at("indices.keys()").reducedBy(user.indexMatcher("manage_index")).whenEmpty(isForbidden()) + ); + } finally { + cluster.getInternalNodeClient().admin().indices().open(new OpenIndexRequest("index_bw1")).actionGet(); + } + } + + @Test + public void closeIndex_wildcard() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + HttpResponse httpResponse = restClient.post("*/_close"); + + if (clusterConfig.legacyPrivilegeEvaluation) { + if (user.indexMatcher("manage_index").coversAll(ALL_NON_HIDDEN_INDICES)) { + assertThat(httpResponse, isOk()); + } else { + assertThat(httpResponse, isForbidden()); + } + } else { + if (!user.indexMatcher("manage_index").isEmpty()) { + assertThat( + httpResponse, + containsExactly(ALL_NON_HIDDEN_INDICES).at("indices.keys()") + .reducedBy(user.indexMatcher("manage_index")) + .whenEmpty(isOk()) + ); + } else { + assertThat(httpResponse, isForbidden()); + } + } + } finally { + cluster.getInternalNodeClient().admin().indices().open(new OpenIndexRequest("*")).actionGet(); + } + } + + @Test + public void closeIndex_openIndex() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + HttpResponse httpResponse = restClient.post("index_bw1/_close"); + assertThat( + httpResponse, + containsExactly(index_bw1).at("indices.keys()").reducedBy(user.indexMatcher("manage_index")).whenEmpty(isForbidden()) + ); + httpResponse = restClient.post("index_bw1/_open"); + + if (containsExactly(index_bw1).reducedBy(user.indexMatcher("manage_index")).isEmpty()) { + assertThat(httpResponse, isForbidden()); + } else { + assertThat(httpResponse, isOk()); + } + } finally { + cluster.getInternalNodeClient().admin().indices().open(new OpenIndexRequest("index_bw1")).actionGet(); + } + } + + @Test + public void rollover_explicitTargetIndex() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + createInitialTestObjects(alias_bwx.on(index_bw1).writeIndex(index_bw1)); + + HttpResponse httpResponse = restClient.postJson("alias_bwx/_rollover/index_bwx1", """ + { + "conditions": { + "max_age": "0s" + } + }"""); + + System.out.println(httpResponse.getBody()); + + if (clusterConfig.legacyPrivilegeEvaluation) { + if (user.indexMatcher("manage_alias").covers(index_bw1) && user.indexMatcher("manage_index").covers(index_bw2)) { + assertThat(httpResponse, isOk()); + } else { + assertThat(httpResponse, isForbidden()); + } + } else { + if (user.indexMatcher("manage_alias").covers(alias_bwx) && user.indexMatcher("manage_index").covers(index_bw2)) { + assertThat(httpResponse, isOk()); + } else { + assertThat(httpResponse, isForbidden()); + } + } + } finally { + delete(alias_bwx, index_bwx1); + } + } + + @After + public void refresh() { + cluster.getInternalNodeClient().admin().indices().refresh(new RefreshRequest("*")).actionGet(); + } + + @ParametersFactory(shuffle = false, argumentFormatting = "%1$s, %3$s") + public static Collection params() { + List result = new ArrayList<>(); + + for (ClusterConfig clusterConfig : ClusterConfig.values()) { + for (TestSecurityConfig.User user : USERS) { + result.add(new Object[] { clusterConfig, user, user.getDescription() }); + } + } + return result; + } + + public IndexAuthorizationReadWriteIntTests(ClusterConfig clusterConfig, TestSecurityConfig.User user, String description) + throws Exception { + this.user = user; + this.cluster = clusterConfig.cluster(IndexAuthorizationReadWriteIntTests::clusterBuilder); + this.clusterConfig = clusterConfig; + } + + private void createInitialTestObjects(TestIndexOrAliasOrDatastream... testIndexLikeArray) { + TestIndexOrAliasOrDatastream.createInitialTestObjects(cluster, testIndexLikeArray); + } + + private void delete(TestIndexOrAliasOrDatastream... testIndexLikeArray) { + TestIndexOrAliasOrDatastream.delete(cluster, testIndexLikeArray); + } + + private void delete(String... paths) { + try (TestRestClient adminRestClient = cluster.getAdminCertRestClient()) { + for (String path : paths) { + HttpResponse response = adminRestClient.delete(path); + if (response.getStatusCode() != 200 && response.getStatusCode() != 404) { + throw new RuntimeException("Error while deleting " + path + "\n" + response.getBody()); + } + } + } + } +} diff --git a/src/integrationTest/java/org/opensearch/security/privileges/PrivilegesEvaluatorTest.java b/src/integrationTest/java/org/opensearch/security/privileges/int_tests/MiscPrivilegesIntTests.java similarity index 66% rename from src/integrationTest/java/org/opensearch/security/privileges/PrivilegesEvaluatorTest.java rename to src/integrationTest/java/org/opensearch/security/privileges/int_tests/MiscPrivilegesIntTests.java index adc0b212f5..bf7276ef92 100644 --- a/src/integrationTest/java/org/opensearch/security/privileges/PrivilegesEvaluatorTest.java +++ b/src/integrationTest/java/org/opensearch/security/privileges/int_tests/MiscPrivilegesIntTests.java @@ -9,7 +9,7 @@ * GitHub history for details. */ -package org.opensearch.security.privileges; +package org.opensearch.security.privileges.int_tests; import com.carrotsearch.randomizedtesting.annotations.ThreadLeakScope; import org.apache.http.HttpStatus; @@ -30,21 +30,16 @@ import static org.hamcrest.Matchers.equalTo; import static org.opensearch.test.framework.TestSecurityConfig.AuthcDomain.AUTHC_HTTPBASIC_INTERNAL; -/** -* This is a port for the test -* org.opensearch.security.privileges.PrivilegesEvaluatorTest to the new test -* framework for direct comparison -*/ @RunWith(com.carrotsearch.randomizedtesting.RandomizedRunner.class) @ThreadLeakScope(ThreadLeakScope.Scope.NONE) -public class PrivilegesEvaluatorTest { +public class MiscPrivilegesIntTests { protected final static TestSecurityConfig.User NEGATIVE_LOOKAHEAD = new TestSecurityConfig.User("negative_lookahead_user").roles( new Role("negative_lookahead_role").indexPermissions("read").on("/^(?!t.*).*/").clusterPermissions("cluster_composite_ops") ); protected final static TestSecurityConfig.User NEGATED_REGEX = new TestSecurityConfig.User("negated_regex_user").roles( - new Role("negated_regex_role").indexPermissions("read").on("/^[a-z].*/").clusterPermissions("cluster_composite_ops") + new Role("negated_regex_role").indexPermissions("read").on("/^[a-r].*/").clusterPermissions("cluster_composite_ops") ); protected final static TestSecurityConfig.User SEARCH_TEMPLATE = new TestSecurityConfig.User("search_template_user").roles( @@ -58,11 +53,6 @@ public class PrivilegesEvaluatorTest { .clusterPermissions(RenderSearchTemplateAction.NAME) ); - private String TEST_QUERY = - "{\"source\":{\"query\":{\"match\":{\"service\":\"{{service_name}}\"}}},\"params\":{\"service_name\":\"Oracle\"}}"; - - private String TEST_DOC = "{\"source\": {\"title\": \"Spirited Away\"}}"; - private String TEST_RENDER_SEARCH_TEMPLATE_QUERY = "{\"params\":{\"status\":[\"pending\",\"published\"]},\"source\":\"{\\\"query\\\": {\\\"terms\\\": {\\\"status\\\": [\\\"{{#status}}\\\",\\\"{{.}}\\\",\\\"{{/status}}\\\"]}}}\"}"; @@ -99,45 +89,6 @@ public void testRegexPattern() throws Exception { } - @Test - public void testSearchTemplateRequestSuccess() { - // Insert doc into services index with admin user - try (TestRestClient client = cluster.getRestClient(TestSecurityConfig.User.USER_ADMIN)) { - TestRestClient.HttpResponse response = client.postJson("services/_doc", TEST_DOC); - assertThat(response.getStatusCode(), equalTo(HttpStatus.SC_CREATED)); - } - - try (TestRestClient client = cluster.getRestClient(SEARCH_TEMPLATE)) { - final String searchTemplateOnServicesIndex = "services/_search/template"; - final TestRestClient.HttpResponse searchTemplateOnAuthorizedIndexResponse = client.getWithJsonBody( - searchTemplateOnServicesIndex, - TEST_QUERY - ); - assertThat(searchTemplateOnAuthorizedIndexResponse.getStatusCode(), equalTo(HttpStatus.SC_OK)); - } - } - - @Test - public void testSearchTemplateRequestUnauthorizedIndex() { - try (TestRestClient client = cluster.getRestClient(SEARCH_TEMPLATE)) { - final String searchTemplateOnMoviesIndex = "movies/_search/template"; - final TestRestClient.HttpResponse searchTemplateOnUnauthorizedIndexResponse = client.getWithJsonBody( - searchTemplateOnMoviesIndex, - TEST_QUERY - ); - assertThat(searchTemplateOnUnauthorizedIndexResponse.getStatusCode(), equalTo(HttpStatus.SC_FORBIDDEN)); - } - } - - @Test - public void testSearchTemplateRequestUnauthorizedAllIndices() { - try (TestRestClient client = cluster.getRestClient(SEARCH_TEMPLATE)) { - final String searchTemplateOnAllIndices = "_search/template"; - final TestRestClient.HttpResponse searchOnAllIndicesResponse = client.getWithJsonBody(searchTemplateOnAllIndices, TEST_QUERY); - assertThat(searchOnAllIndicesResponse.getStatusCode(), equalTo(HttpStatus.SC_FORBIDDEN)); - } - } - @Test public void testRenderSearchTemplateRequestFailure() { try (TestRestClient client = cluster.getRestClient(SEARCH_TEMPLATE)) { diff --git a/src/integrationTest/java/org/opensearch/security/privileges/int_tests/SnapshotAuthorizationIntTests.java b/src/integrationTest/java/org/opensearch/security/privileges/int_tests/SnapshotAuthorizationIntTests.java new file mode 100644 index 0000000000..1abf3dbcca --- /dev/null +++ b/src/integrationTest/java/org/opensearch/security/privileges/int_tests/SnapshotAuthorizationIntTests.java @@ -0,0 +1,436 @@ +/* + * 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. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.privileges.int_tests; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import javax.annotation.concurrent.NotThreadSafe; + +import com.carrotsearch.randomizedtesting.annotations.ParametersFactory; +import com.carrotsearch.randomizedtesting.annotations.ThreadLeakScope; +import com.google.common.collect.ImmutableList; +import org.apache.hc.core5.http.HttpEntity; +import org.junit.After; +import org.junit.AfterClass; +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.opensearch.action.admin.indices.refresh.RefreshRequest; +import org.opensearch.test.framework.TestIndex; +import org.opensearch.test.framework.TestIndexOrAliasOrDatastream; +import org.opensearch.test.framework.TestSecurityConfig; +import org.opensearch.test.framework.cluster.LocalCluster; +import org.opensearch.test.framework.cluster.TestRestClient; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.opensearch.test.framework.TestSecurityConfig.AuthcDomain.AUTHC_HTTPBASIC_INTERNAL; +import static org.opensearch.test.framework.cluster.TestRestClient.json; +import static org.opensearch.test.framework.matcher.IndexApiResponseMatchers.OnResponseIndexMatcher.containsExactly; +import static org.opensearch.test.framework.matcher.IndexApiResponseMatchers.OnUserIndexMatcher.limitedTo; +import static org.opensearch.test.framework.matcher.IndexApiResponseMatchers.OnUserIndexMatcher.limitedToNone; +import static org.opensearch.test.framework.matcher.IndexApiResponseMatchers.OnUserIndexMatcher.unlimitedIncludingOpenSearchSecurityIndex; +import static org.opensearch.test.framework.matcher.RestMatchers.isForbidden; +import static org.opensearch.test.framework.matcher.RestMatchers.isOk; + +/** + * TODO requests on non master node + */ +@RunWith(com.carrotsearch.randomizedtesting.RandomizedRunner.class) +@ThreadLeakScope(ThreadLeakScope.Scope.NONE) +@NotThreadSafe +public class SnapshotAuthorizationIntTests { + static final TestIndex index_a1 = TestIndex.name("index_ar1").documentCount(10).seed(1).build(); + static final TestIndex index_a2 = TestIndex.name("index_ar2").documentCount(11).seed(2).build(); + static final TestIndex index_a3 = TestIndex.name("index_ar3").documentCount(12).seed(3).build(); + static final TestIndex index_b1 = TestIndex.name("index_br1").documentCount(4).seed(4).build(); + static final TestIndex index_b2 = TestIndex.name("index_br2").documentCount(5).seed(5).build(); + static final TestIndex index_b3 = TestIndex.name("index_br3").documentCount(6).seed(6).build(); + static final TestIndex index_c1 = TestIndex.name("index_cr1").documentCount(7).seed(7).build(); + static final TestIndex index_hidden = TestIndex.name("index_hidden").hidden().documentCount(1).seed(8).build(); + static final TestIndex index_hidden_dot = TestIndex.name(".index_hidden_dot").hidden().documentCount(1).seed(9).build(); + static final TestIndex system_index_plugin = TestIndex.name(".system_index_plugin").hidden().documentCount(1).seed(10).build(); + static final TestIndex system_index_plugin_not_existing = TestIndex.name(".system_index_plugin_not_existing") + .hidden() + .documentCount(0) + .build(); // not initially created + + static final TestIndex index_awx1 = TestIndex.name("index_awx1").documentCount(10).seed(11).build(); // not initially created + static final TestIndex index_awx2 = TestIndex.name("index_awx2").documentCount(10).seed(12).build(); // not initially created + + static final TestIndex index_bwx1 = TestIndex.name("index_bwx1").documentCount(10).seed(13).build(); // not initially created + static final TestIndex index_bwx2 = TestIndex.name("index_bwx2").documentCount(10).seed(14).build(); // not initially created + + static TestSecurityConfig.User LIMITED_USER_A = new TestSecurityConfig.User("limited_user_A")// + .description("index_a*")// + .roles( + // + new TestSecurityConfig.Role("r1")// + .clusterPermissions("cluster_composite_ops", "cluster_monitor", "manage_snapshots")// + .indexPermissions("read", "indices_monitor", "indices:admin/refresh*") + .on("index_a*")// + .indexPermissions("write", "manage") + .on("index_aw*") + )// + .indexMatcher("read", limitedTo(index_a1, index_a2, index_awx1, index_awx2))// + .indexMatcher("write", limitedTo(index_awx1, index_awx2)); + + static TestSecurityConfig.User LIMITED_USER_B = new TestSecurityConfig.User("limited_user_B")// + .description("index_b*")// + .roles( + // + new TestSecurityConfig.Role("r1")// + .clusterPermissions("cluster_composite_ops", "cluster_monitor", "manage_snapshots")// + .indexPermissions("read", "indices_monitor", "indices:admin/refresh*") + .on("index_b*")// + .indexPermissions("write", "manage") + .on("index_bw*") + )// + .indexMatcher("read", limitedTo(index_b1, index_b2, index_bwx1, index_bwx2))// + .indexMatcher("write", limitedTo(index_bwx1, index_bwx2)); + + static TestSecurityConfig.User LIMITED_USER_B_SYSTEM_INDEX = new TestSecurityConfig.User("limited_user_B_system_index")// + .description("index_b*, .system_index_plugin")// + .roles( + // + new TestSecurityConfig.Role("r1")// + .clusterPermissions("cluster_composite_ops", "cluster_monitor", "manage_snapshots")// + .indexPermissions("read", "indices_monitor", "indices:admin/refresh*") + .on("index_b*")// + .indexPermissions("write", "manage") + .on("index_bw*") + .indexPermissions("read", "indices_monitor", "indices:admin/refresh*", "system:admin/system_index") + .on(".system_index_plugin", ".system_index_plugin_not_existing") + .indexPermissions("write", "manage", "system:admin/system_index") + .on(".system_index_plugin_not_existing") + + )// + .indexMatcher("read", limitedTo(index_b1, index_b2, index_bwx1, index_bwx2, system_index_plugin))// + .indexMatcher("write", limitedTo(index_bwx1, index_bwx2, system_index_plugin_not_existing)); + + static TestSecurityConfig.User LIMITED_USER_AB = new TestSecurityConfig.User("limited_user_AB")// + .description("index_a*, index_b*")// + .roles( + // + new TestSecurityConfig.Role("r1")// + .clusterPermissions("cluster_composite_ops", "cluster_monitor", "manage_snapshots")// + .indexPermissions("read", "indices_monitor", "indices:admin/refresh*") + .on("index_a*", "index_b*")// + .indexPermissions("write", "manage") + .on("index_aw*", "index_bw*") + )// + .indexMatcher("read", limitedTo(index_a1, index_a2, index_awx1, index_awx2, index_b1, index_b2, index_bwx1, index_bwx2))// + .indexMatcher("write", limitedTo(index_awx1, index_awx2, index_bwx1, index_bwx2)); + + static final TestSecurityConfig.User LIMITED_USER_NONE = new TestSecurityConfig.User("limited_user_none")// + .description("no index privileges")// + .roles( + new TestSecurityConfig.Role("r1")// + .clusterPermissions("cluster_composite_ops_ro", "cluster_monitor") + )// + .indexMatcher("read", limitedToNone())// + .indexMatcher("write", limitedToNone()); + + static final TestSecurityConfig.User UNLIMITED_USER = new TestSecurityConfig.User("unlimited_user")// + .description("unlimited")// + .roles( + new TestSecurityConfig.Role("r1")// + .clusterPermissions("cluster_composite_ops_ro", "cluster_monitor", "manage_snapshots") + .indexPermissions("*") + .on("*")// + + )// + .indexMatcher( + "read", + limitedTo( + index_a1, + index_a2, + index_a3, + index_awx1, + index_awx2, + index_b1, + index_b2, + index_b3, + index_bwx1, + index_bwx2, + index_c1, + index_hidden, + index_hidden_dot + ) + )// + .indexMatcher( + "write", + limitedTo( + index_a1, + index_a2, + index_a3, + index_awx1, + index_awx2, + index_b1, + index_b2, + index_b3, + index_bwx1, + index_bwx2, + index_c1, + index_hidden, + index_hidden_dot + ) + ); + + /** + * The SUPER_UNLIMITED_USER authenticates with an admin cert, which will cause all access control code to be skipped. + * This serves as a base for comparison with the default behavior. + */ + static final TestSecurityConfig.User SUPER_UNLIMITED_USER = new TestSecurityConfig.User("super_unlimited_user")// + .description("super unlimited (admin cert)")// + .adminCertUser()// + .indexMatcher("read", unlimitedIncludingOpenSearchSecurityIndex())// + .indexMatcher("write", unlimitedIncludingOpenSearchSecurityIndex()); + + static final List USERS = ImmutableList.of( + LIMITED_USER_A, + LIMITED_USER_B, + LIMITED_USER_B_SYSTEM_INDEX, + LIMITED_USER_AB, + LIMITED_USER_NONE, + UNLIMITED_USER, + SUPER_UNLIMITED_USER + ); + + static LocalCluster.Builder clusterBuilder() { + return new LocalCluster.Builder().singleNode() + .authc(AUTHC_HTTPBASIC_INTERNAL) + .users(USERS)// + .indices( + index_a1, + index_a2, + index_a3, + index_b1, + index_b2, + index_b3, + index_c1, + index_hidden, + index_hidden_dot, + system_index_plugin + )// + .snapshotRepositories("test_repository") + .plugin(IndexAuthorizationReadOnlyIntTests.SystemIndexTestPlugin.class); + } + + @AfterClass + public static void stopClusters() { + for (ClusterConfig clusterConfig : ClusterConfig.values()) { + clusterConfig.shutdown(); + } + } + + final TestSecurityConfig.User user; + final LocalCluster cluster; + final ClusterConfig clusterConfig; + + @Test + public void restore_singleIndex() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + createInitialTestObjects(index_awx1); + createInitialTestSnapshot("_snapshot/test_repository/single_index_snapshot", json("indices", "index_awx1")); + + delete(index_awx1); + + TestRestClient.HttpResponse httpResponse = restClient.post( + "_snapshot/test_repository/single_index_snapshot/_restore?wait_for_completion=true" + ); + System.out.println(httpResponse.getBody()); + + assertThat( + httpResponse, + containsExactly(index_awx1).at("snapshot.indices").butForbiddenIfIncomplete(user.indexMatcher("write")) + ); + + } finally { + delete("_snapshot/test_repository/single_index_snapshot"); + delete(index_awx1); + } + } + + @Test + public void restore_singleIndex_rename1() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + createInitialTestObjects(index_awx1); + createInitialTestSnapshot("_snapshot/test_repository/single_index_snapshot", json("indices", "index_awx1")); + + TestRestClient.HttpResponse httpResponse = restClient.post( + "_snapshot/test_repository/single_index_snapshot/_restore?wait_for_completion=true", + json("rename_pattern", "index_(.+)x1", "rename_replacement", "index_$1x2") + ); + System.out.println(httpResponse.getBody()); + + assertThat( + httpResponse, + containsExactly(index_awx2).at("snapshot.indices").butForbiddenIfIncomplete(user.indexMatcher("write")) + ); + + } finally { + delete("_snapshot/test_repository/single_index_snapshot"); + delete(index_awx1, index_awx2); + } + } + + @Test + public void restore_singleIndex_rename2() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + createInitialTestObjects(index_awx1); + createInitialTestSnapshot("_snapshot/test_repository/single_index_snapshot", json("indices", "index_awx1")); + + TestRestClient.HttpResponse httpResponse = restClient.post( + "_snapshot/test_repository/single_index_snapshot/_restore?wait_for_completion=true", + json("rename_pattern", "index_a(.*)", "rename_replacement", "index_b$1") + ); + System.out.println(httpResponse.getBody()); + + assertThat( + httpResponse, + containsExactly(index_bwx1).at("snapshot.indices").butForbiddenIfIncomplete(user.indexMatcher("write")) + ); + + } finally { + delete("_snapshot/test_repository/single_index_snapshot"); + delete(index_awx1, index_bwx1); + } + } + + @Test + public void restore_singleIndex_renameToSystemIndex() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + createInitialTestObjects(index_awx1); + createInitialTestSnapshot("_snapshot/test_repository/single_index_snapshot", json("indices", "index_awx1")); + + TestRestClient.HttpResponse httpResponse = restClient.post( + "_snapshot/test_repository/single_index_snapshot/_restore?wait_for_completion=true", + json("rename_pattern", "index_awx1", "rename_replacement", system_index_plugin_not_existing.name()) + ); + System.out.println(httpResponse.getBody()); + + if (clusterConfig.systemIndexPrivilegeEnabled || user == SUPER_UNLIMITED_USER) { + assertThat( + httpResponse, + containsExactly(system_index_plugin_not_existing).at("snapshot.indices") + .butForbiddenIfIncomplete(user.indexMatcher("write")) + ); + } else { + assertThat(httpResponse, isForbidden()); + } + } finally { + delete("_snapshot/test_repository/single_index_snapshot"); + delete(index_awx1, system_index_plugin_not_existing); + } + } + + @Test + public void restore_singleIndexFromAllIndices() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + createInitialTestObjects(index_awx1); + createInitialTestSnapshot("_snapshot/test_repository/all_index_snapshot", json()); + + delete(index_awx1); + + TestRestClient.HttpResponse httpResponse = restClient.post( + "_snapshot/test_repository/all_index_snapshot/_restore?wait_for_completion=true", + json("indices", "index_awx1") + ); + System.out.println(httpResponse.getBody()); + + assertThat( + httpResponse, + containsExactly(index_awx1).at("snapshot.indices").butForbiddenIfIncomplete(user.indexMatcher("write")) + ); + + } finally { + delete("_snapshot/test_repository/all_index_snapshot"); + delete(index_awx1); + } + } + + @Test + public void restore_all_globalState() throws Exception { + try (TestRestClient restClient = cluster.getRestClient(user)) { + createInitialTestObjects(index_awx1, index_awx2, index_bwx1, index_bwx2); + createInitialTestSnapshot("_snapshot/test_repository/all_index_snapshot", json("indices", "index_*w*")); + + delete(index_awx1, index_awx2, index_bwx1, index_bwx2); + + TestRestClient.HttpResponse httpResponse = restClient.post( + "_snapshot/test_repository/all_index_snapshot/_restore?wait_for_completion=true", + json("include_global_state", true) + ); + System.out.println(httpResponse.getBody()); + + if (user == SUPER_UNLIMITED_USER) { + assertThat(httpResponse, isOk()); + } else { + assertThat(httpResponse, isForbidden()); + } + + } finally { + delete("_snapshot/test_repository/all_index_snapshot"); + delete(index_awx1, index_awx2, index_bwx1, index_bwx2); + } + } + + @After + public void refresh() { + cluster.getInternalNodeClient().admin().indices().refresh(new RefreshRequest("*")).actionGet(); + } + + @ParametersFactory(shuffle = false, argumentFormatting = "%1$s, %3$s") + public static Collection params() { + List result = new ArrayList<>(); + + for (ClusterConfig clusterConfig : ClusterConfig.values()) { + for (TestSecurityConfig.User user : USERS) { + result.add(new Object[] { clusterConfig, user, user.getDescription() }); + } + } + return result; + } + + public SnapshotAuthorizationIntTests(ClusterConfig clusterConfig, TestSecurityConfig.User user, String description) throws Exception { + this.user = user; + this.cluster = clusterConfig.cluster(SnapshotAuthorizationIntTests::clusterBuilder); + this.clusterConfig = clusterConfig; + } + + private void createInitialTestObjects(TestIndexOrAliasOrDatastream... testIndexLikeArray) { + TestIndexOrAliasOrDatastream.createInitialTestObjects(cluster, testIndexLikeArray); + } + + private void createInitialTestSnapshot(String snapshotName, HttpEntity requestBody) { + try (TestRestClient client = cluster.getAdminCertRestClient()) { + TestRestClient.HttpResponse httpResponse = client.put(snapshotName + "?wait_for_completion=true", requestBody); + assertThat(httpResponse, isOk()); + } + } + + private void delete(TestIndexOrAliasOrDatastream... testIndexLikeArray) { + TestIndexOrAliasOrDatastream.delete(cluster, testIndexLikeArray); + } + + private void delete(String... paths) { + try (TestRestClient adminRestClient = cluster.getAdminCertRestClient()) { + for (String path : paths) { + TestRestClient.HttpResponse response = adminRestClient.delete(path); + if (response.getStatusCode() != 200 && response.getStatusCode() != 404) { + throw new RuntimeException("Error while deleting " + path + "\n" + response.getBody()); + } + } + } + } +} diff --git a/src/integrationTest/java/org/opensearch/security/util/MockPrivilegeEvaluationContextBuilder.java b/src/integrationTest/java/org/opensearch/security/util/MockPrivilegeEvaluationContextBuilder.java index a2312f0f7f..0e3816ba1d 100644 --- a/src/integrationTest/java/org/opensearch/security/util/MockPrivilegeEvaluationContextBuilder.java +++ b/src/integrationTest/java/org/opensearch/security/util/MockPrivilegeEvaluationContextBuilder.java @@ -20,14 +20,16 @@ import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; +import org.opensearch.action.ActionRequest; +import org.opensearch.action.support.ActionRequestMetadata; import org.opensearch.cluster.ClusterState; import org.opensearch.cluster.metadata.IndexNameExpressionResolver; import org.opensearch.cluster.metadata.Metadata; import org.opensearch.common.settings.Settings; import org.opensearch.common.util.concurrent.ThreadContext; import org.opensearch.security.privileges.ActionPrivileges; +import org.opensearch.security.privileges.IndicesRequestResolver; import org.opensearch.security.privileges.PrivilegesEvaluationContext; -import org.opensearch.security.resolver.IndexResolverReplacer; import org.opensearch.security.user.User; /** @@ -47,6 +49,8 @@ public static MockPrivilegeEvaluationContextBuilder ctx() { private Set roles = new HashSet<>(); private ClusterState clusterState = EMPTY_CLUSTER_STATE; private ActionPrivileges actionPrivileges = ActionPrivileges.EMPTY; + private String action; + private ActionRequest request; public MockPrivilegeEvaluationContextBuilder attr(String key, String value) { this.attributes.put(key, value); @@ -72,6 +76,16 @@ public MockPrivilegeEvaluationContextBuilder actionPrivileges(ActionPrivileges a return this; } + public MockPrivilegeEvaluationContextBuilder action(String action) { + this.action = action; + return this; + } + + public MockPrivilegeEvaluationContextBuilder request(ActionRequest request) { + this.request = request; + return this; + } + public PrivilegesEvaluationContext get() { IndexNameExpressionResolver indexNameExpressionResolver = new IndexNameExpressionResolver(new ThreadContext(Settings.EMPTY)); @@ -79,11 +93,12 @@ public PrivilegesEvaluationContext get() { return new PrivilegesEvaluationContext( user, ImmutableSet.copyOf(roles), + action, + request, + ActionRequestMetadata.empty(), null, - null, - null, - new IndexResolverReplacer(indexNameExpressionResolver, () -> clusterState, null), indexNameExpressionResolver, + new IndicesRequestResolver(indexNameExpressionResolver), () -> clusterState, this.actionPrivileges ); diff --git a/src/integrationTest/java/org/opensearch/test/framework/TestAlias.java b/src/integrationTest/java/org/opensearch/test/framework/TestAlias.java new file mode 100644 index 0000000000..aff626f444 --- /dev/null +++ b/src/integrationTest/java/org/opensearch/test/framework/TestAlias.java @@ -0,0 +1,153 @@ +/* + * 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. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.test.framework; + +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import com.google.common.collect.ImmutableSet; + +import org.opensearch.action.admin.indices.alias.IndicesAliasesRequest; +import org.opensearch.rest.action.admin.indices.AliasesNotFoundException; +import org.opensearch.transport.client.Client; + +public class TestAlias implements TestIndexOrAliasOrDatastream { + + private final String name; + private final ImmutableSet indices; + private final TestIndexOrAliasOrDatastream writeIndex; + private final boolean hidden; + + private Set documentIds; + private Map documents; + + public TestAlias(String name, TestIndexOrAliasOrDatastream... indices) { + this.name = name; + this.indices = ImmutableSet.copyOf(indices); + this.writeIndex = null; + this.hidden = false; + } + + TestAlias(String name, ImmutableSet indices, TestIndexOrAliasOrDatastream writeIndex, boolean hidden) { + this.name = name; + this.indices = indices; + this.writeIndex = writeIndex; + this.hidden = hidden; + } + + public TestAlias on(TestIndexOrAliasOrDatastream... indices) { + return new TestAlias(this.name, ImmutableSet.copyOf(indices), this.writeIndex, this.hidden); + } + + public TestAlias writeIndex(TestIndexOrAliasOrDatastream writeIndex) { + return new TestAlias(this.name, this.indices, writeIndex, this.hidden); + } + + public TestAlias hidden() { + return new TestAlias(this.name, this.indices, this.writeIndex, true); + } + + @Override + public String toString() { + return "Test alias '" + name + "'"; + } + + @Override + public void create(Client client) { + client.admin() + .indices() + .aliases( + new IndicesAliasesRequest().addAliasAction( + IndicesAliasesRequest.AliasActions.add().indices(getIndexNamesAsArray()).alias(name).isHidden(hidden) + ) + ) + .actionGet(); + + if (writeIndex != null) { + client.admin() + .indices() + .aliases( + new IndicesAliasesRequest().addAliasAction( + IndicesAliasesRequest.AliasActions.add().index(writeIndex.name()).alias(name).writeIndex(true) + ) + ) + .actionGet(); + } + } + + @Override + public void delete(Client client) { + try { + client.admin() + .indices() + .aliases(new IndicesAliasesRequest().addAliasAction(IndicesAliasesRequest.AliasActions.remove().alias(name).indices("*"))) + .actionGet(); + } catch (AliasesNotFoundException e) { + // It is fine if the alias to be deleted does not exist + } + } + + @Override + public String name() { + return name; + } + + public ImmutableSet getIndices() { + return indices; + } + + public String[] getIndexNamesAsArray() { + return indices.stream().map(TestIndexOrAliasOrDatastream::name).collect(Collectors.toSet()).toArray(new String[0]); + } + + @Override + public Set documentIds() { + Set result = this.documentIds; + + if (result == null) { + result = new HashSet<>(); + for (TestIndexOrAliasOrDatastream testIndex : this.indices) { + result.addAll(testIndex.documentIds()); + } + + result = Collections.unmodifiableSet(result); + this.documentIds = result; + } + + return result; + } + + @Override + public Map documents() { + Map result = this.documents; + + if (result == null) { + result = new HashMap<>(); + for (TestIndexOrAliasOrDatastream testIndex : this.indices) { + result.putAll(testIndex.documents()); + } + + result = Collections.unmodifiableMap(result); + this.documents = result; + } + + return result; + } + + public static TestIndex.Builder name(String name) { + return new TestIndex.Builder().name(name); + } +} diff --git a/src/integrationTest/java/org/opensearch/test/framework/TestComponentTemplate.java b/src/integrationTest/java/org/opensearch/test/framework/TestComponentTemplate.java new file mode 100644 index 0000000000..8d3450ab5a --- /dev/null +++ b/src/integrationTest/java/org/opensearch/test/framework/TestComponentTemplate.java @@ -0,0 +1,73 @@ +/* + * 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. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.test.framework; + +import java.util.Map; + +import com.google.common.collect.ImmutableMap; + +import org.opensearch.action.admin.indices.template.put.PutComponentTemplateAction; +import org.opensearch.cluster.metadata.ComponentTemplate; +import org.opensearch.common.xcontent.LoggingDeprecationHandler; +import org.opensearch.common.xcontent.json.JsonXContent; +import org.opensearch.core.common.bytes.BytesReference; +import org.opensearch.core.xcontent.NamedXContentRegistry; +import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.core.xcontent.XContentParser; +import org.opensearch.transport.client.Client; + +public class TestComponentTemplate { + public static TestComponentTemplate DATA_STREAM_MINIMAL = new TestComponentTemplate( + "test_component_template_data_stream_minimal", + new TestMapping(new TestMapping.Property("@timestamp", "date", "date_optional_time||epoch_millis")) + ); + + private final String name; + private final TestMapping mapping; + + public TestComponentTemplate(String name, TestMapping mapping) { + this.name = name; + this.mapping = mapping; + } + + public String getName() { + return name; + } + + public TestMapping getMapping() { + return mapping; + } + + public void create(Client client) throws Exception { + try (XContentBuilder builder = JsonXContent.contentBuilder().map(getAsMap())) { + try ( + XContentParser parser = JsonXContent.jsonXContent.createParser( + NamedXContentRegistry.EMPTY, + LoggingDeprecationHandler.INSTANCE, + BytesReference.bytes(builder).streamInput() + ) + ) { + client.admin() + .indices() + .execute( + PutComponentTemplateAction.INSTANCE, + new PutComponentTemplateAction.Request(name).componentTemplate(ComponentTemplate.parse(parser)) + ) + .actionGet(); + } + } + } + + public Map getAsMap() { + return ImmutableMap.of("template", ImmutableMap.of("mappings", mapping.getAsMap())); + } +} diff --git a/src/integrationTest/java/org/opensearch/test/framework/TestData.java b/src/integrationTest/java/org/opensearch/test/framework/TestData.java index 606b56c834..1e4ae20667 100644 --- a/src/integrationTest/java/org/opensearch/test/framework/TestData.java +++ b/src/integrationTest/java/org/opensearch/test/framework/TestData.java @@ -11,6 +11,7 @@ package org.opensearch.test.framework; import java.nio.ByteBuffer; +import java.time.Instant; import java.util.ArrayList; import java.util.Base64; import java.util.Collections; @@ -18,6 +19,7 @@ import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Random; import java.util.Set; import java.util.UUID; @@ -27,13 +29,16 @@ import com.google.common.cache.Cache; import com.google.common.cache.CacheBuilder; +import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.opensearch.action.DocWriteRequest; import org.opensearch.action.admin.indices.create.CreateIndexRequest; import org.opensearch.action.admin.indices.refresh.RefreshRequest; +import org.opensearch.action.admin.indices.rollover.RolloverRequest; import org.opensearch.action.delete.DeleteRequest; import org.opensearch.action.index.IndexRequest; import org.opensearch.common.settings.Settings; @@ -88,6 +93,16 @@ public static TestData.Builder documentCount(int documentCount) { "attr_object.obj_attr_object.obj_obj_attr_text.keyword" ); + public static final ImmutableList DEPARTMENTS = ImmutableList.of( + "dept_a_1", + "dept_a_2", + "dept_a_3", + "dept_b_1", + "dept_b_2", + "dept_c", + "dept_d" + ); + private static final Cache cache; static { @@ -97,7 +112,6 @@ public static TestData.Builder documentCount(int documentCount) { private String[] ipAddresses; private String[] threeWordPhrases; - private String[] departments = new String[] { "dept_a_1", "dept_a_2", "dept_a_3", "dept_b_1", "dept_b_2", "dept_c", "dept_d" }; private int size; private int deletedDocumentCount; private int refreshAfter; @@ -106,45 +120,20 @@ public static TestData.Builder documentCount(int documentCount) { private Map> documentsByDepartment; private Set deletedDocuments; private long subRandomSeed; + private final String timestampColumn; - public TestData(int seed, int size, int deletedDocumentCount, int refreshAfter) { + public TestData(int seed, int size, int deletedDocumentCount, int refreshAfter, String timestampColumnName) { Random random = new Random(seed); this.ipAddresses = createRandomIpAddresses(random); this.threeWordPhrases = createRandomThreeWordPhrases(random); this.size = size; this.deletedDocumentCount = deletedDocumentCount; this.refreshAfter = refreshAfter; - // this.additionalAttributes = additionalAttributes; this.subRandomSeed = random.nextLong(); + this.timestampColumn = timestampColumnName; this.createTestDocuments(random); } - private TestData( - String[] ipAddresses, - String[] departments, - int size, - int deletedDocumentCount, - int refreshAfter, - Map allDocuments, - Map retainedDocuments, - Map> documentsByDepartment, - Set deletedDocuments, - long subRandomSeed - ) { - super(); - this.ipAddresses = ipAddresses; - this.departments = departments; - this.size = size; - this.deletedDocumentCount = deletedDocumentCount; - this.refreshAfter = refreshAfter; - this.allDocuments = allDocuments; - this.retainedDocuments = retainedDocuments; - this.documentsByDepartment = documentsByDepartment; - // this.additionalAttributes = additionalAttributes; - this.deletedDocuments = deletedDocuments; - this.subRandomSeed = subRandomSeed; - } - public void createIndex(Client client, String name, Settings settings) { log.info( "creating test index " @@ -220,6 +209,53 @@ public void createIndex(Client client, String name, Settings settings) { log.info("Test index creation finished after " + (System.currentTimeMillis() - start) + " ms"); } + public void putDocuments(Client client, String name, int rolloverAfter) { + try { + Random random = new Random(subRandomSeed); + long start = System.currentTimeMillis(); + + int nextRefresh = (int) Math.floor((random.nextGaussian() * 0.5 + 0.5) * refreshAfter); + int nextRollover = rolloverAfter != -1 ? rolloverAfter : Integer.MAX_VALUE; + int i = 0; + + for (Map.Entry entry : allDocuments.entrySet()) { + String id = entry.getKey(); + TestDocument document = entry.getValue(); + + client.index( + new IndexRequest(name).source(document.content, XContentType.JSON).id(id).opType(DocWriteRequest.OpType.CREATE) + ).actionGet(); + + if (i > nextRefresh) { + client.admin().indices().refresh(new RefreshRequest(name)).actionGet(); + double g = random.nextGaussian(); + + nextRefresh = (int) Math.floor((g * 0.5 + 1) * refreshAfter) + i + 1; + log.debug("refresh at " + i + " " + g + " " + (g * 0.5 + 1)); + } + + if (i > nextRollover) { + client.admin().indices().rolloverIndex(new RolloverRequest(name, null)); + + nextRollover += rolloverAfter; + } + + i++; + } + + client.admin().indices().refresh(new RefreshRequest(name)).actionGet(); + + for (String id : deletedDocuments) { + client.delete(new DeleteRequest(name, id)).actionGet(); + } + + client.admin().indices().refresh(new RefreshRequest(name)).actionGet(); + log.info("Test index creation finished after " + (System.currentTimeMillis() - start) + " ms"); + } catch (Exception e) { + throw new RuntimeException("Error while wring test documents to index " + name, e); + } + } + private void createTestDocuments(Random random) { Map allDocuments = new HashMap<>(size); @@ -365,6 +401,9 @@ private TestDocument randomDocument(Random random) { ImmutableMap.of("obj_obj_attr_text", "value_" + random.nextInt()) ) ); + if (timestampColumn != null) { + builder.put(timestampColumn, randomTimestamp(random)); + } return new TestDocument(randomId(random), builder.build()); } @@ -374,7 +413,12 @@ private String randomIpAddress(Random random) { } private String randomDepartmentName(Random random) { - return departments[random.nextInt(departments.length)]; + return DEPARTMENTS.get(random.nextInt(DEPARTMENTS.size())); + } + + private String randomTimestamp(Random random) { + long epochMillis = random.longs(1, -2857691960709L, 2857691960709L).findFirst().getAsLong(); + return Instant.ofEpochMilli(epochMillis).toString(); } private String randomThreeWordPhrase(Random random) { @@ -418,6 +462,10 @@ public int getDeletedDocumentCount() { return deletedDocumentCount; } + public Map getRetainedDocuments() { + return retainedDocuments; + } + public TestDocuments documents() { return new TestDocuments(this.retainedDocuments); } @@ -442,15 +490,16 @@ private static class Key { private final int size; private final int deletedDocumentCount; private final int refreshAfter; - // private final ImmutableMap additionalAttributes; + private final String timestampColumnName; - public Key(int seed, int size, int deletedDocumentCount, int refreshAfter) { + public Key(int seed, int size, int deletedDocumentCount, int refreshAfter, String timestampColumnName) { super(); this.seed = seed; this.size = size; this.deletedDocumentCount = deletedDocumentCount; this.refreshAfter = refreshAfter; // this.additionalAttributes = additionalAttributes; + this.timestampColumnName = timestampColumnName; } @Override @@ -461,6 +510,7 @@ public int hashCode() { result = prime * result + refreshAfter; result = prime * result + seed; result = prime * result + size; + result = prime * result + Objects.hashCode(timestampColumnName); return result; } @@ -488,6 +538,9 @@ public boolean equals(Object obj) { if (size != other.size) { return false; } + if (!Objects.equals(timestampColumnName, other.timestampColumnName)) { + return false; + } return true; } @@ -501,6 +554,7 @@ public static class Builder { private double deletedDocumentFraction = 0.06; private int refreshAfter = -1; private int segmentCount = 17; + private String timestampColumnName; public Builder() { super(); @@ -536,6 +590,11 @@ public Builder segmentCount(int segmentCount) { return this; } + public Builder timestampColumnName(String timestampColumnName) { + this.timestampColumnName = timestampColumnName; + return this; + } + public Key toKey() { if (deletedDocumentCount == -1) { this.deletedDocumentCount = (int) (this.size * deletedDocumentFraction); @@ -545,14 +604,14 @@ public Key toKey() { this.refreshAfter = this.size / this.segmentCount; } - return new Key(seed, size, deletedDocumentCount, refreshAfter); + return new Key(seed, size, deletedDocumentCount, refreshAfter, timestampColumnName); } public TestData get() { Key key = toKey(); try { - return cache.get(key, () -> new TestData(seed, size, deletedDocumentCount, refreshAfter)); + return cache.get(key, () -> new TestData(seed, size, deletedDocumentCount, refreshAfter, timestampColumnName)); } catch (ExecutionException e) { throw new RuntimeException(e); } @@ -641,6 +700,14 @@ public TestDocument get(String id) { public Set allIds() { return this.documents.keySet(); } + + public Map allDocs() { + ImmutableMap.Builder mapBuilder = ImmutableMap.builder(); + for (TestDocument testDocument : this.documents.values()) { + mapBuilder.put(testDocument.id, testDocument); + } + return mapBuilder.build(); + } } public static class TestDocument { @@ -756,22 +823,6 @@ public TestDocument withOnlyAttributes(String... attributes) { return new TestDocument(this.id, ImmutableMap.copyOf(newContent)); } - /* - public TestDocument withOnlyAttributes(Set attributes) { - Map newContent = new HashMap<>(); - for (String attri) - this.content.forEach((k, v) -> { - if (k.contains(".")) { - addAttributesRecursively(this.content, newContent, k.split("\\."), 0); - } else { - if (attributes.contains(k)) { - newContent.put(k, v); - } - } - }); - - } - */ public TestDocument applyTransform(DocumentTransformer transformerFunction) { return transformerFunction.transform(this); } diff --git a/src/integrationTest/java/org/opensearch/test/framework/TestDataStream.java b/src/integrationTest/java/org/opensearch/test/framework/TestDataStream.java new file mode 100644 index 0000000000..26e5d6d1b2 --- /dev/null +++ b/src/integrationTest/java/org/opensearch/test/framework/TestDataStream.java @@ -0,0 +1,127 @@ +/* + * 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. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.test.framework; + +import java.util.Map; +import java.util.Set; + +import org.opensearch.action.admin.indices.datastream.CreateDataStreamAction; +import org.opensearch.action.admin.indices.datastream.DeleteDataStreamAction; +import org.opensearch.transport.client.Client; + +public class TestDataStream implements TestIndexOrAliasOrDatastream { + + private final String name; + private final TestData testData; + private final int rolloverAfter; + + public TestDataStream(String name, TestData testData, int rolloverAfter) { + this.name = name; + this.testData = testData; + this.rolloverAfter = rolloverAfter; + } + + @Override + public void create(Client client) { + client.admin().indices().createDataStream(new CreateDataStreamAction.Request(name)).actionGet(); + testData.putDocuments(client, name, rolloverAfter); + } + + @Override + public void delete(Client client) { + client.admin().indices().deleteDataStream(new DeleteDataStreamAction.Request(new String[] { name })).actionGet(); + } + + public String name() { + return name; + } + + public TestData testData() { + return testData; + } + + public static Builder name(String name) { + return new Builder().name(name); + } + + @Override + public String toString() { + return "Test data stream '" + name + '\''; + } + + public static class Builder { + private String name; + private final TestData.Builder testDataBuilder = new TestData.Builder().timestampColumnName("@timestamp") + .deletedDocumentFraction(0); + private TestData testData; + private int rolloverAfter = -1; + + public Builder name(String name) { + this.name = name; + return this; + } + + public Builder data(TestData data) { + this.testData = data; + return this; + } + + public Builder seed(int seed) { + testDataBuilder.seed(seed); + return this; + } + + public Builder documentCount(int size) { + testDataBuilder.documentCount(size); + return this; + } + + public Builder refreshAfter(int refreshAfter) { + testDataBuilder.refreshAfter(refreshAfter); + return this; + } + + public Builder rolloverAfter(int rolloverAfter) { + this.rolloverAfter = rolloverAfter; + return this; + } + + public Builder segmentCount(int segmentCount) { + testDataBuilder.segmentCount(segmentCount); + return this; + } + + /*public Builder attr(String name, Object value) { + testDataBuilder.attr(name, value); + return this; + }*/ + + public TestDataStream build() { + if (testData == null) { + testData = testDataBuilder.get(); + } + + return new TestDataStream(name, testData, rolloverAfter); + } + } + + @Override + public Set documentIds() { + return testData().getRetainedDocuments().keySet(); + } + + @Override + public Map documents() { + return testData().getRetainedDocuments(); + } + +} diff --git a/src/integrationTest/java/org/opensearch/test/framework/TestIndex.java b/src/integrationTest/java/org/opensearch/test/framework/TestIndex.java index 734e0b5333..90f4d1bf14 100644 --- a/src/integrationTest/java/org/opensearch/test/framework/TestIndex.java +++ b/src/integrationTest/java/org/opensearch/test/framework/TestIndex.java @@ -1,38 +1,43 @@ /* -* Copyright 2021-2022 floragunn GmbH -* -* Licensed under the Apache License, Version 2.0 (the "License"); -* you may not use this file except in compliance with the License. -* You may obtain a copy of the License at -* -* http://www.apache.org/licenses/LICENSE-2.0 -* -* Unless required by applicable law or agreed to in writing, software -* distributed under the License is distributed on an "AS IS" BASIS, -* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -* See the License for the specific language governing permissions and -* limitations under the License. -* -*/ + * Copyright 2021-2022 floragunn GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ /* -* 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. -* -* Modifications Copyright OpenSearch Contributors. See -* GitHub history for details. -*/ + * 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. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ package org.opensearch.test.framework; +import java.util.Map; +import java.util.Set; + import org.opensearch.action.admin.indices.create.CreateIndexRequest; +import org.opensearch.action.admin.indices.delete.DeleteIndexRequest; import org.opensearch.common.settings.Settings; +import org.opensearch.index.IndexNotFoundException; import org.opensearch.transport.client.Client; -public class TestIndex { +public class TestIndex implements TestIndexOrAliasOrDatastream { private final String name; private final Settings settings; @@ -44,6 +49,7 @@ public TestIndex(String name, Settings settings, TestData testData) { this.testData = testData; } + @Override public void create(Client client) { if (testData != null) { testData.createIndex(client, name, settings); @@ -52,10 +58,34 @@ public void create(Client client) { } } + @Override + public void delete(Client client) { + try { + client.admin().indices().delete(new DeleteIndexRequest(name)).actionGet(); + } catch (IndexNotFoundException e) { + // It is fine if the object to be deleted does not exist + } + } + + @Override public String name() { return name; } + @Override + public Set documentIds() { + return testData.documents().allIds(); + } + + @Override + public Map documents() { + return testData.documents().allDocs(); + } + + public TestData.TestDocument anyDocument() { + return testData.anyDocument(); + } + public static Builder name(String name) { return new Builder().name(name); } @@ -63,6 +93,7 @@ public static Builder name(String name) { public static class Builder { private String name; private Settings.Builder settings = Settings.builder(); + private TestData.Builder testDataBuilder = new TestData.Builder(); private TestData testData; public Builder name(String name) { @@ -80,15 +111,70 @@ public Builder shards(int value) { return this; } + public Builder hidden() { + settings.put("index.hidden", true); + return this; + } + public Builder data(TestData testData) { this.testData = testData; return this; } + public Builder seed(int seed) { + testDataBuilder.seed(seed); + return this; + } + + public Builder documentCount(int size) { + testDataBuilder.documentCount(size); + return this; + } + public TestIndex build() { + if (testData == null) { + testData = testDataBuilder.get(); + } + return new TestIndex(name, settings.build(), testData); } } + /** + * This returns a magic TestIndexLike object symbolizing the internal OpenSearch security + * config index. This is supposed to be used with the IndexApiResponseMatchers. + */ + public static TestIndexOrAliasOrDatastream openSearchSecurityConfigIndex() { + return OPEN_SEARCH_SECURITY_CONFIG_INDEX; + } + + private final static TestIndexOrAliasOrDatastream OPEN_SEARCH_SECURITY_CONFIG_INDEX = new TestIndexOrAliasOrDatastream() { + + @Override + public String name() { + return ".opendistro_security"; + } + + @Override + public Map documents() { + return null; + } + + @Override + public void create(Client client) { + throw new UnsupportedOperationException(); + } + + @Override + public void delete(Client client) { + throw new UnsupportedOperationException(); + } + + @Override + public Set documentIds() { + return null; + } + }; + } diff --git a/src/integrationTest/java/org/opensearch/test/framework/TestIndexOrAliasOrDatastream.java b/src/integrationTest/java/org/opensearch/test/framework/TestIndexOrAliasOrDatastream.java new file mode 100644 index 0000000000..de4aa06e9f --- /dev/null +++ b/src/integrationTest/java/org/opensearch/test/framework/TestIndexOrAliasOrDatastream.java @@ -0,0 +1,58 @@ +/* + * 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. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.test.framework; + +import java.util.Map; +import java.util.Set; + +import org.opensearch.test.framework.cluster.LocalCluster; +import org.opensearch.transport.client.Client; + +public interface TestIndexOrAliasOrDatastream { + String name(); + + Set documentIds(); + + Map documents(); + + void create(Client client); + + void delete(Client client); + + default TestIndexOrAliasOrDatastream intersection(TestIndexOrAliasOrDatastream other) { + if (other == this) { + return this; + } + + if (!this.name().equals(other.name())) { + throw new IllegalArgumentException("Cannot intersect different indices: " + this + " vs " + other); + } + + return this; + } + + static void createInitialTestObjects(LocalCluster cluster, TestIndexOrAliasOrDatastream... testIndexLikeArray) { + try (Client client = cluster.getInternalNodeClient()) { + for (TestIndexOrAliasOrDatastream testIndexLike : testIndexLikeArray) { + testIndexLike.create(client); + } + } + } + + static void delete(LocalCluster cluster, TestIndexOrAliasOrDatastream... testIndexLikeArray) { + try (Client client = cluster.getInternalNodeClient()) { + for (TestIndexOrAliasOrDatastream testIndexLike : testIndexLikeArray) { + testIndexLike.delete(client); + } + } + } +} diff --git a/src/integrationTest/java/org/opensearch/test/framework/TestIndexTemplate.java b/src/integrationTest/java/org/opensearch/test/framework/TestIndexTemplate.java new file mode 100644 index 0000000000..675638bb8b --- /dev/null +++ b/src/integrationTest/java/org/opensearch/test/framework/TestIndexTemplate.java @@ -0,0 +1,107 @@ +/* + * 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. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.test.framework; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; + +import org.opensearch.action.admin.indices.template.put.PutComposableIndexTemplateAction; +import org.opensearch.cluster.metadata.ComposableIndexTemplate; +import org.opensearch.common.xcontent.LoggingDeprecationHandler; +import org.opensearch.common.xcontent.json.JsonXContent; +import org.opensearch.core.common.bytes.BytesReference; +import org.opensearch.core.xcontent.NamedXContentRegistry; +import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.core.xcontent.XContentParser; +import org.opensearch.transport.client.Client; + +public class TestIndexTemplate { + public static final TestIndexTemplate DATA_STREAM_MINIMAL = new TestIndexTemplate("test_index_template_data_stream_minimal", "ds_*") + .dataStream() + .composedOf(TestComponentTemplate.DATA_STREAM_MINIMAL); + + private final String name; + private final ImmutableList indexPatterns; + private Object dataStream; + private ImmutableList composedOf = ImmutableList.of(); + private int priority = 0; + + public TestIndexTemplate(String name, String... indexPatterns) { + this.name = name; + this.indexPatterns = ImmutableList.copyOf(indexPatterns); + } + + public TestIndexTemplate dataStream() { + this.dataStream = ImmutableMap.of(); + return this; + } + + public TestIndexTemplate dataStream(String k, Object v) { + this.dataStream = ImmutableMap.of(k, v); + return this; + } + + public TestIndexTemplate composedOf(TestComponentTemplate... composedOf) { + this.composedOf = ImmutableList.copyOf(composedOf); + return this; + } + + public TestIndexTemplate priority(int priority) { + this.priority = priority; + return this; + } + + public String getName() { + return name; + } + + public List getComposedOf() { + return composedOf; + } + + public void create(Client client) throws Exception { + try (XContentBuilder builder = JsonXContent.contentBuilder().map(getAsMap())) { + try ( + XContentParser parser = JsonXContent.jsonXContent.createParser( + NamedXContentRegistry.EMPTY, + LoggingDeprecationHandler.INSTANCE, + BytesReference.bytes(builder).streamInput() + ) + ) { + client.admin() + .indices() + .execute( + PutComposableIndexTemplateAction.INSTANCE, + new PutComposableIndexTemplateAction.Request(name).indexTemplate(ComposableIndexTemplate.parse(parser)) + ) + .actionGet(); + } + } + } + + public Map getAsMap() { + return ImmutableMap.of( + "index_patterns", + indexPatterns, + "priority", + priority, + "data_stream", + dataStream, + "composed_of", + composedOf.stream().map(TestComponentTemplate::getName).collect(Collectors.toList()) + ); + } +} diff --git a/src/integrationTest/java/org/opensearch/test/framework/TestMapping.java b/src/integrationTest/java/org/opensearch/test/framework/TestMapping.java new file mode 100644 index 0000000000..ba0ec95d0a --- /dev/null +++ b/src/integrationTest/java/org/opensearch/test/framework/TestMapping.java @@ -0,0 +1,52 @@ +/* + * 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. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.test.framework; + +import java.util.Map; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; + +public class TestMapping { + + private final ImmutableMap properties; + + public TestMapping(Property... properties) { + this.properties = ImmutableMap.copyOf( + ImmutableList.copyOf(properties).stream().collect(ImmutableMap.toImmutableMap(Property::getName, Property::getAsMap)) + ); + } + + public Map getAsMap() { + return ImmutableMap.of("properties", this.properties); + } + + public static class Property { + final String name; + final String type; + final String format; + + public Property(String name, String type, String format) { + this.name = name; + this.type = type; + this.format = format; + } + + public String getName() { + return name; + } + + public Map getAsMap() { + return ImmutableMap.of("type", type, "format", format); + } + } +} diff --git a/src/integrationTest/java/org/opensearch/test/framework/TestSecurityConfig.java b/src/integrationTest/java/org/opensearch/test/framework/TestSecurityConfig.java index d58070ab45..0aa14cccfd 100644 --- a/src/integrationTest/java/org/opensearch/test/framework/TestSecurityConfig.java +++ b/src/integrationTest/java/org/opensearch/test/framework/TestSecurityConfig.java @@ -35,6 +35,7 @@ import java.util.Arrays; import java.util.Collections; import java.util.HashMap; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -72,6 +73,7 @@ import org.opensearch.security.securityconf.impl.v7.RoleV7; import org.opensearch.security.support.ConfigConstants; import org.opensearch.test.framework.cluster.OpenSearchClientProvider.UserCredentialsHolder; +import org.opensearch.test.framework.matcher.IndexApiResponseMatchers; import org.opensearch.transport.client.Client; import static org.apache.http.HttpHeaders.AUTHORIZATION; @@ -139,6 +141,11 @@ public TestSecurityConfig doNotFailOnForbidden(boolean doNotFailOnForbidden) { return this; } + public TestSecurityConfig privilegesEvaluationType(String privilegesEvaluationType) { + config.privilegesEvaluationType(privilegesEvaluationType); + return this; + } + public TestSecurityConfig xff(XffConfig xffConfig) { config.xffConfig(xffConfig); return this; @@ -161,11 +168,7 @@ public TestSecurityConfig authz(AuthzDomain authzDomain) { public TestSecurityConfig user(User user) { this.internalUsers.put(user.name, user); - - for (Role role : user.roles) { - this.roles.put(role.name, role); - } - + // The user's roles will be collected by aggregateRoles() when the configuration is written return this; } @@ -197,6 +200,7 @@ public TestSecurityConfig roles(Role... roles) { if (this.roles.containsKey(role.name)) { throw new IllegalStateException("Role with name " + role.name + " is already defined"); } + role.addedIndependentlyOfUser = true; this.roles.put(role.name, role); } @@ -263,6 +267,7 @@ public static class Config implements ToXContentObject { private boolean anonymousAuth; private Boolean doNotFailOnForbidden; + private String privilegesEvaluationType; private XffConfig xffConfig; private OnBehalfOfConfig onBehalfOfConfig; private Map authcDomainMap = new LinkedHashMap<>(); @@ -280,6 +285,11 @@ public Config doNotFailOnForbidden(Boolean doNotFailOnForbidden) { return this; } + public Config privilegesEvaluationType(String privilegesEvaluationType) { + this.privilegesEvaluationType = privilegesEvaluationType; + return this; + } + public Config xffConfig(XffConfig xffConfig) { this.xffConfig = xffConfig; return this; @@ -325,7 +335,9 @@ public XContentBuilder toXContent(XContentBuilder xContentBuilder, Params params if (doNotFailOnForbidden != null) { xContentBuilder.field("do_not_fail_on_forbidden", doNotFailOnForbidden); } - + if (privilegesEvaluationType != null) { + xContentBuilder.field("privileges_evaluation_type", privilegesEvaluationType); + } xContentBuilder.field("authc", authcDomainMap); if (authzDomainMap.isEmpty() == false) { xContentBuilder.field("authz", authzDomainMap); @@ -456,9 +468,15 @@ public static final class User implements UserCredentialsHolder, ToXContentObjec private String password; List roles = new ArrayList<>(); List backendRoles = new ArrayList<>(); + /** + * This will be initialized by aggregateRoles() + */ + Set roleNames; String requestedTenant; private Map attributes = new HashMap<>(); private Map, Object> matchers = new HashMap<>(); + private Map indexMatchers = new HashMap<>(); + private boolean adminCertUser = false; private Boolean hidden = null; @@ -484,11 +502,7 @@ public User password(String password) { } public User roles(Role... roles) { - // We scope the role names by user to keep tests free of potential side effects - String roleNamePrefix = "user_" + this.getName() + "__"; - this.roles.addAll( - Arrays.asList(roles).stream().map((r) -> r.clone().name(roleNamePrefix + r.getName())).collect(Collectors.toSet()) - ); + this.roles.addAll(Arrays.asList(roles)); return this; } @@ -512,6 +526,21 @@ public User attr(String key, String value) { return this; } + public User indexMatcher(String key, IndexApiResponseMatchers.IndexMatcher indexMatcher) { + this.indexMatchers.put(key, indexMatcher); + return this; + } + + public IndexApiResponseMatchers.IndexMatcher indexMatcher(String key) { + IndexApiResponseMatchers.IndexMatcher result = this.indexMatchers.get(key); + + if (result != null) { + return result; + } else { + throw new RuntimeException("Unknown index matcher " + key + " in user " + this.name); + } + } + public User hash(String hash) { this.hash = hash; return this; @@ -535,7 +564,21 @@ public String getPassword() { } public Set getRoleNames() { - return roles.stream().map(Role::getName).collect(Collectors.toSet()); + return roleNames; + } + + public String getDescription() { + return description; + } + + @Override + public boolean isAdminCertUser() { + return adminCertUser; + } + + public User adminCertUser() { + this.adminCertUser = true; + return this; } public Object getAttribute(String attributeName) { @@ -640,6 +683,12 @@ public static class Role implements ToXContentObject { private String description; + /** + * This will be set to true, if this was added using the roles() method on TestSecurityConfig. + * Then, we will consider this a role which is shared between users and we won't scope its name. + */ + private boolean addedIndependentlyOfUser = false; + public Role(String name) { this(name, null); } @@ -1078,7 +1127,7 @@ public void initIndex(Client client) { if (auditConfiguration != null) { writeSingleEntryConfigToIndex(client, CType.AUDIT, "config", auditConfiguration); } - writeConfigToIndex(client, CType.ROLES, roles); + writeConfigToIndex(client, CType.ROLES, aggregateRoles()); writeConfigToIndex(client, CType.INTERNALUSERS, internalUsers); writeConfigToIndex(client, CType.ROLESMAPPING, rolesMapping); writeConfigToIndex(client, CType.ACTIONGROUPS, actionGroups); @@ -1090,7 +1139,36 @@ public void initIndex(Client client) { writeConfigToIndex(client, entry.getKey(), entry.getValue()); } } + } + + /** + * Merges the globally defined roles with the roles defined by user. Roles defined by user will be scoped + * so that user definitions cannot interfere with others. + */ + private Map aggregateRoles() { + Map result = new HashMap<>(this.roles); + + for (User user : this.internalUsers.values()) { + if (user.roleNames == null) { + user.roleNames = new HashSet<>(); + } + + for (Role role : user.roles) { + if (role.addedIndependentlyOfUser) { + // This is a globally defined role, we just use this + user.roleNames.add(role.name); + } else { + // This is role that is locally defined for the user; let's scope the name + if (!role.name.startsWith("user_" + user.name)) { + role.name = "user_" + user.name + "__" + role.name; + } + user.roleNames.add(role.name); + result.put(role.name, role); + } + } + } + return result; } public void updateInternalUsersConfiguration(Client client, List users) { diff --git a/src/integrationTest/java/org/opensearch/test/framework/cluster/LocalCluster.java b/src/integrationTest/java/org/opensearch/test/framework/cluster/LocalCluster.java index c1e6fca059..e19cfd693e 100644 --- a/src/integrationTest/java/org/opensearch/test/framework/cluster/LocalCluster.java +++ b/src/integrationTest/java/org/opensearch/test/framework/cluster/LocalCluster.java @@ -44,6 +44,7 @@ import org.apache.logging.log4j.Logger; import org.junit.rules.ExternalResource; +import org.opensearch.action.admin.cluster.repositories.put.PutRepositoryRequest; import org.opensearch.common.settings.Settings; import org.opensearch.node.PluginAwareNode; import org.opensearch.plugins.Plugin; @@ -57,7 +58,11 @@ import org.opensearch.test.framework.AuthFailureListeners; import org.opensearch.test.framework.AuthzDomain; import org.opensearch.test.framework.OnBehalfOfConfig; +import org.opensearch.test.framework.TestAlias; +import org.opensearch.test.framework.TestComponentTemplate; +import org.opensearch.test.framework.TestDataStream; import org.opensearch.test.framework.TestIndex; +import org.opensearch.test.framework.TestIndexTemplate; import org.opensearch.test.framework.TestSecurityConfig; import org.opensearch.test.framework.TestSecurityConfig.Role; import org.opensearch.test.framework.XffConfig; @@ -98,6 +103,11 @@ public class LocalCluster extends ExternalResource implements AutoCloseable, Ope private final Map remotes; private volatile LocalOpenSearchCluster localOpenSearchCluster; private final List testIndices; + private final List testAliases; + private final List testDataStreams; + private final List testComponentTemplates; + private final List testIndexTemplates; + private final List testSnapshotRepositories; private boolean loadConfigurationIntoIndex; @@ -114,6 +124,11 @@ private LocalCluster( List clusterDependencies, Map remotes, List testIndices, + List testAliases, + List testDataStreams, + List testComponentTemplates, + List testIndexTemplates, + List testSnapshotRepositories, boolean loadConfigurationIntoIndex, String defaultConfigurationInitDirectory, Integer expectedNodeStartupCount @@ -131,6 +146,11 @@ private LocalCluster( this.remotes = remotes; this.clusterDependencies = clusterDependencies; this.testIndices = testIndices; + this.testAliases = testAliases; + this.testDataStreams = testDataStreams; + this.testComponentTemplates = testComponentTemplates; + this.testIndexTemplates = testIndexTemplates; + this.testSnapshotRepositories = testSnapshotRepositories; this.loadConfigurationIntoIndex = loadConfigurationIntoIndex; if (StringUtils.isNoneBlank(defaultConfigurationInitDirectory)) { System.setProperty(INIT_CONFIGURATION_DIR, defaultConfigurationInitDirectory); @@ -263,11 +283,29 @@ private void start() { } try (Client client = getInternalNodeClient()) { + for (TestComponentTemplate testComponentTemplate : this.testComponentTemplates) { + testComponentTemplate.create(client); + } + for (TestIndexTemplate indexTemplate : this.testIndexTemplates) { + indexTemplate.create(client); + } + for (TestIndex index : this.testIndices) { index.create(client); } - } + for (TestDataStream dataStream : this.testDataStreams) { + dataStream.create(client); + } + + for (TestAlias alias : this.testAliases) { + alias.create(client); + } + + for (String snapshotRepository : this.testSnapshotRepositories) { + createSnapshotRepository(client, snapshotRepository); + } + } } catch (Exception e) { log.error("Local ES cluster start failed", e); throw new RuntimeException(e); @@ -319,6 +357,13 @@ public void triggerConfigurationReloadForCTypes(Client client, List cType } } + private void createSnapshotRepository(Client client, String snapshotRepository) { + client.admin() + .cluster() + .putRepository(new PutRepositoryRequest(snapshotRepository).type("fs").settings(Map.of("location", getSnapshotDirPath()))) + .actionGet(); + } + public CertificateData getAdminCertificate() { return testCertificates.getAdminCertificateData(); } @@ -335,6 +380,11 @@ public static class Builder { private Map remoteClusters = new HashMap<>(); private List clusterDependencies = new ArrayList<>(); private List testIndices = new ArrayList<>(); + private List testAliases = new ArrayList<>(); + private List testDataStreams = new ArrayList<>(); + private List testIndexTemplates = new ArrayList<>(); + private List testComponentTemplates = new ArrayList<>(); + private List testSnapshotRepositories = new ArrayList<>(); private ClusterManager clusterManager = ClusterManager.DEFAULT; private TestSecurityConfig testSecurityConfig = new TestSecurityConfig(); private String clusterName = "local_cluster"; @@ -472,6 +522,33 @@ public Builder indices(Collection indices) { return this; } + public Builder aliases(TestAlias... aliases) { + this.testAliases.addAll(Arrays.asList(aliases)); + return this; + } + + public Builder dataStreams(TestDataStream... dataStreams) { + this.testDataStreams.addAll(Arrays.asList(dataStreams)); + return this; + } + + public Builder indexTemplates(TestIndexTemplate... indexTemplates) { + for (TestIndexTemplate indexTemplate : indexTemplates) { + this.testIndexTemplates.add(indexTemplate); + for (TestComponentTemplate testComponentTemplate : indexTemplate.getComposedOf()) { + if (!this.testComponentTemplates.contains(testComponentTemplate)) { + this.testComponentTemplates.add(testComponentTemplate); + } + } + } + return this; + } + + public Builder snapshotRepositories(String... repositoryNames) { + this.testSnapshotRepositories.addAll(Arrays.asList(repositoryNames)); + return this; + } + public Builder users(TestSecurityConfig.User... users) { return this.users(Arrays.asList(users)); } @@ -581,6 +658,11 @@ public Builder doNotFailOnForbidden(boolean doNotFailOnForbidden) { return this; } + public Builder privilegesEvaluationType(String privilegesEvaluationType) { + testSecurityConfig.privilegesEvaluationType(privilegesEvaluationType); + return this; + } + public Builder defaultConfigurationInitDirectory(String defaultConfigurationInitDirectory) { this.defaultConfigurationInitDirectory = defaultConfigurationInitDirectory; return this; @@ -610,6 +692,11 @@ public LocalCluster build() { clusterDependencies, remoteClusters, testIndices, + testAliases, + testDataStreams, + testComponentTemplates, + testIndexTemplates, + testSnapshotRepositories, loadConfigurationIntoIndex, defaultConfigurationInitDirectory, expectedNodeStartupCount diff --git a/src/integrationTest/java/org/opensearch/test/framework/cluster/OpenSearchClientProvider.java b/src/integrationTest/java/org/opensearch/test/framework/cluster/OpenSearchClientProvider.java index 131ff65615..5c3340ff2a 100644 --- a/src/integrationTest/java/org/opensearch/test/framework/cluster/OpenSearchClientProvider.java +++ b/src/integrationTest/java/org/opensearch/test/framework/cluster/OpenSearchClientProvider.java @@ -105,9 +105,16 @@ default TestRestClient getRestClient(UserCredentialsHolder user, CertificateData } default TestRestClient getRestClient(UserCredentialsHolder user, Header... headers) { + if (user.isAdminCertUser()) { + return getRestClient(getTestCertificates().getAdminCertificateData()); + } return getRestClient(user.getName(), user.getPassword(), null, headers); } + default TestRestClient getAdminCertRestClient() { + return getRestClient(getTestCertificates().getAdminCertificateData()); + } + default RestHighLevelClient getRestHighLevelClient(String username, String password, Header... headers) { return getRestHighLevelClient(new UserCredentialsHolder() { @Override @@ -298,6 +305,10 @@ public interface UserCredentialsHolder { String getName(); String getPassword(); + + default boolean isAdminCertUser() { + return false; + } } } diff --git a/src/integrationTest/java/org/opensearch/test/framework/cluster/TestRestClient.java b/src/integrationTest/java/org/opensearch/test/framework/cluster/TestRestClient.java index fd4acbfb76..bc279c6c95 100644 --- a/src/integrationTest/java/org/opensearch/test/framework/cluster/TestRestClient.java +++ b/src/integrationTest/java/org/opensearch/test/framework/cluster/TestRestClient.java @@ -35,6 +35,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; @@ -59,6 +60,7 @@ import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; import org.apache.hc.client5.http.impl.classic.CloseableHttpResponse; import org.apache.hc.client5.http.routing.HttpRoutePlanner; +import org.apache.hc.core5.http.ContentType; import org.apache.hc.core5.http.Header; import org.apache.hc.core5.http.HttpEntity; import org.apache.hc.core5.http.io.entity.StringEntity; @@ -72,9 +74,13 @@ import org.opensearch.core.xcontent.ToXContentObject; import org.opensearch.security.DefaultObjectMapper; +import com.nimbusds.jose.shaded.gson.Gson; + import static java.lang.String.format; import static java.util.Objects.requireNonNull; import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.anyOf; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.not; import static org.hamcrest.Matchers.notNullValue; @@ -120,6 +126,12 @@ public HttpResponse get(String path, Header... headers) { return executeRequest(new HttpGet(getHttpServerUri() + "/" + path), headers); } + public HttpResponse get(String path, HttpEntity entity, Header... headers) { + HttpGet uriRequest = new HttpGet(getHttpServerUri() + "/" + path); + uriRequest.setEntity(entity); + return executeRequest(uriRequest, headers); + } + public HttpResponse getWithoutLeadingSlash(String path, Header... headers) { HttpUriRequest req = new HttpGet(getHttpServerUri()); req.setPath(path); @@ -184,10 +196,22 @@ public HttpResponse put(String path) { return executeRequest(uriRequest); } + public HttpResponse put(String path, HttpEntity entity, Header... headers) { + HttpPut uriRequest = new HttpPut(getHttpServerUri() + "/" + path); + uriRequest.setEntity(entity); + return executeRequest(uriRequest, headers); + } + public HttpResponse delete(String path, Header... headers) { return executeRequest(new HttpDelete(getHttpServerUri() + "/" + path), headers); } + public HttpResponse delete(String path, HttpEntity entity, Header... headers) { + HttpDelete uriRequest = new HttpDelete(getHttpServerUri() + "/" + path); + uriRequest.setEntity(entity); + return executeRequest(uriRequest, headers); + } + public HttpResponse postJson(String path, String body, Header... headers) { HttpPost uriRequest = new HttpPost(getHttpServerUri() + "/" + path); uriRequest.setEntity(new StringEntity(body)); @@ -203,6 +227,12 @@ public HttpResponse post(String path) { return executeRequest(uriRequest); } + public HttpResponse post(String path, HttpEntity entity, Header... headers) { + HttpPost uriRequest = new HttpPost(getHttpServerUri() + "/" + path); + uriRequest.setEntity(entity); + return executeRequest(uriRequest, headers); + } + public HttpResponse patch(String path, ToXContentObject body) { return patch(path, Strings.toString(XContentType.JSON, body)); } @@ -315,12 +345,12 @@ public HttpResponse(CloseableHttpResponse inner) throws IllegalStateException, I private void verifyContentType() { final String contentType = this.getHeader(HttpHeaders.CONTENT_TYPE).getValue(); if (contentType.contains("application/json")) { - assertThat("Response body format was not json, body: " + body, body.charAt(0), equalTo('{')); + assertThat("Response body format was not json, body: " + body, body.charAt(0), anyOf(equalTo('{'), equalTo('['))); } else { assertThat( "Response body format was json, whereas content-type was " + contentType + ", body: " + body, body.charAt(0), - not(equalTo('{')) + allOf(not(equalTo('{')), not(equalTo('['))) ); } @@ -495,4 +525,14 @@ public void close() { // TODO: Is there anything to clean up here? } + public static HttpEntity json(Object... attributes) { + Map map = new HashMap<>(); + + for (int i = 0; i < attributes.length - 1; i += 2) { + map.put(attributes[i].toString(), attributes[i + 1]); + } + + return new StringEntity(new Gson().toJson(map), ContentType.APPLICATION_JSON); + } + } diff --git a/src/integrationTest/java/org/opensearch/test/framework/matcher/IndexApiResponseMatchers.java b/src/integrationTest/java/org/opensearch/test/framework/matcher/IndexApiResponseMatchers.java new file mode 100644 index 0000000000..6280844943 --- /dev/null +++ b/src/integrationTest/java/org/opensearch/test/framework/matcher/IndexApiResponseMatchers.java @@ -0,0 +1,709 @@ +/* + * 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. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.test.framework.matcher; + +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import com.google.common.collect.ImmutableSet; +import com.google.common.collect.Sets; +import com.fasterxml.jackson.core.JsonProcessingException; +import org.hamcrest.Description; +import org.hamcrest.DiagnosingMatcher; +import org.hamcrest.Matcher; + +import org.opensearch.security.DefaultObjectMapper; +import org.opensearch.test.framework.TestIndex; +import org.opensearch.test.framework.TestIndexOrAliasOrDatastream; +import org.opensearch.test.framework.cluster.TestRestClient; + +import com.jayway.jsonpath.Configuration; +import com.jayway.jsonpath.JsonPath; +import com.jayway.jsonpath.Option; +import com.jayway.jsonpath.spi.json.JacksonJsonProvider; +import com.jayway.jsonpath.spi.mapper.JacksonMappingProvider; + +import static com.fasterxml.jackson.core.JsonToken.START_ARRAY; + +/** + * This class provides Hamcrest matchers that can be used as test oracles on the HTTP responses of index REST APIs. + *

+ * On a high level, the idea behind this class is like this: + *

    + *
  • Test users can be associated with IndexMatcher instances via the TestSecurityConfig.User.indexMatcher() method. These define the maximum index space the user can operate on. There may be several index matchers per user, targeting different groups of operations.
  • + *
  • The results of REST API calls can be also associated with a maximum space of indices the operation could work on. Combined with the user specific index matcher, one can determine the intersection of the allowed indices and thus the indices that are allowed in the particular case. The matchers support JSON path expressions to extract information on indices from the HTTP response bodies. See IndexAuthorizationReadOnlyIntTests for examples.
  • + *
+ */ +public class IndexApiResponseMatchers { + + /** + * Matchers that are directly used on HTTP responses + */ + public interface OnResponseIndexMatcher extends IndexMatcher { + + /** + * Retrieves the actual indices from the HTTP response JSON body using this JSON path expression. + * If you are asserting on an HTTP response, specifying a JSON path is madatory. + */ + OnResponseIndexMatcher at(String jsonPath); + + /** + * Calculates the intersection of this index matcher and the given other index matcher. + * If this index matcher expects the indices a,b,c and the other index matcher expects b,c,d, + * the resulting matcher will expect b,c. + */ + OnResponseIndexMatcher reducedBy(IndexMatcher other); + + /** + * Asserts on a specific HTTP status code if the set of indices expected by this matcher is empty. + */ + OnResponseIndexMatcher whenEmpty(RestMatchers.HttpResponseMatcher statusCode); + + /** + * Checks whether the indices of this matcher are a subset of the other index matcher. + * If that is not the case, the given HTTP error will be expected in the response on which we are asserting. + */ + OnResponseIndexMatcher butFailIfIncomplete(IndexMatcher other, RestMatchers.HttpResponseMatcher statusCode); + + default IndexMatcher butForbiddenIfIncomplete(IndexMatcher other) { + return butFailIfIncomplete(other, RestMatchers.isForbidden()); + } + + /** + * Asserts that a TestRestClient.HttpResponse object refers exactly to a specific set of indices. + *

+ * Use this matcher like this: + *

+         *     assertThat(httpResponse, containsExactly(index_a1, index_a2).at("hits.hits[*]._index"))
+         * 
+ * This will verify that the HTTP response lists the indices index_a1 and index_a2 at the place specified by the JSON path query. + *

+ * It is possible to reduce the expected indices based on a test user this way: + *

+         *     assertThat(httpResponse, containsExactly(index_a1, index_a2).at("hits.hits[*]._index").reducedBy(user.inderMatcher("search"))
+         * 
+ * This will calculate the intersection of the indices specified here and of the indices specified with the user index matcher. + * The existence of exactly these indices will be asserted. + *

+ * This method has the special feature that you can also specify data streams; it will then assert that + * the backing indices of the data streams will be present in the result set. + */ + static ContainsExactlyMatcher containsExactly(TestIndexOrAliasOrDatastream... testIndices) { + return containsExactly(Arrays.asList(testIndices)); + } + + static ContainsExactlyMatcher containsExactly(Collection testIndices) { + Map indexNameMap = new HashMap<>(); + boolean containsOpenSearchIndices = false; + + for (TestIndexOrAliasOrDatastream testIndex : testIndices) { + if (testIndex == TestIndex.openSearchSecurityConfigIndex()) { + containsOpenSearchIndices = true; + } else { + indexNameMap.put(testIndex.name(), testIndex); + } + } + + return new ContainsExactlyMatcher(indexNameMap, containsOpenSearchIndices); + } + } + + /** + * Matchers that are associated with TestSecurityConfig.User objects via the indexMatcher() method + */ + public interface OnUserIndexMatcher extends IndexMatcher { + + static OnUserIndexMatcher limitedTo(TestIndexOrAliasOrDatastream... testIndices) { + return limitedTo(Arrays.asList(testIndices)); + } + + static OnUserIndexMatcher limitedTo(Collection testIndices) { + Map indexNameMap = new HashMap<>(); + + for (TestIndexOrAliasOrDatastream testIndex : testIndices) { + indexNameMap.put(testIndex.name(), testIndex); + } + + return new LimitedToMatcher(indexNameMap); + } + + static IndexMatcher unlimited() { + return new UnlimitedMatcher(); + } + + static IndexMatcher unlimitedIncludingOpenSearchSecurityIndex() { + return new UnlimitedMatcher(true); + } + + static IndexMatcher limitedToNone() { + return new LimitedToMatcher(Collections.emptyMap()); + } + + /** + * Adds the given indices to the set of indices this matcher is limited to. + * @param testIndices additional indices for the limitation. + * @return a new IndexMatcher instance with the new limit. + */ + OnUserIndexMatcher and(TestIndexOrAliasOrDatastream... testIndices); + } + + /** + * The returned IndexMatcher objects implement this interface. + */ + public interface IndexMatcher extends Matcher { + /** + * Checks whether this matcher expects an empty set of indices. + */ + boolean isEmpty(); + + /** + * Returns the number of indices expected by this matcher. + */ + int size(); + + boolean containsOpenSearchIndices(); + + boolean covers(TestIndexOrAliasOrDatastream testIndex); + + default boolean coversAll(TestIndexOrAliasOrDatastream... testIndices) { + return Stream.of(testIndices).allMatch(this::covers); + } + + default boolean coversAll(Collection testIndices) { + return testIndices.stream().allMatch(this::covers); + } + } + + public static class ContainsExactlyMatcher extends AbstractIndexMatcher implements OnResponseIndexMatcher { + private static final Pattern DS_BACKING_INDEX_PATTERN = Pattern.compile("\\.ds-(.+)-[0-9]+"); + + ContainsExactlyMatcher(Map indexNameMap, boolean containsOpenSearchIndices) { + super(indexNameMap, containsOpenSearchIndices); + } + + ContainsExactlyMatcher( + Map indexNameMap, + boolean containsOpenSearchIndices, + String jsonPath, + RestMatchers.HttpResponseMatcher statusCodeWhenEmpty + ) { + super(indexNameMap, containsOpenSearchIndices, jsonPath, statusCodeWhenEmpty); + } + + @Override + public void describeTo(Description description) { + if (indexNameMap.isEmpty()) { + if (this.statusCodeWhenEmpty.statusCode() == 200) { + description.appendText("a 200 OK response with an empty result set"); + } else { + this.statusCodeWhenEmpty.describeTo(description); + description.appendText("a response with status code " + this.statusCodeWhenEmpty); + } + } else { + description.appendText( + "a 200 OK response with exactly the indices " + indexNameMap.keySet().stream().collect(Collectors.joining(", ")) + ); + } + } + + @Override + protected boolean matchesImpl(Collection collection, Description mismatchDescription, TestRestClient.HttpResponse response) { + // Flatten the collection + collection = collection.stream() + .flatMap(e -> e instanceof Collection ? ((Collection) e).stream() : Stream.of(e)) + .collect(Collectors.toSet()); + + return matchesByIndices(collection, mismatchDescription, response); + } + + protected boolean matchesByIndices( + Collection collection, + Description mismatchDescription, + TestRestClient.HttpResponse response + ) { + ImmutableSet expectedIndices = this.getExpectedIndices(); + ImmutableSet.Builder seenIndicesBuilder = ImmutableSet.builderWithExpectedSize(expectedIndices.size()); + ImmutableSet.Builder seenOpenSearchIndicesBuilder = new ImmutableSet.Builder<>(); + + for (Object object : collection) { + String index = object.toString(); + + if (containsOpenSearchIndices && (index.startsWith(".opendistro"))) { + seenOpenSearchIndicesBuilder.add(index); + } else if (index.startsWith(".ds-")) { + // We do a special treatment for data stream backing indices. We convert these to the normal data streams if expected + // indices contains these. + java.util.regex.Matcher matcher = DS_BACKING_INDEX_PATTERN.matcher(index); + + if (matcher.matches() && expectedIndices.contains(matcher.group(1))) { + seenIndicesBuilder.add(matcher.group(1)); + } else { + seenIndicesBuilder.add(index); + } + } else { + seenIndicesBuilder.add(index); + } + } + + ImmutableSet seenIndices = seenIndicesBuilder.build(); + + ImmutableSet unexpectedIndices = Sets.difference(seenIndices, expectedIndices).immutableCopy(); + ImmutableSet missingIndices = Sets.difference(expectedIndices, seenIndices).immutableCopy(); + + if (containsOpenSearchIndices && seenOpenSearchIndicesBuilder.build().size() == 0) { + missingIndices = ImmutableSet.builderWithExpectedSize(missingIndices.size() + 1) + .addAll(missingIndices) + .add(".opensearch indices") + .build(); + } + + if (unexpectedIndices.isEmpty() && missingIndices.isEmpty()) { + return true; + } else { + if (!missingIndices.isEmpty()) { + mismatchDescription.appendText("result does not contain expected indices; found: ") + .appendValue(seenIndices) + .appendText("; missing: ") + .appendValue(missingIndices) + .appendText("\n\n") + .appendText(formatResponse(response)); + } + + if (!unexpectedIndices.isEmpty()) { + mismatchDescription.appendText("result does contain indices that were not expected: ") + .appendValue(unexpectedIndices) + .appendText("\n\n") + .appendText(formatResponse(response)); + } + return false; + } + } + + @Override + public OnResponseIndexMatcher reducedBy(IndexMatcher other) { + if (other instanceof LimitedToMatcher) { + return new ContainsExactlyMatcher( + testIndicesIntersection(this.indexNameMap, ((LimitedToMatcher) other).indexNameMap), // + this.containsOpenSearchIndices && other.containsOpenSearchIndices(), // + this.jsonPath, + this.statusCodeWhenEmpty + ); + } else if (other instanceof ContainsExactlyMatcher) { + return new ContainsExactlyMatcher( + testIndicesIntersection(this.indexNameMap, ((ContainsExactlyMatcher) other).indexNameMap), // + this.containsOpenSearchIndices && other.containsOpenSearchIndices(), // + this.jsonPath, + this.statusCodeWhenEmpty + ); + } else if (other instanceof UnlimitedMatcher) { + return new ContainsExactlyMatcher( + this.indexNameMap, // + this.containsOpenSearchIndices && other.containsOpenSearchIndices(), // + this.jsonPath, + this.statusCodeWhenEmpty + ); + } else { + throw new RuntimeException("Unexpected argument " + other); + } + } + + @Override + public OnResponseIndexMatcher at(String jsonPath) { + return new ContainsExactlyMatcher(indexNameMap, containsOpenSearchIndices, jsonPath, statusCodeWhenEmpty); + } + + @Override + public OnResponseIndexMatcher whenEmpty(RestMatchers.HttpResponseMatcher statusCode) { + return new ContainsExactlyMatcher(indexNameMap, containsOpenSearchIndices, jsonPath, statusCode); + } + + @Override + public boolean covers(TestIndexOrAliasOrDatastream testIndex) { + return indexNameMap.containsKey(testIndex.name()); + } + + @Override + public OnResponseIndexMatcher butFailIfIncomplete(IndexMatcher other, RestMatchers.HttpResponseMatcher statusCode) { + if (other instanceof UnlimitedMatcher) { + return this; + } + + Map intersection = testIndicesIntersection( + this.indexNameMap, + ((AbstractIndexMatcher) other).indexNameMap + ); + if (!intersection.equals(this.indexNameMap)) { + return new StatusCodeMatcher(statusCode); + } else { + return this.reducedBy(other); + } + } + + public OnResponseIndexMatcher andFromRemote(String prefix, TestIndexOrAliasOrDatastream... remoteTestIndices) { + Map indexNameMap = new HashMap<>(this.indexNameMap); + + for (TestIndexOrAliasOrDatastream testIndex : remoteTestIndices) { + indexNameMap.put(prefix + ":" + testIndex.name(), testIndex); + } + + return new ContainsExactlyMatcher(indexNameMap, this.containsOpenSearchIndices); + } + } + + static class StatusCodeMatcher extends DiagnosingMatcher implements OnResponseIndexMatcher { + private RestMatchers.HttpResponseMatcher expectedStatusCode; + + public StatusCodeMatcher(RestMatchers.HttpResponseMatcher expectedStatusCode) { + this.expectedStatusCode = expectedStatusCode; + } + + @Override + public void describeTo(Description description) { + this.expectedStatusCode.describeTo(description); + } + + @Override + protected boolean matches(Object item, Description mismatchDescription) { + return this.expectedStatusCode.matches(item, mismatchDescription); + } + + @Override + public boolean isEmpty() { + return true; + } + + @Override + public boolean containsOpenSearchIndices() { + return true; + } + + @Override + public int size() { + return 0; + } + + @Override + public boolean covers(TestIndexOrAliasOrDatastream testIndex) { + return false; + } + + @Override + public OnResponseIndexMatcher at(String jsonPath) { + return this; + } + + @Override + public OnResponseIndexMatcher reducedBy(IndexMatcher other) { + return this; + } + + @Override + public OnResponseIndexMatcher whenEmpty(RestMatchers.HttpResponseMatcher statusCode) { + return this; + } + + @Override + public OnResponseIndexMatcher butFailIfIncomplete(IndexMatcher other, RestMatchers.HttpResponseMatcher statusCode) { + return this; + } + } + + static class LimitedToMatcher extends AbstractIndexMatcher implements OnUserIndexMatcher { + + LimitedToMatcher(Map indexNameMap) { + super(indexNameMap, false); + } + + @Override + public void describeTo(Description description) { + if (indexNameMap.isEmpty()) { + if (this.statusCodeWhenEmpty.statusCode() == 200) { + description.appendText("a 200 OK response with an empty result set"); + } else { + this.statusCodeWhenEmpty.describeTo(description); + } + } else { + description.appendText( + "a 200 OK response no indices other than " + indexNameMap.keySet().stream().collect(Collectors.joining(", ")) + ); + } + } + + @Override + protected boolean matchesImpl(Collection collection, Description mismatchDescription, TestRestClient.HttpResponse response) { + return matchesByIndices(collection, mismatchDescription, response); + } + + @Override + public boolean covers(TestIndexOrAliasOrDatastream testIndex) { + return indexNameMap.containsKey(testIndex.name()); + } + + protected boolean matchesByIndices( + Collection collection, + Description mismatchDescription, + TestRestClient.HttpResponse response + ) { + ImmutableSet expectedIndices = this.getExpectedIndices(); + ImmutableSet.Builder seenIndicesBuilder = ImmutableSet.builderWithExpectedSize(expectedIndices.size()); + + for (Object object : collection) { + seenIndicesBuilder.add(object.toString()); + } + + ImmutableSet seenIndices = seenIndicesBuilder.build(); + ImmutableSet unexpectedIndices = Sets.difference(seenIndices, expectedIndices).immutableCopy(); + + if (unexpectedIndices.isEmpty()) { + return true; + } else { + mismatchDescription.appendText("result does contain indices that were not expected: ") + .appendValue(unexpectedIndices) + .appendText("\n\n") + .appendValue(formatResponse(response)); + return false; + } + } + + @Override + public OnUserIndexMatcher and(TestIndexOrAliasOrDatastream... testIndices) { + Map indexNameMap = new HashMap<>(this.indexNameMap); + + for (TestIndexOrAliasOrDatastream testIndex : testIndices) { + indexNameMap.put(testIndex.name(), testIndex); + } + + return new LimitedToMatcher(indexNameMap); + } + } + + static class UnlimitedMatcher extends DiagnosingMatcher implements OnUserIndexMatcher { + + private final boolean containsOpenSearchIndices; + + UnlimitedMatcher() { + this.containsOpenSearchIndices = false; + } + + UnlimitedMatcher(boolean containsOpenSearchIndices) { + this.containsOpenSearchIndices = containsOpenSearchIndices; + } + + @Override + public void describeTo(Description description) { + description.appendText("unlimited indices"); + } + + @Override + protected boolean matches(Object item, Description mismatchDescription) { + if (item instanceof TestRestClient.HttpResponse) { + TestRestClient.HttpResponse response = (TestRestClient.HttpResponse) item; + + if (response.getStatusCode() != 200) { + mismatchDescription.appendText("Expected status code 200 but status was: ") + .appendValue(response.getStatusCode() + " " + response.getStatusReason()); + return false; + } + } + + return true; + } + + @Override + public boolean isEmpty() { + return false; + } + + @Override + public boolean containsOpenSearchIndices() { + return containsOpenSearchIndices; + } + + @Override + public int size() { + throw new IllegalStateException("The UnlimitedMatcher cannot specify a size"); + } + + @Override + public boolean covers(TestIndexOrAliasOrDatastream testIndex) { + return true; + } + + @Override + public OnUserIndexMatcher and(TestIndexOrAliasOrDatastream... testIndices) { + return this; + } + } + + static abstract class AbstractIndexMatcher extends DiagnosingMatcher implements IndexMatcher { + protected final Map indexNameMap; + protected final String jsonPath; + protected final RestMatchers.HttpResponseMatcher statusCodeWhenEmpty; + protected final boolean containsOpenSearchIndices; + + AbstractIndexMatcher(Map indexNameMap, boolean containsOpenSearchIndices) { + this.indexNameMap = indexNameMap; + this.jsonPath = null; + this.statusCodeWhenEmpty = RestMatchers.isOk(); + this.containsOpenSearchIndices = containsOpenSearchIndices; + } + + AbstractIndexMatcher( + Map indexNameMap, + boolean containsOpenSearchIndices, + String jsonPath, + RestMatchers.HttpResponseMatcher statusCodeWhenEmpty + ) { + this.indexNameMap = indexNameMap; + this.jsonPath = jsonPath; + this.statusCodeWhenEmpty = statusCodeWhenEmpty; + this.containsOpenSearchIndices = containsOpenSearchIndices; + } + + @Override + protected boolean matches(Object item, Description mismatchDescription) { + TestRestClient.HttpResponse response = null; + + if (item instanceof TestRestClient.HttpResponse) { + response = (TestRestClient.HttpResponse) item; + + if (indexNameMap.isEmpty()) { + if (response.getStatusCode() != this.statusCodeWhenEmpty.statusCode()) { + mismatchDescription.appendText("Status was: ") + .appendValue(response.getStatusCode() + " " + response.getStatusReason()) + .appendText("\n\n") + .appendText(formatResponse(response)); + return false; + } + + if (response.getStatusCode() != 200) { + return true; + } + } + + try { + if (response.getBody().startsWith(START_ARRAY.asString())) { + item = DefaultObjectMapper.objectMapper.readValue(response.getBody(), List.class); + } else { + item = DefaultObjectMapper.objectMapper.readValue(response.getBody(), Map.class); + } + } catch (JsonProcessingException e) { + mismatchDescription.appendText("Unable to parse body: ").appendValue(e.getMessage()); + return false; + } + } + + if (jsonPath != null) { + Configuration config = Configuration.builder() + .jsonProvider(new JacksonJsonProvider()) + .mappingProvider(new JacksonMappingProvider()) + .evaluationListener() + .options(Option.SUPPRESS_EXCEPTIONS) + .build(); + + item = JsonPath.using(config).parse(item).read(jsonPath); + + if (item == null) { + mismatchDescription.appendText("Unable to find JSON Path: ") + .appendValue(jsonPath) + .appendText("\n\n") + .appendText(formatResponse(response)); + return false; + } + } + + if (!(item instanceof Collection)) { + item = Collections.singleton(item); + } + + return matchesImpl((Collection) item, mismatchDescription, response); + } + + protected abstract boolean matchesImpl( + Collection collection, + Description mismatchDescription, + TestRestClient.HttpResponse response + ); + + @Override + public boolean isEmpty() { + return indexNameMap.isEmpty(); + } + + @Override + public int size() { + if (!containsOpenSearchIndices) { + return indexNameMap.size(); + } else { + throw new RuntimeException("Size cannot be exactly specified because containsOpenSearchIndices is true"); + } + } + + @Override + public boolean containsOpenSearchIndices() { + return containsOpenSearchIndices; + } + + protected Map testIndicesIntersection( + Map map1, + Map map2 + ) { + Map result = new HashMap<>(); + + for (Map.Entry entry : map1.entrySet()) { + String key = entry.getKey(); + TestIndexOrAliasOrDatastream index1 = entry.getValue(); + TestIndexOrAliasOrDatastream index2 = map2.get(key); + + if (index2 == null && key.contains(":")) { + // This is a remote index, which might be in map2 just stored without its remote index prefix + // Let's just strip the prefix and try again + index2 = map2.get(key.substring(key.indexOf(':') + 1)); + } + + if (index2 == null) { + continue; + } + + result.put(key, index1.intersection(index2)); + } + + return Collections.unmodifiableMap(result); + } + + protected ImmutableSet getExpectedIndices() { + return ImmutableSet.copyOf(indexNameMap.keySet()); + } + + } + + private static String formatResponse(TestRestClient.HttpResponse response) { + if (response == null) { + return ""; + } + + String start = response.getStatusCode() + " " + response.getStatusReason() + "\n"; + + if (response.isJsonContentType()) { + return start + response.bodyAsJsonNode().toPrettyString(); + } else { + return start + response.getBody(); + } + } +} diff --git a/src/integrationTest/java/org/opensearch/test/framework/matcher/RestMatchers.java b/src/integrationTest/java/org/opensearch/test/framework/matcher/RestMatchers.java index 301f81b80e..96faab57c1 100644 --- a/src/integrationTest/java/org/opensearch/test/framework/matcher/RestMatchers.java +++ b/src/integrationTest/java/org/opensearch/test/framework/matcher/RestMatchers.java @@ -9,234 +9,172 @@ */ package org.opensearch.test.framework.matcher; +import java.util.Map; + +import com.google.common.collect.ImmutableMap; +import com.fasterxml.jackson.databind.JsonNode; import org.hamcrest.Description; import org.hamcrest.DiagnosingMatcher; -import org.opensearch.core.rest.RestStatus; import org.opensearch.test.framework.cluster.TestRestClient.HttpResponse; public class RestMatchers { private RestMatchers() {} - public static DiagnosingMatcher isOk() { - return new DiagnosingMatcher<>() { - - @Override - public void describeTo(Description description) { - description.appendText("Response has status 200 OK"); - } - - @Override - protected boolean matches(Object item, Description mismatchDescription) { - if (!(item instanceof HttpResponse)) { - mismatchDescription.appendValue(item).appendText(" is not a HttpResponse"); - return false; - } - - HttpResponse response = (HttpResponse) item; - - if (response.getStatusCode() == RestStatus.OK.getStatus()) { - return true; - } else { - mismatchDescription.appendText("Status is not 200 OK: ").appendValue(item); - return false; - } + public static HttpResponseMatcher isOk() { + return new HttpResponseMatcher(200, "OK"); + } - } + public static HttpResponseMatcher isCreated() { + return new HttpResponseMatcher(201, "Created"); + } - }; + public static OpenSearchErrorHttpResponseMatcher isForbidden() { + return new OpenSearchErrorHttpResponseMatcher(403, "Forbidden"); } public static DiagnosingMatcher isForbidden(String jsonPointer, String patternString) { - return new DiagnosingMatcher<>() { - - @Override - public void describeTo(Description description) { - description.appendText("Response has status 403 Forbidden with a JSON response that has the value ") - .appendValue(patternString) - .appendText(" at ") - .appendValue(jsonPointer); - } - - @Override - protected boolean matches(Object item, Description mismatchDescription) { - if (!(item instanceof HttpResponse)) { - mismatchDescription.appendValue(item).appendText(" is not a HttpResponse"); - return false; - } - - HttpResponse response = (HttpResponse) item; + return isForbidden().withAttribute(jsonPointer, patternString); + } - if (response.getStatusCode() != RestStatus.FORBIDDEN.getStatus()) { - mismatchDescription.appendText("Status is not 403 Forbidden: ").appendText("\n").appendValue(item); - return false; - } + public static OpenSearchErrorHttpResponseMatcher isBadRequest() { + return new OpenSearchErrorHttpResponseMatcher(400, "Bad Request"); + } - try { - String value = response.getTextFromJsonBody(jsonPointer); + public static DiagnosingMatcher isBadRequest(String jsonPointer, String patternString) { + return isBadRequest().withAttribute(jsonPointer, patternString); + } - if (value == null) { - mismatchDescription.appendText("Could not find value at " + jsonPointer).appendText("\n").appendValue(item); - return false; - } + public static OpenSearchErrorHttpResponseMatcher isNotImplemented() { + return new OpenSearchErrorHttpResponseMatcher(501, "Not Implemented"); + } - if (value.contains(patternString)) { - return true; - } else { - mismatchDescription.appendText("Value at " + jsonPointer + " does not match pattern: " + patternString + "\n") - .appendValue(item); - return false; - } - } catch (Exception e) { - mismatchDescription.appendText("Parsing request body failed with " + e).appendText("\n").appendValue(item); - return false; - } - } - }; + public static DiagnosingMatcher isMethodNotImplemented(String jsonPointer, String patternString) { + return isNotImplemented().withAttribute(jsonPointer, patternString); } - public static DiagnosingMatcher isBadRequest(String jsonPointer, String patternString) { - return new DiagnosingMatcher<>() { - - @Override - public void describeTo(Description description) { - description.appendText("Response has status 400 Bad Request with a JSON response that has the value ") - .appendValue(patternString) - .appendText(" at ") - .appendValue(jsonPointer); - } + public static OpenSearchErrorHttpResponseMatcher isInternalServerError() { + return new OpenSearchErrorHttpResponseMatcher(500, "Internal Server Error"); + } - @Override - protected boolean matches(Object item, Description mismatchDescription) { - if (!(item instanceof HttpResponse)) { - mismatchDescription.appendValue(item).appendText(" is not a HttpResponse"); - return false; - } + public static DiagnosingMatcher isInternalServerError(String jsonPointer, String patternString) { + return isInternalServerError().withAttribute(jsonPointer, patternString); + } - HttpResponse response = (HttpResponse) item; + public static OpenSearchErrorHttpResponseMatcher isNotFound() { + return new OpenSearchErrorHttpResponseMatcher(404, "Not Found"); + } - if (response.getStatusCode() != RestStatus.BAD_REQUEST.getStatus()) { - mismatchDescription.appendText("Status is not 400 Bad Request: ").appendText("\n").appendValue(item); - return false; - } + public static class HttpResponseMatcher extends DiagnosingMatcher { + final int statusCode; + final String statusName; + + HttpResponseMatcher(int statusCode, String statusName) { + this.statusCode = statusCode; + this.statusName = statusName; + } + + @Override + public void describeTo(Description description) { + description.appendText("Response has status " + statusCode + " " + statusName); + } + + @Override + protected boolean matches(Object item, Description mismatchDescription) { + if (!(item instanceof HttpResponse response)) { + mismatchDescription.appendValue(item).appendText(" is not a HttpResponse"); + return false; + } - try { - String value = response.getTextFromJsonBody(jsonPointer); + if (response.getStatusCode() == this.statusCode) { + return true; + } else { + mismatchDescription.appendText("Status is not " + statusCode + " " + statusName + ":\n").appendValue(item); + return false; + } + } - if (value == null) { - mismatchDescription.appendText("Could not find value at " + jsonPointer).appendText("\n").appendValue(item); - return false; - } + public int statusCode() { + return this.statusCode; + } - if (value.contains(patternString)) { - return true; - } else { - mismatchDescription.appendText("Value at " + jsonPointer + " does not match pattern: " + patternString + "\n") - .appendValue(item); - return false; - } - } catch (Exception e) { - mismatchDescription.appendText("Parsing request body failed with " + e).appendText("\n").appendValue(item); - return false; - } - } - }; } - public static DiagnosingMatcher isMethodNotImplemented(String jsonPointer, String patternString) { - return new DiagnosingMatcher<>() { - - @Override - public void describeTo(Description description) { - description.appendText("Response has status 501 Method Not Implemented with a JSON response that has the value ") - .appendValue(patternString) - .appendText(" at ") - .appendValue(jsonPointer); + public static class OpenSearchErrorHttpResponseMatcher extends HttpResponseMatcher { + final ImmutableMap attributes; + + OpenSearchErrorHttpResponseMatcher(int statusCode, String statusName) { + super(statusCode, statusName); + this.attributes = ImmutableMap.of(); + } + + OpenSearchErrorHttpResponseMatcher(int statusCode, String statusName, ImmutableMap attributes) { + super(statusCode, statusName); + this.attributes = attributes; + } + + public OpenSearchErrorHttpResponseMatcher withReason(String reason) { + return withAttribute("/error/reason", reason); + } + + public OpenSearchErrorHttpResponseMatcher withType(String type) { + return withAttribute("/error/type", type); + } + + public OpenSearchErrorHttpResponseMatcher withAttribute(String jsonPointer, String value) { + return new OpenSearchErrorHttpResponseMatcher( + this.statusCode, + this.statusName, + ImmutableMap.builder().putAll(this.attributes).put(jsonPointer, value).build() + ); + } + + @Override + public void describeTo(Description description) { + super.describeTo(description); + for (Map.Entry entry : this.attributes.entrySet()) { + description.appendText(" with " + entry.getKey() + " " + entry.getValue()); } + } - @Override - protected boolean matches(Object item, Description mismatchDescription) { - if (!(item instanceof HttpResponse)) { - mismatchDescription.appendValue(item).appendText(" is not a HttpResponse"); - return false; - } + @Override + protected boolean matches(Object item, Description mismatchDescription) { + if (!super.matches(item, mismatchDescription)) { + return false; + } - HttpResponse response = (HttpResponse) item; + HttpResponse response = (HttpResponse) item; + boolean result = true; - if (response.getStatusCode() != RestStatus.NOT_IMPLEMENTED.getStatus()) { - mismatchDescription.appendText("Status is not 501 Method Not Implemented: ").appendText("\n").appendValue(item); - return false; - } + if (!this.attributes.isEmpty()) { + JsonNode responseDocument; try { - String value = response.getTextFromJsonBody(jsonPointer); - - if (value == null) { - mismatchDescription.appendText("Could not find value at " + jsonPointer).appendText("\n").appendValue(item); - return false; - } - - if (value.contains(patternString)) { - return true; - } else { - mismatchDescription.appendText("Value at " + jsonPointer + " does not match pattern: " + patternString + "\n") - .appendValue(item); - return false; - } + responseDocument = response.bodyAsJsonNode(); } catch (Exception e) { mismatchDescription.appendText("Parsing request body failed with " + e).appendText("\n").appendValue(item); return false; } - } - }; - } - - public static DiagnosingMatcher isInternalServerError(String jsonPointer, String patternString) { - return new DiagnosingMatcher<>() { - - @Override - public void describeTo(Description description) { - description.appendText("Response has status 500 Internal Server Error with a JSON response that has the value ") - .appendValue(patternString) - .appendText(" at ") - .appendValue(jsonPointer); - } - @Override - protected boolean matches(Object item, Description mismatchDescription) { - if (!(item instanceof HttpResponse)) { - mismatchDescription.appendValue(item).appendText(" is not a HttpResponse"); - return false; - } - - HttpResponse response = (HttpResponse) item; - - if (response.getStatusCode() != RestStatus.INTERNAL_SERVER_ERROR.getStatus()) { - mismatchDescription.appendText("Status is not 500 Internal Server Error: ").appendText("\n").appendValue(item); - return false; + for (Map.Entry entry : this.attributes.entrySet()) { + String actualValue = responseDocument.at(entry.getKey()).asText(); + String expectedValue = entry.getValue(); + if (actualValue == null || !actualValue.contains(entry.getValue())) { + mismatchDescription.appendText(entry.getKey() + " is not " + expectedValue + ": ") + .appendValue(actualValue) + .appendText("\n"); + result = false; + } } + } - try { - String value = response.getTextFromJsonBody(jsonPointer); + if (!result) { + mismatchDescription.appendValue(item); + } - if (value == null) { - mismatchDescription.appendText("Could not find value at " + jsonPointer).appendText("\n").appendValue(item); - return false; - } + return result; + } - if (value.contains(patternString)) { - return true; - } else { - mismatchDescription.appendText("Value at " + jsonPointer + " does not match pattern: " + patternString + "\n") - .appendValue(item); - return false; - } - } catch (Exception e) { - mismatchDescription.appendText("Parsing request body failed with " + e).appendText("\n").appendValue(item); - return false; - } - } - }; } } diff --git a/src/integrationTest/resources/log4j2-test.properties b/src/integrationTest/resources/log4j2-test.properties index 587bae2ddc..06f2dba218 100644 --- a/src/integrationTest/resources/log4j2-test.properties +++ b/src/integrationTest/resources/log4j2-test.properties @@ -10,6 +10,8 @@ appender.console.filter.prerelease.type=RegexFilter appender.console.filter.prerelease.regex=.+\\Qis a pre-release version of OpenSearch and is not suitable for production\\E appender.console.filter.prerelease.onMatch=DENY appender.console.filter.prerelease.onMismatch=NEUTRAL +appender.console.follow = true +appender.console.immediateFlush = true appender.capturing.type = LogCapturingAppender appender.capturing.name = logCapturingAppender diff --git a/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java b/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java index a052a81973..b7b1ab0cd6 100644 --- a/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java +++ b/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java @@ -146,6 +146,7 @@ import org.opensearch.security.auditlog.config.AuditConfig.Filter.FilterEntries; import org.opensearch.security.auditlog.impl.AuditLogImpl; import org.opensearch.security.auth.BackendRegistry; +import org.opensearch.security.auth.RolesInjector; import org.opensearch.security.compliance.ComplianceIndexingOperationListener; import org.opensearch.security.compliance.ComplianceIndexingOperationListenerImpl; import org.opensearch.security.configuration.AdminDNs; @@ -154,7 +155,6 @@ import org.opensearch.security.configuration.ConfigurationRepository; import org.opensearch.security.configuration.DlsFlsRequestValve; import org.opensearch.security.configuration.DlsFlsValveImpl; -import org.opensearch.security.configuration.PrivilegesInterceptorImpl; import org.opensearch.security.configuration.SecurityFlsDlsIndexSearcherWrapper; import org.opensearch.security.dlic.rest.api.Endpoint; import org.opensearch.security.dlic.rest.api.SecurityRestApiActions; @@ -169,13 +169,13 @@ import org.opensearch.security.http.XFFResolver; import org.opensearch.security.identity.SecurePluginSubject; import org.opensearch.security.identity.SecurityTokenManager; +import org.opensearch.security.privileges.ConfigurableRoleMapper; +import org.opensearch.security.privileges.PrivilegesConfiguration; import org.opensearch.security.privileges.PrivilegesEvaluationException; -import org.opensearch.security.privileges.PrivilegesEvaluator; -import org.opensearch.security.privileges.PrivilegesInterceptor; import org.opensearch.security.privileges.RestLayerPrivilegesEvaluator; +import org.opensearch.security.privileges.RoleMapper; import org.opensearch.security.privileges.actionlevel.RoleBasedActionPrivileges; import org.opensearch.security.privileges.dlsfls.DlsFlsBaseContext; -import org.opensearch.security.resolver.IndexResolverReplacer; import org.opensearch.security.resources.ResourceAccessControlClient; import org.opensearch.security.resources.ResourceAccessHandler; import org.opensearch.security.resources.ResourceIndexListener; @@ -271,7 +271,8 @@ public final class OpenSearchSecurityPlugin extends OpenSearchSecuritySSLPlugin private boolean sslCertReloadEnabled; private volatile SecurityInterceptor si; - private volatile PrivilegesEvaluator evaluator; + private volatile PrivilegesConfiguration privilegesConfiguration; + private volatile RoleMapper roleMapper; private volatile UserService userService; private volatile RestLayerPrivilegesEvaluator restLayerEvaluator; private volatile ConfigurationRepository cr; @@ -287,7 +288,6 @@ public final class OpenSearchSecurityPlugin extends OpenSearchSecuritySSLPlugin private volatile DynamicConfigFactory dcf; private final List demoCertHashes = new ArrayList(3); private volatile SecurityFilter sf; - private volatile IndexResolverReplacer irr; private final AtomicReference namedXContentRegistry = new AtomicReference<>(NamedXContentRegistry.EMPTY);; private volatile DlsFlsRequestValve dlsFlsValve = null; private volatile OpensearchDynamicSetting transportPassiveAuthSetting; @@ -627,21 +627,27 @@ public List getRestHandlers( // FGAC enabled == not sslOnly if (!SSLConfig.isSslOnlyMode()) { handlers.add( - new SecurityInfoAction(settings, restController, Objects.requireNonNull(evaluator), Objects.requireNonNull(threadPool)) + new SecurityInfoAction( + settings, + restController, + Objects.requireNonNull(privilegesConfiguration), + Objects.requireNonNull(threadPool) + ) ); handlers.add( new SecurityHealthAction( settings, restController, Objects.requireNonNull(backendRegistry), - Objects.requireNonNull(evaluator) + Objects.requireNonNull(privilegesConfiguration) ) ); handlers.add( new DashboardsInfoAction( settings, restController, - Objects.requireNonNull(evaluator), + Objects.requireNonNull(privilegesConfiguration), + Objects.requireNonNull(cr), Objects.requireNonNull(threadPool) ) ); @@ -649,7 +655,7 @@ public List getRestHandlers( new TenantInfoAction( settings, restController, - Objects.requireNonNull(evaluator), + Objects.requireNonNull(privilegesConfiguration), Objects.requireNonNull(threadPool), Objects.requireNonNull(cs), Objects.requireNonNull(adminDns), @@ -687,7 +693,8 @@ public List getRestHandlers( cr, cs, principalExtractor, - evaluator, + roleMapper, + privilegesConfiguration, threadPool, Objects.requireNonNull(auditLog), sslSettingsManager, @@ -761,7 +768,8 @@ public void onIndexModule(IndexModule indexModule) { cs, auditLog, ciol, - evaluator, + privilegesConfiguration, + roleMapper, dlsFlsValve::getCurrentConfig, dlsFlsBaseContext ) @@ -1131,7 +1139,6 @@ public Collection createComponents( this.cs.addListener(cih); final IndexNameExpressionResolver resolver = new IndexNameExpressionResolver(threadPool.getThreadContext()); - irr = new IndexResolverReplacer(resolver, clusterService::state, cih); final String DEFAULT_INTERCLUSTER_REQUEST_EVALUATOR_CLASS = DefaultInterClusterRequestEvaluator.class.getName(); InterClusterRequestEvaluator interClusterRequestEvaluator = new DefaultInterClusterRequestEvaluator(settings); @@ -1147,15 +1154,11 @@ public Collection createComponents( UserFactory userFactory = new UserFactory.Caching(settings); - final PrivilegesInterceptor privilegesInterceptor; - namedXContentRegistry.set(xContentRegistry); if (SSLConfig.isSslOnlyMode()) { auditLog = new NullAuditLog(); - privilegesInterceptor = new PrivilegesInterceptor(resolver, clusterService, localClient, threadPool); } else { auditLog = new AuditLogImpl(settings, configPath, localClient, threadPool, resolver, clusterService, environment, userFactory); - privilegesInterceptor = new PrivilegesInterceptorImpl(resolver, clusterService, localClient, threadPool); } sslExceptionHandler = new AuditLogSslExceptionHandler(auditLog); @@ -1171,26 +1174,31 @@ public Collection createComponents( final XFFResolver xffResolver = new XFFResolver(threadPool); backendRegistry = new BackendRegistry(settings, adminDns, xffResolver, auditLog, threadPool, cih); backendRegistry.registerClusterSettingsChangeListener(clusterService.getClusterSettings()); - tokenManager = new SecurityTokenManager(cs, threadPool, userService); + tokenManager = new SecurityTokenManager(cs, threadPool, userService, roleMapper); final CompatConfig compatConfig = new CompatConfig(environment, transportPassiveAuthSetting); rsIndexHandler = new ResourceSharingIndexHandler(localClient, threadPool); - evaluator = new PrivilegesEvaluator( + RoleMapper roleMapper = new RolesInjector.InjectedRoleMapper( + new ConfigurableRoleMapper(cr, settings), + threadPool.getThreadContext() + ); + this.roleMapper = roleMapper; + PrivilegesConfiguration privilegesConfiguration = new PrivilegesConfiguration( + cr, clusterService, clusterService::state, + localClient, + roleMapper, threadPool, - threadPool.getThreadContext(), - cr, resolver, auditLog, settings, - privilegesInterceptor, - cih, - irr + cih::getReasonForUnavailability ); + this.privilegesConfiguration = privilegesConfiguration; - dlsFlsBaseContext = new DlsFlsBaseContext(evaluator, threadPool.getThreadContext(), adminDns); + dlsFlsBaseContext = new DlsFlsBaseContext(privilegesConfiguration, threadPool.getThreadContext(), adminDns); if (SSLConfig.isSslOnlyMode()) { dlsFlsValve = new DlsFlsRequestValve.NoopDlsFlsRequestValve(); @@ -1207,7 +1215,12 @@ public Collection createComponents( cr.subscribeOnChange(configMap -> { ((DlsFlsValveImpl) dlsFlsValve).updateConfiguration(cr.getConfiguration(CType.ROLES)); }); } - ResourceAccessHandler resourceAccessHandler = new ResourceAccessHandler(threadPool, rsIndexHandler, adminDns, evaluator); + ResourceAccessHandler resourceAccessHandler = new ResourceAccessHandler( + threadPool, + rsIndexHandler, + adminDns, + privilegesConfiguration + ); if (settings.getAsBoolean( ConfigConstants.OPENSEARCH_RESOURCE_SHARING_ENABLED, ConfigConstants.OPENSEARCH_RESOURCE_SHARING_ENABLED_DEFAULT @@ -1226,7 +1239,8 @@ public Collection createComponents( sf = new SecurityFilter( settings, - evaluator, + privilegesConfiguration, + roleMapper, adminDns, dlsFlsValve, auditLog, @@ -1234,7 +1248,6 @@ public Collection createComponents( cs, cih, compatConfig, - irr, xffResolver, resourcePluginInfo.getResourceIndices(), resourceAccessHandler @@ -1248,7 +1261,7 @@ public Collection createComponents( principalExtractor = ReflectionHelper.instantiatePrincipalExtractor(principalExtractorClass); } - restLayerEvaluator = new RestLayerPrivilegesEvaluator(evaluator); + restLayerEvaluator = new RestLayerPrivilegesEvaluator(privilegesConfiguration); securityRestHandler = new SecurityRestFilter( backendRegistry, @@ -1263,9 +1276,7 @@ public Collection createComponents( dcf = new DynamicConfigFactory(cr, settings, configPath, localClient, threadPool, cih, passwordHasher); dcf.registerDCFListener(backendRegistry); dcf.registerDCFListener(compatConfig); - dcf.registerDCFListener(irr); dcf.registerDCFListener(xffResolver); - dcf.registerDCFListener(evaluator); dcf.registerDCFListener(securityRestHandler); dcf.registerDCFListener(tokenManager); if (!(auditLog instanceof NullAuditLog)) { @@ -1305,7 +1316,7 @@ public Collection createComponents( components.add(cr); components.add(xffResolver); components.add(backendRegistry); - components.add(evaluator); + components.add(privilegesConfiguration); components.add(restLayerEvaluator); components.add(si); components.add(dcf); @@ -2403,7 +2414,7 @@ public PluginSubject getPluginSubject(Plugin plugin) { } } pluginPermissions.getCluster_permissions().add(BulkAction.NAME); - evaluator.updatePluginToActionPrivileges(pluginPrincipal, pluginPermissions); + privilegesConfiguration.updatePluginToActionPrivileges(pluginPrincipal, pluginPermissions); } return subject; } diff --git a/src/main/java/org/opensearch/security/auth/RolesInjector.java b/src/main/java/org/opensearch/security/auth/RolesInjector.java index 42afc77ad2..2b6ea82dca 100644 --- a/src/main/java/org/opensearch/security/auth/RolesInjector.java +++ b/src/main/java/org/opensearch/security/auth/RolesInjector.java @@ -15,6 +15,8 @@ package org.opensearch.security.auth; +import java.util.Arrays; +import java.util.HashSet; import java.util.Map; import java.util.Set; @@ -23,8 +25,12 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.opensearch.OpenSearchSecurityException; import org.opensearch.common.util.concurrent.ThreadContext; +import org.opensearch.core.common.transport.TransportAddress; +import org.opensearch.core.rest.RestStatus; import org.opensearch.security.auditlog.AuditLog; +import org.opensearch.security.privileges.RoleMapper; import org.opensearch.security.support.ConfigConstants; import org.opensearch.security.user.User; import org.opensearch.threadpool.ThreadPool; @@ -92,4 +98,51 @@ private void addUser(final User user, final ThreadPool threadPool) { } } + + /** + * For users injected by this class, no role mapping shall be performed. This RoleMapper checks whether there + * is an injected user (by header) and skips default role mapping (realized by the delegate) if so. + */ + public static class InjectedRoleMapper implements RoleMapper { + + private final ThreadContext threadContext; + private final RoleMapper defaultRoleMapper; + + public InjectedRoleMapper(RoleMapper defaultRoleMapper, ThreadContext threadContext) { + this.threadContext = threadContext; + this.defaultRoleMapper = defaultRoleMapper; + } + + @Override + public ImmutableSet map(User user, TransportAddress caller) { + ImmutableSet mappedRoles; + + if (threadContext.getTransient(ConfigConstants.OPENDISTRO_SECURITY_INJECTED_ROLES) != null) { + // Just return the security roles, like they were initialized in the injectUserAndRoles() method above + mappedRoles = user.getSecurityRoles(); + } else { + // No injected user => use default role mapping + mappedRoles = defaultRoleMapper.map(user, caller); + } + + String injectedRolesValidationString = threadContext.getTransient( + ConfigConstants.OPENDISTRO_SECURITY_INJECTED_ROLES_VALIDATION + ); + if (injectedRolesValidationString != null) { + // Moved from + // https://github.com/opensearch-project/security/blob/d29095f26dba1a26308c69b608dc926bd40c0f52/src/main/java/org/opensearch/security/privileges/PrivilegesEvaluator.java#L406 + // See also https://github.com/opensearch-project/security/pull/1367 + HashSet injectedRolesValidationSet = new HashSet<>(Arrays.asList(injectedRolesValidationString.split(","))); + if (!mappedRoles.containsAll(injectedRolesValidationSet)) { + throw new OpenSearchSecurityException( + String.format("No mapping for %s on roles %s", user, injectedRolesValidationSet), + RestStatus.FORBIDDEN + ); + } + mappedRoles = ImmutableSet.copyOf(injectedRolesValidationSet); + } + + return mappedRoles; + } + } } diff --git a/src/main/java/org/opensearch/security/configuration/ClusterInfoHolder.java b/src/main/java/org/opensearch/security/configuration/ClusterInfoHolder.java index 7ccbeb6d14..e4ccb026f4 100644 --- a/src/main/java/org/opensearch/security/configuration/ClusterInfoHolder.java +++ b/src/main/java/org/opensearch/security/configuration/ClusterInfoHolder.java @@ -101,4 +101,12 @@ public Boolean hasClusterManager() { } return false; } + + public String getReasonForUnavailability() { + if (!hasClusterManager()) { + return CLUSTER_MANAGER_NOT_PRESENT; + } else { + return null; + } + } } diff --git a/src/main/java/org/opensearch/security/configuration/DlsFilterLevelActionHandler.java b/src/main/java/org/opensearch/security/configuration/DlsFilterLevelActionHandler.java index 1e179b1243..d9ba6cb316 100644 --- a/src/main/java/org/opensearch/security/configuration/DlsFilterLevelActionHandler.java +++ b/src/main/java/org/opensearch/security/configuration/DlsFilterLevelActionHandler.java @@ -39,6 +39,8 @@ import org.opensearch.action.search.SearchScrollAction; import org.opensearch.cluster.metadata.IndexMetadata; import org.opensearch.cluster.metadata.IndexNameExpressionResolver; +import org.opensearch.cluster.metadata.OptionallyResolvedIndices; +import org.opensearch.cluster.metadata.ResolvedIndices; import org.opensearch.cluster.service.ClusterService; import org.opensearch.common.document.DocumentField; import org.opensearch.common.util.concurrent.ThreadContext; @@ -63,7 +65,6 @@ import org.opensearch.security.privileges.dlsfls.DocumentPrivileges; import org.opensearch.security.privileges.dlsfls.IndexToRuleMap; import org.opensearch.security.queries.QueryBuilderTraverser; -import org.opensearch.security.resolver.IndexResolverReplacer.Resolved; import org.opensearch.security.support.ConfigConstants; import org.opensearch.security.support.ReflectiveAttributeAccessors; import org.opensearch.security.util.ParentChildrenQueryDetector; @@ -131,7 +132,7 @@ public static boolean handle( private final ActionRequest request; private final ActionListener listener; private final IndexToRuleMap dlsRestrictionMap; - private final Resolved resolved; + private final OptionallyResolvedIndices resolved; private final boolean requiresIndexScoping; private final Client nodeClient; private final ClusterService clusterService; @@ -162,7 +163,9 @@ public static boolean handle( this.threadContext = threadContext; this.resolver = resolver; - this.requiresIndexScoping = resolved.isLocalAll() || resolved.getAllIndicesResolved(clusterService, resolver).size() != 1; + this.requiresIndexScoping = resolved instanceof ResolvedIndices resolvedIndices + ? resolvedIndices.local().names().size() != 1 + : true; } private boolean handle() { @@ -474,7 +477,7 @@ private boolean modifyQuery(String localClusterAlias) throws IOException { int queryCount = 0; - Set indices = resolved.getAllIndicesResolved(clusterService, resolver); + Set indices = resolved.local().names(clusterService.state()); for (String index : indices) { String prefixedIndex; diff --git a/src/main/java/org/opensearch/security/configuration/DlsFlsValveImpl.java b/src/main/java/org/opensearch/security/configuration/DlsFlsValveImpl.java index 9762bfdc64..614103e6b6 100644 --- a/src/main/java/org/opensearch/security/configuration/DlsFlsValveImpl.java +++ b/src/main/java/org/opensearch/security/configuration/DlsFlsValveImpl.java @@ -35,12 +35,14 @@ import org.opensearch.action.ActionRequest; import org.opensearch.action.RealtimeRequest; import org.opensearch.action.admin.indices.shrink.ResizeRequest; +import org.opensearch.action.bulk.BulkAction; import org.opensearch.action.bulk.BulkItemRequest; import org.opensearch.action.bulk.BulkShardRequest; -import org.opensearch.action.get.MultiGetAction; +import org.opensearch.action.search.MultiSearchAction; import org.opensearch.action.search.SearchRequest; import org.opensearch.action.update.UpdateRequest; import org.opensearch.cluster.metadata.IndexNameExpressionResolver; +import org.opensearch.cluster.metadata.OptionallyResolvedIndices; import org.opensearch.cluster.service.ClusterService; import org.opensearch.common.settings.Settings; import org.opensearch.common.util.concurrent.ThreadContext; @@ -50,6 +52,8 @@ import org.opensearch.core.xcontent.MediaTypeRegistry; import org.opensearch.core.xcontent.NamedXContentRegistry; import org.opensearch.index.query.ParsedQuery; +import org.opensearch.index.reindex.ReindexAction; +import org.opensearch.script.mustache.RenderSearchTemplateAction; import org.opensearch.search.DocValueFormat; import org.opensearch.search.aggregations.AggregationBuilder; import org.opensearch.search.aggregations.AggregatorFactories; @@ -76,7 +80,6 @@ import org.opensearch.security.privileges.dlsfls.DlsRestriction; import org.opensearch.security.privileges.dlsfls.FieldMasking; import org.opensearch.security.privileges.dlsfls.IndexToRuleMap; -import org.opensearch.security.resolver.IndexResolverReplacer; import org.opensearch.security.securityconf.DynamicConfigFactory; import org.opensearch.security.securityconf.impl.SecurityDynamicConfiguration; import org.opensearch.security.securityconf.impl.v7.RoleV7; @@ -85,8 +88,6 @@ import org.opensearch.threadpool.ThreadPool; import org.opensearch.transport.client.Client; -import static org.opensearch.security.privileges.PrivilegesEvaluator.isClusterPerm; - public class DlsFlsValveImpl implements DlsFlsRequestValve { private static final String MAP_EXECUTION_HINT = "map"; @@ -139,13 +140,12 @@ public DlsFlsValveImpl( */ @Override public boolean invoke(PrivilegesEvaluationContext context, final ActionListener listener) { - if (HeaderHelper.isInternalOrPluginRequest(threadContext) - || (isClusterPerm(context.getAction()) && !MultiGetAction.NAME.equals(context.getAction()))) { + if (HeaderHelper.isInternalOrPluginRequest(threadContext) || !isApplicable(context.getAction())) { return true; } DlsFlsProcessedConfig config = this.dlsFlsProcessedConfig.get(); ActionRequest request = context.getRequest(); - IndexResolverReplacer.Resolved resolved = context.getResolvedRequest(); + OptionallyResolvedIndices resolved = context.getResolvedRequest(); try { boolean hasDlsRestrictions = !config.getDocumentPrivileges().isUnrestricted(context, resolved); @@ -172,14 +172,12 @@ public boolean invoke(PrivilegesEvaluationContext context, final ActionListener< if (mode == Mode.FILTER_LEVEL) { doFilterLevelDls = true; - dlsRestrictionMap = config.getDocumentPrivileges() - .getRestrictions(context, resolved.getAllIndicesResolved(clusterService, context.getIndexNameExpressionResolver())); + dlsRestrictionMap = config.getDocumentPrivileges().getRestrictions(context, resolved.local().names(context.clusterState())); } else if (mode == Mode.LUCENE_LEVEL) { doFilterLevelDls = false; } else { // mode == Mode.ADAPTIVE Mode modeByHeader = getDlsModeHeader(); - dlsRestrictionMap = config.getDocumentPrivileges() - .getRestrictions(context, resolved.getAllIndicesResolved(clusterService, context.getIndexNameExpressionResolver())); + dlsRestrictionMap = config.getDocumentPrivileges().getRestrictions(context, resolved.local().names(context.clusterState())); if (modeByHeader == Mode.FILTER_LEVEL) { doFilterLevelDls = true; @@ -497,6 +495,34 @@ public boolean isFieldAllowed(String index, String field) throws PrivilegesEvalu return config.getFieldPrivileges().getRestriction(privilegesEvaluationContext, index).isAllowedRecursive(field); } + private static boolean isApplicable(String action) { + if (action.startsWith("cluster:")) { + // Cluster actions are generally not applicable + return false; + } + if (action.startsWith("indices:admin/template/") || action.startsWith("indices:admin/index_template/")) { + // Template related actions can be safely executed without DLS/FLS applied + return false; + } + if (action.equals(BulkAction.NAME)) { + // We do not need to consider top-level bulk actions; we check the shard level later + return false; + } + if (action.equals(MultiSearchAction.NAME)) { + // We do not need to consider top-level multi search actions; we check the search actions that are executed later + return false; + } + if (action.equals(RenderSearchTemplateAction.NAME)) { + // Template related actions trigger further sub actions which we check later + return false; + } + if (action.equals(ReindexAction.NAME)) { + // Reindex actions break apart in search and bulk actions; we will handle on these levels + return false; + } + return true; + } + private static InternalAggregation aggregateBuckets(InternalAggregation aggregation) { if (aggregation instanceof StringTerms) { StringTerms stringTerms = (StringTerms) aggregation; diff --git a/src/main/java/org/opensearch/security/configuration/PrivilegesInterceptorImpl.java b/src/main/java/org/opensearch/security/configuration/PrivilegesInterceptorImpl.java index 6a64eada3e..e6dc0f1bae 100644 --- a/src/main/java/org/opensearch/security/configuration/PrivilegesInterceptorImpl.java +++ b/src/main/java/org/opensearch/security/configuration/PrivilegesInterceptorImpl.java @@ -13,6 +13,7 @@ import java.util.Map; import java.util.Set; +import java.util.function.Supplier; import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; @@ -44,13 +45,14 @@ import org.opensearch.cluster.metadata.IndexAbstraction; import org.opensearch.cluster.metadata.IndexMetadata; import org.opensearch.cluster.metadata.IndexNameExpressionResolver; +import org.opensearch.cluster.metadata.OptionallyResolvedIndices; +import org.opensearch.cluster.metadata.ResolvedIndices; import org.opensearch.cluster.service.ClusterService; +import org.opensearch.security.privileges.DashboardsMultiTenancyConfiguration; import org.opensearch.security.privileges.DocumentAllowList; import org.opensearch.security.privileges.PrivilegesEvaluationContext; import org.opensearch.security.privileges.PrivilegesInterceptor; import org.opensearch.security.privileges.TenantPrivileges; -import org.opensearch.security.resolver.IndexResolverReplacer.Resolved; -import org.opensearch.security.securityconf.DynamicConfigModel; import org.opensearch.security.user.User; import org.opensearch.threadpool.ThreadPool; import org.opensearch.transport.client.Client; @@ -77,13 +79,20 @@ public class PrivilegesInterceptorImpl extends PrivilegesInterceptor { protected final Logger log = LogManager.getLogger(this.getClass()); + private final Supplier tenantPrivilegesSupplier; + private final Supplier multiTenancyConfigurationSupplier; + public PrivilegesInterceptorImpl( IndexNameExpressionResolver resolver, ClusterService clusterService, Client client, - ThreadPool threadPool + ThreadPool threadPool, + Supplier tenantPrivilegesSupplier, + Supplier multiTenancyConfigurationSupplier ) { super(resolver, clusterService, client, threadPool); + this.tenantPrivilegesSupplier = tenantPrivilegesSupplier; + this.multiTenancyConfigurationSupplier = multiTenancyConfigurationSupplier; } /** @@ -97,25 +106,31 @@ public ReplaceResult replaceDashboardsIndex( final ActionRequest request, final String action, final User user, - final DynamicConfigModel config, - final Resolved requestedResolved, - final PrivilegesEvaluationContext context, - final TenantPrivileges tenantPrivileges + final PrivilegesEvaluationContext context ) { + DashboardsMultiTenancyConfiguration config = this.multiTenancyConfigurationSupplier.get(); - final boolean enabled = config.isDashboardsMultitenancyEnabled();// config.dynamic.kibana.multitenancy_enabled; + final boolean enabled = config.multitenancyEnabled();// config.dynamic.kibana.multitenancy_enabled; if (!enabled) { return CONTINUE_EVALUATION_REPLACE_RESULT; } + OptionallyResolvedIndices optionallyResolvedIndices = context.getResolvedRequest(); + TenantPrivileges tenantPrivileges = this.tenantPrivilegesSupplier.get(); + + if (!(optionallyResolvedIndices instanceof ResolvedIndices resolvedIndices)) { + // If we have no information about the indices, it is safe to skip multi tenancy handling + return CONTINUE_EVALUATION_REPLACE_RESULT; + } + // next two lines needs to be retrieved from configuration - final String dashboardsServerUsername = config.getDashboardsServerUsername();// config.dynamic.kibana.server_username; - final String dashboardsIndexName = config.getDashboardsIndexname();// config.dynamic.kibana.index; + final String dashboardsServerUsername = config.dashboardsServerUsername();// config.dynamic.kibana.server_username; + final String dashboardsIndexName = config.dashboardsIndex();// config.dynamic.kibana.index; String requestedTenant = user.getRequestedTenant(); if (USER_TENANT.equals(requestedTenant)) { - final boolean private_tenant_enabled = config.isDashboardsPrivateTenantEnabled(); + final boolean private_tenant_enabled = config.privateTenantEnabled(); if (!private_tenant_enabled) { return ACCESS_DENIED_REPLACE_RESULT; } @@ -129,7 +144,7 @@ public ReplaceResult replaceDashboardsIndex( // intercept when requests are not made by the kibana server and if the kibana index/alias (.kibana) is the only index/alias // involved final boolean dashboardsIndexOnly = !user.getName().equals(dashboardsServerUsername) - && resolveToDashboardsIndexOrAlias(requestedResolved, dashboardsIndexName); + && resolveToDashboardsIndexOrAlias(resolvedIndices, dashboardsIndexName); final boolean isTraceEnabled = log.isTraceEnabled(); TenantPrivileges.ActionType actionType = getActionTypeForAction(action); @@ -157,12 +172,12 @@ public ReplaceResult replaceDashboardsIndex( if (isDebugEnabled && !user.getName().equals(dashboardsServerUsername)) { // log statements only here - log.debug("requestedResolved: " + requestedResolved); + log.debug("requestedResolved: {}", resolvedIndices); } // request not made by the kibana server and user index is the only index/alias involved - if (!user.getName().equals(dashboardsServerUsername) && !requestedResolved.isLocalAll()) { - final Set indices = requestedResolved.getAllIndices(); + if (!user.getName().equals(dashboardsServerUsername) && resolvedIndices.local().names().size() == 1) { + final Set indices = resolvedIndices.local().namesOfIndices(context.clusterState()); final String tenantIndexName = toUserIndexName(dashboardsIndexName, requestedTenant); if (indices.size() == 1 && indices.iterator().next().startsWith(tenantIndexName) @@ -201,7 +216,7 @@ public ReplaceResult replaceDashboardsIndex( if (isTraceEnabled) { log.trace("not a request to only the .kibana index"); log.trace(user.getName() + "/" + dashboardsServerUsername); - log.trace(requestedResolved + " does not contain only " + dashboardsIndexName); + log.trace(resolvedIndices + " does not contain only " + dashboardsIndexName); } } @@ -393,15 +408,7 @@ private String toUserIndexName(final String originalDashboardsIndex, final Strin return originalDashboardsIndex + "_" + tenant.hashCode() + "_" + tenant.toLowerCase().replaceAll("[^a-z0-9]+", EMPTY_STRING); } - private static boolean resolveToDashboardsIndexOrAlias(final Resolved requestedResolved, final String dashboardsIndexName) { - if (requestedResolved.isLocalAll()) { - return false; - } - final Set allIndices = requestedResolved.getAllIndices(); - if (allIndices.size() == 1 && allIndices.iterator().next().equals(dashboardsIndexName)) { - return true; - } - final Set aliases = requestedResolved.getAliases(); - return (aliases.size() == 1 && aliases.iterator().next().equals(dashboardsIndexName)); + private static boolean resolveToDashboardsIndexOrAlias(final ResolvedIndices requestedResolved, final String dashboardsIndexName) { + return requestedResolved.local().names().size() == 1 && requestedResolved.local().names().contains(dashboardsIndexName); } } diff --git a/src/main/java/org/opensearch/security/configuration/SecurityFlsDlsIndexSearcherWrapper.java b/src/main/java/org/opensearch/security/configuration/SecurityFlsDlsIndexSearcherWrapper.java index 2cabfbd1a4..96c1616183 100644 --- a/src/main/java/org/opensearch/security/configuration/SecurityFlsDlsIndexSearcherWrapper.java +++ b/src/main/java/org/opensearch/security/configuration/SecurityFlsDlsIndexSearcherWrapper.java @@ -37,9 +37,10 @@ import org.opensearch.security.auditlog.AuditLog; import org.opensearch.security.compliance.ComplianceIndexingOperationListener; import org.opensearch.security.privileges.DocumentAllowList; +import org.opensearch.security.privileges.PrivilegesConfiguration; import org.opensearch.security.privileges.PrivilegesEvaluationContext; import org.opensearch.security.privileges.PrivilegesEvaluationException; -import org.opensearch.security.privileges.PrivilegesEvaluator; +import org.opensearch.security.privileges.RoleMapper; import org.opensearch.security.privileges.dlsfls.DlsFlsBaseContext; import org.opensearch.security.privileges.dlsfls.DlsFlsProcessedConfig; import org.opensearch.security.privileges.dlsfls.DlsRestriction; @@ -69,11 +70,12 @@ public SecurityFlsDlsIndexSearcherWrapper( final ClusterService clusterService, final AuditLog auditlog, final ComplianceIndexingOperationListener ciol, - final PrivilegesEvaluator evaluator, + final PrivilegesConfiguration privilegesConfiguration, + final RoleMapper roleMapper, final Supplier dlsFlsProcessedConfigSupplier, final DlsFlsBaseContext dlsFlsBaseContext ) { - super(indexService, settings, adminDNs, evaluator); + super(indexService, settings, adminDNs, privilegesConfiguration, roleMapper); Set metadataFieldsCopy; if (indexService.getMetadata().getState() == IndexMetadata.State.CLOSE) { if (log.isDebugEnabled()) { diff --git a/src/main/java/org/opensearch/security/configuration/SystemIndexSearcherWrapper.java b/src/main/java/org/opensearch/security/configuration/SystemIndexSearcherWrapper.java index 447f134877..e7f12be80c 100644 --- a/src/main/java/org/opensearch/security/configuration/SystemIndexSearcherWrapper.java +++ b/src/main/java/org/opensearch/security/configuration/SystemIndexSearcherWrapper.java @@ -33,6 +33,7 @@ import org.apache.logging.log4j.Logger; import org.apache.lucene.index.DirectoryReader; +import org.opensearch.cluster.metadata.ResolvedIndices; import org.opensearch.common.CheckedFunction; import org.opensearch.common.settings.Settings; import org.opensearch.common.util.concurrent.ThreadContext; @@ -40,18 +41,15 @@ import org.opensearch.core.index.Index; import org.opensearch.index.IndexService; import org.opensearch.indices.SystemIndexRegistry; +import org.opensearch.security.privileges.PrivilegesConfiguration; import org.opensearch.security.privileges.PrivilegesEvaluationContext; -import org.opensearch.security.privileges.PrivilegesEvaluator; import org.opensearch.security.privileges.PrivilegesEvaluatorResponse; -import org.opensearch.security.resolver.IndexResolverReplacer; -import org.opensearch.security.securityconf.ConfigModel; +import org.opensearch.security.privileges.RoleMapper; import org.opensearch.security.support.ConfigConstants; import org.opensearch.security.support.HeaderHelper; import org.opensearch.security.support.WildcardMatcher; import org.opensearch.security.user.User; -import org.greenrobot.eventbus.Subscribe; - public class SystemIndexSearcherWrapper implements CheckedFunction { protected final Logger log = LogManager.getLogger(this.getClass()); @@ -59,8 +57,8 @@ public class SystemIndexSearcherWrapper implements CheckedFunction securityRoles = evaluator.mapRoles(user, caller); + final Set securityRoles = roleMapper.map(user, caller); if (allowedRolesMatcher.matchAny(securityRoles)) { return true; } diff --git a/src/main/java/org/opensearch/security/dlic/rest/api/AccountApiAction.java b/src/main/java/org/opensearch/security/dlic/rest/api/AccountApiAction.java index c6a01ecad9..a74983733e 100644 --- a/src/main/java/org/opensearch/security/dlic/rest/api/AccountApiAction.java +++ b/src/main/java/org/opensearch/security/dlic/rest/api/AccountApiAction.java @@ -128,7 +128,9 @@ private void userAccount( final TransportAddress remoteAddress, final SecurityDynamicConfiguration configuration ) { - PrivilegesEvaluationContext context = securityApiDependencies.privilegesEvaluator().createContext(user, null); + PrivilegesEvaluationContext context = securityApiDependencies.privilegesConfiguration() + .privilegesEvaluator() + .createContext(user, null); ok( channel, (builder, params) -> builder.startObject() @@ -139,7 +141,7 @@ private void userAccount( .field("user_requested_tenant", user.getRequestedTenant()) .field("backend_roles", user.getRoles()) .field("custom_attribute_names", user.getCustomAttributesMap().keySet()) - .field("tenants", securityApiDependencies.privilegesEvaluator().tenantPrivileges().tenantMap(context)) + .field("tenants", securityApiDependencies.privilegesConfiguration().tenantPrivileges().tenantMap(context)) .field("roles", context.getMappedRoles()) .endObject() ); diff --git a/src/main/java/org/opensearch/security/dlic/rest/api/PermissionsInfoAction.java b/src/main/java/org/opensearch/security/dlic/rest/api/PermissionsInfoAction.java index db67e9b979..06f407f715 100644 --- a/src/main/java/org/opensearch/security/dlic/rest/api/PermissionsInfoAction.java +++ b/src/main/java/org/opensearch/security/dlic/rest/api/PermissionsInfoAction.java @@ -35,7 +35,7 @@ import org.opensearch.security.auditlog.AuditLog; import org.opensearch.security.configuration.AdminDNs; import org.opensearch.security.configuration.ConfigurationRepository; -import org.opensearch.security.privileges.PrivilegesEvaluator; +import org.opensearch.security.privileges.RoleMapper; import org.opensearch.security.ssl.transport.PrincipalExtractor; import org.opensearch.security.support.ConfigConstants; import org.opensearch.security.user.User; @@ -60,7 +60,7 @@ public class PermissionsInfoAction extends BaseRestHandler { private final RestApiPrivilegesEvaluator restApiPrivilegesEvaluator; private final ThreadPool threadPool; - private final PrivilegesEvaluator privilegesEvaluator; + private final RoleMapper roleMapper; private final ConfigurationRepository configurationRepository; protected PermissionsInfoAction( @@ -72,17 +72,17 @@ protected PermissionsInfoAction( final ConfigurationRepository configurationRepository, final ClusterService cs, final PrincipalExtractor principalExtractor, - final PrivilegesEvaluator privilegesEvaluator, + final RoleMapper roleMapper, ThreadPool threadPool, AuditLog auditLog ) { super(); this.threadPool = threadPool; - this.privilegesEvaluator = privilegesEvaluator; + this.roleMapper = roleMapper; this.restApiPrivilegesEvaluator = new RestApiPrivilegesEvaluator( settings, adminDNs, - privilegesEvaluator, + roleMapper, principalExtractor, configPath, threadPool @@ -129,7 +129,7 @@ public void accept(RestChannel channel) throws Exception { final User user = threadPool.getThreadContext().getTransient(ConfigConstants.OPENDISTRO_SECURITY_USER); final TransportAddress remoteAddress = threadPool.getThreadContext() .getTransient(ConfigConstants.OPENDISTRO_SECURITY_REMOTE_ADDRESS); - Set userRoles = privilegesEvaluator.mapRoles(user, remoteAddress); + Set userRoles = roleMapper.map(user, remoteAddress); Boolean hasApiAccess = restApiPrivilegesEvaluator.currentUserHasRestApiAccess(userRoles); Map> disabledEndpoints = restApiPrivilegesEvaluator.getDisabledEndpointsForCurrentUser( user.getName(), diff --git a/src/main/java/org/opensearch/security/dlic/rest/api/RestApiAdminPrivilegesEvaluator.java b/src/main/java/org/opensearch/security/dlic/rest/api/RestApiAdminPrivilegesEvaluator.java index faa0217db2..b45c5754f4 100644 --- a/src/main/java/org/opensearch/security/dlic/rest/api/RestApiAdminPrivilegesEvaluator.java +++ b/src/main/java/org/opensearch/security/dlic/rest/api/RestApiAdminPrivilegesEvaluator.java @@ -23,7 +23,8 @@ import org.opensearch.core.common.transport.TransportAddress; import org.opensearch.security.configuration.AdminDNs; import org.opensearch.security.dlic.rest.support.Utils; -import org.opensearch.security.privileges.PrivilegesEvaluator; +import org.opensearch.security.privileges.PrivilegesConfiguration; +import org.opensearch.security.privileges.PrivilegesEvaluationContext; import org.opensearch.security.securityconf.impl.v7.ActionGroupsV7; import org.opensearch.security.securityconf.impl.v7.RoleV7; import org.opensearch.security.support.WildcardMatcher; @@ -75,7 +76,7 @@ default String build() { private final ThreadContext threadContext; - private final PrivilegesEvaluator privilegesEvaluator; + private final PrivilegesConfiguration privilegesConfiguration; private final AdminDNs adminDNs; @@ -83,12 +84,12 @@ default String build() { public RestApiAdminPrivilegesEvaluator( final ThreadContext threadContext, - final PrivilegesEvaluator privilegesEvaluator, + final PrivilegesConfiguration privilegesConfiguration, final AdminDNs adminDNs, final boolean restapiAdminEnabled ) { this.threadContext = threadContext; - this.privilegesEvaluator = privilegesEvaluator; + this.privilegesConfiguration = privilegesConfiguration; this.adminDNs = adminDNs; this.restapiAdminEnabled = restapiAdminEnabled; } @@ -106,11 +107,10 @@ public boolean isCurrentUserAdminFor(final Endpoint endpoint, final String actio return false; } final String permission = ENDPOINTS_WITH_PERMISSIONS.get(endpoint).build(action); - final boolean hasAccess = privilegesEvaluator.hasRestAdminPermissions( - userAndRemoteAddress.getLeft(), - userAndRemoteAddress.getRight(), - permission - ); + PrivilegesEvaluationContext context = privilegesConfiguration.privilegesEvaluator() + .createContext(userAndRemoteAddress.getLeft(), permission); + final boolean hasAccess = context.getActionPrivileges().hasExplicitClusterPrivilege(context, permission).isAllowed(); + if (logger.isDebugEnabled()) { logger.debug( "User {} with permission {} {} access to endpoint {}", diff --git a/src/main/java/org/opensearch/security/dlic/rest/api/RestApiPrivilegesEvaluator.java b/src/main/java/org/opensearch/security/dlic/rest/api/RestApiPrivilegesEvaluator.java index f1a336986b..5a3b66a561 100644 --- a/src/main/java/org/opensearch/security/dlic/rest/api/RestApiPrivilegesEvaluator.java +++ b/src/main/java/org/opensearch/security/dlic/rest/api/RestApiPrivilegesEvaluator.java @@ -37,7 +37,7 @@ import org.opensearch.security.dlic.rest.support.Utils; import org.opensearch.security.filter.SecurityRequest; import org.opensearch.security.filter.SecurityRequestFactory; -import org.opensearch.security.privileges.PrivilegesEvaluator; +import org.opensearch.security.privileges.RoleMapper; import org.opensearch.security.ssl.transport.PrincipalExtractor; import org.opensearch.security.ssl.util.SSLRequestHelper; import org.opensearch.security.support.ConfigConstants; @@ -50,7 +50,7 @@ public class RestApiPrivilegesEvaluator { protected final Logger logger = LogManager.getLogger(this.getClass()); private final AdminDNs adminDNs; - private final PrivilegesEvaluator privilegesEvaluator; + private final RoleMapper roleMapper; private final PrincipalExtractor principalExtractor; private final Path configPath; private final ThreadPool threadPool; @@ -77,14 +77,14 @@ public class RestApiPrivilegesEvaluator { public RestApiPrivilegesEvaluator( final Settings settings, final AdminDNs adminDNs, - final PrivilegesEvaluator privilegesEvaluator, + final RoleMapper roleMapper, final PrincipalExtractor principalExtractor, final Path configPath, ThreadPool threadPool ) { this.adminDNs = adminDNs; - this.privilegesEvaluator = privilegesEvaluator; + this.roleMapper = roleMapper; this.principalExtractor = principalExtractor; this.configPath = configPath; this.threadPool = threadPool; @@ -376,7 +376,7 @@ private String checkRoleBasedAccessPermissions(RestRequest request, Endpoint end final TransportAddress remoteAddress = userAndRemoteAddress.getRight(); // map the users Security roles - Set userRoles = privilegesEvaluator.mapRoles(user, remoteAddress); + Set userRoles = roleMapper.map(user, remoteAddress); // check if user has any role that grants access if (currentUserHasRestApiAccess(userRoles)) { diff --git a/src/main/java/org/opensearch/security/dlic/rest/api/SecurityApiDependencies.java b/src/main/java/org/opensearch/security/dlic/rest/api/SecurityApiDependencies.java index 498230423f..cb985899b1 100644 --- a/src/main/java/org/opensearch/security/dlic/rest/api/SecurityApiDependencies.java +++ b/src/main/java/org/opensearch/security/dlic/rest/api/SecurityApiDependencies.java @@ -15,7 +15,7 @@ import org.opensearch.security.auditlog.AuditLog; import org.opensearch.security.configuration.AdminDNs; import org.opensearch.security.configuration.ConfigurationRepository; -import org.opensearch.security.privileges.PrivilegesEvaluator; +import org.opensearch.security.privileges.PrivilegesConfiguration; import org.opensearch.security.support.ConfigConstants; public class SecurityApiDependencies { @@ -26,12 +26,12 @@ public class SecurityApiDependencies { private final AuditLog auditLog; private final Settings settings; - private final PrivilegesEvaluator privilegesEvaluator; + private final PrivilegesConfiguration privilegesConfiguration; public SecurityApiDependencies( final AdminDNs adminDNs, final ConfigurationRepository configurationRepository, - final PrivilegesEvaluator privilegesEvaluator, + final PrivilegesConfiguration privilegesConfiguration, final RestApiPrivilegesEvaluator restApiPrivilegesEvaluator, final RestApiAdminPrivilegesEvaluator restApiAdminPrivilegesEvaluator, final AuditLog auditLog, @@ -39,7 +39,7 @@ public SecurityApiDependencies( ) { this.adminDNs = adminDNs; this.configurationRepository = configurationRepository; - this.privilegesEvaluator = privilegesEvaluator; + this.privilegesConfiguration = privilegesConfiguration; this.restApiPrivilegesEvaluator = restApiPrivilegesEvaluator; this.restApiAdminPrivilegesEvaluator = restApiAdminPrivilegesEvaluator; this.auditLog = auditLog; @@ -50,8 +50,8 @@ public AdminDNs adminDNs() { return adminDNs; } - public PrivilegesEvaluator privilegesEvaluator() { - return privilegesEvaluator; + public PrivilegesConfiguration privilegesConfiguration() { + return privilegesConfiguration; } public ConfigurationRepository configurationRepository() { diff --git a/src/main/java/org/opensearch/security/dlic/rest/api/SecurityRestApiActions.java b/src/main/java/org/opensearch/security/dlic/rest/api/SecurityRestApiActions.java index e82a96493a..3039b9c2d1 100644 --- a/src/main/java/org/opensearch/security/dlic/rest/api/SecurityRestApiActions.java +++ b/src/main/java/org/opensearch/security/dlic/rest/api/SecurityRestApiActions.java @@ -23,7 +23,8 @@ import org.opensearch.security.configuration.AdminDNs; import org.opensearch.security.configuration.ConfigurationRepository; import org.opensearch.security.hasher.PasswordHasher; -import org.opensearch.security.privileges.PrivilegesEvaluator; +import org.opensearch.security.privileges.PrivilegesConfiguration; +import org.opensearch.security.privileges.RoleMapper; import org.opensearch.security.resources.ResourceSharingIndexHandler; import org.opensearch.security.resources.migrate.MigrateResourceSharingInfoApiAction; import org.opensearch.security.ssl.SslSettingsManager; @@ -45,7 +46,8 @@ public static Collection getHandler( final ConfigurationRepository configurationRepository, final ClusterService clusterService, final PrincipalExtractor principalExtractor, - final PrivilegesEvaluator evaluator, + final RoleMapper roleMapper, + final PrivilegesConfiguration privilegesConfiguration, final ThreadPool threadPool, final AuditLog auditLog, final SslSettingsManager sslSettingsManager, @@ -57,11 +59,11 @@ public static Collection getHandler( final var securityApiDependencies = new SecurityApiDependencies( adminDns, configurationRepository, - evaluator, - new RestApiPrivilegesEvaluator(settings, adminDns, evaluator, principalExtractor, configPath, threadPool), + privilegesConfiguration, + new RestApiPrivilegesEvaluator(settings, adminDns, roleMapper, principalExtractor, configPath, threadPool), new RestApiAdminPrivilegesEvaluator( threadPool.getThreadContext(), - evaluator, + privilegesConfiguration, adminDns, settings.getAsBoolean(SECURITY_RESTAPI_ADMIN_ENABLED, false) ), @@ -85,7 +87,7 @@ public static Collection getHandler( configurationRepository, clusterService, principalExtractor, - evaluator, + roleMapper, threadPool, auditLog ), diff --git a/src/main/java/org/opensearch/security/filter/SecurityFilter.java b/src/main/java/org/opensearch/security/filter/SecurityFilter.java index c731c3545c..abd1329250 100644 --- a/src/main/java/org/opensearch/security/filter/SecurityFilter.java +++ b/src/main/java/org/opensearch/security/filter/SecurityFilter.java @@ -61,7 +61,10 @@ import org.opensearch.action.search.SearchRequest; import org.opensearch.action.support.ActionFilter; import org.opensearch.action.support.ActionFilterChain; +import org.opensearch.action.support.ActionRequestMetadata; import org.opensearch.action.update.UpdateRequest; +import org.opensearch.cluster.metadata.OptionallyResolvedIndices; +import org.opensearch.cluster.metadata.ResolvedIndices; import org.opensearch.cluster.service.ClusterService; import org.opensearch.common.settings.Settings; import org.opensearch.common.util.concurrent.ThreadContext; @@ -85,17 +88,19 @@ import org.opensearch.security.configuration.CompatConfig; import org.opensearch.security.configuration.DlsFlsRequestValve; import org.opensearch.security.http.XFFResolver; +import org.opensearch.security.privileges.PrivilegesConfiguration; import org.opensearch.security.privileges.PrivilegesEvaluationContext; import org.opensearch.security.privileges.PrivilegesEvaluator; import org.opensearch.security.privileges.PrivilegesEvaluatorResponse; import org.opensearch.security.privileges.ResourceAccessEvaluator; -import org.opensearch.security.resolver.IndexResolverReplacer; +import org.opensearch.security.privileges.RoleMapper; import org.opensearch.security.resources.ResourceAccessHandler; import org.opensearch.security.support.Base64Helper; import org.opensearch.security.support.ConfigConstants; import org.opensearch.security.support.HeaderHelper; import org.opensearch.security.support.SourceFieldsContext; import org.opensearch.security.support.WildcardMatcher; +import org.opensearch.security.user.ThreadContextUserInfo; import org.opensearch.security.user.User; import org.opensearch.tasks.Task; import org.opensearch.threadpool.ThreadPool; @@ -107,7 +112,8 @@ public class SecurityFilter implements ActionFilter { protected final Logger log = LogManager.getLogger(this.getClass()); - private final PrivilegesEvaluator evalp; + private final PrivilegesConfiguration privilegesConfiguration; + private final RoleMapper roleMapper; private final AdminDNs adminDns; private final DlsFlsRequestValve dlsFlsValve; private final AuditLog auditLog; @@ -115,15 +121,17 @@ public class SecurityFilter implements ActionFilter { private final ClusterService cs; private final ClusterInfoHolder clusterInfoHolder; private final CompatConfig compatConfig; - private final IndexResolverReplacer indexResolverReplacer; + private final XFFResolver xffResolver; private final WildcardMatcher immutableIndicesMatcher; private final RolesInjector rolesInjector; private final UserInjector userInjector; private final ResourceAccessEvaluator resourceAccessEvaluator; + private final ThreadContextUserInfo threadContextUserInfo; public SecurityFilter( final Settings settings, - final PrivilegesEvaluator evalp, + PrivilegesConfiguration privilegesConfiguration, + RoleMapper roleMapper, final AdminDNs adminDns, DlsFlsRequestValve dlsFlsValve, AuditLog auditLog, @@ -131,12 +139,12 @@ public SecurityFilter( ClusterService cs, final ClusterInfoHolder clusterInfoHolder, final CompatConfig compatConfig, - final IndexResolverReplacer indexResolverReplacer, final XFFResolver xffResolver, Set resourceIndices, ResourceAccessHandler resourceAccessHandler ) { - this.evalp = evalp; + this.privilegesConfiguration = privilegesConfiguration; + this.roleMapper = roleMapper; this.adminDns = adminDns; this.dlsFlsValve = dlsFlsValve; this.auditLog = auditLog; @@ -144,13 +152,14 @@ public SecurityFilter( this.cs = cs; this.clusterInfoHolder = clusterInfoHolder; this.compatConfig = compatConfig; - this.indexResolverReplacer = indexResolverReplacer; + this.xffResolver = xffResolver; this.immutableIndicesMatcher = WildcardMatcher.from( settings.getAsList(ConfigConstants.SECURITY_COMPLIANCE_IMMUTABLE_INDICES, Collections.emptyList()) ); this.rolesInjector = new RolesInjector(auditLog); this.userInjector = new UserInjector(settings, threadPool, auditLog, xffResolver); this.resourceAccessEvaluator = new ResourceAccessEvaluator(resourceIndices, settings, resourceAccessHandler); + this.threadContextUserInfo = new ThreadContextUserInfo(threadPool.getThreadContext(), privilegesConfiguration, settings); log.info("{} indices are made immutable.", immutableIndicesMatcher); } @@ -169,12 +178,13 @@ public void app Task task, final String action, Request request, + ActionRequestMetadata actionRequestMetadata, ActionListener listener, ActionFilterChain chain ) { try (StoredContext ctx = threadPool.getThreadContext().newStoredContext(true)) { org.apache.logging.log4j.ThreadContext.clearAll(); - apply0(task, action, request, listener, chain); + apply0(task, action, request, actionRequestMetadata, listener, chain); } } @@ -186,6 +196,7 @@ private void ap Task task, final String action, Request request, + ActionRequestMetadata actionRequestMetadata, ActionListener listener, ActionFilterChain chain ) { @@ -199,7 +210,7 @@ private void ap if (complianceConfig != null && complianceConfig.isEnabled()) { attachSourceFieldContext(request); } - final Set injectedRoles = rolesInjector.injectUserAndRoles(threadPool); + rolesInjector.injectUserAndRoles(threadPool); User user = threadContext.getTransient(ConfigConstants.OPENDISTRO_SECURITY_USER); if (user == null) { UserInjector.Result injectedUser = userInjector.getInjectedUser(); @@ -310,13 +321,13 @@ private void ap if (request instanceof BulkShardRequest) { for (BulkItemRequest bsr : ((BulkShardRequest) request).items()) { - isImmutable = checkImmutableIndices(bsr.request(), listener); + isImmutable = checkImmutableIndices(bsr.request(), actionRequestMetadata, listener); if (isImmutable) { break; } } } else { - isImmutable = checkImmutableIndices(request, listener); + isImmutable = checkImmutableIndices(request, actionRequestMetadata, listener); } if (isImmutable) { @@ -327,7 +338,6 @@ private void ap if (Origin.LOCAL.toString().equals(threadContext.getTransient(ConfigConstants.OPENDISTRO_SECURITY_ORIGIN)) && (interClusterRequest || HeaderHelper.isDirectRequest(threadContext)) - && (injectedRoles == null) && (user == null)) { chain.proceed(task, action, request, listener); @@ -379,25 +389,14 @@ private void ap } } - final PrivilegesEvaluator eval = evalp; - - if (!eval.isInitialized()) { - StringBuilder error = new StringBuilder("OpenSearch Security not initialized for "); - error.append(action); - if (!clusterInfoHolder.hasClusterManager()) { - error.append(String.format(". %s", ClusterInfoHolder.CLUSTER_MANAGER_NOT_PRESENT)); - } - - log.error(error.toString()); - listener.onFailure(new OpenSearchSecurityException(error.toString(), RestStatus.SERVICE_UNAVAILABLE)); - return; - } + final PrivilegesEvaluator eval = this.privilegesConfiguration.privilegesEvaluator(); if (log.isTraceEnabled()) { log.trace("Evaluate permissions for user: {}", user.getName()); } - PrivilegesEvaluationContext context = eval.createContext(user, action, request, task, injectedRoles); + PrivilegesEvaluationContext context = eval.createContext(user, action, request, actionRequestMetadata, task); + this.threadContextUserInfo.setUserInfoInThreadContext(context); User finalUser = user; Consumer handleUnauthorized = response -> { auditLog.logMissingPrivileges(action, request, task); @@ -405,13 +404,7 @@ private void ap if (!response.getMissingSecurityRoles().isEmpty()) { err = String.format("No mapping for %s on roles %s", finalUser, response.getMissingSecurityRoles()); } else { - err = (injectedRoles != null) - ? String.format( - "no permissions for %s and associated roles %s", - response.getMissingPrivileges(), - context.getMappedRoles() - ) - : String.format("no permissions for %s and %s", response.getMissingPrivileges(), finalUser); + err = String.format("no permissions for %s and %s", response.getMissingPrivileges(), finalUser); } log.debug(err); listener.onFailure(new OpenSearchSecurityException(err, RestStatus.FORBIDDEN)); @@ -567,7 +560,7 @@ private boolean handlePermissionCheckRequest( } @SuppressWarnings("rawtypes") - private boolean checkImmutableIndices(Object request, ActionListener listener) { + private boolean checkImmutableIndices(Object request, ActionRequestMetadata actionRequestMetadata, ActionListener listener) { final boolean isModifyIndexRequest = request instanceof DeleteRequest || request instanceof UpdateRequest || request instanceof UpdateByQueryRequest @@ -577,24 +570,24 @@ private boolean checkImmutableIndices(Object request, ActionListener listener) { || request instanceof CloseIndexRequest || request instanceof IndicesAliasesRequest; - if (isModifyIndexRequest && isRequestIndexImmutable(request)) { + if (isModifyIndexRequest && isRequestIndexImmutable(request, actionRequestMetadata)) { listener.onFailure(new OpenSearchSecurityException("Index is immutable", RestStatus.FORBIDDEN)); return true; } - if ((request instanceof IndexRequest) && isRequestIndexImmutable(request)) { + if ((request instanceof IndexRequest) && isRequestIndexImmutable(request, actionRequestMetadata)) { ((IndexRequest) request).opType(OpType.CREATE); } return false; } - private boolean isRequestIndexImmutable(Object request) { - final IndexResolverReplacer.Resolved resolved = indexResolverReplacer.resolveRequest(request); - if (resolved.isLocalAll()) { + private boolean isRequestIndexImmutable(Object request, ActionRequestMetadata actionRequestMetadata) { + OptionallyResolvedIndices optionalResolvedIndices = actionRequestMetadata.resolvedIndices(); + if (optionalResolvedIndices instanceof ResolvedIndices resolvedIndices) { + return immutableIndicesMatcher.matchAny(resolvedIndices.local().namesOfIndices(cs.state())); + } else { return true; } - final Set allIndices = resolved.getAllIndices(); - return immutableIndicesMatcher.matchAny(allIndices); } } diff --git a/src/main/java/org/opensearch/security/filter/SecurityRestFilter.java b/src/main/java/org/opensearch/security/filter/SecurityRestFilter.java index 10934e9a57..260dacb2c8 100644 --- a/src/main/java/org/opensearch/security/filter/SecurityRestFilter.java +++ b/src/main/java/org/opensearch/security/filter/SecurityRestFilter.java @@ -250,14 +250,13 @@ void authorizeRequest(RestHandler original, SecurityRequestChannel request, User .findFirst(); final boolean routeSupportsRestAuthorization = handler.isPresent() && handler.get() instanceof NamedRoute; if (routeSupportsRestAuthorization) { - PrivilegesEvaluatorResponse pres = new PrivilegesEvaluatorResponse(); NamedRoute route = ((NamedRoute) handler.get()); // Check both route.actionNames() and route.name(). The presence of either is sufficient. Set actionNames = ImmutableSet.builder() .addAll(route.actionNames() != null ? route.actionNames() : Collections.emptySet()) .add(route.name()) .build(); - pres = evaluator.evaluate(user, route.name(), actionNames); + PrivilegesEvaluatorResponse pres = evaluator.evaluate(user, route.name(), actionNames); if (log.isDebugEnabled()) { log.debug(pres.toString()); diff --git a/src/main/java/org/opensearch/security/identity/SecurityTokenManager.java b/src/main/java/org/opensearch/security/identity/SecurityTokenManager.java index 519162c980..0bcf81a81d 100644 --- a/src/main/java/org/opensearch/security/identity/SecurityTokenManager.java +++ b/src/main/java/org/opensearch/security/identity/SecurityTokenManager.java @@ -31,7 +31,7 @@ import org.opensearch.security.authtoken.jwt.ExpiringBearerAuthToken; import org.opensearch.security.authtoken.jwt.JwtVendor; import org.opensearch.security.authtoken.jwt.claims.OBOJwtClaimsBuilder; -import org.opensearch.security.securityconf.ConfigModel; +import org.opensearch.security.privileges.RoleMapper; import org.opensearch.security.securityconf.DynamicConfigModel; import org.opensearch.security.support.ConfigConstants; import org.opensearch.security.user.User; @@ -53,21 +53,22 @@ public class SecurityTokenManager implements TokenManager { private final ClusterService cs; private final ThreadPool threadPool; private final UserService userService; + private final RoleMapper roleMapper; private Settings oboSettings = null; - private ConfigModel configModel = null; private final LongSupplier timeProvider = System::currentTimeMillis; private static final Integer OBO_MAX_EXPIRY_SECONDS = 600; - public SecurityTokenManager(final ClusterService cs, final ThreadPool threadPool, final UserService userService) { + public SecurityTokenManager( + final ClusterService cs, + final ThreadPool threadPool, + final UserService userService, + RoleMapper roleMapper + ) { this.cs = cs; this.threadPool = threadPool; this.userService = userService; - } - - @Subscribe - public void onConfigModelChanged(final ConfigModel configModel) { - this.configModel = configModel; + this.roleMapper = roleMapper; } @Subscribe @@ -90,7 +91,7 @@ JwtVendor createJwtVendor(final Settings settings) { } public boolean issueOnBehalfOfTokenAllowed() { - return oboSettings != null && configModel != null; + return oboSettings != null; } @Override @@ -117,7 +118,7 @@ public ExpiringBearerAuthToken issueOnBehalfOfToken(final Subject subject, final } final TransportAddress callerAddress = null; /* OBO tokens must not roles based on location from network address */ - final Set mappedRoles = configModel.mapSecurityRoles(user, callerAddress); + final Set mappedRoles = roleMapper.map(user, callerAddress); final long currentTimeMs = timeProvider.getAsLong(); final Date now = new Date(currentTimeMs); diff --git a/src/main/java/org/opensearch/security/privileges/ActionPrivileges.java b/src/main/java/org/opensearch/security/privileges/ActionPrivileges.java index 9ee104246f..d47d9be4c1 100644 --- a/src/main/java/org/opensearch/security/privileges/ActionPrivileges.java +++ b/src/main/java/org/opensearch/security/privileges/ActionPrivileges.java @@ -13,7 +13,7 @@ import java.util.Set; -import org.opensearch.security.resolver.IndexResolverReplacer; +import org.opensearch.cluster.metadata.OptionallyResolvedIndices; /** * Defines the general interface for evaluating privileges on actions. References to ActionPrivileges instances @@ -77,9 +77,18 @@ public interface ActionPrivileges { PrivilegesEvaluatorResponse hasIndexPrivilege( PrivilegesEvaluationContext context, Set actions, - IndexResolverReplacer.Resolved resolvedIndices + OptionallyResolvedIndices resolvedIndices ); + /** + * Checks whether this instance provides privileges for the provided actions on any possible index. + *

+ * Returns a PrivilegesEvaluatorResponse with allowed=true if privileges are available. + *

+ * If no privileges are available, this method will return PrivilegeEvaluatorResponse.insufficient() + */ + PrivilegesEvaluatorResponse hasIndexPrivilegeForAnyIndex(PrivilegesEvaluationContext context, Set actions); + /** * Checks whether this instance provides explicit privileges for the combination of the provided action, * the provided indices and the provided roles. @@ -90,7 +99,7 @@ PrivilegesEvaluatorResponse hasIndexPrivilege( PrivilegesEvaluatorResponse hasExplicitIndexPrivilege( PrivilegesEvaluationContext context, Set actions, - IndexResolverReplacer.Resolved resolvedIndices + OptionallyResolvedIndices resolvedIndices ); ActionPrivileges EMPTY = new ActionPrivileges() { @@ -113,16 +122,21 @@ public PrivilegesEvaluatorResponse hasExplicitClusterPrivilege(PrivilegesEvaluat public PrivilegesEvaluatorResponse hasIndexPrivilege( PrivilegesEvaluationContext context, Set actions, - IndexResolverReplacer.Resolved resolvedIndices + OptionallyResolvedIndices resolvedIndices ) { return PrivilegesEvaluatorResponse.insufficient("all of " + actions).reason("User has no privileges"); } + @Override + public PrivilegesEvaluatorResponse hasIndexPrivilegeForAnyIndex(PrivilegesEvaluationContext context, Set actions) { + return PrivilegesEvaluatorResponse.insufficient("all of " + actions).reason("User has no privileges"); + } + @Override public PrivilegesEvaluatorResponse hasExplicitIndexPrivilege( PrivilegesEvaluationContext context, Set actions, - IndexResolverReplacer.Resolved resolvedIndices + OptionallyResolvedIndices resolvedIndices ) { return PrivilegesEvaluatorResponse.insufficient("all of " + actions).reason("User has no privileges"); } diff --git a/src/main/java/org/opensearch/security/privileges/ConfigurableRoleMapper.java b/src/main/java/org/opensearch/security/privileges/ConfigurableRoleMapper.java new file mode 100644 index 0000000000..14ab7ffa42 --- /dev/null +++ b/src/main/java/org/opensearch/security/privileges/ConfigurableRoleMapper.java @@ -0,0 +1,249 @@ +/* + * 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. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.privileges; + +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; + +import com.google.common.collect.ArrayListMultimap; +import com.google.common.collect.ImmutableSet; +import com.google.common.collect.ListMultimap; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import org.opensearch.common.settings.Settings; +import org.opensearch.core.common.transport.TransportAddress; +import org.opensearch.security.configuration.ConfigurationRepository; +import org.opensearch.security.securityconf.impl.CType; +import org.opensearch.security.securityconf.impl.SecurityDynamicConfiguration; +import org.opensearch.security.securityconf.impl.v7.ConfigV7; +import org.opensearch.security.securityconf.impl.v7.RoleMappingsV7; +import org.opensearch.security.support.ConfigConstants; +import org.opensearch.security.support.HostResolverMode; +import org.opensearch.security.support.WildcardMatcher; +import org.opensearch.security.user.User; + +/** + * A RoleMapper implementation that automatically picks up changes from the role mapping configuration in the configuration index. + */ +public class ConfigurableRoleMapper implements RoleMapper { + private final static Logger log = LogManager.getLogger(ConfigurableRoleMapper.class); + + private final AtomicReference activeConfiguration = new AtomicReference<>(); + + public ConfigurableRoleMapper(ConfigurationRepository configurationRepository, ResolutionMode resolutionMode) { + if (configurationRepository != null) { + configurationRepository.subscribeOnChange(configMap -> { + HostResolverMode hostResolverMode = getHostResolverMode(configurationRepository.getConfiguration(CType.CONFIG)); + SecurityDynamicConfiguration rawRoleMappingConfiguration = configurationRepository.getConfiguration( + CType.ROLESMAPPING + ); + if (rawRoleMappingConfiguration == null) { + rawRoleMappingConfiguration = SecurityDynamicConfiguration.empty(CType.ROLESMAPPING); + } + + this.activeConfiguration.set(new CompiledConfiguration(rawRoleMappingConfiguration, hostResolverMode, resolutionMode)); + }); + } + } + + public ConfigurableRoleMapper(ConfigurationRepository configurationRepository, Settings settings) { + this(configurationRepository, ResolutionMode.fromSettings(settings)); + } + + @Override + public ImmutableSet map(User user, TransportAddress caller) { + CompiledConfiguration activeConfiguration = this.activeConfiguration.get(); + + if (activeConfiguration != null) { + return activeConfiguration.map(user, caller); + } else { + return ImmutableSet.of(); + } + } + + /** + * Determines which roles are used in the final set of effective roles returned by the map() method. + * + * The setting is sourced from the plugins.secutiry.roles_mapping_resolution setting. + */ + enum ResolutionMode { + /** + * Include only the target roles from the role mapping configuration. + */ + MAPPING_ONLY, + + /** + * Include only the backend roles. This effectively disables the role mapping process. + */ + BACKENDROLES_ONLY, + + /** + * Include the union of the target roles and the source backend roles. + */ + BOTH; + + static ResolutionMode fromSettings(Settings settings) { + + try { + return ResolutionMode.valueOf( + settings.get(ConfigConstants.SECURITY_ROLES_MAPPING_RESOLUTION, ResolutionMode.MAPPING_ONLY.toString()).toUpperCase() + ); + } catch (Exception e) { + log.error("Cannot apply roles mapping resolution", e); + return ResolutionMode.MAPPING_ONLY; + } + } + } + + private static HostResolverMode getHostResolverMode(SecurityDynamicConfiguration configConfig) { + final HostResolverMode defaultValue = HostResolverMode.IP_HOSTNAME; + + if (configConfig == null) { + return defaultValue; + } + + ConfigV7 config = configConfig.getCEntry(CType.CONFIG.name()); + if (config == null || config.dynamic == null) { + return defaultValue; + } + return HostResolverMode.fromConfig(config.dynamic.hosts_resolver_mode); + } + + /** + * Moved from https://github.com/opensearch-project/security/blob/d29095f26dba1a26308c69b608dc926bd40c0f52/src/main/java/org/opensearch/security/securityconf/ConfigModelV7.java + */ + static class CompiledConfiguration implements RoleMapper { + + private final ResolutionMode resolutionMode; + private final HostResolverMode hostResolverMode; + + private ListMultimap users; + private ListMultimap, String> abars; + private ListMultimap bars; + private ListMultimap hosts; + + private List userMatchers; + private List barMatchers; + private List hostMatchers; + + private CompiledConfiguration( + SecurityDynamicConfiguration rolemappings, + HostResolverMode hostResolverMode, + ResolutionMode resolutionMode + ) { + + this.hostResolverMode = hostResolverMode; + this.resolutionMode = resolutionMode; + + users = ArrayListMultimap.create(); + abars = ArrayListMultimap.create(); + bars = ArrayListMultimap.create(); + hosts = ArrayListMultimap.create(); + + for (final Map.Entry roleMap : rolemappings.getCEntries().entrySet()) { + final String roleMapKey = roleMap.getKey(); + final RoleMappingsV7 roleMapValue = roleMap.getValue(); + + for (String u : roleMapValue.getUsers()) { + users.put(u, roleMapKey); + } + + final Set abar = new HashSet<>(roleMapValue.getAnd_backend_roles()); + + if (!abar.isEmpty()) { + abars.put(WildcardMatcher.matchers(abar), roleMapKey); + } + + for (String bar : roleMapValue.getBackend_roles()) { + bars.put(bar, roleMapKey); + } + + for (String host : roleMapValue.getHosts()) { + hosts.put(host, roleMapKey); + } + } + + userMatchers = WildcardMatcher.matchers(users.keySet()); + barMatchers = WildcardMatcher.matchers(bars.keySet()); + hostMatchers = WildcardMatcher.matchers(hosts.keySet()); + + } + + @Override + public ImmutableSet map(final User user, final TransportAddress caller) { + + if (user == null) { + return ImmutableSet.of(); + } + + ImmutableSet.Builder result = ImmutableSet.builderWithExpectedSize( + user.getSecurityRoles().size() + user.getRoles().size() + ); + + result.addAll(user.getSecurityRoles()); + + if (resolutionMode == ResolutionMode.BOTH || resolutionMode == ResolutionMode.BACKENDROLES_ONLY) { + result.addAll(user.getRoles()); + } + + if (((resolutionMode == ResolutionMode.BOTH || resolutionMode == ResolutionMode.MAPPING_ONLY))) { + + for (String p : WildcardMatcher.getAllMatchingPatterns(userMatchers, user.getName())) { + result.addAll(users.get(p)); + } + for (String p : WildcardMatcher.getAllMatchingPatterns(barMatchers, user.getRoles())) { + result.addAll(bars.get(p)); + } + + for (List patterns : abars.keySet()) { + if (patterns.stream().allMatch(p -> p.matchAny(user.getRoles()))) { + result.addAll(abars.get(patterns)); + } + } + + if (caller != null) { + // IPV4 or IPv6 (compressed and without scope identifiers) + final String ipAddress = caller.getAddress(); + + for (String p : WildcardMatcher.getAllMatchingPatterns(hostMatchers, ipAddress)) { + result.addAll(hosts.get(p)); + } + + if (caller.address() != null + && (hostResolverMode == HostResolverMode.IP_HOSTNAME || hostResolverMode == HostResolverMode.IP_HOSTNAME_LOOKUP)) { + final String hostName = caller.address().getHostString(); + + for (String p : WildcardMatcher.getAllMatchingPatterns(hostMatchers, hostName)) { + result.addAll(hosts.get(p)); + } + } + + if (caller.address() != null && hostResolverMode == HostResolverMode.IP_HOSTNAME_LOOKUP) { + + final String resolvedHostName = caller.address().getHostName(); + + for (String p : WildcardMatcher.getAllMatchingPatterns(hostMatchers, resolvedHostName)) { + result.addAll(hosts.get(p)); + } + } + } + } + + return result.build(); + } + } + +} diff --git a/src/main/java/org/opensearch/security/privileges/DashboardsMultiTenancyConfiguration.java b/src/main/java/org/opensearch/security/privileges/DashboardsMultiTenancyConfiguration.java new file mode 100644 index 0000000000..ebd3306478 --- /dev/null +++ b/src/main/java/org/opensearch/security/privileges/DashboardsMultiTenancyConfiguration.java @@ -0,0 +1,72 @@ +/* + * 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. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.privileges; + +import org.opensearch.security.securityconf.impl.v7.ConfigV7; + +public class DashboardsMultiTenancyConfiguration { + public static final DashboardsMultiTenancyConfiguration DEFAULT = new DashboardsMultiTenancyConfiguration(new ConfigV7.Kibana()); + + private final boolean multitenancyEnabled; + private final boolean privateTenantEnabled; + private final String defaultTenant; + private final String index; + private final String serverUsername; + private final String role; + + public DashboardsMultiTenancyConfiguration(ConfigV7.Kibana dashboardsConfig) { + this.multitenancyEnabled = dashboardsConfig.multitenancy_enabled; + this.privateTenantEnabled = dashboardsConfig.private_tenant_enabled; + this.defaultTenant = dashboardsConfig.default_tenant; + this.index = dashboardsConfig.index; + this.serverUsername = dashboardsConfig.server_username; + this.role = dashboardsConfig.opendistro_role; + } + + public DashboardsMultiTenancyConfiguration(ConfigV7 generalConfig) { + this(dashboardsConfig(generalConfig)); + } + + public boolean multitenancyEnabled() { + return multitenancyEnabled; + } + + public boolean privateTenantEnabled() { + return privateTenantEnabled; + } + + public String dashboardsDefaultTenant() { + return defaultTenant; + } + + public String dashboardsIndex() { + return index; + } + + public String dashboardsServerUsername() { + return serverUsername; + } + + public String dashboardsOpenSearchRole() { + return role; + } + + private static ConfigV7.Kibana dashboardsConfig(ConfigV7 generalConfig) { + if (generalConfig != null && generalConfig.dynamic != null && generalConfig.dynamic.kibana != null) { + return generalConfig.dynamic.kibana; + } else { + // Fallback to defaults + return new ConfigV7.Kibana(); + } + } + +} diff --git a/src/main/java/org/opensearch/security/privileges/DocumentAllowList.java b/src/main/java/org/opensearch/security/privileges/DocumentAllowList.java index 6e41857737..e5518eb63d 100644 --- a/src/main/java/org/opensearch/security/privileges/DocumentAllowList.java +++ b/src/main/java/org/opensearch/security/privileges/DocumentAllowList.java @@ -17,6 +17,8 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.opensearch.action.ActionRequest; +import org.opensearch.action.get.GetRequest; import org.opensearch.common.util.concurrent.ThreadContext; import org.opensearch.security.support.ConfigConstants; @@ -45,6 +47,37 @@ public static DocumentAllowList get(ThreadContext threadContext) { } } + public static boolean isAllowed(ActionRequest request, ThreadContext threadContext) { + String docAllowListHeader = threadContext.getHeader(ConfigConstants.OPENDISTRO_SECURITY_DOC_ALLOWLIST_HEADER); + + if (docAllowListHeader == null) { + return false; + } + + if (!(request instanceof GetRequest)) { + return false; + } + + try { + DocumentAllowList documentAllowList = DocumentAllowList.parse(docAllowListHeader); + GetRequest getRequest = (GetRequest) request; + + if (documentAllowList.isAllowed(getRequest.index(), getRequest.id())) { + if (log.isDebugEnabled()) { + log.debug("Request " + request + " is allowed by " + documentAllowList); + } + + return true; + } else { + return false; + } + + } catch (Exception e) { + log.error("Error while handling document allow list: " + docAllowListHeader, e); + return false; + } + } + private static final DocumentAllowList EMPTY = new DocumentAllowList(); private final Set entries = new HashSet<>(); diff --git a/src/main/java/org/opensearch/security/privileges/IndexPattern.java b/src/main/java/org/opensearch/security/privileges/IndexPattern.java index 014af99e54..977cbfe15c 100644 --- a/src/main/java/org/opensearch/security/privileges/IndexPattern.java +++ b/src/main/java/org/opensearch/security/privileges/IndexPattern.java @@ -21,6 +21,7 @@ import org.apache.logging.log4j.Logger; import org.opensearch.cluster.metadata.IndexAbstraction; +import org.opensearch.cluster.metadata.IndexMetadata; import org.opensearch.cluster.metadata.IndexNameExpressionResolver; import org.opensearch.security.support.WildcardMatcher; @@ -35,7 +36,7 @@ public class IndexPattern { /** * An IndexPattern which does not match any index. */ - public static final IndexPattern EMPTY = new IndexPattern(WildcardMatcher.NONE, ImmutableList.of(), ImmutableList.of()); + public static final IndexPattern EMPTY = new IndexPattern(WildcardMatcher.NONE, ImmutableList.of(), ImmutableList.of(), false); /** * Plain index patterns without any dynamic expressions like user attributes and date math. @@ -53,17 +54,71 @@ public class IndexPattern { */ private final ImmutableList dateMathExpressions; private final int hashCode; - - private IndexPattern(WildcardMatcher staticPattern, ImmutableList patternTemplates, ImmutableList dateMathExpressions) { + private final boolean memberIndexPrivilegesYieldAliasPrivileges; + + private IndexPattern( + WildcardMatcher staticPattern, + ImmutableList patternTemplates, + ImmutableList dateMathExpressions, + boolean memberIndexPrivilegesYieldALiasPrivileges + ) { this.staticPattern = staticPattern; this.patternTemplates = patternTemplates; this.dateMathExpressions = dateMathExpressions; this.hashCode = staticPattern.hashCode() + patternTemplates.hashCode() + dateMathExpressions.hashCode(); + this.memberIndexPrivilegesYieldAliasPrivileges = memberIndexPrivilegesYieldALiasPrivileges; } - public boolean matches(String index, PrivilegesEvaluationContext context, Map indexMetadata) + public boolean matches( + String indexOrAliasOrDatastream, + PrivilegesEvaluationContext context, + Map indexMetadata + ) throws PrivilegesEvaluationException { + + if (matchesDirectly(indexOrAliasOrDatastream, context)) { + return true; + } + + IndexAbstraction indexAbstraction = indexMetadata.get(indexOrAliasOrDatastream); + + if (indexAbstraction instanceof IndexAbstraction.Index) { + // Check for the privilege for aliases or data streams containing this index + + if (indexAbstraction.getParentDataStream() != null) { + if (matchesDirectly(indexAbstraction.getParentDataStream().getName(), context)) { + return true; + } + } + + // Retrieve aliases: The use of getWriteIndex() is a bit messy, but it is the only way to access + // alias metadata from here. + for (String alias : indexAbstraction.getWriteIndex().getAliases().keySet()) { + if (matchesDirectly(alias, context)) { + return true; + } + } + + return false; + } else if (this.memberIndexPrivilegesYieldAliasPrivileges + && (indexAbstraction instanceof IndexAbstraction.Alias || indexAbstraction instanceof IndexAbstraction.DataStream)) { + // We have a data stream or alias: If we have no match so far, let's also check whether we have privileges for all members. + + for (IndexMetadata memberIndex : indexAbstraction.getIndices()) { + if (!matchesDirectly(memberIndex.getIndex().getName(), context)) { + return false; + } + } + + // If we could match all members, we have a match + return true; + } else { + return false; + } + } + + private boolean matchesDirectly(String indexOrAliasOrDatastream, PrivilegesEvaluationContext context) throws PrivilegesEvaluationException { - if (staticPattern != WildcardMatcher.NONE && staticPattern.test(index)) { + if (staticPattern != WildcardMatcher.NONE && staticPattern.test(indexOrAliasOrDatastream)) { return true; } @@ -72,7 +127,7 @@ public boolean matches(String index, PrivilegesEvaluationContext context, Map constantPatterns = new ArrayList<>(); private List patternTemplates = new ArrayList<>(); private List dateMathExpressions = new ArrayList<>(); + private boolean memberIndexPrivilegesYieldAliasPrivileges; + + public Builder(boolean memberIndexPrivilegesYieldAliasPrivileges) { + this.memberIndexPrivilegesYieldAliasPrivileges = memberIndexPrivilegesYieldAliasPrivileges; + } public void add(List source) { for (int i = 0; i < source.size(); i++) { @@ -236,18 +281,22 @@ public IndexPattern build() { return new IndexPattern( constantPatterns.size() != 0 ? WildcardMatcher.from(constantPatterns) : WildcardMatcher.NONE, ImmutableList.copyOf(patternTemplates), - ImmutableList.copyOf(dateMathExpressions) + ImmutableList.copyOf(dateMathExpressions), + this.memberIndexPrivilegesYieldAliasPrivileges ); } } - public static IndexPattern from(List source) { - Builder builder = new Builder(); + public static IndexPattern from(List source, boolean memberIndexPrivilegesYieldAliasPrivileges) { + Builder builder = new Builder(memberIndexPrivilegesYieldAliasPrivileges); builder.add(source); return builder.build(); } - public static IndexPattern from(String... source) { - return from(Arrays.asList(source)); + /** + * Only for testing. + */ + static IndexPattern from(String... source) { + return from(Arrays.asList(source), true); } } diff --git a/src/main/java/org/opensearch/security/privileges/IndicesRequestModifier.java b/src/main/java/org/opensearch/security/privileges/IndicesRequestModifier.java new file mode 100644 index 0000000000..1b6ac780fd --- /dev/null +++ b/src/main/java/org/opensearch/security/privileges/IndicesRequestModifier.java @@ -0,0 +1,80 @@ +/* + * 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. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ +package org.opensearch.security.privileges; + +import java.util.Collection; +import java.util.List; +import java.util.stream.Stream; + +import org.opensearch.action.ActionRequest; +import org.opensearch.action.IndicesRequest; +import org.opensearch.action.admin.indices.segments.PitSegmentsRequest; +import org.opensearch.cluster.metadata.ResolvedIndices; + +/** + * Provides methods to modify the local indices of an IndicesRequest. All methods use the ResolvedIndices metadata object + * to make sure that remote indices are properly retained. + *

+ * We need the distinction between local indices and remote indices because authorization on remote indices is performed + * on the remote cluster - thus, we can leave them here just as they are. + */ +public class IndicesRequestModifier { + + public boolean setLocalIndices(ActionRequest targetRequest, ResolvedIndices resolvedIndices, Collection newIndices) { + if (newIndices.isEmpty()) { + return setLocalIndicesToEmpty(targetRequest, resolvedIndices); + } + + if (targetRequest instanceof PitSegmentsRequest) { + // PitSegmentsRequest implements IndicesRequest.Replaceable, but ignores all specified indices + return false; + } else if (targetRequest instanceof IndicesRequest.Replaceable) { + ((IndicesRequest.Replaceable) targetRequest).indices(concat(newIndices, resolvedIndices.remote().asRawExpressions())); + return true; + } else { + return false; + } + } + + public boolean setLocalIndicesToEmpty(ActionRequest targetRequest, ResolvedIndices resolvedIndices) { + if (targetRequest instanceof PitSegmentsRequest) { + // PitSegmentsRequest implements IndicesRequest.Replaceable, but ignores all specified indices + return false; + } else if (targetRequest instanceof IndicesRequest.Replaceable replaceable) { + if (resolvedIndices.remote().isEmpty()) { + if (replaceable.indicesOptions().expandWildcardsOpen() || replaceable.indicesOptions().expandWildcardsClosed()) { + // If the request expands wildcards, we use an index expression which resolves to no indices + replaceable.indices(".none*,-*"); + return true; + } else if (replaceable.indicesOptions().allowNoIndices()) { + // If the request does not expand wildcards, we use a index name that cannot exist. + replaceable.indices("-.none*"); + return true; + } else { + // In this case, we cannot perform replacement. But it also won't be necessary due to the + // semantics of the feature + return false; + } + } else { + // If we have remote indices, things get much easier + replaceable.indices(resolvedIndices.remote().asRawExpressionsArray()); + return true; + } + } else { + return false; + } + } + + private String[] concat(Collection local, List remote) { + return Stream.concat(local.stream(), remote.stream()).toArray(String[]::new); + } + +} diff --git a/src/main/java/org/opensearch/security/privileges/IndicesRequestResolver.java b/src/main/java/org/opensearch/security/privileges/IndicesRequestResolver.java new file mode 100644 index 0000000000..da18b4ea06 --- /dev/null +++ b/src/main/java/org/opensearch/security/privileges/IndicesRequestResolver.java @@ -0,0 +1,60 @@ +/* + * 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. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ +package org.opensearch.security.privileges; + +import java.util.function.Supplier; + +import org.opensearch.action.ActionRequest; +import org.opensearch.action.IndicesRequest; +import org.opensearch.action.support.ActionRequestMetadata; +import org.opensearch.cluster.ClusterState; +import org.opensearch.cluster.metadata.IndexNameExpressionResolver; +import org.opensearch.cluster.metadata.OptionallyResolvedIndices; +import org.opensearch.cluster.metadata.ResolvedIndices; + +public class IndicesRequestResolver { + protected final IndexNameExpressionResolver indexNameExpressionResolver; + + public IndicesRequestResolver(IndexNameExpressionResolver indexNameExpressionResolver) { + this.indexNameExpressionResolver = indexNameExpressionResolver; + } + + public OptionallyResolvedIndices resolve( + ActionRequest request, + ActionRequestMetadata actionRequestMetadata, + Supplier clusterStateSupplier + ) { + OptionallyResolvedIndices providedIndices = actionRequestMetadata.resolvedIndices(); + if (providedIndices instanceof ResolvedIndices) { + return providedIndices; + } else { + // The action does not implement the resolution mechanism; we have to do it by ourselves + return resolveFallback(request, clusterStateSupplier.get()); + } + } + + public OptionallyResolvedIndices resolve( + ActionRequest request, + ActionRequestMetadata actionRequestMetadata, + PrivilegesEvaluationContext context + ) { + return resolve(request, actionRequestMetadata, context::clusterState); + } + + private OptionallyResolvedIndices resolveFallback(ActionRequest request, ClusterState clusterState) { + if (request instanceof IndicesRequest indicesRequest) { + return ResolvedIndices.of(this.indexNameExpressionResolver.concreteResolvedIndices(clusterState, indicesRequest)); + } else { + return ResolvedIndices.unknown(); + } + } + +} diff --git a/src/main/java/org/opensearch/security/privileges/PitPrivilegesEvaluator.java b/src/main/java/org/opensearch/security/privileges/PitPrivilegesEvaluator.java deleted file mode 100644 index 4fd4141b08..0000000000 --- a/src/main/java/org/opensearch/security/privileges/PitPrivilegesEvaluator.java +++ /dev/null @@ -1,96 +0,0 @@ -/* - * 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. - * - * Modifications Copyright OpenSearch Contributors. See - * GitHub history for details. - */ -package org.opensearch.security.privileges; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.concurrent.TimeUnit; - -import com.google.common.collect.ImmutableSet; - -import org.opensearch.action.ActionRequest; -import org.opensearch.action.admin.indices.segments.PitSegmentsRequest; -import org.opensearch.action.search.CreatePitRequest; -import org.opensearch.action.search.DeletePitRequest; -import org.opensearch.common.unit.TimeValue; -import org.opensearch.security.OpenSearchSecurityPlugin; -import org.opensearch.security.resolver.IndexResolverReplacer; - -/** - * This class evaluates privileges for point in time (Delete and List all) operations. - * For aliases - users must have either alias permission or backing index permissions - * For data streams - users must have access to backing indices permission + data streams permission. - */ -public class PitPrivilegesEvaluator { - - public PrivilegesEvaluatorResponse evaluate( - final ActionRequest request, - final PrivilegesEvaluationContext context, - final ActionPrivileges actionPrivileges, - final String action, - final PrivilegesEvaluatorResponse presponse, - final IndexResolverReplacer irr - ) { - - if (!(request instanceof DeletePitRequest || request instanceof PitSegmentsRequest)) { - return presponse; - } - List pitIds = new ArrayList<>(); - - if (request instanceof DeletePitRequest) { - DeletePitRequest deletePitRequest = (DeletePitRequest) request; - pitIds = deletePitRequest.getPitIds(); - } else if (request instanceof PitSegmentsRequest) { - PitSegmentsRequest pitSegmentsRequest = (PitSegmentsRequest) request; - pitIds = pitSegmentsRequest.getPitIds(); - } - // if request is for all PIT IDs, skip custom pit ids evaluation - if (pitIds.size() == 1 && "_all".equals(pitIds.get(0))) { - return presponse; - } else { - return handlePitsAccess(pitIds, context, actionPrivileges, action, presponse, irr); - } - } - - /** - * Handle access for delete operation / pit segments operation where PIT IDs are explicitly passed - */ - private PrivilegesEvaluatorResponse handlePitsAccess( - List pitIds, - PrivilegesEvaluationContext context, - ActionPrivileges actionPrivileges, - final String action, - PrivilegesEvaluatorResponse presponse, - final IndexResolverReplacer irr - ) { - Map pitToIndicesMap = OpenSearchSecurityPlugin.GuiceHolder.getPitService().getIndicesForPits(pitIds); - Set pitIndices = new HashSet<>(); - // add indices across all PITs to a set and evaluate if user has access to all indices - for (String[] indices : pitToIndicesMap.values()) { - pitIndices.addAll(Arrays.asList(indices)); - } - String[] indicesArr = new String[pitIndices.size()]; - CreatePitRequest req = new CreatePitRequest(new TimeValue(1, TimeUnit.DAYS), true, pitIndices.toArray(indicesArr)); - final IndexResolverReplacer.Resolved pitResolved = irr.resolveRequest(req); - PrivilegesEvaluatorResponse subResponse = actionPrivileges.hasIndexPrivilege(context, ImmutableSet.of(action), pitResolved); - // Only if user has access to all PIT's indices, allow operation, otherwise continue evaluation in PrivilegesEvaluator. - if (subResponse.isAllowed()) { - presponse.allowed = true; - presponse.markComplete(); - } - - return presponse; - } -} diff --git a/src/main/java/org/opensearch/security/privileges/PrivilegesConfiguration.java b/src/main/java/org/opensearch/security/privileges/PrivilegesConfiguration.java new file mode 100644 index 0000000000..be47961efd --- /dev/null +++ b/src/main/java/org/opensearch/security/privileges/PrivilegesConfiguration.java @@ -0,0 +1,284 @@ +/* + * 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. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ +package org.opensearch.security.privileges; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Supplier; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import org.opensearch.cluster.ClusterState; +import org.opensearch.cluster.metadata.IndexNameExpressionResolver; +import org.opensearch.cluster.service.ClusterService; +import org.opensearch.common.settings.Settings; +import org.opensearch.security.auditlog.AuditLog; +import org.opensearch.security.configuration.ConfigurationRepository; +import org.opensearch.security.configuration.PrivilegesInterceptorImpl; +import org.opensearch.security.privileges.actionlevel.RuntimeOptimizedActionPrivileges; +import org.opensearch.security.securityconf.DynamicConfigFactory; +import org.opensearch.security.securityconf.FlattenedActionGroups; +import org.opensearch.security.securityconf.impl.CType; +import org.opensearch.security.securityconf.impl.SecurityDynamicConfiguration; +import org.opensearch.security.securityconf.impl.v7.ActionGroupsV7; +import org.opensearch.security.securityconf.impl.v7.ConfigV7; +import org.opensearch.security.securityconf.impl.v7.RoleV7; +import org.opensearch.security.securityconf.impl.v7.TenantV7; +import org.opensearch.threadpool.ThreadPool; +import org.opensearch.transport.client.Client; + +/** + * This class manages and gives access to various additional classes which are derived from privileges related configuration in + * the security plugin. + *

+ * This is especially: + *

    + *
  • The current PrivilegesEvaluator instance
  • + *
  • The current Dashboards multi tenancy configuration
  • + *
  • The current action groups configuration
  • + *
+ * This class also manages updates to the different configuration objects. + *

+ * Historically, most of this information has been located directly in PrivilegesEvaluator instances. To concentrate + * the purpose of PrivilegesEvaluator to just action based privilege evaluation, the information was distributed amongst + * several classes. + */ +public class PrivilegesConfiguration { + private final static Logger log = LogManager.getLogger(PrivilegesConfiguration.class); + + private final AtomicReference tenantPrivileges = new AtomicReference<>(TenantPrivileges.EMPTY); + private final AtomicReference privilegesEvaluator; + private final AtomicReference actionGroups = new AtomicReference<>(FlattenedActionGroups.EMPTY); + private final Map pluginIdToRolePrivileges = new HashMap<>(); + private final AtomicReference multiTenancyConfiguration = new AtomicReference<>( + DashboardsMultiTenancyConfiguration.DEFAULT + ); + private final PrivilegesInterceptorImpl privilegesInterceptor; + private final SpecialIndices specialIndices; + + /** + * The pure static action groups should be ONLY used by action privileges for plugins; only those cannot and should + * not have knowledge of any action groups defined in the dynamic configuration. All other functionality should + * use the action groups derived from the dynamic configuration (which is always computed on the fly on + * configuration updates). + */ + private final FlattenedActionGroups staticActionGroups; + + public PrivilegesConfiguration( + ConfigurationRepository configurationRepository, + ClusterService clusterService, + Supplier clusterStateSupplier, + Client client, + RoleMapper roleMapper, + ThreadPool threadPool, + IndexNameExpressionResolver resolver, + AuditLog auditLog, + Settings settings, + Supplier unavailablityReasonSupplier + ) { + + this.privilegesEvaluator = new AtomicReference<>(new PrivilegesEvaluator.NotInitialized(unavailablityReasonSupplier)); + this.privilegesInterceptor = new PrivilegesInterceptorImpl( + resolver, + clusterService, + client, + threadPool, + this.tenantPrivileges::get, + this.multiTenancyConfiguration::get + ); + this.staticActionGroups = buildStaticActionGroups(); + this.specialIndices = new SpecialIndices(settings); + + if (configurationRepository != null) { + configurationRepository.subscribeOnChange(configMap -> { + SecurityDynamicConfiguration actionGroupsConfiguration = configurationRepository.getConfiguration( + CType.ACTIONGROUPS + ); + SecurityDynamicConfiguration rolesConfiguration = configurationRepository.getConfiguration(CType.ROLES) + .withStaticConfig(); + SecurityDynamicConfiguration tenantConfiguration = configurationRepository.getConfiguration(CType.TENANTS) + .withStaticConfig(); + ConfigV7 generalConfiguration = configurationRepository.getConfiguration(CType.CONFIG).getCEntry(CType.CONFIG.name()); + + FlattenedActionGroups flattenedActionGroups = new FlattenedActionGroups(actionGroupsConfiguration.withStaticConfig()); + this.actionGroups.set(flattenedActionGroups); + + PrivilegesEvaluator currentPrivilegesEvaluator = privilegesEvaluator.get(); + PrivilegesEvaluationType privilegesEvaluationType = PrivilegesEvaluationType.getFrom( + configurationRepository.getConfiguration(CType.CONFIG) + ); + PrivilegesEvaluationType currentEvaluationType = PrivilegesEvaluationType.typeOf(currentPrivilegesEvaluator); + + if (privilegesEvaluationType != currentEvaluationType) { + if (privilegesEvaluationType == PrivilegesEvaluationType.LEGACY) { + PrivilegesEvaluator oldInstance = privilegesEvaluator.getAndSet( + new org.opensearch.security.privileges.actionlevel.legacy.PrivilegesEvaluator( + clusterService, + clusterStateSupplier, + roleMapper, + threadPool, + threadPool.getThreadContext(), + resolver, + auditLog, + settings, + privilegesInterceptor, + flattenedActionGroups, + staticActionGroups, + rolesConfiguration, + generalConfiguration, + pluginIdToRolePrivileges + ) + ); + if (oldInstance != null) { + oldInstance.shutdown(); + } + } else { + PrivilegesEvaluator oldInstance = privilegesEvaluator.getAndSet( + new org.opensearch.security.privileges.actionlevel.nextgen.PrivilegesEvaluator( + clusterStateSupplier, + roleMapper, + threadPool, + threadPool.getThreadContext(), + resolver, + settings, + privilegesInterceptor, + flattenedActionGroups, + staticActionGroups, + rolesConfiguration, + generalConfiguration, + pluginIdToRolePrivileges, + new RuntimeOptimizedActionPrivileges.SpecialIndexProtection( + this.specialIndices::isUniversallyDeniedIndex, + this.specialIndices::isSystemIndex + ) + ) + ); + if (oldInstance != null) { + oldInstance.shutdown(); + } + } + } else { + privilegesEvaluator.get().updateConfiguration(flattenedActionGroups, rolesConfiguration, generalConfiguration); + } + + try { + this.multiTenancyConfiguration.set(new DashboardsMultiTenancyConfiguration(generalConfiguration)); + } catch (Exception e) { + log.error("Error while updating DashboardsMultiTenancyConfiguration", e); + } + + try { + this.tenantPrivileges.set(new TenantPrivileges(rolesConfiguration, tenantConfiguration, flattenedActionGroups)); + } catch (Exception e) { + log.error("Error while updating TenantPrivileges", e); + } + }); + } + + if (clusterService != null) { + clusterService.addListener(event -> { this.privilegesEvaluator.get().updateClusterStateMetadata(clusterService); }); + } + } + + /** + * For testing only: Creates a passive PrivilegesConfiguration object with the given PrivilegesEvaluator implementation and otherwise + * just defaults. + */ + public PrivilegesConfiguration(PrivilegesEvaluator privilegesEvaluator) { + this.privilegesEvaluator = new AtomicReference<>(privilegesEvaluator); + this.privilegesInterceptor = null; + this.staticActionGroups = buildStaticActionGroups(); + this.specialIndices = new SpecialIndices(Settings.EMPTY); + } + + /** + * Returns the current tenant privileges object. Important: Do not store the references to the instances returned here; these will change + * after configuration updates. + */ + public TenantPrivileges tenantPrivileges() { + return this.tenantPrivileges.get(); + } + + /** + * Returns the current PrivilegesEvaluator implementation. Important: Do not store the references to the instances returned here; these will change + * after configuration updates. + */ + public PrivilegesEvaluator privilegesEvaluator() { + return this.privilegesEvaluator.get(); + } + + /** + * Returns the current action groups configuration. Important: Do not store the references to the instances returned here; these will change + * after configuration updates. + */ + public FlattenedActionGroups actionGroups() { + return this.actionGroups.get(); + } + + /** + * Returns the current Dashboards multi tenancy configuration. Important: Do not store the references to the instances returned here; these will change + * after configuration updates. + */ + public DashboardsMultiTenancyConfiguration multiTenancyConfiguration() { + return this.multiTenancyConfiguration.get(); + } + + public void updatePluginToActionPrivileges(String pluginIdentifier, RoleV7 pluginPermissions) { + pluginIdToRolePrivileges.put(pluginIdentifier, pluginPermissions); + } + + public boolean isInitialized() { + return this.privilegesEvaluator().isInitialized(); + } + + /** + * TODO: Think about better names + */ + enum PrivilegesEvaluationType { + LEGACY, + NEXT_GEN; + + static PrivilegesEvaluationType getFrom(SecurityDynamicConfiguration configConfig) { + final PrivilegesEvaluationType defaultValue = PrivilegesEvaluationType.LEGACY; + + if (configConfig == null) { + return defaultValue; + } + + ConfigV7 config = configConfig.getCEntry(CType.CONFIG.name()); + if (config == null || config.dynamic == null) { + return defaultValue; + } + if (NEXT_GEN.name().equalsIgnoreCase(config.dynamic.privilegesEvaluationType)) { + return NEXT_GEN; + } else { + return LEGACY; + } + } + + static PrivilegesEvaluationType typeOf(PrivilegesEvaluator privilegesEvaluator) { + if (privilegesEvaluator instanceof org.opensearch.security.privileges.actionlevel.legacy.PrivilegesEvaluator) { + return PrivilegesEvaluationType.LEGACY; + } else if (privilegesEvaluator instanceof org.opensearch.security.privileges.actionlevel.nextgen.PrivilegesEvaluator) { + return PrivilegesEvaluationType.NEXT_GEN; + } else { + return null; + } + } + } + + private static FlattenedActionGroups buildStaticActionGroups() { + return new FlattenedActionGroups(DynamicConfigFactory.addStatics(SecurityDynamicConfiguration.empty(CType.ACTIONGROUPS))); + } + +} diff --git a/src/main/java/org/opensearch/security/privileges/PrivilegesEvaluationContext.java b/src/main/java/org/opensearch/security/privileges/PrivilegesEvaluationContext.java index b4cc2fe805..a475a26f4c 100644 --- a/src/main/java/org/opensearch/security/privileges/PrivilegesEvaluationContext.java +++ b/src/main/java/org/opensearch/security/privileges/PrivilegesEvaluationContext.java @@ -17,10 +17,11 @@ import com.google.common.collect.ImmutableSet; import org.opensearch.action.ActionRequest; +import org.opensearch.action.support.ActionRequestMetadata; import org.opensearch.cluster.ClusterState; import org.opensearch.cluster.metadata.IndexAbstraction; import org.opensearch.cluster.metadata.IndexNameExpressionResolver; -import org.opensearch.security.resolver.IndexResolverReplacer; +import org.opensearch.cluster.metadata.OptionallyResolvedIndices; import org.opensearch.security.support.WildcardMatcher; import org.opensearch.security.user.User; import org.opensearch.tasks.Task; @@ -38,13 +39,14 @@ public class PrivilegesEvaluationContext { private final User user; private final String action; private final ActionRequest request; - private IndexResolverReplacer.Resolved resolvedRequest; + private OptionallyResolvedIndices resolvedIndices; private Map indicesLookup; private final Task task; private ImmutableSet mappedRoles; - private final IndexResolverReplacer indexResolverReplacer; private final IndexNameExpressionResolver indexNameExpressionResolver; + private final IndicesRequestResolver indicesRequestResolver; private final Supplier clusterStateSupplier; + private final ActionRequestMetadata actionRequestMetadata; /** * Stores the ActionPrivileges instance to be used for this request. Plugin system users or users created from @@ -64,9 +66,10 @@ public PrivilegesEvaluationContext( ImmutableSet mappedRoles, String action, ActionRequest request, + ActionRequestMetadata actionRequestMetadata, Task task, - IndexResolverReplacer indexResolverReplacer, IndexNameExpressionResolver indexNameExpressionResolver, + IndicesRequestResolver indicesRequestResolver, Supplier clusterStateSupplier, ActionPrivileges actionPrivileges ) { @@ -75,9 +78,10 @@ public PrivilegesEvaluationContext( this.action = action; this.request = request; this.clusterStateSupplier = clusterStateSupplier; - this.indexResolverReplacer = indexResolverReplacer; this.indexNameExpressionResolver = indexNameExpressionResolver; + this.indicesRequestResolver = indicesRequestResolver; this.task = task; + this.actionRequestMetadata = actionRequestMetadata; this.actionPrivileges = actionPrivileges; } @@ -118,12 +122,14 @@ public ActionRequest getRequest() { return request; } - public IndexResolverReplacer.Resolved getResolvedRequest() { - IndexResolverReplacer.Resolved result = this.resolvedRequest; - + public OptionallyResolvedIndices getResolvedRequest() { + OptionallyResolvedIndices result = this.resolvedIndices; if (result == null) { - result = indexResolverReplacer.resolveRequest(request); - this.resolvedRequest = result; + this.resolvedIndices = result = this.indicesRequestResolver.resolve( + this.request, + this.actionRequestMetadata, + this.clusterStateSupplier + ); } return result; @@ -137,20 +143,8 @@ public ImmutableSet getMappedRoles() { return mappedRoles; } - /** - * Note: Ideally, mappedRoles would be an unmodifiable attribute. PrivilegesEvaluator however contains logic - * related to OPENDISTRO_SECURITY_INJECTED_ROLES_VALIDATION which first validates roles and afterwards modifies - * them again. Thus, we need to be able to set this attribute. - * - * However, this method should be only used for this one particular phase. Normally, all roles should be determined - * upfront and stay constant during the whole privilege evaluation process. - */ - void setMappedRoles(ImmutableSet mappedRoles) { - this.mappedRoles = mappedRoles; - } - - public Supplier getClusterStateSupplier() { - return clusterStateSupplier; + public ClusterState clusterState() { + return clusterStateSupplier.get(); } public Map getIndicesLookup() { @@ -182,8 +176,8 @@ public String toString() { + '\'' + ", request=" + request - + ", resolvedRequest=" - + resolvedRequest + + ", resolvedIndices=" + + resolvedIndices + ", mappedRoles=" + mappedRoles + '}'; diff --git a/src/main/java/org/opensearch/security/privileges/PrivilegesEvaluator.java b/src/main/java/org/opensearch/security/privileges/PrivilegesEvaluator.java index 9cfe99de94..df5807a082 100644 --- a/src/main/java/org/opensearch/security/privileges/PrivilegesEvaluator.java +++ b/src/main/java/org/opensearch/security/privileges/PrivilegesEvaluator.java @@ -26,893 +26,128 @@ package org.opensearch.security.privileges; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.HashMap; -import java.util.HashSet; -import java.util.Iterator; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.StringJoiner; -import java.util.concurrent.atomic.AtomicReference; import java.util.function.Supplier; -import com.google.common.collect.ImmutableList; -import com.google.common.collect.ImmutableSet; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - import org.opensearch.OpenSearchSecurityException; import org.opensearch.action.ActionRequest; -import org.opensearch.action.IndicesRequest; -import org.opensearch.action.admin.cluster.shards.ClusterSearchShardsRequest; -import org.opensearch.action.admin.cluster.snapshots.restore.RestoreSnapshotRequest; -import org.opensearch.action.admin.indices.alias.IndicesAliasesAction; -import org.opensearch.action.admin.indices.alias.IndicesAliasesRequest; -import org.opensearch.action.admin.indices.alias.IndicesAliasesRequest.AliasActions; -import org.opensearch.action.admin.indices.create.AutoCreateAction; -import org.opensearch.action.admin.indices.create.CreateIndexAction; -import org.opensearch.action.admin.indices.create.CreateIndexRequest; -import org.opensearch.action.admin.indices.delete.DeleteIndexAction; -import org.opensearch.action.admin.indices.mapping.get.GetFieldMappingsRequest; -import org.opensearch.action.admin.indices.mapping.put.AutoPutMappingAction; -import org.opensearch.action.admin.indices.mapping.put.PutMappingAction; -import org.opensearch.action.bulk.BulkAction; -import org.opensearch.action.bulk.BulkItemRequest; -import org.opensearch.action.bulk.BulkRequest; -import org.opensearch.action.bulk.BulkShardRequest; -import org.opensearch.action.delete.DeleteAction; -import org.opensearch.action.get.GetRequest; -import org.opensearch.action.get.MultiGetAction; -import org.opensearch.action.index.IndexAction; -import org.opensearch.action.search.MultiSearchAction; -import org.opensearch.action.search.SearchAction; -import org.opensearch.action.search.SearchRequest; -import org.opensearch.action.search.SearchScrollAction; -import org.opensearch.action.support.IndicesOptions; -import org.opensearch.action.termvectors.MultiTermVectorsAction; -import org.opensearch.action.update.UpdateAction; -import org.opensearch.cluster.ClusterState; -import org.opensearch.cluster.metadata.AliasMetadata; -import org.opensearch.cluster.metadata.IndexMetadata; -import org.opensearch.cluster.metadata.IndexNameExpressionResolver; -import org.opensearch.cluster.metadata.Metadata; +import org.opensearch.action.support.ActionRequestMetadata; import org.opensearch.cluster.service.ClusterService; -import org.opensearch.common.settings.Settings; -import org.opensearch.common.util.concurrent.ThreadContext; -import org.opensearch.core.common.Strings; -import org.opensearch.core.common.transport.TransportAddress; -import org.opensearch.index.reindex.ReindexAction; -import org.opensearch.script.mustache.RenderSearchTemplateAction; -import org.opensearch.security.auditlog.AuditLog; -import org.opensearch.security.configuration.ClusterInfoHolder; -import org.opensearch.security.configuration.ConfigurationRepository; -import org.opensearch.security.privileges.actionlevel.RoleBasedActionPrivileges; -import org.opensearch.security.privileges.actionlevel.SubjectBasedActionPrivileges; -import org.opensearch.security.resolver.IndexResolverReplacer; -import org.opensearch.security.resolver.IndexResolverReplacer.Resolved; -import org.opensearch.security.securityconf.ConfigModel; -import org.opensearch.security.securityconf.DynamicConfigFactory; -import org.opensearch.security.securityconf.DynamicConfigModel; +import org.opensearch.core.rest.RestStatus; import org.opensearch.security.securityconf.FlattenedActionGroups; -import org.opensearch.security.securityconf.impl.CType; -import org.opensearch.security.securityconf.impl.DashboardSignInOption; import org.opensearch.security.securityconf.impl.SecurityDynamicConfiguration; -import org.opensearch.security.securityconf.impl.v7.ActionGroupsV7; +import org.opensearch.security.securityconf.impl.v7.ConfigV7; import org.opensearch.security.securityconf.impl.v7.RoleV7; -import org.opensearch.security.securityconf.impl.v7.TenantV7; -import org.opensearch.security.support.Base64Helper; -import org.opensearch.security.support.ConfigConstants; -import org.opensearch.security.support.WildcardMatcher; import org.opensearch.security.user.User; import org.opensearch.tasks.Task; -import org.opensearch.threadpool.ThreadPool; - -import org.greenrobot.eventbus.Subscribe; -import static org.opensearch.security.OpenSearchSecurityPlugin.traceAction; -import static org.opensearch.security.support.ConfigConstants.OPENDISTRO_SECURITY_USER_INFO_THREAD_CONTEXT; -import static org.opensearch.security.support.ConfigConstants.USER_ATTRIBUTE_SERIALIZATION_ENABLED; -import static org.opensearch.security.support.ConfigConstants.USER_ATTRIBUTE_SERIALIZATION_ENABLED_DEFAULT; -import static org.opensearch.security.support.SecurityUtils.escapePipe; +public interface PrivilegesEvaluator { -public class PrivilegesEvaluator { - - private static final String USER_TENANT = "__user__"; - private static final String GLOBAL_TENANT = "global_tenant"; - private static final String READ_ACCESS = "READ"; - private static final String WRITE_ACCESS = "WRITE"; - private static final String NO_ACCESS = "NONE"; + default PrivilegesEvaluationContext createContext(User user, String action) { + return createContext(user, action, null, ActionRequestMetadata.empty(), null); + } - static final WildcardMatcher DNFOF_MATCHER = WildcardMatcher.from( - ImmutableList.of( - "indices:data/read/*", - "indices:admin/mappings/fields/get*", - "indices:admin/shards/search_shards", - "indices:admin/resolve/index", - "indices:monitor/settings/get", - "indices:monitor/stats", - "indices:admin/aliases/get" - ) + PrivilegesEvaluationContext createContext( + User user, + String action, + ActionRequest actionRequest, + ActionRequestMetadata actionRequestMetadata, + Task task ); - private static final WildcardMatcher ACTION_MATCHER = WildcardMatcher.from("indices:data/read/*search*"); - - private static final IndicesOptions ALLOW_EMPTY = IndicesOptions.fromOptions(true, true, false, false); - - protected final Logger log = LogManager.getLogger(this.getClass()); - private final Supplier clusterStateSupplier; + PrivilegesEvaluatorResponse evaluate(PrivilegesEvaluationContext context); - private final IndexNameExpressionResolver resolver; - - private final AuditLog auditLog; - private ThreadContext threadContext; - - private PrivilegesInterceptor privilegesInterceptor; - - private final boolean checkSnapshotRestoreWritePrivileges; - - private final ClusterInfoHolder clusterInfoHolder; - private final ConfigurationRepository configurationRepository; - private ConfigModel configModel; - private final IndexResolverReplacer irr; - private final SnapshotRestoreEvaluator snapshotRestoreEvaluator; - private final SystemIndexAccessEvaluator systemIndexAccessEvaluator; - private final ProtectedIndexAccessEvaluator protectedIndexAccessEvaluator; - private final TermsAggregationEvaluator termsAggregationEvaluator; - private final PitPrivilegesEvaluator pitPrivilegesEvaluator; - private DynamicConfigModel dcm; - private final Settings settings; - private final AtomicReference actionPrivileges = new AtomicReference<>(); - private final AtomicReference tenantPrivileges = new AtomicReference<>(); - private final Map pluginIdToActionPrivileges = new HashMap<>(); - - /** - * The pure static action groups should be ONLY used by action privileges for plugins; only those cannot and should - * not have knowledge of any action groups defined in the dynamic configuration. All other functionality should - * use the action groups derived from the dynamic configuration (which is always computed on the fly on - * configuration updates). - */ - private final FlattenedActionGroups staticActionGroups; - - public PrivilegesEvaluator( - final ClusterService clusterService, - Supplier clusterStateSupplier, - ThreadPool threadPool, - final ThreadContext threadContext, - final ConfigurationRepository configurationRepository, - final IndexNameExpressionResolver resolver, - AuditLog auditLog, - final Settings settings, - final PrivilegesInterceptor privilegesInterceptor, - final ClusterInfoHolder clusterInfoHolder, - final IndexResolverReplacer irr - ) { - - super(); - this.resolver = resolver; - this.auditLog = auditLog; - - this.threadContext = threadContext; - this.privilegesInterceptor = privilegesInterceptor; - this.clusterStateSupplier = clusterStateSupplier; - this.settings = settings; - - this.checkSnapshotRestoreWritePrivileges = settings.getAsBoolean( - ConfigConstants.SECURITY_CHECK_SNAPSHOT_RESTORE_WRITE_PRIVILEGES, - ConfigConstants.SECURITY_DEFAULT_CHECK_SNAPSHOT_RESTORE_WRITE_PRIVILEGES - ); - - this.clusterInfoHolder = clusterInfoHolder; - this.irr = irr; - snapshotRestoreEvaluator = new SnapshotRestoreEvaluator(settings, auditLog); - systemIndexAccessEvaluator = new SystemIndexAccessEvaluator(settings, auditLog, irr); - protectedIndexAccessEvaluator = new ProtectedIndexAccessEvaluator(settings, auditLog); - termsAggregationEvaluator = new TermsAggregationEvaluator(); - pitPrivilegesEvaluator = new PitPrivilegesEvaluator(); - this.configurationRepository = configurationRepository; - this.staticActionGroups = new FlattenedActionGroups( - DynamicConfigFactory.addStatics(SecurityDynamicConfiguration.empty(CType.ACTIONGROUPS)) - ); - - if (configurationRepository != null) { - configurationRepository.subscribeOnChange(configMap -> { - SecurityDynamicConfiguration actionGroupsConfiguration = configurationRepository.getConfiguration( - CType.ACTIONGROUPS - ); - SecurityDynamicConfiguration rolesConfiguration = configurationRepository.getConfiguration(CType.ROLES); - SecurityDynamicConfiguration tenantConfiguration = configurationRepository.getConfiguration(CType.TENANTS); - - this.updateConfiguration(actionGroupsConfiguration, rolesConfiguration, tenantConfiguration); - }); - } - - if (clusterService != null) { - clusterService.addListener(event -> { - RoleBasedActionPrivileges actionPrivileges = PrivilegesEvaluator.this.actionPrivileges.get(); - if (actionPrivileges != null) { - actionPrivileges.clusterStateMetadataDependentPrivileges().updateClusterStateMetadataAsync(clusterService, threadPool); - } - }); - } - } + boolean isClusterPermission(String action); void updateConfiguration( - SecurityDynamicConfiguration actionGroupsConfiguration, + FlattenedActionGroups actionGroups, SecurityDynamicConfiguration rolesConfiguration, - SecurityDynamicConfiguration tenantConfiguration - ) { - FlattenedActionGroups flattenedActionGroups = new FlattenedActionGroups(actionGroupsConfiguration.withStaticConfig()); - rolesConfiguration = rolesConfiguration.withStaticConfig(); - tenantConfiguration = tenantConfiguration.withStaticConfig(); - try { - RoleBasedActionPrivileges actionPrivileges = new RoleBasedActionPrivileges(rolesConfiguration, flattenedActionGroups, settings); - Metadata metadata = clusterStateSupplier.get().metadata(); - actionPrivileges.updateStatefulIndexPrivileges(metadata.getIndicesLookup(), metadata.version()); - RoleBasedActionPrivileges oldInstance = this.actionPrivileges.getAndSet(actionPrivileges); - - if (oldInstance != null) { - oldInstance.clusterStateMetadataDependentPrivileges().shutdown(); - } - } catch (Exception e) { - log.error("Error while updating ActionPrivileges", e); - } - - try { - this.tenantPrivileges.set(new TenantPrivileges(rolesConfiguration, tenantConfiguration, flattenedActionGroups)); - } catch (Exception e) { - log.error("Error while updating TenantPrivileges", e); - } - } - - @Subscribe - public void onConfigModelChanged(ConfigModel configModel) { - this.configModel = configModel; - } - - @Subscribe - public void onDynamicConfigModelChanged(DynamicConfigModel dcm) { - this.dcm = dcm; - } - - public boolean hasRestAdminPermissions(final User user, final TransportAddress remoteAddress, final String permission) { - PrivilegesEvaluationContext context = createContext(user, permission); - return context.getActionPrivileges().hasExplicitClusterPrivilege(context, permission).isAllowed(); - } - - public boolean isInitialized() { - return configModel != null && dcm != null && actionPrivileges.get() != null; - } - - private boolean isUserAttributeSerializationEnabled() { - return this.settings.getAsBoolean(USER_ATTRIBUTE_SERIALIZATION_ENABLED, USER_ATTRIBUTE_SERIALIZATION_ENABLED_DEFAULT); - } - - private void setUserInfoInThreadContext(PrivilegesEvaluationContext context) { - if (threadContext.getTransient(OPENDISTRO_SECURITY_USER_INFO_THREAD_CONTEXT) == null) { - StringJoiner joiner = new StringJoiner("|"); - // Escape any pipe characters in the values before joining - joiner.add(escapePipe(context.getUser().getName())); - joiner.add(escapePipe(String.join(",", context.getUser().getRoles()))); - joiner.add(escapePipe(String.join(",", context.getMappedRoles()))); - - String requestedTenant = context.getUser().getRequestedTenant(); - joiner.add(requestedTenant); - - String tenantAccessToCheck = getTenancyAccess(context); - joiner.add(tenantAccessToCheck); - log.debug(joiner); - - if (this.isUserAttributeSerializationEnabled()) { - joiner.add(Base64Helper.serializeObject(new HashMap<>(context.getUser().getCustomAttributesMap()))); - } - - threadContext.putTransient(OPENDISTRO_SECURITY_USER_INFO_THREAD_CONTEXT, joiner.toString()); - } - } - - public PrivilegesEvaluationContext createContext(User user, String action) { - return createContext(user, action, null, null, null); - } - - private String getTenancyAccess(PrivilegesEvaluationContext context) { - String requestedTenant = context.getUser().getRequestedTenant(); - final String tenant = Strings.isNullOrEmpty(requestedTenant) ? GLOBAL_TENANT : requestedTenant; - if (tenantPrivileges.get().hasTenantPrivilege(context, tenant, TenantPrivileges.ActionType.WRITE)) { - return WRITE_ACCESS; - } else if (tenantPrivileges.get().hasTenantPrivilege(context, tenant, TenantPrivileges.ActionType.READ)) { - return READ_ACCESS; - } else { - return NO_ACCESS; - } - } - - public PrivilegesEvaluationContext createContext( - User user, - String action0, - ActionRequest request, - Task task, - Set injectedRoles - ) { - if (!isInitialized()) { - StringBuilder error = new StringBuilder("OpenSearch Security is not initialized."); - if (!clusterInfoHolder.hasClusterManager()) { - error.append(String.format(" %s", ClusterInfoHolder.CLUSTER_MANAGER_NOT_PRESENT)); - } - throw new OpenSearchSecurityException(error.toString()); - } - - TransportAddress caller = threadContext.getTransient(ConfigConstants.OPENDISTRO_SECURITY_REMOTE_ADDRESS); - - ActionPrivileges actionPrivileges; - ImmutableSet mappedRoles; - - if (user.isPluginUser()) { - mappedRoles = ImmutableSet.of(); - actionPrivileges = this.pluginIdToActionPrivileges.get(user.getName()); - if (actionPrivileges == null) { - actionPrivileges = ActionPrivileges.EMPTY; - } - } else { - mappedRoles = ImmutableSet.copyOf((injectedRoles == null) ? mapRoles(user, caller) : injectedRoles); - actionPrivileges = this.actionPrivileges.get(); - } - - return new PrivilegesEvaluationContext( - user, - mappedRoles, - action0, - request, - task, - irr, - resolver, - clusterStateSupplier, - actionPrivileges - ); - } - - public PrivilegesEvaluatorResponse evaluate(PrivilegesEvaluationContext context) { - - if (!isInitialized()) { - StringBuilder error = new StringBuilder("OpenSearch Security is not initialized."); - if (!clusterInfoHolder.hasClusterManager()) { - error.append(String.format(" %s", ClusterInfoHolder.CLUSTER_MANAGER_NOT_PRESENT)); - } - throw new OpenSearchSecurityException(error.toString()); - } - - String action0 = context.getAction(); - ImmutableSet mappedRoles = context.getMappedRoles(); - User user = context.getUser(); - ActionRequest request = context.getRequest(); - Task task = context.getTask(); - - if (action0.startsWith("internal:indices/admin/upgrade")) { - action0 = "indices:admin/upgrade"; - } - - if (AutoCreateAction.NAME.equals(action0)) { - action0 = CreateIndexAction.NAME; - } - - if (AutoPutMappingAction.NAME.equals(action0)) { - action0 = PutMappingAction.NAME; - } - - PrivilegesEvaluatorResponse presponse = new PrivilegesEvaluatorResponse(); - - final String injectedRolesValidationString = threadContext.getTransient( - ConfigConstants.OPENDISTRO_SECURITY_INJECTED_ROLES_VALIDATION - ); - if (injectedRolesValidationString != null) { - HashSet injectedRolesValidationSet = new HashSet<>(Arrays.asList(injectedRolesValidationString.split(","))); - if (!mappedRoles.containsAll(injectedRolesValidationSet)) { - presponse.allowed = false; - presponse.missingSecurityRoles.addAll(injectedRolesValidationSet); - log.info("Roles {} are not mapped to the user {}", injectedRolesValidationSet, user); - return presponse; - } - mappedRoles = ImmutableSet.copyOf(injectedRolesValidationSet); - context.setMappedRoles(mappedRoles); - } - - setUserInfoInThreadContext(context); - - final boolean isDebugEnabled = log.isDebugEnabled(); - if (isDebugEnabled) { - log.debug("Evaluate permissions for {}", user); - log.debug("Action: {} ({})", action0, request.getClass().getSimpleName()); - log.debug("Mapped roles: {}", mappedRoles.toString()); - } - - ActionPrivileges actionPrivileges = context.getActionPrivileges(); - if (actionPrivileges == null) { - throw new OpenSearchSecurityException("OpenSearch Security is not initialized: roles configuration is missing"); - } - - if (request instanceof BulkRequest && (Strings.isNullOrEmpty(user.getRequestedTenant()))) { - // Shortcut for bulk actions. The details are checked on the lower level of the BulkShardRequests (Action - // indices:data/write/bulk[s]). - // This shortcut is only possible if the default tenant is selected, as we might need to rewrite the request for non-default - // tenants. - // No further access check for the default tenant is necessary, as access will be also checked on the TransportShardBulkAction - // level. - - presponse = actionPrivileges.hasClusterPrivilege(context, action0); - - if (!presponse.allowed) { - log.info( - "No cluster-level perm match for {} [Action [{}]] [RolesChecked {}]. No permissions for {}", - user, - action0, - mappedRoles, - presponse.getMissingPrivileges() - ); - } - return presponse; - } - - final Resolved requestedResolved = context.getResolvedRequest(); - - if (isDebugEnabled) { - log.debug("RequestedResolved : {}", requestedResolved); - } - - // check snapshot/restore requests - // NOTE: Has to go first as restore request could be for protected and/or system indices and the request may - // fail with 403 if system index or protected index evaluators are triggered first - if (snapshotRestoreEvaluator.evaluate(request, task, action0, clusterInfoHolder, presponse).isComplete()) { - return presponse; - } - - // System index access - if (systemIndexAccessEvaluator.evaluate(request, task, action0, requestedResolved, presponse, context, actionPrivileges, user) - .isComplete()) { - return presponse; - } - - // Protected index access - if (protectedIndexAccessEvaluator.evaluate(request, task, action0, requestedResolved, presponse, mappedRoles).isComplete()) { - return presponse; - } - - // check access for point in time requests - if (pitPrivilegesEvaluator.evaluate(request, context, actionPrivileges, action0, presponse, irr).isComplete()) { - return presponse; - } - - final boolean dnfofEnabled = dcm.isDnfofEnabled(); - - final boolean isTraceEnabled = log.isTraceEnabled(); - if (isTraceEnabled) { - log.trace("dnfof enabled? {}", dnfofEnabled); - } - - final boolean serviceAccountUser = user.isServiceAccount(); - if (isClusterPerm(action0)) { - if (serviceAccountUser) { - log.info("{} is a service account which doesn't have access to cluster level permission: {}", user, action0); - return PrivilegesEvaluatorResponse.insufficient(action0); - } - - presponse = actionPrivileges.hasClusterPrivilege(context, action0); - - if (!presponse.allowed) { - log.info( - "No cluster-level perm match for {} {} [Action [{}]] [RolesChecked {}]. No permissions for {}", - user, - requestedResolved, - action0, - mappedRoles, - presponse.getMissingPrivileges() - ); - return presponse; - } else { - - if (request instanceof RestoreSnapshotRequest && checkSnapshotRestoreWritePrivileges) { - if (isDebugEnabled) { - log.debug("Normally allowed but we need to apply some extra checks for a restore request."); - } - } else { - if (privilegesInterceptor.getClass() != PrivilegesInterceptor.class) { - - final PrivilegesInterceptor.ReplaceResult replaceResult = privilegesInterceptor.replaceDashboardsIndex( - request, - action0, - user, - dcm, - requestedResolved, - context, - this.tenantPrivileges.get() - ); - - if (isDebugEnabled) { - log.debug("Result from privileges interceptor for cluster perm: {}", replaceResult); - } - - if (!replaceResult.continueEvaluation) { - if (replaceResult.accessDenied) { - auditLog.logMissingPrivileges(action0, request, task); - } else { - presponse.allowed = true; - presponse.createIndexRequestBuilder = replaceResult.createIndexRequestBuilder; - } - return presponse; - } - } - - if (isDebugEnabled) { - log.debug("Allowed because we have cluster permissions for {}", action0); - } - presponse.allowed = true; - return presponse; - } - } - } - - if (checkDocAllowListHeader(user, action0, request)) { - presponse.allowed = true; - return presponse; - } - - // term aggregations - if (termsAggregationEvaluator.evaluate(requestedResolved, request, context, actionPrivileges, presponse).isComplete()) { - return presponse; - } - - ImmutableSet allIndexPermsRequired = evaluateAdditionalIndexPermissions(request, action0); - - if (isDebugEnabled) { - log.debug( - "Requested {} from {}", - allIndexPermsRequired, - threadContext.getTransient(ConfigConstants.OPENDISTRO_SECURITY_REMOTE_ADDRESS) - ); - } - - if (isDebugEnabled) { - log.debug("Requested resolved index types: {}", requestedResolved); - log.debug("Security roles: {}", mappedRoles); - } - - // TODO exclude Security index - - if (privilegesInterceptor.getClass() != PrivilegesInterceptor.class) { - - final PrivilegesInterceptor.ReplaceResult replaceResult = privilegesInterceptor.replaceDashboardsIndex( - request, - action0, - user, - dcm, - requestedResolved, - context, - this.tenantPrivileges.get() - ); - - if (isDebugEnabled) { - log.debug("Result from privileges interceptor: {}", replaceResult); - } - - if (!replaceResult.continueEvaluation) { - if (replaceResult.accessDenied) { - auditLog.logMissingPrivileges(action0, request, task); - return PrivilegesEvaluatorResponse.insufficient(action0); - } else { - presponse.allowed = true; - presponse.createIndexRequestBuilder = replaceResult.createIndexRequestBuilder; - return presponse; - } - } - } - - boolean dnfofPossible = dnfofEnabled && DNFOF_MATCHER.test(action0); - - presponse = actionPrivileges.hasIndexPrivilege(context, allIndexPermsRequired, requestedResolved); - - if (presponse.isPartiallyOk()) { - if (dnfofPossible) { - if (irr.replace(request, true, presponse.getAvailableIndices())) { - return PrivilegesEvaluatorResponse.ok(); - } - } - } else if (!presponse.isAllowed()) { - if (dnfofPossible && dcm.isDnfofForEmptyResultsEnabled() && request instanceof IndicesRequest.Replaceable) { - ((IndicesRequest.Replaceable) request).indices(new String[0]); - - if (request instanceof SearchRequest) { - ((SearchRequest) request).indicesOptions(ALLOW_EMPTY); - } else if (request instanceof ClusterSearchShardsRequest) { - ((ClusterSearchShardsRequest) request).indicesOptions(ALLOW_EMPTY); - } else if (request instanceof GetFieldMappingsRequest) { - ((GetFieldMappingsRequest) request).indicesOptions(ALLOW_EMPTY); - } - - return PrivilegesEvaluatorResponse.ok(); - } - } - - if (presponse.isAllowed()) { - if (checkFilteredAliases(requestedResolved, action0, isDebugEnabled)) { - presponse.allowed = false; - return presponse; - } - - if (isDebugEnabled) { - log.debug("Allowed because we have all indices permissions for {}", action0); - } - } else { - log.info( - "No {}-level perm match for {} {}: {} [Action [{}]] [RolesChecked {}]", - "index", - user, - requestedResolved, - presponse.getReason(), - action0, - mappedRoles - ); - log.info("Index to privilege matrix:\n{}", presponse.getPrivilegeMatrix()); - if (presponse.hasEvaluationExceptions()) { - log.info("Evaluation errors:\n{}", presponse.getEvaluationExceptionInfo()); - } - } - - return presponse; - } - - public Set mapRoles(final User user, final TransportAddress caller) { - return this.configModel.mapSecurityRoles(user, caller); - } - - public TenantPrivileges tenantPrivileges() { - return this.tenantPrivileges.get(); - } - - public boolean multitenancyEnabled() { - return privilegesInterceptor.getClass() != PrivilegesInterceptor.class && dcm.isDashboardsMultitenancyEnabled(); - } - - public boolean privateTenantEnabled() { - return privilegesInterceptor.getClass() != PrivilegesInterceptor.class && dcm.isDashboardsPrivateTenantEnabled(); - } - - public String dashboardsDefaultTenant() { - return dcm.getDashboardsDefaultTenant(); - } - - public boolean notFailOnForbiddenEnabled() { - return privilegesInterceptor.getClass() != PrivilegesInterceptor.class && dcm.isDnfofEnabled(); - } - - public String dashboardsIndex() { - return dcm.getDashboardsIndexname(); - } + ConfigV7 generalConfiguration + ); - public String dashboardsServerUsername() { - return dcm.getDashboardsServerUsername(); - } + void updateClusterStateMetadata(ClusterService clusterService); - public String dashboardsOpenSearchRole() { - return dcm.getDashboardsOpenSearchRole(); - } + /** + * Shuts down any background processes or other resources that need an explicit shut down + */ + void shutdown(); - public List getSignInOptions() { - return dcm.getSignInOptions(); - } + boolean notFailOnForbiddenEnabled(); - private ImmutableSet evaluateAdditionalIndexPermissions(final ActionRequest request, final String originalAction) { - ImmutableSet.Builder additionalPermissionsRequired = ImmutableSet.builder(); + boolean isInitialized(); - if (!isClusterPerm(originalAction)) { - additionalPermissionsRequired.add(originalAction); - } + /** + * A PrivilegesEvaluator implementation that just throws "not initialized" exceptions. + * Used initially by PrivilegesConfiguration. + */ + class NotInitialized implements PrivilegesEvaluator { + private final Supplier unavailablityReasonSupplier; - if (request instanceof ClusterSearchShardsRequest) { - additionalPermissionsRequired.add(SearchAction.NAME); + NotInitialized(Supplier unavailablityReasonSupplier) { + this.unavailablityReasonSupplier = unavailablityReasonSupplier; } - if (request instanceof BulkShardRequest) { - BulkShardRequest bsr = (BulkShardRequest) request; - for (BulkItemRequest bir : bsr.items()) { - switch (bir.request().opType()) { - case CREATE: - additionalPermissionsRequired.add(IndexAction.NAME); - break; - case INDEX: - additionalPermissionsRequired.add(IndexAction.NAME); - break; - case DELETE: - additionalPermissionsRequired.add(DeleteAction.NAME); - break; - case UPDATE: - additionalPermissionsRequired.add(UpdateAction.NAME); - break; - } - } + @Override + public PrivilegesEvaluationContext createContext( + User user, + String action, + ActionRequest actionRequest, + ActionRequestMetadata actionRequestMetadata, + Task task + ) { + throw exception(); } - if (request instanceof IndicesAliasesRequest) { - IndicesAliasesRequest bsr = (IndicesAliasesRequest) request; - for (AliasActions bir : bsr.getAliasActions()) { - switch (bir.actionType()) { - case REMOVE_INDEX: - additionalPermissionsRequired.add(DeleteIndexAction.NAME); - break; - default: - break; - } - } + @Override + public PrivilegesEvaluatorResponse evaluate(PrivilegesEvaluationContext context) { + throw exception(); } - if (request instanceof CreateIndexRequest) { - CreateIndexRequest cir = (CreateIndexRequest) request; - if (cir.aliases() != null && !cir.aliases().isEmpty()) { - additionalPermissionsRequired.add(IndicesAliasesAction.NAME); - } - } - - if (request instanceof RestoreSnapshotRequest && checkSnapshotRestoreWritePrivileges) { - additionalPermissionsRequired.addAll(ConfigConstants.SECURITY_SNAPSHOT_RESTORE_NEEDED_WRITE_PRIVILEGES); + @Override + public boolean isClusterPermission(String action) { + return false; } - ImmutableSet result = additionalPermissionsRequired.build(); - - if (result.size() > 1) { - traceAction("Additional permissions required: {}", result); - } + @Override + public void updateConfiguration( + FlattenedActionGroups actionGroups, + SecurityDynamicConfiguration rolesConfiguration, + ConfigV7 generalConfiguration + ) { - if (log.isDebugEnabled() && result.size() > 1) { - log.debug("Additional permissions required: {}", result); } - return result; - } - - public static boolean isClusterPerm(String action0) { - return (action0.startsWith("cluster:") - || action0.startsWith("indices:admin/template/") - || action0.startsWith("indices:admin/index_template/") - || action0.startsWith(SearchScrollAction.NAME) - || (action0.equals(BulkAction.NAME)) - || (action0.equals(MultiGetAction.NAME)) - || (action0.startsWith(MultiSearchAction.NAME)) - || (action0.equals(MultiTermVectorsAction.NAME)) - || (action0.equals(ReindexAction.NAME)) - || (action0.equals(RenderSearchTemplateAction.NAME))); - } + @Override + public void updateClusterStateMetadata(ClusterService clusterService) { - @SuppressWarnings("unchecked") - private boolean checkFilteredAliases(Resolved requestedResolved, String action, boolean isDebugEnabled) { - final String faMode = dcm.getFilteredAliasMode();// getConfigSettings().dynamic.filtered_alias_mode; - - if (!"disallow".equals(faMode)) { - return false; - } - - if (!ACTION_MATCHER.test(action)) { - return false; } - Iterable indexMetaDataCollection; - - if (requestedResolved.isLocalAll()) { - indexMetaDataCollection = new Iterable() { - @Override - public Iterator iterator() { - return clusterStateSupplier.get().getMetadata().getIndices().values().iterator(); - } - }; - } else { - Set indexMetaDataSet = new HashSet<>(requestedResolved.getAllIndices().size()); + @Override + public void shutdown() { - for (String requestAliasOrIndex : requestedResolved.getAllIndices()) { - IndexMetadata indexMetaData = clusterStateSupplier.get().getMetadata().getIndices().get(requestAliasOrIndex); - if (indexMetaData == null) { - if (isDebugEnabled) { - log.debug("{} does not exist in cluster metadata", requestAliasOrIndex); - } - continue; - } - - indexMetaDataSet.add(indexMetaData); - } - - indexMetaDataCollection = indexMetaDataSet; } - // check filtered aliases - for (IndexMetadata indexMetaData : indexMetaDataCollection) { - - final List filteredAliases = new ArrayList(); - - final Map aliases = indexMetaData.getAliases(); - - if (aliases != null && aliases.size() > 0) { - if (isDebugEnabled) { - log.debug("Aliases for {}: {}", indexMetaData.getIndex().getName(), aliases); - } - - final Iterator it = aliases.keySet().iterator(); - while (it.hasNext()) { - final String alias = it.next(); - final AliasMetadata aliasMetadata = aliases.get(alias); - - if (aliasMetadata != null && aliasMetadata.filteringRequired()) { - filteredAliases.add(aliasMetadata); - if (isDebugEnabled) { - log.debug("{} is a filtered alias {}", alias, aliasMetadata.getFilter()); - } - } else { - if (isDebugEnabled) { - log.debug("{} is not an alias or does not have a filter", alias); - } - } - } - } - - if (filteredAliases.size() > 1 && ACTION_MATCHER.test(action)) { - // TODO add queries as dls queries (works only if dls module is installed) - log.error( - "More than one ({}) filtered alias found for same index ({}). This is currently not supported. Aliases: {}", - filteredAliases.size(), - indexMetaData.getIndex().getName(), - toString(filteredAliases) - ); - return true; - } - } // end-for - - return false; - } - private boolean checkDocAllowListHeader(User user, String action, ActionRequest request) { - String docAllowListHeader = threadContext.getHeader(ConfigConstants.OPENDISTRO_SECURITY_DOC_ALLOWLIST_HEADER); - - if (docAllowListHeader == null) { + @Override + public boolean notFailOnForbiddenEnabled() { return false; } - if (!(request instanceof GetRequest)) { + @Override + public boolean isInitialized() { return false; } - try { - DocumentAllowList documentAllowList = DocumentAllowList.parse(docAllowListHeader); - GetRequest getRequest = (GetRequest) request; - - if (documentAllowList.isAllowed(getRequest.index(), getRequest.id())) { - if (log.isDebugEnabled()) { - log.debug("Request " + request + " is allowed by " + documentAllowList); - } + private OpenSearchSecurityException exception() { + StringBuilder error = new StringBuilder("OpenSearch Security is not initialized"); + String reason = this.unavailablityReasonSupplier.get(); - return true; + if (reason != null) { + error.append(": ").append(reason); } else { - return false; + error.append("."); } - } catch (Exception e) { - log.error("Error while handling document allow list: " + docAllowListHeader, e); - return false; + return new OpenSearchSecurityException(error.toString(), RestStatus.SERVICE_UNAVAILABLE); } - } - - private List toString(List aliases) { - if (aliases == null || aliases.size() == 0) { - return Collections.emptyList(); - } - - final List ret = new ArrayList<>(aliases.size()); + }; - for (final AliasMetadata amd : aliases) { - if (amd != null) { - ret.add(amd.alias()); - } - } - - return Collections.unmodifiableList(ret); - } - - public void updatePluginToActionPrivileges(String pluginIdentifier, RoleV7 pluginPermissions) { - pluginIdToActionPrivileges.put(pluginIdentifier, new SubjectBasedActionPrivileges(pluginPermissions, staticActionGroups)); - } } diff --git a/src/main/java/org/opensearch/security/privileges/PrivilegesEvaluatorResponse.java b/src/main/java/org/opensearch/security/privileges/PrivilegesEvaluatorResponse.java index d072ec301c..d069a7caf0 100644 --- a/src/main/java/org/opensearch/security/privileges/PrivilegesEvaluatorResponse.java +++ b/src/main/java/org/opensearch/security/privileges/PrivilegesEvaluatorResponse.java @@ -31,8 +31,10 @@ import java.util.Collections; import java.util.HashSet; import java.util.List; +import java.util.Objects; import java.util.Set; +import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableSet; import org.opensearch.action.admin.indices.create.CreateIndexRequestBuilder; @@ -42,12 +44,13 @@ public class PrivilegesEvaluatorResponse { boolean allowed = false; Set missingSecurityRoles = new HashSet<>(); - PrivilegesEvaluatorResponseState state = PrivilegesEvaluatorResponseState.PENDING; CreateIndexRequestBuilder createIndexRequestBuilder; private Set onlyAllowedForIndices = ImmutableSet.of(); private CheckTable indexToActionCheckTable; + private ImmutableList subResults = ImmutableList.of(); private String privilegeMatrix; private String reason; + private PrivilegesEvaluatorResponse originalResult; /** * Contains issues that were encountered during privilege evaluation. Can be used for logging. @@ -101,7 +104,13 @@ public PrivilegesEvaluatorResponse reason(String reason) { * Returns a diagnostic string that contains issues that were encountered during privilege evaluation. Can be used for logging. */ public String getEvaluationExceptionInfo() { - StringBuilder result = new StringBuilder("Exceptions encountered during privilege evaluation:\n"); + if (this.evaluationExceptions.isEmpty()) { + return "No errors"; + } + + StringBuilder result = new StringBuilder( + this.evaluationExceptions.size() == 1 ? "One error:\n" : this.evaluationExceptions.size() + " errors:\n" + ); for (PrivilegesEvaluationException evaluationException : this.evaluationExceptions) { result.append(evaluationException.getNestedMessages()).append("\n"); @@ -127,7 +136,24 @@ public String getPrivilegeMatrix() { String result = this.privilegeMatrix; if (result == null) { - result = this.indexToActionCheckTable.toTableString("ok", "MISSING"); + String topLevelMatrix; + + if (this.indexToActionCheckTable != null) { + topLevelMatrix = this.indexToActionCheckTable.toTableString("ok", "MISSING"); + } else { + topLevelMatrix = "n/a"; + } + + if (subResults.isEmpty()) { + result = topLevelMatrix; + } else { + StringBuilder resultBuilder = new StringBuilder(topLevelMatrix); + for (PrivilegesEvaluatorResponse subResult : subResults) { + resultBuilder.append("\n"); + resultBuilder.append(subResult.getPrivilegeMatrix()); + } + result = resultBuilder.toString(); + } this.privilegeMatrix = result; } return result; @@ -141,22 +167,41 @@ public CreateIndexRequestBuilder getCreateIndexRequestBuilder() { return createIndexRequestBuilder; } - public PrivilegesEvaluatorResponse markComplete() { - this.state = PrivilegesEvaluatorResponseState.COMPLETE; - return this; + public PrivilegesEvaluatorResponse originalResult() { + return this.originalResult; } - public PrivilegesEvaluatorResponse markPending() { - this.state = PrivilegesEvaluatorResponseState.PENDING; - return this; + public boolean privilegesAreComplete() { + if (originalResult != null && !originalResult.privilegesAreComplete()) { + return false; + } else if (indexToActionCheckTable != null && !indexToActionCheckTable.isComplete()) { + return false; + } else if (!subResults.isEmpty() && subResults.stream().anyMatch(subResult -> !subResult.privilegesAreComplete())) { + return false; + } else { + return this.allowed; + } } - public boolean isComplete() { - return this.state == PrivilegesEvaluatorResponseState.COMPLETE; + public PrivilegesEvaluatorResponse insufficient(List subResults) { + String reason = this.reason; + if (reason == null) { + reason = subResults.stream().map(result -> result.reason).filter(Objects::nonNull).findFirst().orElse(null); + } + PrivilegesEvaluatorResponse result = new PrivilegesEvaluatorResponse(); + result.allowed = false; + result.indexToActionCheckTable = this.indexToActionCheckTable; + result.subResults = ImmutableList.copyOf(subResults); + result.reason = reason; + return result; } - public boolean isPending() { - return this.state == PrivilegesEvaluatorResponseState.PENDING; + public PrivilegesEvaluatorResponse originalResult(PrivilegesEvaluatorResponse originalResult) { + if (originalResult != null && !originalResult.evaluationExceptions.isEmpty()) { + this.originalResult = originalResult; + this.evaluationExceptions.addAll(originalResult.evaluationExceptions); + } + return this; } @Override @@ -176,6 +221,20 @@ public static PrivilegesEvaluatorResponse ok() { return response; } + public static PrivilegesEvaluatorResponse ok(CheckTable indexToActionCheckTable) { + PrivilegesEvaluatorResponse response = new PrivilegesEvaluatorResponse(); + response.indexToActionCheckTable = indexToActionCheckTable; + response.allowed = true; + return response; + } + + public static PrivilegesEvaluatorResponse ok(CreateIndexRequestBuilder createIndexRequestBuilder) { + PrivilegesEvaluatorResponse response = new PrivilegesEvaluatorResponse(); + response.allowed = true; + response.createIndexRequestBuilder = createIndexRequestBuilder; + return response; + } + public static PrivilegesEvaluatorResponse partiallyOk( Set availableIndices, CheckTable indexToActionCheckTable diff --git a/src/main/java/org/opensearch/security/privileges/PrivilegesInterceptor.java b/src/main/java/org/opensearch/security/privileges/PrivilegesInterceptor.java index 0ae809bc9d..9486fdff3d 100644 --- a/src/main/java/org/opensearch/security/privileges/PrivilegesInterceptor.java +++ b/src/main/java/org/opensearch/security/privileges/PrivilegesInterceptor.java @@ -31,8 +31,6 @@ import org.opensearch.cluster.metadata.IndexNameExpressionResolver; import org.opensearch.cluster.service.ClusterService; import org.opensearch.common.util.concurrent.ThreadContext; -import org.opensearch.security.resolver.IndexResolverReplacer.Resolved; -import org.opensearch.security.securityconf.DynamicConfigModel; import org.opensearch.security.user.User; import org.opensearch.threadpool.ThreadPool; import org.opensearch.transport.client.Client; @@ -40,15 +38,27 @@ public class PrivilegesInterceptor { public static class ReplaceResult { - final boolean continueEvaluation; - final boolean accessDenied; - final CreateIndexRequestBuilder createIndexRequestBuilder; + public final boolean continueEvaluation; + public final boolean accessDenied; + public final CreateIndexRequestBuilder createIndexRequestBuilder; private ReplaceResult(boolean continueEvaluation, boolean accessDenied, CreateIndexRequestBuilder createIndexRequestBuilder) { this.continueEvaluation = continueEvaluation; this.accessDenied = accessDenied; this.createIndexRequestBuilder = createIndexRequestBuilder; } + + @Override + public String toString() { + return "ReplaceResult{" + + "continueEvaluation=" + + continueEvaluation + + ", accessDenied=" + + accessDenied + + ", createIndexRequestBuilder=" + + createIndexRequestBuilder + + '}'; + } } public static final ReplaceResult CONTINUE_EVALUATION_REPLACE_RESULT = new ReplaceResult(true, false, null); @@ -80,10 +90,7 @@ public ReplaceResult replaceDashboardsIndex( final ActionRequest request, final String action, final User user, - final DynamicConfigModel config, - final Resolved requestedResolved, - final PrivilegesEvaluationContext context, - final TenantPrivileges tenantPrivileges + final PrivilegesEvaluationContext context ) { throw new RuntimeException("not implemented"); } diff --git a/src/main/java/org/opensearch/security/privileges/ResourceAccessEvaluator.java b/src/main/java/org/opensearch/security/privileges/ResourceAccessEvaluator.java index 1984028b77..df5749978d 100644 --- a/src/main/java/org/opensearch/security/privileges/ResourceAccessEvaluator.java +++ b/src/main/java/org/opensearch/security/privileges/ResourceAccessEvaluator.java @@ -71,21 +71,23 @@ public void evaluateAsync( final PrivilegesEvaluationContext context, final ActionListener pResponseListener ) { - PrivilegesEvaluatorResponse pResponse = new PrivilegesEvaluatorResponse(); - log.debug("Evaluating resource access"); // if it reached this evaluator, it is safe to assume that the request if of DocRequest type DocRequest req = (DocRequest) request; - resourceAccessHandler.hasPermission(req.id(), req.index(), action, context, ActionListener.wrap(hasAccess -> { - if (hasAccess) { - pResponse.allowed = true; - pResponseListener.onResponse(pResponse.markComplete()); - return; - } - pResponseListener.onResponse(PrivilegesEvaluatorResponse.insufficient(action).markComplete()); - }, e -> { pResponseListener.onResponse(pResponse.markComplete()); })); + resourceAccessHandler.hasPermission( + req.id(), + req.index(), + action, + context, + ActionListener.wrap( + hasAccess -> pResponseListener.onResponse( + hasAccess ? PrivilegesEvaluatorResponse.ok() : PrivilegesEvaluatorResponse.insufficient(action) + ), + pResponseListener::onFailure + ) + ); } /** diff --git a/src/main/java/org/opensearch/security/privileges/RestLayerPrivilegesEvaluator.java b/src/main/java/org/opensearch/security/privileges/RestLayerPrivilegesEvaluator.java index 5274ad3456..4890dd5a89 100644 --- a/src/main/java/org/opensearch/security/privileges/RestLayerPrivilegesEvaluator.java +++ b/src/main/java/org/opensearch/security/privileges/RestLayerPrivilegesEvaluator.java @@ -20,14 +20,14 @@ public class RestLayerPrivilegesEvaluator { protected final Logger log = LogManager.getLogger(this.getClass()); - private final PrivilegesEvaluator privilegesEvaluator; + private final PrivilegesConfiguration privilegesConfiguration; - public RestLayerPrivilegesEvaluator(PrivilegesEvaluator privilegesEvaluator) { - this.privilegesEvaluator = privilegesEvaluator; + public RestLayerPrivilegesEvaluator(PrivilegesConfiguration privilegesConfiguration) { + this.privilegesConfiguration = privilegesConfiguration; } public PrivilegesEvaluatorResponse evaluate(final User user, final String routeName, final Set actions) { - PrivilegesEvaluationContext context = privilegesEvaluator.createContext(user, routeName); + PrivilegesEvaluationContext context = privilegesConfiguration.privilegesEvaluator().createContext(user, routeName); final boolean isDebugEnabled = log.isDebugEnabled(); if (isDebugEnabled) { diff --git a/src/main/java/org/opensearch/security/privileges/RoleMapper.java b/src/main/java/org/opensearch/security/privileges/RoleMapper.java new file mode 100644 index 0000000000..5a5011968f --- /dev/null +++ b/src/main/java/org/opensearch/security/privileges/RoleMapper.java @@ -0,0 +1,25 @@ +/* + * 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. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.privileges; + +import com.google.common.collect.ImmutableSet; + +import org.opensearch.core.common.transport.TransportAddress; +import org.opensearch.security.user.User; + +/** + * A general interface for components that map users to their effective roles. + */ +@FunctionalInterface +public interface RoleMapper { + ImmutableSet map(User user, TransportAddress caller); +} diff --git a/src/main/java/org/opensearch/security/privileges/SpecialIndices.java b/src/main/java/org/opensearch/security/privileges/SpecialIndices.java new file mode 100644 index 0000000000..2cc0480978 --- /dev/null +++ b/src/main/java/org/opensearch/security/privileges/SpecialIndices.java @@ -0,0 +1,43 @@ +/* + * 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. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ +package org.opensearch.security.privileges; + +import org.opensearch.common.settings.Settings; +import org.opensearch.indices.SystemIndexRegistry; +import org.opensearch.security.support.ConfigConstants; +import org.opensearch.security.support.WildcardMatcher; + +/** + * Contains information regarding system indices and other specially handled indices + */ +public class SpecialIndices { + private final String securityIndex; + private final WildcardMatcher manuallyConfiguredSystemIndexMatcher; + + public SpecialIndices(Settings settings) { + this.securityIndex = settings.get( + ConfigConstants.SECURITY_CONFIG_INDEX_NAME, + ConfigConstants.OPENDISTRO_SECURITY_DEFAULT_CONFIG_INDEX + ); + this.manuallyConfiguredSystemIndexMatcher = WildcardMatcher.from( + settings.getAsList(ConfigConstants.SECURITY_SYSTEM_INDICES_KEY, ConfigConstants.SECURITY_SYSTEM_INDICES_DEFAULT) + ); + } + + public boolean isUniversallyDeniedIndex(String index) { + return index.equals(securityIndex); + } + + public boolean isSystemIndex(String index) { + return this.manuallyConfiguredSystemIndexMatcher.test(index) || SystemIndexRegistry.matchesSystemIndexPattern(index); + } + +} diff --git a/src/main/java/org/opensearch/security/privileges/TermsAggregationEvaluator.java b/src/main/java/org/opensearch/security/privileges/TermsAggregationEvaluator.java deleted file mode 100644 index a2cd1c16a7..0000000000 --- a/src/main/java/org/opensearch/security/privileges/TermsAggregationEvaluator.java +++ /dev/null @@ -1,118 +0,0 @@ -/* - * Copyright 2015-2018 _floragunn_ GmbH - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/* - * 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. - * - * Modifications Copyright OpenSearch Contributors. See - * GitHub history for details. - */ - -package org.opensearch.security.privileges; - -import com.google.common.collect.ImmutableSet; -import com.google.common.collect.Sets; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -import org.opensearch.action.ActionRequest; -import org.opensearch.action.fieldcaps.FieldCapabilitiesAction; -import org.opensearch.action.get.GetAction; -import org.opensearch.action.get.MultiGetAction; -import org.opensearch.action.search.MultiSearchAction; -import org.opensearch.action.search.SearchAction; -import org.opensearch.action.search.SearchRequest; -import org.opensearch.index.query.MatchNoneQueryBuilder; -import org.opensearch.index.query.QueryBuilder; -import org.opensearch.index.query.TermsQueryBuilder; -import org.opensearch.search.aggregations.AggregationBuilder; -import org.opensearch.search.aggregations.bucket.terms.TermsAggregationBuilder; -import org.opensearch.security.resolver.IndexResolverReplacer.Resolved; - -public class TermsAggregationEvaluator { - - protected final Logger log = LogManager.getLogger(this.getClass()); - - private static final ImmutableSet READ_ACTIONS = ImmutableSet.of( - MultiSearchAction.NAME, - MultiGetAction.NAME, - GetAction.NAME, - SearchAction.NAME, - FieldCapabilitiesAction.NAME - ); - - private static final QueryBuilder NONE_QUERY = new MatchNoneQueryBuilder(); - - public TermsAggregationEvaluator() {} - - public PrivilegesEvaluatorResponse evaluate( - final Resolved resolved, - final ActionRequest request, - PrivilegesEvaluationContext context, - ActionPrivileges actionPrivileges, - PrivilegesEvaluatorResponse presponse - ) { - try { - if (request instanceof SearchRequest) { - SearchRequest sr = (SearchRequest) request; - - if (sr.source() != null - && sr.source().query() == null - && sr.source().aggregations() != null - && sr.source().aggregations().getAggregatorFactories() != null - && sr.source().aggregations().getAggregatorFactories().size() == 1 - && sr.source().size() == 0) { - AggregationBuilder ab = sr.source().aggregations().getAggregatorFactories().iterator().next(); - if (ab instanceof TermsAggregationBuilder && "terms".equals(ab.getType()) && "indices".equals(ab.getName())) { - if ("_index".equals(((TermsAggregationBuilder) ab).field()) - && ab.getPipelineAggregations().isEmpty() - && ab.getSubAggregations().isEmpty()) { - - PrivilegesEvaluatorResponse subResponse = actionPrivileges.hasIndexPrivilege( - context, - READ_ACTIONS, - Resolved._LOCAL_ALL - ); - - if (subResponse.isPartiallyOk()) { - sr.source() - .query( - new TermsQueryBuilder( - "_index", - Sets.union(subResponse.getAvailableIndices(), resolved.getRemoteIndices()) - ) - ); - } else if (!subResponse.isAllowed()) { - sr.source().query(NONE_QUERY); - } - - presponse.allowed = true; - return presponse.markComplete(); - } - } - } - } - } catch (Exception e) { - log.warn("Unable to evaluate terms aggregation", e); - return presponse; - } - - return presponse; - } -} diff --git a/src/main/java/org/opensearch/security/privileges/actionlevel/RoleBasedActionPrivileges.java b/src/main/java/org/opensearch/security/privileges/actionlevel/RoleBasedActionPrivileges.java index 3734f340ab..590fb08f12 100644 --- a/src/main/java/org/opensearch/security/privileges/actionlevel/RoleBasedActionPrivileges.java +++ b/src/main/java/org/opensearch/security/privileges/actionlevel/RoleBasedActionPrivileges.java @@ -18,6 +18,8 @@ import java.util.Map; import java.util.Set; import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Function; +import java.util.function.Predicate; import java.util.stream.Collectors; import com.google.common.collect.ImmutableMap; @@ -39,7 +41,6 @@ import org.opensearch.security.privileges.PrivilegesEvaluationContext; import org.opensearch.security.privileges.PrivilegesEvaluationException; import org.opensearch.security.privileges.PrivilegesEvaluatorResponse; -import org.opensearch.security.resolver.IndexResolverReplacer; import org.opensearch.security.securityconf.FlattenedActionGroups; import org.opensearch.security.securityconf.impl.SecurityDynamicConfiguration; import org.opensearch.security.securityconf.impl.v7.RoleV7; @@ -97,8 +98,29 @@ public class RoleBasedActionPrivileges extends RuntimeOptimizedActionPrivileges private final AtomicReference statefulIndex = new AtomicReference<>(); - public RoleBasedActionPrivileges(SecurityDynamicConfiguration roles, FlattenedActionGroups actionGroups, Settings settings) { - super(new ClusterPrivileges(roles, actionGroups), new IndexPrivileges(roles, actionGroups)); + /** + * Creates a new RoleBasedActionPrivileges instance based on the given parameters. + * + * @param roles the roles form the basis for the privilege configuration + * @param actionGroups the action groups will be used to expand the "allowed_actions" attributes in the roles config + * @param specialIndexProtection configuration that identifies indices for which additional protections should be applied + * @param settings Other settings for this instance. The settings PRECOMPUTED_PRIVILEGES_MAX_HEAP_SIZE and PRECOMPUTED_PRIVILEGES_ENABLED + * will be read from this. + * @param breakDownAliases if true, this class is allowed to break down aliases into member indices to see whether a subset of the member indices have the necessary privileges. This is used for the legacy privilege evaluation. + * It has the issue that filtered aliases are lost in the process. Thus, the new privilege evaluation does not use this any more. + */ + public RoleBasedActionPrivileges( + SecurityDynamicConfiguration roles, + FlattenedActionGroups actionGroups, + SpecialIndexProtection specialIndexProtection, + Settings settings, + boolean breakDownAliases + ) { + super( + new ClusterPrivileges(roles, actionGroups), + new IndexPrivileges(roles, actionGroups, specialIndexProtection, breakDownAliases), + breakDownAliases + ); this.roles = roles; this.actionGroups = actionGroups; this.statefulIndexMaxHeapSize = PRECOMPUTED_PRIVILEGES_MAX_HEAP_SIZE.get(settings); @@ -121,7 +143,7 @@ public void updateStatefulIndexPrivileges(Map indices, StatefulIndexPrivileges statefulIndex = this.statefulIndex.get(); - indices = StatefulIndexPrivileges.relevantOnly(indices); + indices = StatefulIndexPrivileges.relevantOnly(indices, this.index.universallyDeniedIndices); if (statefulIndex == null || !statefulIndex.indices.equals(indices)) { long start = System.currentTimeMillis(); @@ -348,7 +370,17 @@ static class IndexPrivileges extends RuntimeOptimizedActionPrivileges.StaticInde * just results in fewer available privileges. However, having a proper error reporting mechanism would be * kind of nice. */ - IndexPrivileges(SecurityDynamicConfiguration roles, FlattenedActionGroups actionGroups) { + IndexPrivileges( + SecurityDynamicConfiguration roles, + FlattenedActionGroups actionGroups, + SpecialIndexProtection specialIndexProtection, + boolean memberIndexPrivilegesYieldAliasPrivileges + ) { + super(specialIndexProtection); + + Function indexPatternBuilder = k -> new IndexPattern.Builder( + memberIndexPrivilegesYieldAliasPrivileges + ); Map> rolesToActionToIndexPattern = new HashMap<>(); Map> rolesToActionPatternToIndexPattern = new HashMap<>(); @@ -379,12 +411,12 @@ static class IndexPrivileges extends RuntimeOptimizedActionPrivileges.StaticInde if (WildcardMatcher.isExact(permission)) { rolesToActionToIndexPattern.computeIfAbsent(roleName, k -> new HashMap<>()) - .computeIfAbsent(permission, k -> new IndexPattern.Builder()) + .computeIfAbsent(permission, indexPatternBuilder) .add(indexPermissions.getIndex_patterns()); if (WellKnownActions.EXPLICITLY_REQUIRED_INDEX_ACTIONS.contains(permission)) { rolesToExplicitActionToIndexPattern.computeIfAbsent(roleName, k -> new HashMap<>()) - .computeIfAbsent(permission, k -> new IndexPattern.Builder()) + .computeIfAbsent(permission, indexPatternBuilder) .add(indexPermissions.getIndex_patterns()); } @@ -399,7 +431,7 @@ static class IndexPrivileges extends RuntimeOptimizedActionPrivileges.StaticInde for (String action : actionMatcher.iterateMatching(WellKnownActions.INDEX_ACTIONS)) { rolesToActionToIndexPattern.computeIfAbsent(roleName, k -> new HashMap<>()) - .computeIfAbsent(action, k -> new IndexPattern.Builder()) + .computeIfAbsent(action, indexPatternBuilder) .add(indexPermissions.getIndex_patterns()); if (indexPermissions.getIndex_patterns().contains("*")) { @@ -411,7 +443,7 @@ static class IndexPrivileges extends RuntimeOptimizedActionPrivileges.StaticInde } rolesToActionPatternToIndexPattern.computeIfAbsent(roleName, k -> new HashMap<>()) - .computeIfAbsent(actionMatcher, k -> new IndexPattern.Builder()) + .computeIfAbsent(actionMatcher, indexPatternBuilder) .add(indexPermissions.getIndex_patterns()); if (actionMatcher != WildcardMatcher.ANY) { @@ -419,7 +451,7 @@ static class IndexPrivileges extends RuntimeOptimizedActionPrivileges.StaticInde WellKnownActions.EXPLICITLY_REQUIRED_INDEX_ACTIONS )) { rolesToExplicitActionToIndexPattern.computeIfAbsent(roleName, k -> new HashMap<>()) - .computeIfAbsent(action, k -> new IndexPattern.Builder()) + .computeIfAbsent(action, indexPatternBuilder) .add(indexPermissions.getIndex_patterns()); } } @@ -492,10 +524,9 @@ static class IndexPrivileges extends RuntimeOptimizedActionPrivileges.StaticInde * checkTable instance as checked. */ @Override - protected PrivilegesEvaluatorResponse providesPrivilege( + protected IntermediateResult providesPrivilege( PrivilegesEvaluationContext context, Set actions, - IndexResolverReplacer.Resolved resolvedIndices, CheckTable checkTable ) { List exceptions = new ArrayList<>(); @@ -505,7 +536,7 @@ protected PrivilegesEvaluatorResponse providesPrivilege( if (actionToIndexPattern != null) { checkPrivilegeWithIndexPatternOnWellKnownActions(context, actions, checkTable, actionToIndexPattern, exceptions); if (checkTable.isComplete()) { - return PrivilegesEvaluatorResponse.ok(); + return new IntermediateResult(checkTable).evaluationExceptions(exceptions); } } } @@ -523,36 +554,94 @@ protected PrivilegesEvaluatorResponse providesPrivilege( if (actionPatternToIndexPattern != null) { checkPrivilegesForNonWellKnownActions(context, actions, checkTable, actionPatternToIndexPattern, exceptions); if (checkTable.isComplete()) { - return PrivilegesEvaluatorResponse.ok(); + return new IntermediateResult(checkTable).evaluationExceptions(exceptions); + } + } + } + } + + return new IntermediateResult(checkTable).evaluationExceptions(exceptions); + } + + @Override + protected PrivilegesEvaluatorResponse providesPrivilegeOnAnyIndex( + PrivilegesEvaluationContext context, + Set actions, + CheckTable checkTable + ) { + for (String role : context.getMappedRoles()) { + ImmutableMap actionToIndexPattern = this.rolesToActionToIndexPattern.get(role); + if (actionToIndexPattern != null) { + checkTable.checkIf( + checkTable.getRows(), + action -> !actionToIndexPattern.getOrDefault(action, IndexPattern.EMPTY).isEmpty() + ); + if (checkTable.isComplete()) { + return PrivilegesEvaluatorResponse.ok(checkTable); + } + } + } + + // If all actions are well-known, the index.rolesToActionToIndexPattern data structure that was evaluated above, + // would have contained all the actions if privileges are provided. If there are non-well-known actions among the + // actions, we also have to evaluate action patterns to check the authorization + + if (!checkTable.isComplete() && !allWellKnownIndexActions(actions)) { + for (String role : context.getMappedRoles()) { + ImmutableMap actionPatternToIndexPattern = this.rolesToActionPatternToIndexPattern.get( + role + ); + if (actionPatternToIndexPattern != null) { + for (String action : actions) { + for (Map.Entry entry : actionPatternToIndexPattern.entrySet()) { + WildcardMatcher actionMatcher = entry.getKey(); + IndexPattern indexPattern = entry.getValue(); + + if (actionMatcher.test(action) && !indexPattern.isEmpty()) { + checkTable.getRows().forEach(index -> checkTable.check(index, action)); + if (checkTable.isComplete()) { + return PrivilegesEvaluatorResponse.ok(checkTable); + } + } + } } } } } - return responseForIncompletePrivileges(context, resolvedIndices, checkTable, exceptions); + return PrivilegesEvaluatorResponse.insufficient(checkTable) + .reason("The user does not have any index privileges for the requested action"); } /** - * Returns PrivilegesEvaluatorResponse.ok() if the user identified in the context object has privileges for all + * Returns IntermediateResult.ok() if the user identified in the context object has privileges for all * indices (using *) for the given actions. Returns null otherwise. Then, further checks must be done to check * the user's privileges. + *

+ * As a side-effect, this method will mark the available index/action combinations in the provided + * checkTable instance as checked. */ @Override - protected PrivilegesEvaluatorResponse checkWildcardIndexPrivilegesOnWellKnownActions( + protected IntermediateResult checkWildcardIndexPrivilegesOnWellKnownActions( PrivilegesEvaluationContext context, - Set actions + Set actions, + CheckTable checkTable ) { ImmutableSet effectiveRoles = context.getMappedRoles(); for (String action : actions) { ImmutableCompactSubSet rolesWithWildcardIndexPrivileges = this.actionToRolesWithWildcardIndexPrivileges.get(action); - if (rolesWithWildcardIndexPrivileges == null || !rolesWithWildcardIndexPrivileges.containsAny(effectiveRoles)) { - return null; + if (rolesWithWildcardIndexPrivileges != null && rolesWithWildcardIndexPrivileges.containsAny(effectiveRoles)) { + checkTable.checkIf(index -> true, action); } } - return PrivilegesEvaluatorResponse.ok(); + if (checkTable.isComplete()) { + return new IntermediateResult(checkTable); + } else { + return null; + } } /** @@ -582,7 +671,7 @@ protected PrivilegesEvaluatorResponse providesExplicitPrivilege( for (String index : checkTable.iterateUncheckedRows(action)) { try { if (indexPattern.matches(index, context, indexMetadata) && checkTable.check(index, action)) { - return PrivilegesEvaluatorResponse.ok(); + return PrivilegesEvaluatorResponse.ok(checkTable); } } catch (PrivilegesEvaluationException e) { // We can ignore these errors, as this max leads to fewer privileges than available @@ -599,6 +688,40 @@ protected PrivilegesEvaluatorResponse providesExplicitPrivilege( .reason("No explicit privileges have been provided for the referenced indices.") .evaluationExceptions(exceptions); } + + @Override + protected boolean providesExplicitPrivilege( + PrivilegesEvaluationContext context, + String index, + String action, + List exceptions + ) { + Map indexMetadata = context.getIndicesLookup(); + + for (String role : context.getMappedRoles()) { + ImmutableMap actionToIndexPattern = this.rolesToExplicitActionToIndexPattern.get(role); + + if (actionToIndexPattern != null) { + IndexPattern indexPattern = actionToIndexPattern.get(action); + + if (indexPattern != null) { + try { + if (indexPattern.matches(index, context, indexMetadata)) { + return true; + } + } catch (PrivilegesEvaluationException e) { + // We can ignore these errors, as this max leads to fewer privileges than available + log.error("Error while evaluating index pattern of role {}. Ignoring entry", role, e); + exceptions.add(new PrivilegesEvaluationException("Error while evaluating role " + role, e)); + } + + } + } + + } + + return false; + } } /** @@ -688,7 +811,7 @@ static class StatefulIndexPrivileges extends RuntimeOptimizedActionPrivileges.St continue; } - WildcardMatcher indexMatcher = IndexPattern.from(indexPermissions.getIndex_patterns()).getStaticPattern(); + WildcardMatcher indexMatcher = IndexPattern.from(indexPermissions.getIndex_patterns(), false).getStaticPattern(); if (indexMatcher == WildcardMatcher.NONE) { // The pattern is likely blank because there are only templated patterns. @@ -798,26 +921,25 @@ static class StatefulIndexPrivileges extends RuntimeOptimizedActionPrivileges.St * is returned, because then the remaining logic needs only to check for the unchecked cases. * * @param actions the actions the user needs to have privileges for - * @param resolvedIndices the index the user needs to have privileges for * @param context context information like user, resolved roles, etc. * @param checkTable An action/index matrix. This method will modify the table as a side effect and check the cells where privileges are present. * @return PrivilegesEvaluatorResponse.ok() or null. */ @Override - protected PrivilegesEvaluatorResponse providesPrivilege( + protected IntermediateResult providesPrivilege( Set actions, - IndexResolverReplacer.Resolved resolvedIndices, PrivilegesEvaluationContext context, CheckTable checkTable ) { Map indexMetadata = context.getIndicesLookup(); ImmutableSet effectiveRoles = context.getMappedRoles(); + Set indices = checkTable.getRows(); for (String action : actions) { Map> indexToRoles = actionToIndexToRoles.get(action); if (indexToRoles != null) { - for (String index : resolvedIndices.getAllIndices()) { + for (String index : indices) { String lookupIndex = index; if (index.startsWith(DataStream.BACKING_INDEX_PREFIX)) { @@ -831,7 +953,7 @@ protected PrivilegesEvaluatorResponse providesPrivilege( if (rolesWithPrivileges != null && rolesWithPrivileges.containsAny(effectiveRoles)) { if (checkTable.check(index, action)) { - return PrivilegesEvaluatorResponse.ok(); + return new IntermediateResult(checkTable); } } } @@ -869,31 +991,20 @@ static String backingIndexToDataStream(String index, MapIndices which are not matched by includeIndices + *

  • Indices which are universally denied * */ - static Map relevantOnly(Map indices) { - // First pass: Check if we need to filter at all - boolean doFilter = false; + static Map relevantOnly( + Map indices, + Predicate universallyDeniedIndices + ) { + ImmutableMap.Builder builder = ImmutableMap.builder(); for (IndexAbstraction indexAbstraction : indices.values()) { - if (indexAbstraction instanceof IndexAbstraction.Index) { - if (indexAbstraction.getParentDataStream() != null - || indexAbstraction.getWriteIndex().getState() == IndexMetadata.State.CLOSE) { - doFilter = true; - break; - } + if (universallyDeniedIndices != null && universallyDeniedIndices.test(indexAbstraction.getName())) { + continue; } - } - if (!doFilter) { - return indices; - } - - // Second pass: Only if we actually need filtering, we will do it - ImmutableMap.Builder builder = ImmutableMap.builder(); - - for (IndexAbstraction indexAbstraction : indices.values()) { if (indexAbstraction instanceof IndexAbstraction.Index) { if (indexAbstraction.getParentDataStream() == null && indexAbstraction.getWriteIndex().getState() != IndexMetadata.State.CLOSE) { diff --git a/src/main/java/org/opensearch/security/privileges/actionlevel/RuntimeOptimizedActionPrivileges.java b/src/main/java/org/opensearch/security/privileges/actionlevel/RuntimeOptimizedActionPrivileges.java index 1ab6a11fbb..80ffc66693 100644 --- a/src/main/java/org/opensearch/security/privileges/actionlevel/RuntimeOptimizedActionPrivileges.java +++ b/src/main/java/org/opensearch/security/privileges/actionlevel/RuntimeOptimizedActionPrivileges.java @@ -11,10 +11,15 @@ package org.opensearch.security.privileges.actionlevel; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; +import java.util.function.Predicate; +import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; import org.apache.commons.collections4.CollectionUtils; @@ -22,12 +27,15 @@ import org.apache.logging.log4j.Logger; import org.opensearch.cluster.metadata.IndexAbstraction; +import org.opensearch.cluster.metadata.IndexMetadata; +import org.opensearch.cluster.metadata.OptionallyResolvedIndices; +import org.opensearch.cluster.metadata.ResolvedIndices; import org.opensearch.security.privileges.ActionPrivileges; import org.opensearch.security.privileges.IndexPattern; import org.opensearch.security.privileges.PrivilegesEvaluationContext; import org.opensearch.security.privileges.PrivilegesEvaluationException; import org.opensearch.security.privileges.PrivilegesEvaluatorResponse; -import org.opensearch.security.resolver.IndexResolverReplacer; +import org.opensearch.security.support.ConfigConstants; import org.opensearch.security.support.WildcardMatcher; import com.selectivem.collections.CheckTable; @@ -50,9 +58,18 @@ public abstract class RuntimeOptimizedActionPrivileges implements ActionPrivileg protected final ClusterPrivileges cluster; protected final StaticIndexPrivileges index; - RuntimeOptimizedActionPrivileges(ClusterPrivileges cluster, StaticIndexPrivileges index) { + /** + * If true, aliases or data streams which do not have direct privileges, will be broken down into individual indices + * to check whether there are privileges for the individual indices. This is mainly for backwards compatibility. The + * new privilege evaluation mode won't use this, as this does not work well with filtered aliases (the filters would + * just disappear). + */ + protected final boolean breakDownAliases; + + RuntimeOptimizedActionPrivileges(ClusterPrivileges cluster, StaticIndexPrivileges index, boolean breakDownAliases) { this.cluster = cluster; this.index = index; + this.breakDownAliases = breakDownAliases; } @Override @@ -84,14 +101,9 @@ public PrivilegesEvaluatorResponse hasExplicitClusterPrivilege(PrivilegesEvaluat public PrivilegesEvaluatorResponse hasIndexPrivilege( PrivilegesEvaluationContext context, Set actions, - IndexResolverReplacer.Resolved resolvedIndices + OptionallyResolvedIndices resolvedIndices ) { - PrivilegesEvaluatorResponse response = this.index.checkWildcardIndexPrivilegesOnWellKnownActions(context, actions); - if (response != null) { - return response; - } - - if (!resolvedIndices.isLocalAll() && resolvedIndices.getAllIndices().isEmpty()) { + if (resolvedIndices.local().isEmpty()) { // This is necessary for requests which operate on remote indices. // Access control for the remote indices will be performed on the remote cluster. log.debug("No local indices; grant the request"); @@ -100,20 +112,21 @@ public PrivilegesEvaluatorResponse hasIndexPrivilege( // TODO one might want to consider to create a semantic wrapper for action in order to be better tell apart // what's the action and what's the index in the generic parameters of CheckTable. - CheckTable checkTable = CheckTable.create( - resolvedIndices.getAllIndicesResolved(context.getClusterStateSupplier(), context.getIndexNameExpressionResolver()), - actions - ); + CheckTable checkTable = CheckTable.create(resolvedIndices.local().names(context.clusterState()), actions); + + IntermediateResult result = this.index.checkWildcardIndexPrivilegesOnWellKnownActions(context, actions, checkTable); + if (result != null) { + return this.index.finalizeResult(context, result); + } StatefulIndexPrivileges statefulIndex = this.currentStatefulIndexPrivileges(); - PrivilegesEvaluatorResponse resultFromStatefulIndex = null; if (statefulIndex != null) { - resultFromStatefulIndex = statefulIndex.providesPrivilege(actions, resolvedIndices, context, checkTable); + IntermediateResult resultFromStatefulIndex = statefulIndex.providesPrivilege(actions, context, checkTable); if (resultFromStatefulIndex != null) { // If we get a result from statefulIndex, we are done. - return resultFromStatefulIndex; + return this.index.finalizeResult(context, resultFromStatefulIndex); } // Otherwise, we need to carry on checking privileges using the non-stateful object. @@ -121,7 +134,29 @@ public PrivilegesEvaluatorResponse hasIndexPrivilege( // We can carry on using this as an intermediate result and further complete checkTable below. } - return this.index.providesPrivilege(context, actions, resolvedIndices, checkTable); + IntermediateResult resultFromStaticIndex = this.index.providesPrivilege(context, actions, checkTable); + + if (this.breakDownAliases + && !checkTable.isComplete() + && resolvedIndices instanceof ResolvedIndices + && containsAliasesOrDataStreams(context, checkTable.getIncompleteRows())) { + // If we could not gather privileges for all aliases, try to break down aliases and data streams into individual members + // This is for backwards compatibility only + return this.breakDownAliases(context, actions, checkTable); + } + + return this.index.finalizeResult(context, resultFromStaticIndex); + } + + @Override + public PrivilegesEvaluatorResponse hasIndexPrivilegeForAnyIndex(PrivilegesEvaluationContext context, Set actions) { + CheckTable checkTable = CheckTable.create(Set.of("_any_index"), actions); + IntermediateResult result = this.index.checkWildcardIndexPrivilegesOnWellKnownActions(context, actions, checkTable); + if (result != null) { + return PrivilegesEvaluatorResponse.ok(checkTable); + } + + return this.index.providesPrivilegeOnAnyIndex(context, actions, checkTable); } /** @@ -135,13 +170,13 @@ public PrivilegesEvaluatorResponse hasIndexPrivilege( public PrivilegesEvaluatorResponse hasExplicitIndexPrivilege( PrivilegesEvaluationContext context, Set actions, - IndexResolverReplacer.Resolved resolvedIndices + OptionallyResolvedIndices resolvedIndices ) { if (!CollectionUtils.containsAny(actions, WellKnownActions.EXPLICITLY_REQUIRED_INDEX_ACTIONS)) { return PrivilegesEvaluatorResponse.insufficient(CheckTable.create(ImmutableSet.of("_"), actions)); } - CheckTable checkTable = CheckTable.create(resolvedIndices.getAllIndices(), actions); + CheckTable checkTable = CheckTable.create(resolvedIndices.local().names(context.clusterState()), actions); return this.index.providesExplicitPrivilege(context, actions, checkTable); } @@ -151,6 +186,56 @@ public PrivilegesEvaluatorResponse hasExplicitIndexPrivilege( */ protected abstract StatefulIndexPrivileges currentStatefulIndexPrivileges(); + protected PrivilegesEvaluatorResponse breakDownAliases( + PrivilegesEvaluationContext context, + Set actions, + CheckTable checkTable + ) { + Map indicesLookup = context.getIndicesLookup(); + Set newIndices = new HashSet<>(); + + for (String index : checkTable.getRows()) { + if (checkTable.isRowComplete(index)) { + newIndices.add(index); + } else { + IndexAbstraction indexAbstraction = indicesLookup.get(index); + if (indexAbstraction instanceof IndexAbstraction.Alias || indexAbstraction instanceof IndexAbstraction.DataStream) { + indexAbstraction.getIndices().forEach(i -> newIndices.add(i.getIndex().getName())); + } else { + newIndices.add(index); + } + } + } + + CheckTable newCheckTable = CheckTable.create(newIndices, actions); + for (String action : actions) { + newCheckTable.checkIf(index -> checkTable.getRows().contains(index) && checkTable.isChecked(index, action), action); + } + + StatefulIndexPrivileges statefulIndex = this.currentStatefulIndexPrivileges(); + if (statefulIndex != null) { + IntermediateResult resultFromStatefulIndex = statefulIndex.providesPrivilege(actions, context, newCheckTable); + if (resultFromStatefulIndex != null) { + // If we get a result from statefulIndex, we are done. + return this.index.finalizeResult(context, resultFromStatefulIndex); + } + } + + IntermediateResult resultFromStaticIndex = this.index.providesPrivilege(context, actions, newCheckTable); + return this.index.finalizeResult(context, resultFromStaticIndex); + } + + private boolean containsAliasesOrDataStreams(PrivilegesEvaluationContext context, Collection names) { + Map indicesLookup = context.getIndicesLookup(); + for (String name : names) { + IndexAbstraction indexAbstraction = indicesLookup.get(name); + if (indexAbstraction instanceof IndexAbstraction.Alias || indexAbstraction instanceof IndexAbstraction.DataStream) { + return true; + } + } + return false; + } + /** * Base class for evaluating cluster privileges. */ @@ -260,34 +345,46 @@ PrivilegesEvaluatorResponse providesAnyPrivilege(PrivilegesEvaluationContext con * This is the slowest way to check for a privilege. */ protected abstract boolean checkPrivilegeViaActionMatcher(PrivilegesEvaluationContext context, String action); - } /** * Base class for evaluating index permissions which evaluates index patterns at privilege evaluation time. */ protected abstract static class StaticIndexPrivileges { + protected final Predicate universallyDeniedIndices; + protected final Predicate indicesNeedingSystemIndexPrivileges; + + protected StaticIndexPrivileges(SpecialIndexProtection specialIndexProtection) { + this.universallyDeniedIndices = specialIndexProtection.universallyDeniedIndices; + this.indicesNeedingSystemIndexPrivileges = specialIndexProtection.indicesNeedingSystemIndexPrivileges; + } /** * Checks whether this instance provides privileges for the combination of the provided action, * the provided indices and the provided roles. *

    - * Returns a PrivilegesEvaluatorResponse with allowed=true if privileges are available. - *

    - * If privileges are only available for a sub-set of indices, isPartiallyOk() will return true - * and the indices for which privileges are available are returned by getAvailableIndices(). This allows the - * do_not_fail_on_forbidden behaviour. - *

    * This method will only verify privileges for the index/action combinations which are un-checked in * the checkTable instance provided to this method. Checked index/action combinations are considered to be * "already fulfilled by other means" - usually that comes from the stateful data structure. * As a side-effect, this method will further mark the available index/action combinations in the provided * checkTable instance as checked. */ - protected abstract PrivilegesEvaluatorResponse providesPrivilege( + protected abstract IntermediateResult providesPrivilege( + PrivilegesEvaluationContext context, + Set actions, + CheckTable checkTable + ); + + /** + * Checks whether this instance provides privileges for the provided action on any possible index. + *

    + * As a side-effect, this method will mark the available actions in the provided checkTable instance as checked. + * This method should not try to interpret the index names in the check table; as we are interested in any + * index, the index names will be arbitrary. + */ + protected abstract PrivilegesEvaluatorResponse providesPrivilegeOnAnyIndex( PrivilegesEvaluationContext context, Set actions, - IndexResolverReplacer.Resolved resolvedIndices, CheckTable checkTable ); @@ -304,6 +401,13 @@ protected abstract PrivilegesEvaluatorResponse providesExplicitPrivilege( CheckTable checkTable ); + protected abstract boolean providesExplicitPrivilege( + PrivilegesEvaluationContext context, + String index, + String action, + List exceptions + ); + /** * Tests whether the current user (according to the context data) has wildcard index privileges for the given well known index actions. * Returns false if no privileges are given or if the given actions are not well known actions. @@ -311,9 +415,10 @@ protected abstract PrivilegesEvaluatorResponse providesExplicitPrivilege( * Implementations of this class may interpret the context data differently; they can check the mapped roles * or just the subject. */ - protected abstract PrivilegesEvaluatorResponse checkWildcardIndexPrivilegesOnWellKnownActions( + protected abstract IntermediateResult checkWildcardIndexPrivilegesOnWellKnownActions( PrivilegesEvaluationContext context, - Set actions + Set actions, + CheckTable checkTable ); /** @@ -401,33 +506,71 @@ protected void checkPrivilegesForNonWellKnownActions( } /** - * Creates a PrivilegesEvaluationResponse in the case we find that the user does not have full privileges. - * This result is built based on the state of the given check table: - *

      - *
    • If the check table is empty, a result with the state "insufficient" will be returned
    • - *
    • If the check table is not empty, a result with the state "partially ok" will be returned. The response - * object will carry a list of the indices for which we have privileges. This can be used for the DNFOF mode.
    • - *
    + * When we have finished the "normal" privilege evaluation, which is based on index_permissions in roles.yml, + * we have to pass the CheckTable with the available privileges through this method in order to have specially + * protected indices and actions removed again from the CheckTable. */ - protected PrivilegesEvaluatorResponse responseForIncompletePrivileges( - PrivilegesEvaluationContext context, - IndexResolverReplacer.Resolved resolvedIndices, - CheckTable checkTable, - List exceptions - ) { + protected PrivilegesEvaluatorResponse finalizeResult(PrivilegesEvaluationContext context, IntermediateResult intermediateResult) { + CheckTable checkTable = intermediateResult.indexToActionCheckTable; + List exceptions = new ArrayList<>(intermediateResult.exceptions); + if (this.universallyDeniedIndices != null) { + checkTable.uncheckIf(this.universallyDeniedIndices, checkTable.getColumns()); + } + if (this.indicesNeedingSystemIndexPrivileges != null) { + checkTable.uncheckIf(index -> this.isUnauthorizedSystemIndex(context, index, exceptions), checkTable.getColumns()); + } + + if (checkTable.isComplete()) { + return PrivilegesEvaluatorResponse.ok(checkTable); + } + Set availableIndices = checkTable.getCompleteRows(); if (!availableIndices.isEmpty()) { return PrivilegesEvaluatorResponse.partiallyOk(availableIndices, checkTable).evaluationExceptions(exceptions); } - return PrivilegesEvaluatorResponse.insufficient(checkTable) - .reason( - resolvedIndices.getAllIndices().size() == 1 - ? "Insufficient permissions for the referenced index" - : "None of " + resolvedIndices.getAllIndices().size() + " referenced indices has sufficient permissions" - ) - .evaluationExceptions(exceptions); + Set allIndices = checkTable.getRows(); + + String reason; + if (allIndices.size() != 1) { + reason = "None of the referenced indices has sufficient permissions"; + } else { + reason = "Insufficient permissions for the referenced index"; + } + + return PrivilegesEvaluatorResponse.insufficient(checkTable).reason(reason).evaluationExceptions(exceptions); + + } + + /** + * Returns true if the given indexOrAlias is a system index or an alias containing a system index AND if + * the current user does not have the necessary explicit privilege to access this system index. + */ + private boolean isUnauthorizedSystemIndex( + PrivilegesEvaluationContext context, + String indexOrAlias, + List exceptions + ) { + if (this.indicesNeedingSystemIndexPrivileges.test(indexOrAlias)) { + return !providesExplicitPrivilege(context, indexOrAlias, ConfigConstants.SYSTEM_INDEX_PERMISSION, exceptions); + } + + IndexAbstraction indexAbstraction = context.getIndicesLookup().get(indexOrAlias); + if (indexAbstraction instanceof IndexAbstraction.Alias alias) { + for (IndexMetadata index : alias.getIndices()) { + if (this.indicesNeedingSystemIndexPrivileges.test(index.getIndex().getName())) { + return !providesExplicitPrivilege( + context, + index.getIndex().getName(), + ConfigConstants.SYSTEM_INDEX_PERMISSION, + exceptions + ); + } + } + } + + return false; } } @@ -450,17 +593,64 @@ protected abstract static class StatefulIndexPrivileges { * is returned, because then the remaining logic needs only to check for the unchecked cases. * * @param actions the actions the user needs to have privileges for - * @param resolvedIndices the index the user needs to have privileges for * @param context context information like user, resolved roles, etc. * @param checkTable An action/index matrix. This method will modify the table as a side effect and check the cells where privileges are present. * @return PrivilegesEvaluatorResponse.ok() or null. */ - protected abstract PrivilegesEvaluatorResponse providesPrivilege( + protected abstract IntermediateResult providesPrivilege( Set actions, - IndexResolverReplacer.Resolved resolvedIndices, PrivilegesEvaluationContext context, CheckTable checkTable ); } + public static class SpecialIndexProtection { + public static final SpecialIndexProtection NONE = new SpecialIndexProtection(null, null); + + protected final Predicate universallyDeniedIndices; + protected final Predicate indicesNeedingSystemIndexPrivileges; + + public SpecialIndexProtection(Predicate universallyDeniedIndices, Predicate indicesNeedingSystemIndexPrivileges) { + this.universallyDeniedIndices = universallyDeniedIndices; + this.indicesNeedingSystemIndexPrivileges = indicesNeedingSystemIndexPrivileges; + } + } + + protected static class IntermediateResult { + + protected final CheckTable indexToActionCheckTable; + protected final String reason; + protected final ImmutableList exceptions; + + protected IntermediateResult(CheckTable indexToActionCheckTable) { + this.indexToActionCheckTable = indexToActionCheckTable; + this.reason = null; + this.exceptions = ImmutableList.of(); + } + + IntermediateResult( + CheckTable indexToActionCheckTable, + String reason, + ImmutableList exceptions + ) { + this.indexToActionCheckTable = indexToActionCheckTable; + this.reason = reason; + this.exceptions = exceptions; + } + + protected IntermediateResult reason(String reason) { + return new IntermediateResult(this.indexToActionCheckTable, reason, this.exceptions); + } + + protected IntermediateResult evaluationExceptions(List exceptions) { + if (exceptions.isEmpty()) { + return this; + } else { + ImmutableList.Builder newExceptions = ImmutableList.builder(); + newExceptions.addAll(this.exceptions); + newExceptions.addAll(exceptions); + return new IntermediateResult(this.indexToActionCheckTable, reason, newExceptions.build()); + } + } + } } diff --git a/src/main/java/org/opensearch/security/privileges/actionlevel/SubjectBasedActionPrivileges.java b/src/main/java/org/opensearch/security/privileges/actionlevel/SubjectBasedActionPrivileges.java index ee04f61105..23d83d3d8b 100644 --- a/src/main/java/org/opensearch/security/privileges/actionlevel/SubjectBasedActionPrivileges.java +++ b/src/main/java/org/opensearch/security/privileges/actionlevel/SubjectBasedActionPrivileges.java @@ -17,6 +17,7 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.function.Function; import java.util.stream.Collectors; import com.google.common.collect.ImmutableMap; @@ -25,11 +26,11 @@ import org.apache.logging.log4j.Logger; import org.opensearch.cluster.metadata.IndexAbstraction; +import org.opensearch.security.privileges.ActionPrivileges; import org.opensearch.security.privileges.IndexPattern; import org.opensearch.security.privileges.PrivilegesEvaluationContext; import org.opensearch.security.privileges.PrivilegesEvaluationException; import org.opensearch.security.privileges.PrivilegesEvaluatorResponse; -import org.opensearch.security.resolver.IndexResolverReplacer; import org.opensearch.security.securityconf.FlattenedActionGroups; import org.opensearch.security.securityconf.impl.v7.RoleV7; import org.opensearch.security.support.WildcardMatcher; @@ -48,6 +49,24 @@ * This class is useful for plugin users and API tokens. */ public class SubjectBasedActionPrivileges extends RuntimeOptimizedActionPrivileges { + + public static ImmutableMap buildFromMap( + Map pluginIdToRolePrivileges, + FlattenedActionGroups staticActionGroups, + RuntimeOptimizedActionPrivileges.SpecialIndexProtection specialIndexProtection + ) { + Map result = new HashMap<>(pluginIdToRolePrivileges.size()); + + for (Map.Entry entry : pluginIdToRolePrivileges.entrySet()) { + result.put( + entry.getKey(), + new SubjectBasedActionPrivileges(entry.getValue(), staticActionGroups, specialIndexProtection, false) + ); + } + + return ImmutableMap.copyOf(result); + } + private static final Logger log = LogManager.getLogger(SubjectBasedActionPrivileges.class); /** @@ -59,8 +78,17 @@ public class SubjectBasedActionPrivileges extends RuntimeOptimizedActionPrivileg * @param actionGroups The FlattenedActionGroups instance that shall be used to resolve the action groups * specified in the roles configuration. */ - public SubjectBasedActionPrivileges(RoleV7 role, FlattenedActionGroups actionGroups) { - super(new ClusterPrivileges(actionGroups.resolve(role.getCluster_permissions())), new IndexPrivileges(role, actionGroups)); + public SubjectBasedActionPrivileges( + RoleV7 role, + FlattenedActionGroups actionGroups, + SpecialIndexProtection specialIndexProtection, + boolean breakDownAliases + ) { + super( + new ClusterPrivileges(actionGroups.resolve(role.getCluster_permissions())), + new IndexPrivileges(role, actionGroups, specialIndexProtection, breakDownAliases), + breakDownAliases + ); } /** @@ -215,7 +243,17 @@ static class IndexPrivileges extends RuntimeOptimizedActionPrivileges.StaticInde /** * Creates pre-computed index privileges based on the given parameters. */ - IndexPrivileges(RoleV7 role, FlattenedActionGroups actionGroups) { + IndexPrivileges( + RoleV7 role, + FlattenedActionGroups actionGroups, + SpecialIndexProtection specialIndexProtection, + boolean memberIndexPrivilegesYieldALiasPrivileges + ) { + super(specialIndexProtection); + + Function indexPatternBuilder = k -> new IndexPattern.Builder( + memberIndexPrivilegesYieldALiasPrivileges + ); Map actionToIndexPattern = new HashMap<>(); Map actionPatternToIndexPattern = new HashMap<>(); @@ -234,11 +272,10 @@ static class IndexPrivileges extends RuntimeOptimizedActionPrivileges.StaticInde // as a last resort later. if (WildcardMatcher.isExact(permission)) { - actionToIndexPattern.computeIfAbsent(permission, k -> new IndexPattern.Builder()) - .add(indexPermissions.getIndex_patterns()); + actionToIndexPattern.computeIfAbsent(permission, indexPatternBuilder).add(indexPermissions.getIndex_patterns()); if (WellKnownActions.EXPLICITLY_REQUIRED_INDEX_ACTIONS.contains(permission)) { - explicitActionToIndexPattern.computeIfAbsent(permission, k -> new IndexPattern.Builder()) + explicitActionToIndexPattern.computeIfAbsent(permission, indexPatternBuilder) .add(indexPermissions.getIndex_patterns()); } @@ -249,20 +286,19 @@ static class IndexPrivileges extends RuntimeOptimizedActionPrivileges.StaticInde WildcardMatcher actionMatcher = WildcardMatcher.from(permission); for (String action : actionMatcher.iterateMatching(WellKnownActions.INDEX_ACTIONS)) { - actionToIndexPattern.computeIfAbsent(action, k -> new IndexPattern.Builder()) - .add(indexPermissions.getIndex_patterns()); + actionToIndexPattern.computeIfAbsent(action, indexPatternBuilder).add(indexPermissions.getIndex_patterns()); if (indexPermissions.getIndex_patterns().contains("*")) { actionWithWildcardIndexPrivileges.add(permission); } } - actionPatternToIndexPattern.computeIfAbsent(actionMatcher, k -> new IndexPattern.Builder()) + actionPatternToIndexPattern.computeIfAbsent(actionMatcher, indexPatternBuilder) .add(indexPermissions.getIndex_patterns()); if (actionMatcher != WildcardMatcher.ANY) { for (String action : actionMatcher.iterateMatching(WellKnownActions.EXPLICITLY_REQUIRED_INDEX_ACTIONS)) { - explicitActionToIndexPattern.computeIfAbsent(action, k -> new IndexPattern.Builder()) + explicitActionToIndexPattern.computeIfAbsent(action, indexPatternBuilder) .add(indexPermissions.getIndex_patterns()); } } @@ -302,17 +338,16 @@ static class IndexPrivileges extends RuntimeOptimizedActionPrivileges.StaticInde * checkTable instance as checked. */ @Override - protected PrivilegesEvaluatorResponse providesPrivilege( + protected IntermediateResult providesPrivilege( PrivilegesEvaluationContext context, Set actions, - IndexResolverReplacer.Resolved resolvedIndices, CheckTable checkTable ) { List exceptions = new ArrayList<>(); checkPrivilegeWithIndexPatternOnWellKnownActions(context, actions, checkTable, actionToIndexPattern, exceptions); if (checkTable.isComplete()) { - return PrivilegesEvaluatorResponse.ok(); + return new IntermediateResult(checkTable).evaluationExceptions(exceptions); } // If all actions are well-known, the index.actionToIndexPattern data structure that was evaluated above, @@ -322,11 +357,50 @@ protected PrivilegesEvaluatorResponse providesPrivilege( if (!allWellKnownIndexActions(actions)) { checkPrivilegesForNonWellKnownActions(context, actions, checkTable, this.actionPatternToIndexPattern, exceptions); if (checkTable.isComplete()) { - return PrivilegesEvaluatorResponse.ok(); + return new IntermediateResult(checkTable).evaluationExceptions(exceptions); + } + } + + return new IntermediateResult(checkTable).evaluationExceptions(exceptions); + } + + @Override + protected PrivilegesEvaluatorResponse providesPrivilegeOnAnyIndex( + PrivilegesEvaluationContext context, + Set actions, + CheckTable checkTable + ) { + checkTable.checkIf( + checkTable.getRows(), + action -> !this.actionToIndexPattern.getOrDefault(action, IndexPattern.EMPTY).isEmpty() + ); + if (checkTable.isComplete()) { + return PrivilegesEvaluatorResponse.ok(checkTable); + } + + // If all actions are well-known, the index.rolesToActionToIndexPattern data structure that was evaluated above, + // would have contained all the actions if privileges are provided. If there are non-well-known actions among the + // actions, we also have to evaluate action patterns to check the authorization + + if (!allWellKnownIndexActions(actions)) { + for (String action : actions) { + for (Map.Entry entry : this.actionPatternToIndexPattern.entrySet()) { + WildcardMatcher actionMatcher = entry.getKey(); + IndexPattern indexPattern = entry.getValue(); + + if (actionMatcher.test(action) && !indexPattern.isEmpty()) { + checkTable.getRows().forEach(index -> checkTable.check(index, action)); + if (checkTable.isComplete()) { + return PrivilegesEvaluatorResponse.ok(checkTable); + } + } + } } + } - return responseForIncompletePrivileges(context, resolvedIndices, checkTable, exceptions); + return PrivilegesEvaluatorResponse.insufficient(checkTable) + .reason("The user does not have any index privileges for the requested action"); } /** @@ -335,9 +409,10 @@ protected PrivilegesEvaluatorResponse providesPrivilege( * the user's privileges. */ @Override - protected PrivilegesEvaluatorResponse checkWildcardIndexPrivilegesOnWellKnownActions( + protected IntermediateResult checkWildcardIndexPrivilegesOnWellKnownActions( PrivilegesEvaluationContext context, - Set actions + Set actions, + CheckTable checkTable ) { for (String action : actions) { if (!this.actionsWithWildcardIndexPrivileges.contains(action)) { @@ -345,7 +420,7 @@ protected PrivilegesEvaluatorResponse checkWildcardIndexPrivilegesOnWellKnownAct } } - return PrivilegesEvaluatorResponse.ok(); + return new IntermediateResult(checkTable); } /** @@ -386,6 +461,33 @@ protected PrivilegesEvaluatorResponse providesExplicitPrivilege( .reason("No explicit privileges have been provided for the referenced indices.") .evaluationExceptions(exceptions); } + + @Override + protected boolean providesExplicitPrivilege( + PrivilegesEvaluationContext context, + String index, + String action, + List exceptions + ) { + Map indexMetadata = context.getIndicesLookup(); + + IndexPattern indexPattern = this.explicitActionToIndexPattern.get(action); + + if (indexPattern != null) { + try { + if (indexPattern.matches(index, context, indexMetadata)) { + return true; + } + } catch (PrivilegesEvaluationException e) { + // We can ignore these errors, as this max leads to fewer privileges than available + log.error("Error while evaluating {}. Ignoring entry", indexPattern, e); + exceptions.add(new PrivilegesEvaluationException("Error while evaluating " + indexPattern, e)); + } + + } + + return false; + } } } diff --git a/src/main/java/org/opensearch/security/privileges/actionlevel/legacy/LegacyIndicesRequestResolver.java b/src/main/java/org/opensearch/security/privileges/actionlevel/legacy/LegacyIndicesRequestResolver.java new file mode 100644 index 0000000000..5058d599c4 --- /dev/null +++ b/src/main/java/org/opensearch/security/privileges/actionlevel/legacy/LegacyIndicesRequestResolver.java @@ -0,0 +1,109 @@ +/* + * 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. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ +package org.opensearch.security.privileges.actionlevel.legacy; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.function.Supplier; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import org.opensearch.action.ActionRequest; +import org.opensearch.action.admin.cluster.snapshots.restore.RestoreSnapshotRequest; +import org.opensearch.action.admin.indices.alias.IndicesAliasesRequest; +import org.opensearch.action.admin.indices.alias.get.GetAliasesRequest; +import org.opensearch.action.admin.indices.create.CreateIndexRequest; +import org.opensearch.action.support.ActionRequestMetadata; +import org.opensearch.cluster.ClusterState; +import org.opensearch.cluster.metadata.IndexNameExpressionResolver; +import org.opensearch.cluster.metadata.OptionallyResolvedIndices; +import org.opensearch.cluster.metadata.ResolvedIndices; +import org.opensearch.security.privileges.IndicesRequestResolver; +import org.opensearch.security.support.SnapshotRestoreHelper; + +/** + * A modified IndicesRequestResolver which keeps the default index resolution behavior of OpenSearch 3.2.0 and earlier + */ +public class LegacyIndicesRequestResolver extends IndicesRequestResolver { + + private final Supplier isNodeElectedMaster; + private static final Logger log = LogManager.getLogger(LegacyIndicesRequestResolver.class); + + public LegacyIndicesRequestResolver(IndexNameExpressionResolver indexNameExpressionResolver, Supplier isNodeElectedMaster) { + super(indexNameExpressionResolver); + this.isNodeElectedMaster = isNodeElectedMaster; + } + + @Override + public OptionallyResolvedIndices resolve( + ActionRequest request, + ActionRequestMetadata actionRequestMetadata, + Supplier clusterStateSupplier + ) { + // For the legacy mode, we still need a couple of special cases to stay backwards compatible + if (request instanceof IndicesAliasesRequest indicesAliasesRequest) { + List indices = new ArrayList<>(); + ClusterState clusterState = clusterStateSupplier.get(); + for (IndicesAliasesRequest.AliasActions aliasActions : indicesAliasesRequest.getAliasActions()) { + indices.addAll( + indexNameExpressionResolver.concreteResolvedIndices(clusterState, aliasActions).namesOfIndices(clusterState) + ); + } + return ResolvedIndices.of(indices); + } else if (request instanceof GetAliasesRequest getAliasesRequest) { + ClusterState clusterState = clusterStateSupplier.get(); + return ResolvedIndices.of( + indexNameExpressionResolver.concreteResolvedIndices(clusterState, getAliasesRequest).namesOfIndices(clusterState) + ); + } else if (request instanceof CreateIndexRequest createIndexRequest) { + return ResolvedIndices.of(indexNameExpressionResolver.resolveDateMathExpression(createIndexRequest.index())); + } else if (request instanceof RestoreSnapshotRequest restoreSnapshotRequest) { + try { + // TODO possibly we need to change the result when we are not master + + if (this.isNodeElectedMaster.get()) { + return SnapshotRestoreHelper.resolveTargetIndices(restoreSnapshotRequest); + } else { + return ResolvedIndices.unknown(); + } + } catch (Exception e) { + log.error("Error while resolving RestoreSnapshotRequest {}", restoreSnapshotRequest, e); + return ResolvedIndices.unknown(); + } + } else { + return flatten(super.resolve(request, actionRequestMetadata, clusterStateSupplier)); + } + } + + /** + * This copies all names from subActions stored in the ResolvedIndices object into the root object. + * This is necessary because the legacy privileges evaluator is not aware of sub actions. + */ + OptionallyResolvedIndices flatten(OptionallyResolvedIndices optionallyResolvedIndices) { + if (!(optionallyResolvedIndices instanceof ResolvedIndices resolvedIndices)) { + return optionallyResolvedIndices; + } + + if (resolvedIndices.local().subActions().isEmpty()) { + return resolvedIndices; + } + + Set names = new HashSet<>(resolvedIndices.local().names()); + for (ResolvedIndices.Local subAction : resolvedIndices.local().subActions().values()) { + names.addAll(subAction.names()); + } + + return ResolvedIndices.of(names).withRemoteIndices(resolvedIndices.remote().asClusterToOriginalIndicesMap()); + } +} diff --git a/src/main/java/org/opensearch/security/privileges/actionlevel/legacy/PrivilegesEvaluator.java b/src/main/java/org/opensearch/security/privileges/actionlevel/legacy/PrivilegesEvaluator.java new file mode 100644 index 0000000000..74e0188bfa --- /dev/null +++ b/src/main/java/org/opensearch/security/privileges/actionlevel/legacy/PrivilegesEvaluator.java @@ -0,0 +1,800 @@ +/* + * Copyright 2015-2018 _floragunn_ GmbH + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* + * 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. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.privileges.actionlevel.legacy; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Supplier; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import org.opensearch.OpenSearchSecurityException; +import org.opensearch.action.ActionRequest; +import org.opensearch.action.IndicesRequest; +import org.opensearch.action.admin.cluster.shards.ClusterSearchShardsRequest; +import org.opensearch.action.admin.cluster.snapshots.restore.RestoreSnapshotRequest; +import org.opensearch.action.admin.indices.alias.IndicesAliasesAction; +import org.opensearch.action.admin.indices.alias.IndicesAliasesRequest; +import org.opensearch.action.admin.indices.alias.IndicesAliasesRequest.AliasActions; +import org.opensearch.action.admin.indices.analyze.AnalyzeAction; +import org.opensearch.action.admin.indices.create.AutoCreateAction; +import org.opensearch.action.admin.indices.create.CreateIndexAction; +import org.opensearch.action.admin.indices.create.CreateIndexRequest; +import org.opensearch.action.admin.indices.delete.DeleteIndexAction; +import org.opensearch.action.admin.indices.mapping.get.GetFieldMappingsRequest; +import org.opensearch.action.admin.indices.mapping.put.AutoPutMappingAction; +import org.opensearch.action.admin.indices.mapping.put.PutMappingAction; +import org.opensearch.action.bulk.BulkAction; +import org.opensearch.action.bulk.BulkItemRequest; +import org.opensearch.action.bulk.BulkRequest; +import org.opensearch.action.bulk.BulkShardRequest; +import org.opensearch.action.delete.DeleteAction; +import org.opensearch.action.get.MultiGetAction; +import org.opensearch.action.index.IndexAction; +import org.opensearch.action.search.MultiSearchAction; +import org.opensearch.action.search.SearchAction; +import org.opensearch.action.search.SearchRequest; +import org.opensearch.action.search.SearchScrollAction; +import org.opensearch.action.support.ActionRequestMetadata; +import org.opensearch.action.support.IndicesOptions; +import org.opensearch.action.termvectors.MultiTermVectorsAction; +import org.opensearch.action.update.UpdateAction; +import org.opensearch.cluster.ClusterState; +import org.opensearch.cluster.metadata.AliasMetadata; +import org.opensearch.cluster.metadata.IndexMetadata; +import org.opensearch.cluster.metadata.IndexNameExpressionResolver; +import org.opensearch.cluster.metadata.Metadata; +import org.opensearch.cluster.metadata.OptionallyResolvedIndices; +import org.opensearch.cluster.metadata.ResolvedIndices; +import org.opensearch.cluster.service.ClusterService; +import org.opensearch.common.settings.Settings; +import org.opensearch.common.util.concurrent.ThreadContext; +import org.opensearch.core.common.Strings; +import org.opensearch.core.common.transport.TransportAddress; +import org.opensearch.index.reindex.ReindexAction; +import org.opensearch.script.mustache.RenderSearchTemplateAction; +import org.opensearch.security.auditlog.AuditLog; +import org.opensearch.security.privileges.ActionPrivileges; +import org.opensearch.security.privileges.DocumentAllowList; +import org.opensearch.security.privileges.IndicesRequestModifier; +import org.opensearch.security.privileges.IndicesRequestResolver; +import org.opensearch.security.privileges.PrivilegesEvaluationContext; +import org.opensearch.security.privileges.PrivilegesEvaluatorResponse; +import org.opensearch.security.privileges.PrivilegesInterceptor; +import org.opensearch.security.privileges.RoleMapper; +import org.opensearch.security.privileges.actionlevel.RoleBasedActionPrivileges; +import org.opensearch.security.privileges.actionlevel.RuntimeOptimizedActionPrivileges; +import org.opensearch.security.privileges.actionlevel.SubjectBasedActionPrivileges; +import org.opensearch.security.securityconf.FlattenedActionGroups; +import org.opensearch.security.securityconf.impl.SecurityDynamicConfiguration; +import org.opensearch.security.securityconf.impl.v7.ConfigV7; +import org.opensearch.security.securityconf.impl.v7.RoleV7; +import org.opensearch.security.support.ConfigConstants; +import org.opensearch.security.support.WildcardMatcher; +import org.opensearch.security.user.User; +import org.opensearch.tasks.Task; +import org.opensearch.threadpool.ThreadPool; + +import static org.opensearch.security.OpenSearchSecurityPlugin.traceAction; + +public class PrivilegesEvaluator implements org.opensearch.security.privileges.PrivilegesEvaluator { + + private static final String USER_TENANT = "__user__"; + + static final WildcardMatcher DNFOF_MATCHER = WildcardMatcher.from( + ImmutableList.of( + "indices:data/read/*", + "indices:admin/mappings/fields/get*", + "indices:admin/shards/search_shards", + "indices:admin/resolve/index", + "indices:monitor/settings/get", + "indices:monitor/stats", + "indices:admin/aliases/get" + ) + ); + + private static final WildcardMatcher ACTION_MATCHER = WildcardMatcher.from("indices:data/read/*search*"); + + private static final IndicesOptions ALLOW_EMPTY = IndicesOptions.fromOptions(true, true, false, false); + + protected final Logger log = LogManager.getLogger(this.getClass()); + private final Supplier clusterStateSupplier; + + private final IndexNameExpressionResolver resolver; + + private final AuditLog auditLog; + private ThreadContext threadContext; + + private final PrivilegesInterceptor privilegesInterceptor; + + private final boolean checkSnapshotRestoreWritePrivileges; + + private final SnapshotRestoreEvaluator snapshotRestoreEvaluator; + private final SystemIndexAccessEvaluator systemIndexAccessEvaluator; + private final ProtectedIndexAccessEvaluator protectedIndexAccessEvaluator; + private final TermsAggregationEvaluator termsAggregationEvaluator; + private final Settings settings; + private final AtomicReference actionPrivileges = new AtomicReference<>(); + private final Map pluginIdToActionPrivileges = new HashMap<>(); + private final IndicesRequestResolver indicesRequestResolver; + private final IndicesRequestModifier indicesRequestModifier = new IndicesRequestModifier(); + private final RoleMapper roleMapper; + private final ThreadPool threadPool; + + private volatile boolean dnfofEnabled = false; + private volatile boolean dnfofForEmptyResultsEnabled = false; + private volatile String filteredAliasMode = null; + + public PrivilegesEvaluator( + final ClusterService clusterService, + Supplier clusterStateSupplier, + RoleMapper roleMapper, + ThreadPool threadPool, + final ThreadContext threadContext, + final IndexNameExpressionResolver resolver, + AuditLog auditLog, + final Settings settings, + final PrivilegesInterceptor privilegesInterceptor, + FlattenedActionGroups actionGroups, + FlattenedActionGroups staticActionGroups, + SecurityDynamicConfiguration rolesConfiguration, + ConfigV7 generalConfiguration, + Map pluginIdToRolePrivileges + ) { + + super(); + this.resolver = resolver; + this.auditLog = auditLog; + this.roleMapper = roleMapper; + + this.threadContext = threadContext; + this.threadPool = threadPool; + this.privilegesInterceptor = privilegesInterceptor; + this.clusterStateSupplier = clusterStateSupplier; + this.settings = settings; + + this.checkSnapshotRestoreWritePrivileges = settings.getAsBoolean( + ConfigConstants.SECURITY_CHECK_SNAPSHOT_RESTORE_WRITE_PRIVILEGES, + ConfigConstants.SECURITY_DEFAULT_CHECK_SNAPSHOT_RESTORE_WRITE_PRIVILEGES + ); + + Supplier isLocalNodeElectedClusterManager = clusterService != null + ? () -> clusterService.state().nodes().isLocalNodeElectedClusterManager() + : () -> false; + + snapshotRestoreEvaluator = new SnapshotRestoreEvaluator(settings, auditLog, isLocalNodeElectedClusterManager); + systemIndexAccessEvaluator = new SystemIndexAccessEvaluator(settings, auditLog); + protectedIndexAccessEvaluator = new ProtectedIndexAccessEvaluator(settings, auditLog); + termsAggregationEvaluator = new TermsAggregationEvaluator(); + this.indicesRequestResolver = new LegacyIndicesRequestResolver(resolver, isLocalNodeElectedClusterManager); + + this.pluginIdToActionPrivileges.putAll(createActionPrivileges(pluginIdToRolePrivileges, staticActionGroups)); + this.updateConfiguration(actionGroups, rolesConfiguration, generalConfiguration); + + } + + @Override + public void updateConfiguration( + FlattenedActionGroups flattenedActionGroups, + SecurityDynamicConfiguration rolesConfiguration, + ConfigV7 generalConfiguration + ) { + this.dnfofEnabled = isDnfofEnabled(generalConfiguration); + this.dnfofForEmptyResultsEnabled = isDnfofEmptyEnabled(generalConfiguration); + this.filteredAliasMode = getFilteredAliasMode(generalConfiguration); + + try { + RoleBasedActionPrivileges actionPrivileges = new RoleBasedActionPrivileges( + rolesConfiguration, + flattenedActionGroups, + RuntimeOptimizedActionPrivileges.SpecialIndexProtection.NONE, + settings, + true + ); + Metadata metadata = clusterStateSupplier.get().metadata(); + actionPrivileges.updateStatefulIndexPrivileges(metadata.getIndicesLookup(), metadata.version()); + RoleBasedActionPrivileges oldInstance = this.actionPrivileges.getAndSet(actionPrivileges); + + if (oldInstance != null) { + oldInstance.clusterStateMetadataDependentPrivileges().shutdown(); + } + } catch (Exception e) { + log.error("Error while updating ActionPrivileges", e); + } + } + + @Override + public boolean isInitialized() { + return true; + } + + @Override + public PrivilegesEvaluationContext createContext(User user, String action) { + return createContext(user, action, null, ActionRequestMetadata.empty(), null); + } + + @Override + public PrivilegesEvaluationContext createContext( + User user, + String action0, + ActionRequest request, + ActionRequestMetadata actionRequestMetadata, + Task task + ) { + TransportAddress caller = threadContext.getTransient(ConfigConstants.OPENDISTRO_SECURITY_REMOTE_ADDRESS); + + ActionPrivileges actionPrivileges; + ImmutableSet mappedRoles; + + if (user.isPluginUser()) { + mappedRoles = ImmutableSet.of(); + actionPrivileges = this.pluginIdToActionPrivileges.get(user.getName()); + if (actionPrivileges == null) { + actionPrivileges = ActionPrivileges.EMPTY; + } + } else { + mappedRoles = this.roleMapper.map(user, caller); + actionPrivileges = this.actionPrivileges.get(); + } + + return new PrivilegesEvaluationContext( + user, + mappedRoles, + action0, + request, + actionRequestMetadata, + task, + resolver, + indicesRequestResolver, + clusterStateSupplier, + actionPrivileges + ); + } + + @Override + public PrivilegesEvaluatorResponse evaluate(PrivilegesEvaluationContext context) { + + String action0 = context.getAction(); + ImmutableSet mappedRoles = context.getMappedRoles(); + User user = context.getUser(); + ActionRequest request = context.getRequest(); + Task task = context.getTask(); + + if (action0.startsWith("internal:indices/admin/upgrade")) { + action0 = "indices:admin/upgrade"; + } + + if (AutoCreateAction.NAME.equals(action0)) { + action0 = CreateIndexAction.NAME; + } + + if (AutoPutMappingAction.NAME.equals(action0)) { + action0 = PutMappingAction.NAME; + } + + PrivilegesEvaluatorResponse presponse; + + final boolean isDebugEnabled = log.isDebugEnabled(); + if (isDebugEnabled) { + log.debug("Evaluate permissions for {}", user); + log.debug("Action: {} ({})", action0, request.getClass().getSimpleName()); + log.debug("Mapped roles: {}", mappedRoles.toString()); + } + + ActionPrivileges actionPrivileges = context.getActionPrivileges(); + if (actionPrivileges == null) { + throw new OpenSearchSecurityException("OpenSearch Security is not initialized: roles configuration is missing"); + } + + if (request instanceof BulkRequest && (Strings.isNullOrEmpty(user.getRequestedTenant()))) { + // Shortcut for bulk actions. The details are checked on the lower level of the BulkShardRequests (Action + // indices:data/write/bulk[s]). + // This shortcut is only possible if the default tenant is selected, as we might need to rewrite the request for non-default + // tenants. + // No further access check for the default tenant is necessary, as access will be also checked on the TransportShardBulkAction + // level. + + presponse = actionPrivileges.hasClusterPrivilege(context, action0); + + if (!presponse.isAllowed()) { + log.info( + "No cluster-level perm match for {} [Action [{}]] [RolesChecked {}]. No permissions for {}", + user, + action0, + mappedRoles, + presponse.getMissingPrivileges() + ); + } + return presponse; + } + + OptionallyResolvedIndices optionallyResolvedIndices = context.getResolvedRequest(); + + if (isDebugEnabled) { + if (request instanceof IndicesRequest indicesRequest) { + log.debug("IndicesRequest: {} {}", indicesRequest.indices(), indicesRequest.indicesOptions()); + } + log.debug("ResolvedIndices: {}", optionallyResolvedIndices); + } + + // check snapshot/restore requests + // NOTE: Has to go first as restore request could be for protected and/or system indices and the request may + // fail with 403 if system index or protected index evaluators are triggered first + presponse = snapshotRestoreEvaluator.evaluate(request, task, action0); + if (presponse != null) { + return presponse; + } + + // System index access + presponse = systemIndexAccessEvaluator.evaluate(request, task, action0, optionallyResolvedIndices, context, actionPrivileges, user); + if (presponse != null) { + return presponse; + } + + // Protected index access + presponse = protectedIndexAccessEvaluator.evaluate(request, task, action0, optionallyResolvedIndices, mappedRoles); + if (presponse != null) { + return presponse; + } + + if (request instanceof AnalyzeAction.Request + && optionallyResolvedIndices instanceof ResolvedIndices resolvedIndices + && resolvedIndices.isEmpty()) { + // If we have an AnalyzeRequest which does not refer to any index, the user is going to execute an + // index independent analyze action. + } + + final boolean dnfofEnabled = this.dnfofEnabled; + + final boolean isTraceEnabled = log.isTraceEnabled(); + if (isTraceEnabled) { + log.trace("dnfof enabled? {}", dnfofEnabled); + } + + final boolean serviceAccountUser = user.isServiceAccount(); + if (isClusterPermission(action0)) { + if (serviceAccountUser) { + log.info("{} is a service account which doesn't have access to cluster level permission: {}", user, action0); + return PrivilegesEvaluatorResponse.insufficient(action0); + } + + presponse = actionPrivileges.hasClusterPrivilege(context, action0); + + if (!presponse.isAllowed()) { + log.info( + "No cluster-level perm match for {} {} [Action [{}]] [RolesChecked {}]. No permissions for {}", + user, + optionallyResolvedIndices, + action0, + mappedRoles, + presponse.getMissingPrivileges() + ); + return presponse; + } else { + + if (request instanceof RestoreSnapshotRequest && checkSnapshotRestoreWritePrivileges) { + if (isDebugEnabled) { + log.debug("Normally allowed but we need to apply some extra checks for a restore request."); + } + } else { + if (privilegesInterceptor.getClass() != PrivilegesInterceptor.class) { + + final PrivilegesInterceptor.ReplaceResult replaceResult = privilegesInterceptor.replaceDashboardsIndex( + request, + action0, + user, + context + ); + + if (isDebugEnabled) { + log.debug("Result from privileges interceptor for cluster perm: {}", replaceResult); + } + + if (!replaceResult.continueEvaluation) { + if (replaceResult.accessDenied) { + auditLog.logMissingPrivileges(action0, request, task); + return PrivilegesEvaluatorResponse.insufficient(action0); + } else { + return PrivilegesEvaluatorResponse.ok(replaceResult.createIndexRequestBuilder); + } + } + } + + if (isDebugEnabled) { + log.debug("Allowed because we have cluster permissions for {}", action0); + } + return presponse; + } + } + } + + if (DocumentAllowList.isAllowed(request, threadContext)) { + return PrivilegesEvaluatorResponse.ok(); + } + + // term aggregations + presponse = termsAggregationEvaluator.evaluate(optionallyResolvedIndices, request, context, actionPrivileges); + if (presponse != null) { + return presponse; + } + + ImmutableSet allIndexPermsRequired = evaluateAdditionalIndexPermissions(request, action0); + + if (isDebugEnabled) { + log.debug( + "Requested {} from {}", + allIndexPermsRequired, + threadContext.getTransient(ConfigConstants.OPENDISTRO_SECURITY_REMOTE_ADDRESS) + ); + } + + if (isDebugEnabled) { + log.debug("Requested resolved index types: {}", optionallyResolvedIndices); + log.debug("Security roles: {}", mappedRoles); + } + + // TODO exclude Security index + + if (privilegesInterceptor.getClass() != PrivilegesInterceptor.class) { + + final PrivilegesInterceptor.ReplaceResult replaceResult = privilegesInterceptor.replaceDashboardsIndex( + request, + action0, + user, + context + ); + + if (isDebugEnabled) { + log.debug("Result from privileges interceptor: {}", replaceResult); + } + + if (!replaceResult.continueEvaluation) { + if (replaceResult.accessDenied) { + auditLog.logMissingPrivileges(action0, request, task); + return PrivilegesEvaluatorResponse.insufficient(action0); + } else { + return PrivilegesEvaluatorResponse.ok(replaceResult.createIndexRequestBuilder); + } + } + } + + boolean dnfofPossible = dnfofEnabled && DNFOF_MATCHER.test(action0); + + if (optionallyResolvedIndices instanceof ResolvedIndices resolvedIndices && resolvedIndices.isEmpty()) { + // If the request is empty, the normal privilege checks would just pass because technically the question + // "are all indices authorized" is true if the set of indices is empty. This means that certain operations + // would be available to any users regardless of their privileges. Thus, we check first whether the user + // has *any* privilege for the given action. + // The main example for such actions is the _analyze action which can operate on indices, but also can + // operate on an empty set of indices. Without this check, it would be always allowed. + // Note: This is a change from previous versions of OpenSearch. The old IndexResolverReplacer would + // get the state of the no-index AnalyzeAction.Request wrong and would always produce an "IndexNotFoundException" + // for analyze requests without any index. + PrivilegesEvaluatorResponse anyPrivilegesResult = actionPrivileges.hasIndexPrivilegeForAnyIndex(context, allIndexPermsRequired); + if (!anyPrivilegesResult.isAllowed()) { + return anyPrivilegesResult; + } + } + + presponse = actionPrivileges.hasIndexPrivilege(context, allIndexPermsRequired, optionallyResolvedIndices); + + if (presponse.isPartiallyOk()) { + if (dnfofPossible && optionallyResolvedIndices instanceof ResolvedIndices resolvedIndices) { + if (this.indicesRequestModifier.setLocalIndices(request, resolvedIndices, presponse.getAvailableIndices())) { + return PrivilegesEvaluatorResponse.ok(); + } + } + } else if (!presponse.isAllowed()) { + if (dnfofPossible && dnfofForEmptyResultsEnabled && request instanceof IndicesRequest.Replaceable) { + ((IndicesRequest.Replaceable) request).indices(new String[0]); + + if (request instanceof SearchRequest) { + ((SearchRequest) request).indicesOptions(ALLOW_EMPTY); + } else if (request instanceof ClusterSearchShardsRequest) { + ((ClusterSearchShardsRequest) request).indicesOptions(ALLOW_EMPTY); + } else if (request instanceof GetFieldMappingsRequest) { + ((GetFieldMappingsRequest) request).indicesOptions(ALLOW_EMPTY); + } + + return PrivilegesEvaluatorResponse.ok(); + } + } + + if (presponse.isAllowed()) { + if (checkFilteredAliases(optionallyResolvedIndices, action0, isDebugEnabled)) { + return PrivilegesEvaluatorResponse.insufficient(action0) + .reason("It is not possible to read from indices with more than two filtered aliases"); + } + + if (isDebugEnabled) { + log.debug("Allowed because we have all indices permissions for {}", action0); + } + } else { + log.info( + "No {}-level perm match for {} {}: {} [Action [{}]] [RolesChecked {}]", + "index", + user, + optionallyResolvedIndices, + presponse.getReason(), + action0, + mappedRoles + ); + log.info("Index to privilege matrix:\n{}", presponse.getPrivilegeMatrix()); + if (presponse.hasEvaluationExceptions()) { + log.info("Evaluation errors:\n{}", presponse.getEvaluationExceptionInfo()); + } + } + + return presponse; + } + + @Override + public void updateClusterStateMetadata(ClusterService clusterService) { + RoleBasedActionPrivileges actionPrivileges = this.actionPrivileges.get(); + if (actionPrivileges != null) { + actionPrivileges.clusterStateMetadataDependentPrivileges().updateClusterStateMetadataAsync(clusterService, threadPool); + } + } + + @Override + public void shutdown() { + RoleBasedActionPrivileges roleBasedActionPrivileges = this.actionPrivileges.get(); + if (roleBasedActionPrivileges != null) { + roleBasedActionPrivileges.clusterStateMetadataDependentPrivileges().shutdown(); + } + } + + @Override + public boolean notFailOnForbiddenEnabled() { + return dnfofEnabled; + } + + private ImmutableSet evaluateAdditionalIndexPermissions(final ActionRequest request, final String originalAction) { + ImmutableSet.Builder additionalPermissionsRequired = ImmutableSet.builder(); + + if (!isClusterPermission(originalAction)) { + additionalPermissionsRequired.add(originalAction); + } + + if (request instanceof ClusterSearchShardsRequest) { + additionalPermissionsRequired.add(SearchAction.NAME); + } + + if (request instanceof BulkShardRequest) { + BulkShardRequest bsr = (BulkShardRequest) request; + for (BulkItemRequest bir : bsr.items()) { + switch (bir.request().opType()) { + case CREATE: + additionalPermissionsRequired.add(IndexAction.NAME); + break; + case INDEX: + additionalPermissionsRequired.add(IndexAction.NAME); + break; + case DELETE: + additionalPermissionsRequired.add(DeleteAction.NAME); + break; + case UPDATE: + additionalPermissionsRequired.add(UpdateAction.NAME); + break; + } + } + } + + if (request instanceof IndicesAliasesRequest) { + IndicesAliasesRequest bsr = (IndicesAliasesRequest) request; + for (AliasActions bir : bsr.getAliasActions()) { + switch (bir.actionType()) { + case REMOVE_INDEX: + additionalPermissionsRequired.add(DeleteIndexAction.NAME); + break; + default: + break; + } + } + } + + if (request instanceof CreateIndexRequest) { + CreateIndexRequest cir = (CreateIndexRequest) request; + if (cir.aliases() != null && !cir.aliases().isEmpty()) { + additionalPermissionsRequired.add(IndicesAliasesAction.NAME); + } + } + + if (request instanceof RestoreSnapshotRequest && checkSnapshotRestoreWritePrivileges) { + additionalPermissionsRequired.addAll(ConfigConstants.SECURITY_SNAPSHOT_RESTORE_NEEDED_WRITE_PRIVILEGES); + } + + ImmutableSet result = additionalPermissionsRequired.build(); + + if (result.size() > 1) { + traceAction("Additional permissions required: {}", result); + } + + if (log.isDebugEnabled() && result.size() > 1) { + log.debug("Additional permissions required: {}", result); + } + + return result; + } + + @Override + public boolean isClusterPermission(String action) { + return isClusterPermissionStatic(action); + } + + static boolean isClusterPermissionStatic(String action0) { + return (action0.startsWith("cluster:") + || action0.startsWith("indices:admin/template/") + || action0.startsWith("indices:admin/index_template/") + || action0.startsWith(SearchScrollAction.NAME) + || (action0.equals(BulkAction.NAME)) + || (action0.equals(MultiGetAction.NAME)) + || (action0.startsWith(MultiSearchAction.NAME)) + || (action0.equals(MultiTermVectorsAction.NAME)) + || (action0.equals(ReindexAction.NAME)) + || (action0.equals(RenderSearchTemplateAction.NAME))); + } + + @SuppressWarnings("unchecked") + private boolean checkFilteredAliases(OptionallyResolvedIndices optionallyRequestedResolved, String action, boolean isDebugEnabled) { + final String faMode = this.filteredAliasMode; + + if (!"disallow".equals(faMode)) { + return false; + } + + if (!ACTION_MATCHER.test(action)) { + return false; + } + + if (!(optionallyRequestedResolved instanceof ResolvedIndices requestedResolved)) { + return false; + } + + Iterable indexMetaDataCollection; + + Set indexMetaDataSet = new HashSet<>(requestedResolved.local().names().size()); + + for (String requestAliasOrIndex : requestedResolved.local().names()) { + IndexMetadata indexMetaData = clusterStateSupplier.get().getMetadata().getIndices().get(requestAliasOrIndex); + if (indexMetaData == null) { + if (isDebugEnabled) { + log.debug("{} does not exist in cluster metadata", requestAliasOrIndex); + } + continue; + } + + indexMetaDataSet.add(indexMetaData); + } + + indexMetaDataCollection = indexMetaDataSet; + + // check filtered aliases + for (IndexMetadata indexMetaData : indexMetaDataCollection) { + + final List filteredAliases = new ArrayList(); + + final Map aliases = indexMetaData.getAliases(); + + if (aliases != null && aliases.size() > 0) { + if (isDebugEnabled) { + log.debug("Aliases for {}: {}", indexMetaData.getIndex().getName(), aliases); + } + + final Iterator it = aliases.keySet().iterator(); + while (it.hasNext()) { + final String alias = it.next(); + final AliasMetadata aliasMetadata = aliases.get(alias); + + if (aliasMetadata != null && aliasMetadata.filteringRequired()) { + filteredAliases.add(aliasMetadata); + if (isDebugEnabled) { + log.debug("{} is a filtered alias {}", alias, aliasMetadata.getFilter()); + } + } else { + if (isDebugEnabled) { + log.debug("{} is not an alias or does not have a filter", alias); + } + } + } + } + + if (filteredAliases.size() > 1 && ACTION_MATCHER.test(action)) { + // TODO add queries as dls queries (works only if dls module is installed) + log.error( + "More than one ({}) filtered alias found for same index ({}). This is currently not supported. Aliases: {}", + filteredAliases.size(), + indexMetaData.getIndex().getName(), + toString(filteredAliases) + ); + return true; + } + } // end-for + + return false; + } + + private List toString(List aliases) { + if (aliases == null || aliases.size() == 0) { + return Collections.emptyList(); + } + + final List ret = new ArrayList<>(aliases.size()); + + for (final AliasMetadata amd : aliases) { + if (amd != null) { + ret.add(amd.alias()); + } + } + + return Collections.unmodifiableList(ret); + } + + private static Map createActionPrivileges( + Map pluginIdToRolePrivileges, + FlattenedActionGroups staticActionGroups + ) { + Map result = new HashMap<>(pluginIdToRolePrivileges.size()); + + for (Map.Entry entry : pluginIdToRolePrivileges.entrySet()) { + result.put( + entry.getKey(), + new SubjectBasedActionPrivileges( + entry.getValue(), + staticActionGroups, + RuntimeOptimizedActionPrivileges.SpecialIndexProtection.NONE, + true + ) + ); + } + + return result; + } + + private static boolean isDnfofEnabled(ConfigV7 generalConfiguration) { + return generalConfiguration.dynamic != null && generalConfiguration.dynamic.do_not_fail_on_forbidden; + } + + private static boolean isDnfofEmptyEnabled(ConfigV7 generalConfiguration) { + return generalConfiguration.dynamic != null && generalConfiguration.dynamic.do_not_fail_on_forbidden_empty; + } + + private static String getFilteredAliasMode(ConfigV7 generalConfiguration) { + return generalConfiguration.dynamic != null ? generalConfiguration.dynamic.filtered_alias_mode : "none"; + } + +} diff --git a/src/main/java/org/opensearch/security/privileges/ProtectedIndexAccessEvaluator.java b/src/main/java/org/opensearch/security/privileges/actionlevel/legacy/ProtectedIndexAccessEvaluator.java similarity index 77% rename from src/main/java/org/opensearch/security/privileges/ProtectedIndexAccessEvaluator.java rename to src/main/java/org/opensearch/security/privileges/actionlevel/legacy/ProtectedIndexAccessEvaluator.java index 877e6fd787..13c9d54a66 100644 --- a/src/main/java/org/opensearch/security/privileges/ProtectedIndexAccessEvaluator.java +++ b/src/main/java/org/opensearch/security/privileges/actionlevel/legacy/ProtectedIndexAccessEvaluator.java @@ -9,7 +9,7 @@ * GitHub history for details. */ -package org.opensearch.security.privileges; +package org.opensearch.security.privileges.actionlevel.legacy; import java.util.ArrayList; import java.util.List; @@ -21,9 +21,10 @@ import org.opensearch.action.ActionRequest; import org.opensearch.action.RealtimeRequest; import org.opensearch.action.search.SearchRequest; +import org.opensearch.cluster.metadata.OptionallyResolvedIndices; import org.opensearch.common.settings.Settings; import org.opensearch.security.auditlog.AuditLog; -import org.opensearch.security.resolver.IndexResolverReplacer; +import org.opensearch.security.privileges.PrivilegesEvaluatorResponse; import org.opensearch.security.support.ConfigConstants; import org.opensearch.security.support.WildcardMatcher; import org.opensearch.tasks.Task; @@ -71,32 +72,22 @@ public PrivilegesEvaluatorResponse evaluate( final ActionRequest request, final Task task, final String action, - final IndexResolverReplacer.Resolved requestedResolved, - final PrivilegesEvaluatorResponse presponse, + final OptionallyResolvedIndices requestedResolved, final Set mappedRoles ) { if (!protectedIndexEnabled) { - return presponse; - } - if (!requestedResolved.isLocalAll() - && indexMatcher.matchAny(requestedResolved.getAllIndices()) - && deniedActionMatcher.test(action) - && !allowedRolesMatcher.matchAny(mappedRoles)) { - auditLog.logMissingPrivileges(action, request, task); - log.warn("{} for '{}' index/indices is not allowed for a regular user", action, indexMatcher); - presponse.allowed = false; - return presponse.markComplete(); + return null; } - if (requestedResolved.isLocalAll() && deniedActionMatcher.test(action) && !allowedRolesMatcher.matchAny(mappedRoles)) { + boolean containsProtectedIndex = requestedResolved.local().containsAny(indexMatcher); + + if (containsProtectedIndex && deniedActionMatcher.test(action) && !allowedRolesMatcher.matchAny(mappedRoles)) { auditLog.logMissingPrivileges(action, request, task); - log.warn("{} for '_all' indices is not allowed for a regular user", action); - presponse.allowed = false; - return presponse.markComplete(); + log.warn("{} for '{}' index/indices is not allowed for a regular user", action, indexMatcher); + return PrivilegesEvaluatorResponse.insufficient(action); } - if ((requestedResolved.isLocalAll() || indexMatcher.matchAny(requestedResolved.getAllIndices())) - && !allowedRolesMatcher.matchAny(mappedRoles)) { + if (containsProtectedIndex && !allowedRolesMatcher.matchAny(mappedRoles)) { final boolean isDebugEnabled = log.isDebugEnabled(); if (request instanceof SearchRequest) { ((SearchRequest) request).requestCache(Boolean.FALSE); @@ -112,6 +103,6 @@ public PrivilegesEvaluatorResponse evaluate( } } } - return presponse; + return null; } } diff --git a/src/main/java/org/opensearch/security/privileges/SnapshotRestoreEvaluator.java b/src/main/java/org/opensearch/security/privileges/actionlevel/legacy/SnapshotRestoreEvaluator.java similarity index 75% rename from src/main/java/org/opensearch/security/privileges/SnapshotRestoreEvaluator.java rename to src/main/java/org/opensearch/security/privileges/actionlevel/legacy/SnapshotRestoreEvaluator.java index 23612e1a52..8e03c3e40e 100644 --- a/src/main/java/org/opensearch/security/privileges/SnapshotRestoreEvaluator.java +++ b/src/main/java/org/opensearch/security/privileges/actionlevel/legacy/SnapshotRestoreEvaluator.java @@ -24,9 +24,10 @@ * GitHub history for details. */ -package org.opensearch.security.privileges; +package org.opensearch.security.privileges.actionlevel.legacy; import java.util.List; +import java.util.function.Supplier; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -35,7 +36,7 @@ import org.opensearch.action.admin.cluster.snapshots.restore.RestoreSnapshotRequest; import org.opensearch.common.settings.Settings; import org.opensearch.security.auditlog.AuditLog; -import org.opensearch.security.configuration.ClusterInfoHolder; +import org.opensearch.security.privileges.PrivilegesEvaluatorResponse; import org.opensearch.security.support.ConfigConstants; import org.opensearch.security.support.SnapshotRestoreHelper; import org.opensearch.tasks.Task; @@ -47,8 +48,13 @@ public class SnapshotRestoreEvaluator { private final String securityIndex; private final AuditLog auditLog; private final boolean restoreSecurityIndexEnabled; + private final Supplier isLocalNodeElectedClusterManagerSupplier; - public SnapshotRestoreEvaluator(final Settings settings, AuditLog auditLog) { + public SnapshotRestoreEvaluator( + final Settings settings, + AuditLog auditLog, + Supplier isLocalNodeElectedClusterManagerSupplier + ) { this.enableSnapshotRestorePrivilege = settings.getAsBoolean( ConfigConstants.SECURITY_ENABLE_SNAPSHOT_RESTORE_PRIVILEGE, ConfigConstants.SECURITY_DEFAULT_ENABLE_SNAPSHOT_RESTORE_PRIVILEGE @@ -60,37 +66,29 @@ public SnapshotRestoreEvaluator(final Settings settings, AuditLog auditLog) { ConfigConstants.OPENDISTRO_SECURITY_DEFAULT_CONFIG_INDEX ); this.auditLog = auditLog; + this.isLocalNodeElectedClusterManagerSupplier = isLocalNodeElectedClusterManagerSupplier; } - public PrivilegesEvaluatorResponse evaluate( - final ActionRequest request, - final Task task, - final String action, - final ClusterInfoHolder clusterInfoHolder, - final PrivilegesEvaluatorResponse presponse - ) { + public PrivilegesEvaluatorResponse evaluate(final ActionRequest request, final Task task, final String action) { if (!(request instanceof RestoreSnapshotRequest)) { - return presponse; + return null; } // snapshot restore for regular users not enabled if (!enableSnapshotRestorePrivilege) { log.warn("{} is not allowed for a regular user", action); - presponse.allowed = false; - return presponse.markComplete(); + return PrivilegesEvaluatorResponse.insufficient(action); } // if this feature is enabled, users can also snapshot and restore // the Security index and the global state if (restoreSecurityIndexEnabled) { - presponse.allowed = true; - return presponse; + return null; } - if (clusterInfoHolder.isLocalNodeElectedClusterManager() == Boolean.FALSE) { - presponse.allowed = true; - return presponse.markComplete(); + if (!isLocalNodeElectedClusterManagerSupplier.get()) { + return PrivilegesEvaluatorResponse.ok(); } final RestoreSnapshotRequest restoreRequest = (RestoreSnapshotRequest) request; @@ -99,8 +97,7 @@ public PrivilegesEvaluatorResponse evaluate( if (restoreRequest.includeGlobalState()) { auditLog.logSecurityIndexAttempt(request, action, task); log.warn("{} with 'include_global_state' enabled is not allowed", action); - presponse.allowed = false; - return presponse.markComplete(); + return PrivilegesEvaluatorResponse.insufficient(action).reason("'include_global_state' is not allowed"); } final List rs = SnapshotRestoreHelper.resolveOriginalIndices(restoreRequest); @@ -108,9 +105,9 @@ public PrivilegesEvaluatorResponse evaluate( if (rs != null && (rs.contains(securityIndex) || rs.contains("_all") || rs.contains("*"))) { auditLog.logSecurityIndexAttempt(request, action, task); log.warn("{} for '{}' as source index is not allowed", action, securityIndex); - presponse.allowed = false; - return presponse.markComplete(); + return PrivilegesEvaluatorResponse.insufficient(action).reason(securityIndex + " as source index is not allowed"); } - return presponse; + + return null; } } diff --git a/src/main/java/org/opensearch/security/privileges/SystemIndexAccessEvaluator.java b/src/main/java/org/opensearch/security/privileges/actionlevel/legacy/SystemIndexAccessEvaluator.java similarity index 51% rename from src/main/java/org/opensearch/security/privileges/SystemIndexAccessEvaluator.java rename to src/main/java/org/opensearch/security/privileges/actionlevel/legacy/SystemIndexAccessEvaluator.java index 68cd42a7a8..af1765c38a 100644 --- a/src/main/java/org/opensearch/security/privileges/SystemIndexAccessEvaluator.java +++ b/src/main/java/org/opensearch/security/privileges/actionlevel/legacy/SystemIndexAccessEvaluator.java @@ -24,12 +24,13 @@ * GitHub history for details. */ -package org.opensearch.security.privileges; +package org.opensearch.security.privileges.actionlevel.legacy; import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.Set; +import java.util.function.Predicate; import java.util.stream.Collectors; import com.google.common.collect.ImmutableSet; @@ -37,19 +38,27 @@ import org.apache.logging.log4j.Logger; import org.opensearch.action.ActionRequest; +import org.opensearch.action.IndicesRequest; import org.opensearch.action.RealtimeRequest; +import org.opensearch.action.admin.cluster.snapshots.restore.RestoreSnapshotAction; import org.opensearch.action.search.SearchRequest; +import org.opensearch.cluster.metadata.Metadata; +import org.opensearch.cluster.metadata.OptionallyResolvedIndices; +import org.opensearch.cluster.metadata.ResolvedIndices; +import org.opensearch.common.regex.Regex; import org.opensearch.common.settings.Settings; import org.opensearch.indices.SystemIndexRegistry; import org.opensearch.security.auditlog.AuditLog; -import org.opensearch.security.resolver.IndexResolverReplacer; -import org.opensearch.security.resolver.IndexResolverReplacer.Resolved; +import org.opensearch.security.privileges.ActionPrivileges; +import org.opensearch.security.privileges.IndicesRequestModifier; +import org.opensearch.security.privileges.PrivilegesEvaluationContext; +import org.opensearch.security.privileges.PrivilegesEvaluatorResponse; import org.opensearch.security.support.ConfigConstants; import org.opensearch.security.support.WildcardMatcher; import org.opensearch.security.user.User; import org.opensearch.tasks.Task; -import static org.opensearch.security.privileges.PrivilegesEvaluator.isClusterPerm; +import static org.opensearch.security.privileges.actionlevel.legacy.PrivilegesEvaluator.isClusterPermissionStatic; /** * This class performs authorization on requests targeting system indices @@ -63,30 +72,27 @@ public class SystemIndexAccessEvaluator { private final String securityIndex; private final AuditLog auditLog; - private final IndexResolverReplacer irr; private final boolean filterSecurityIndex; // for system-indices configuration private final WildcardMatcher systemIndexMatcher; - private final WildcardMatcher superAdminAccessOnlyIndexMatcher; private final WildcardMatcher deniedActionsMatcher; private final boolean isSystemIndexEnabled; private final boolean isSystemIndexPermissionEnabled; private final static ImmutableSet SYSTEM_INDEX_PERMISSION_SET = ImmutableSet.of(ConfigConstants.SYSTEM_INDEX_PERMISSION); + private final IndicesRequestModifier indicesRequestModifier = new IndicesRequestModifier(); - public SystemIndexAccessEvaluator(final Settings settings, AuditLog auditLog, IndexResolverReplacer irr) { + public SystemIndexAccessEvaluator(final Settings settings, AuditLog auditLog) { this.securityIndex = settings.get( ConfigConstants.SECURITY_CONFIG_INDEX_NAME, ConfigConstants.OPENDISTRO_SECURITY_DEFAULT_CONFIG_INDEX ); this.auditLog = auditLog; - this.irr = irr; this.filterSecurityIndex = settings.getAsBoolean(ConfigConstants.SECURITY_FILTER_SECURITYINDEX_FROM_ALL_REQUESTS, false); this.systemIndexMatcher = WildcardMatcher.from( settings.getAsList(ConfigConstants.SECURITY_SYSTEM_INDICES_KEY, ConfigConstants.SECURITY_SYSTEM_INDICES_DEFAULT) ); - this.superAdminAccessOnlyIndexMatcher = WildcardMatcher.from(this.securityIndex); this.isSystemIndexEnabled = settings.getAsBoolean( ConfigConstants.SECURITY_SYSTEM_INDICES_ENABLED_KEY, ConfigConstants.SECURITY_SYSTEM_INDICES_ENABLED_DEFAULT @@ -127,96 +133,56 @@ public PrivilegesEvaluatorResponse evaluate( final ActionRequest request, final Task task, final String action, - final Resolved requestedResolved, - final PrivilegesEvaluatorResponse presponse, + final OptionallyResolvedIndices requestedResolved, final PrivilegesEvaluationContext context, final ActionPrivileges actionPrivileges, final User user ) { - evaluateSystemIndicesAccess(action, requestedResolved, request, task, presponse, context, actionPrivileges, user); + boolean containsSystemIndex = requestedResolved.local().containsAny(this::isSystemIndex); + + PrivilegesEvaluatorResponse response = evaluateSystemIndicesAccess( + action, + requestedResolved, + request, + task, + context, + actionPrivileges, + user, + containsSystemIndex + ); - if (requestedResolved.isLocalAll() - || requestedResolved.getAllIndices().contains(securityIndex) - || requestContainsAnySystemIndices(requestedResolved)) { + if (response == null || response.isAllowed()) { + if (containsSystemIndex) { - if (request instanceof SearchRequest) { - ((SearchRequest) request).requestCache(Boolean.FALSE); - if (log.isDebugEnabled()) { - log.debug("Disable search request cache for this request"); + if (request instanceof SearchRequest) { + ((SearchRequest) request).requestCache(Boolean.FALSE); + if (log.isDebugEnabled()) { + log.debug("Disable search request cache for this request"); + } } - } - if (request instanceof RealtimeRequest) { - ((RealtimeRequest) request).realtime(Boolean.FALSE); - if (log.isDebugEnabled()) { - log.debug("Disable realtime for this request"); + if (request instanceof RealtimeRequest) { + ((RealtimeRequest) request).realtime(Boolean.FALSE); + if (log.isDebugEnabled()) { + log.debug("Disable realtime for this request"); + } } } } - return presponse; - } - /** - * Checks if request is for any system index - * @param requestedResolved request which contains indices to be matched against system indices - * @return true if a match is found, false otherwise - */ - private boolean requestContainsAnySystemIndices(final Resolved requestedResolved) { - return !getAllSystemIndices(requestedResolved).isEmpty(); + return response; } - /** - * Gets all indices requested in the original request. - * It will always return security index if it is present in the request, as security index is protected regardless - * of feature being enabled or disabled - * @param requestedResolved request which contains indices to be matched against system indices - * @return the set of protected system indices present in the request - */ - private Set getAllSystemIndices(final Resolved requestedResolved) { - final Set systemIndices = requestedResolved.getAllIndices() - .stream() - .filter(securityIndex::equals) - .collect(Collectors.toSet()); - if (isSystemIndexEnabled) { - systemIndices.addAll(systemIndexMatcher.getMatchAny(requestedResolved.getAllIndices(), Collectors.toList())); - systemIndices.addAll(SystemIndexRegistry.matchesSystemIndexPattern(requestedResolved.getAllIndices())); + private boolean isSystemIndex(String index) { + if (this.securityIndex.equals(index)) { + return true; } - return systemIndices; - } - - /** - * Checks if request contains any system index that is non-permission-able - * NOTE: Security index is currently non-permission-able - * @param requestedResolved request which contains indices to be matched against non-permission-able system indices - * @return true if the request contains any non-permission-able index,false otherwise - */ - private boolean requestContainsAnyProtectedSystemIndices(final Resolved requestedResolved) { - return !getAllProtectedSystemIndices(requestedResolved).isEmpty(); - } - /** - * Filters the request to get all system indices that are protected and are non-permission-able - * @param requestedResolved request which contains indices to be matched against non-permission-able system indices - * @return the list of protected system indices present in the request - */ - private List getAllProtectedSystemIndices(final Resolved requestedResolved) { - return new ArrayList<>(superAdminAccessOnlyIndexMatcher.getMatchAny(requestedResolved.getAllIndices(), Collectors.toList())); - } - - /** - * Checks if the request contains any regular (non-system and non-protected) indices. - * Regular indices are those that are not categorized as system indices or protected system indices. - * This method helps in identifying requests that might be accessing regular indices alongside system indices. - * @param requestedResolved The resolved object of the request, which contains the list of indices from the original request. - * @return true if the request contains any regular indices, false otherwise. - */ - private boolean requestContainsAnyRegularIndices(final Resolved requestedResolved) { - Set allIndices = requestedResolved.getAllIndices(); - - Set allSystemIndices = getAllSystemIndices(requestedResolved); - List allProtectedSystemIndices = getAllProtectedSystemIndices(requestedResolved); - - return allIndices.stream().anyMatch(index -> !allSystemIndices.contains(index) && !allProtectedSystemIndices.contains(index)); + if (this.isSystemIndexEnabled) { + return this.systemIndexMatcher.test(index) || SystemIndexRegistry.matchesSystemIndexPattern(index); + } else { + return false; + } } /** @@ -234,46 +200,42 @@ private boolean isActionAllowed(String action) { * @param requestedResolved this object contains all indices this request is resolved to * @param request the action request to be used for audit logging * @param task task in which this access check will be performed - * @param presponse the pre-response object that will eventually become a response and returned to the requester * @param context conveys information about user and mapped roles, etc. * @param actionPrivileges the up-to-date ActionPrivileges instance * @param user this user's permissions will be looked up */ - private void evaluateSystemIndicesAccess( + private PrivilegesEvaluatorResponse evaluateSystemIndicesAccess( final String action, - final Resolved requestedResolved, + final OptionallyResolvedIndices requestedResolved, final ActionRequest request, final Task task, - final PrivilegesEvaluatorResponse presponse, final PrivilegesEvaluationContext context, final ActionPrivileges actionPrivileges, - final User user + final User user, + final boolean containsSystemIndex ) { - // Perform access check is system index permissions are enabled - boolean containsSystemIndex = requestContainsAnySystemIndices(requestedResolved); - boolean containsRegularIndex = requestContainsAnyRegularIndices(requestedResolved); boolean serviceAccountUser = user.isServiceAccount(); - if (isSystemIndexPermissionEnabled) { + if (isSystemIndexPermissionEnabled + && (!isClusterPermissionStatic(action) || RestoreSnapshotAction.NAME.equals(action)) + && backwartsCompatGateForSystemIndexPrivileges(action, request)) { + boolean containsRegularIndex = requestedResolved.local().containsAny(index -> !isSystemIndex(index)); + if (serviceAccountUser && containsRegularIndex) { auditLog.logSecurityIndexAttempt(request, action, task); if (!containsSystemIndex && log.isInfoEnabled()) { log.info("{} not permitted for a service account {} on non-system indices.", action, context.getMappedRoles()); } else if (containsSystemIndex && log.isDebugEnabled()) { - List regularIndices = requestedResolved.getAllIndices() + List regularIndices = requestedResolved.local() + .names(context.clusterState()) .stream() - .filter( - index -> !getAllSystemIndices(requestedResolved).contains(index) - && !getAllProtectedSystemIndices(requestedResolved).contains(index) - ) + .filter(index -> !isSystemIndex(index)) .collect(Collectors.toList()); log.debug("Service account cannot access regular indices: {}", regularIndices); } - presponse.allowed = false; - presponse.markComplete(); - return; + return PrivilegesEvaluatorResponse.insufficient(action).reason("Service account cannot access regular indices"); } - boolean containsProtectedIndex = requestContainsAnyProtectedSystemIndices(requestedResolved); + boolean containsProtectedIndex = requestedResolved.local().containsAny(this.securityIndex::equals); if (containsProtectedIndex) { auditLog.logSecurityIndexAttempt(request, action, task); if (log.isInfoEnabled()) { @@ -281,12 +243,10 @@ private void evaluateSystemIndicesAccess( "{} not permitted for a regular user {} on protected system indices {}", action, context.getMappedRoles(), - String.join(", ", getAllProtectedSystemIndices(requestedResolved)) + String.join(", ", this.securityIndex) ); } - presponse.allowed = false; - presponse.markComplete(); - return; + return PrivilegesEvaluatorResponse.insufficient(action); } else if (containsSystemIndex && !actionPrivileges.hasExplicitIndexPrivilege(context, SYSTEM_INDEX_PERMISSION_SET, requestedResolved).isAllowed()) { auditLog.logSecurityIndexAttempt(request, action, task); @@ -295,100 +255,164 @@ private void evaluateSystemIndicesAccess( "No {} permission for user roles {} to System Indices {}", action, context.getMappedRoles(), - String.join(", ", getAllSystemIndices(requestedResolved)) + requestedResolved.local() + .names(context.clusterState()) + .stream() + .filter(this::isSystemIndex) + .collect(Collectors.joining(", ")) ); } - presponse.allowed = false; - presponse.markComplete(); - return; + return PrivilegesEvaluatorResponse.insufficient(action); } } // the following section should only be run for index actions - if (user.isPluginUser() && !isClusterPerm(action)) { + if (user.isPluginUser() && !isClusterPermissionStatic(action)) { if (this.isSystemIndexEnabled) { - Set matchingPluginIndices = SystemIndexRegistry.matchesPluginSystemIndexPattern( + PluginSystemIndexSelection pluginSystemIndexSelection = areIndicesPluginSystemIndices( + context, user.getName().replace("plugin:", ""), - requestedResolved.getAllIndices() + requestedResolved ); - if (requestedResolved.getAllIndices().equals(matchingPluginIndices)) { + if (pluginSystemIndexSelection == PluginSystemIndexSelection.CONTAINS_ONLY_PLUGIN_SYSTEM_INDICES) { // plugin is authorized to perform any actions on its own registered system indices - presponse.allowed = true; - presponse.markComplete(); - return; - } else { - Set matchingSystemIndices = SystemIndexRegistry.matchesSystemIndexPattern(requestedResolved.getAllIndices()); - matchingSystemIndices.removeAll(matchingPluginIndices); - // See if request matches other system indices not belong to the plugin - if (!matchingSystemIndices.isEmpty()) { - if (log.isInfoEnabled()) { - log.info( - "Plugin {} can only perform {} on it's own registered System Indices. System indices from request that match plugin's registered system indices: {}", - user.getName(), - action, - matchingPluginIndices - ); - } - presponse.allowed = false; - presponse.getMissingPrivileges(); - presponse.markComplete(); - return; + return PrivilegesEvaluatorResponse.ok(); + } else if (pluginSystemIndexSelection == PluginSystemIndexSelection.CONTAINS_OTHER_SYSTEM_INDICES) { + if (log.isInfoEnabled()) { + log.info( + "Plugin {} can only perform {} on it's own registered System Indices. Resolved indices: {}", + user.getName(), + action, + requestedResolved + ); } + return PrivilegesEvaluatorResponse.insufficient(action); } } else { // no system index protection and request originating from plugin, allow - presponse.allowed = true; - presponse.markComplete(); - return; + return PrivilegesEvaluatorResponse.ok(); } } if (isActionAllowed(action)) { - if (requestedResolved.isLocalAll()) { + if (!(requestedResolved instanceof ResolvedIndices resolvedIndices)) { if (filterSecurityIndex) { - irr.replace(request, false, "*", "-" + securityIndex); - if (log.isDebugEnabled()) { - log.debug( - "Filtered '{}' from {}, resulting list with *,-{} is {}", - securityIndex, - requestedResolved, - securityIndex, - irr.resolveRequest(request) - ); - } + // TODO + // irr.replace(request, false, "*", "-" + securityIndex); } else { auditLog.logSecurityIndexAttempt(request, action, task); log.warn("{} for '_all' indices is not allowed for a regular user", action); - presponse.allowed = false; - presponse.markComplete(); + return PrivilegesEvaluatorResponse.insufficient(action); } } // if system index is enabled and system index permissions are enabled we don't need to perform any further // checks as it has already been performed via hasExplicitIndexPermission else if (containsSystemIndex && !isSystemIndexPermissionEnabled) { if (filterSecurityIndex) { - Set allWithoutSecurity = new HashSet<>(requestedResolved.getAllIndices()); + Set allWithoutSecurity = new HashSet<>(requestedResolved.local().names(context.clusterState())); allWithoutSecurity.remove(securityIndex); if (allWithoutSecurity.isEmpty()) { if (log.isDebugEnabled()) { log.debug("Filtered '{}' but resulting list is empty", securityIndex); } - presponse.allowed = false; - presponse.markComplete(); - return; + return PrivilegesEvaluatorResponse.insufficient(action); } - irr.replace(request, false, allWithoutSecurity.toArray(new String[0])); + this.indicesRequestModifier.setLocalIndices(request, resolvedIndices, allWithoutSecurity); if (log.isDebugEnabled()) { log.debug("Filtered '{}', resulting list is {}", securityIndex, allWithoutSecurity); } } else { auditLog.logSecurityIndexAttempt(request, action, task); - final String foundSystemIndexes = String.join(", ", getAllSystemIndices(requestedResolved)); + final String foundSystemIndexes = requestedResolved.local() + .names(context.clusterState()) + .stream() + .filter(this::isSystemIndex) + .collect(Collectors.joining(", ")); log.warn("{} for '{}' index is not allowed for a regular user", action, foundSystemIndexes); - presponse.allowed = false; - presponse.markComplete(); + return PrivilegesEvaluatorResponse.insufficient(action); + } + } + } + + return null; + } + + private PluginSystemIndexSelection areIndicesPluginSystemIndices( + PrivilegesEvaluationContext context, + String pluginClassName, + OptionallyResolvedIndices optionallyResolvedIndices + ) { + if (optionallyResolvedIndices instanceof ResolvedIndices resolvedIndices) { + Predicate pluginSystemIndexPredicate = SystemIndexRegistry.getPluginSystemIndexPredicate(pluginClassName); + + boolean containsNonPluginSystemIndex = false; + boolean containsOtherSystemIndex = false; + + for (String index : resolvedIndices.local().namesOfIndices(context.clusterState())) { + if (!pluginSystemIndexPredicate.test(index)) { + containsNonPluginSystemIndex = true; + if (SystemIndexRegistry.matchesSystemIndexPattern(index)) { + containsOtherSystemIndex = true; + } } } + + if (!containsNonPluginSystemIndex) { + return PluginSystemIndexSelection.CONTAINS_ONLY_PLUGIN_SYSTEM_INDICES; + } else if (containsOtherSystemIndex) { + return PluginSystemIndexSelection.CONTAINS_OTHER_SYSTEM_INDICES; + } else { + return PluginSystemIndexSelection.NO_SYSTEM_INDICES; + } + } else { + // If we have an unknown state, we must assume that other system indices are contained + return PluginSystemIndexSelection.CONTAINS_OTHER_SYSTEM_INDICES; } } + + /** + * Previous versions of OpenSearch had the bug that indices requests on "*" (or _all) did not get checked + * in the block of evaluateSystemIndicesAccess() that checks for the explicit system index privileges. + * This is not nice, but also not a big problem, as write operations would be denied anyway at the end of + * the evaluateSystemIndicesAccess(). Read operations would be blocked anyway on the Lucene level. + * With the introduction of the ResolvedIndices object, we do not have a real notion of "is all" any more; + * thus, this method would now block many requests, even if these would be filtered out later by the DNFOF mode + * in PrivilegeEvaluator. + *

    + * To keep backwards compatibility, we have this method which disables the first block of evaluateSystemIndicesAccess() + * for the same cases that were previously skipping its execution. Of course, this is totally hacky, but there + * is no better way to keep the available functionality other than rewriting the whole logic; which is actually done + * in the next gen privilege evaluation code. + * @return true, if the explicit privilege check in evaluateSystemIndicesAccess() shall be executed; false it it shall + * be skipped. + */ + private boolean backwartsCompatGateForSystemIndexPrivileges(String action, ActionRequest actionRequest) { + if (!(actionRequest instanceof IndicesRequest indicesRequest)) { + // If we cannot resolve indices, we go into the explicit privilege check code; the code will then deny the request + return true; + } + + if (deniedActionsMatcher.test(action)) { + // If this is an action that manipulates documents or indices, we also need to do the explicit privilege check. + return true; + } + + String[] indices = indicesRequest.indices(); + boolean isAll = indices == null + || indices.length == 0 + || (indices.length == 1 && (indices[0] == null || Metadata.ALL.equals(indices[0]) || Regex.isMatchAllPattern(indices[0]))); + if (!isAll) { + // For non-is-all requests, previous versions also went through the checks + return true; + } else { + // For is-all requests, we can skip the checks; any data in the indices will be filtered out on the Lucene level + return false; + } + } + + enum PluginSystemIndexSelection { + CONTAINS_ONLY_PLUGIN_SYSTEM_INDICES, + CONTAINS_OTHER_SYSTEM_INDICES, + NO_SYSTEM_INDICES + } } diff --git a/src/main/java/org/opensearch/security/privileges/actionlevel/legacy/TermsAggregationEvaluator.java b/src/main/java/org/opensearch/security/privileges/actionlevel/legacy/TermsAggregationEvaluator.java new file mode 100644 index 0000000000..7b45c72f16 --- /dev/null +++ b/src/main/java/org/opensearch/security/privileges/actionlevel/legacy/TermsAggregationEvaluator.java @@ -0,0 +1,126 @@ +/* + * Copyright 2015-2018 _floragunn_ GmbH + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* + * 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. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.security.privileges.actionlevel.legacy; + +import com.google.common.collect.ImmutableSet; +import com.google.common.collect.Streams; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import org.opensearch.action.ActionRequest; +import org.opensearch.action.fieldcaps.FieldCapabilitiesAction; +import org.opensearch.action.get.GetAction; +import org.opensearch.action.get.MultiGetAction; +import org.opensearch.action.search.MultiSearchAction; +import org.opensearch.action.search.SearchAction; +import org.opensearch.action.search.SearchRequest; +import org.opensearch.cluster.metadata.OptionallyResolvedIndices; +import org.opensearch.cluster.metadata.ResolvedIndices; +import org.opensearch.index.query.MatchNoneQueryBuilder; +import org.opensearch.index.query.QueryBuilder; +import org.opensearch.index.query.TermsQueryBuilder; +import org.opensearch.search.aggregations.AggregationBuilder; +import org.opensearch.search.aggregations.bucket.terms.TermsAggregationBuilder; +import org.opensearch.security.privileges.ActionPrivileges; +import org.opensearch.security.privileges.PrivilegesEvaluationContext; +import org.opensearch.security.privileges.PrivilegesEvaluatorResponse; + +public class TermsAggregationEvaluator { + + protected final Logger log = LogManager.getLogger(this.getClass()); + + private static final ImmutableSet READ_ACTIONS = ImmutableSet.of( + MultiSearchAction.NAME, + MultiGetAction.NAME, + GetAction.NAME, + SearchAction.NAME, + FieldCapabilitiesAction.NAME + ); + + private static final QueryBuilder NONE_QUERY = new MatchNoneQueryBuilder(); + + public TermsAggregationEvaluator() {} + + public PrivilegesEvaluatorResponse evaluate( + OptionallyResolvedIndices optionallyResolvedIndices, + ActionRequest request, + PrivilegesEvaluationContext context, + ActionPrivileges actionPrivileges + ) { + // This is only applicable for SearchRequests and for present ResolvedIndices information (for SearchRequests that is usually the + // case) + if (!(request instanceof SearchRequest sr) || !(optionallyResolvedIndices instanceof ResolvedIndices resolvedIndices)) { + return null; + } + + try { + + if (sr.source() != null + && sr.source().query() == null + && sr.source().aggregations() != null + && sr.source().aggregations().getAggregatorFactories() != null + && sr.source().aggregations().getAggregatorFactories().size() == 1 + && sr.source().size() == 0) { + AggregationBuilder ab = sr.source().aggregations().getAggregatorFactories().iterator().next(); + if (ab instanceof TermsAggregationBuilder && "terms".equals(ab.getType()) && "indices".equals(ab.getName())) { + if ("_index".equals(((TermsAggregationBuilder) ab).field()) + && ab.getPipelineAggregations().isEmpty() + && ab.getSubAggregations().isEmpty()) { + + PrivilegesEvaluatorResponse subResponse = actionPrivileges.hasIndexPrivilege( + context, + READ_ACTIONS, + ResolvedIndices.unknown() + ); + + if (subResponse.isPartiallyOk()) { + sr.source() + .query( + new TermsQueryBuilder( + "_index", + Streams.concat( + subResponse.getAvailableIndices().stream(), + resolvedIndices.remote().asRawExpressions().stream() + ).toArray(String[]::new) + ) + ); + } else if (!subResponse.isAllowed()) { + sr.source().query(NONE_QUERY); + } + + return PrivilegesEvaluatorResponse.ok(); + } + } + } + + } catch (Exception e) { + log.warn("Unable to evaluate terms aggregation", e); + } + + return null; + } +} diff --git a/src/main/java/org/opensearch/security/privileges/actionlevel/nextgen/ActionConfiguration.java b/src/main/java/org/opensearch/security/privileges/actionlevel/nextgen/ActionConfiguration.java new file mode 100644 index 0000000000..0a81e036f2 --- /dev/null +++ b/src/main/java/org/opensearch/security/privileges/actionlevel/nextgen/ActionConfiguration.java @@ -0,0 +1,198 @@ +/* + * 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. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ +package org.opensearch.security.privileges.actionlevel.nextgen; + +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.function.Function; + +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import org.opensearch.action.admin.indices.create.AutoCreateAction; +import org.opensearch.action.admin.indices.create.CreateIndexAction; +import org.opensearch.action.admin.indices.mapping.put.AutoPutMappingAction; +import org.opensearch.action.admin.indices.mapping.put.PutMappingAction; +import org.opensearch.action.admin.indices.template.delete.DeleteComposableIndexTemplateAction; +import org.opensearch.action.admin.indices.template.delete.DeleteIndexTemplateAction; +import org.opensearch.action.admin.indices.template.get.GetComposableIndexTemplateAction; +import org.opensearch.action.admin.indices.template.get.GetIndexTemplatesAction; +import org.opensearch.action.admin.indices.template.post.SimulateIndexTemplateAction; +import org.opensearch.action.admin.indices.template.post.SimulateTemplateAction; +import org.opensearch.action.admin.indices.template.put.PutComposableIndexTemplateAction; +import org.opensearch.action.admin.indices.template.put.PutIndexTemplateAction; +import org.opensearch.action.admin.indices.upgrade.post.UpgradeAction; +import org.opensearch.action.admin.indices.upgrade.post.UpgradeSettingsAction; +import org.opensearch.action.search.GetAllPitsAction; +import org.opensearch.common.settings.Setting; +import org.opensearch.common.settings.Settings; +import org.opensearch.index.reindex.ReindexAction; +import org.opensearch.script.mustache.MultiSearchTemplateAction; +import org.opensearch.script.mustache.RenderSearchTemplateAction; +import org.opensearch.security.privileges.actionlevel.WellKnownActions; + +/** + * This class encapsulates some logic and configuration related to action names used in the PrivilegesEvaluator. + * It exposes a number of config options which affect the way action names are treated. See below. + *

    + * The purpose of these settings is mainly to have an emergency measure in case an action is incorrectly handled + * in PrivilegesEvaluator. That's why they are just documented in this class, but not in the user docs. + */ +class ActionConfiguration { + + /** + * This setting expects a list of action names (like "indices:data/read/search"); all action names + * that are listed here will be treated as "cluster privileges" by the PrivilegesEvaluator. + * That means privileges for these actions must be specified in the cluster_privileges section in roles.yml. + */ + public static Setting> FORCE_AS_CLUSTER_ACTIONS = Setting.listSetting( + "plugins.security.privileges_evaluation.actions.force_as_cluster_actions", + Collections.emptyList(), + Function.identity(), + Setting.Property.NodeScope + ); + + /** + * This setting expects a list of action mapping strings; these need to be formatted like + * "indices:data/write/bulk>indices:data/write/index". Whenever PrivilegesEvaluator receives an action called + * X, it will check in the mapping wheter X is mapped to Y. If so, it will check privileges for Y. + */ + public static Setting> MAP_ACTION_NAMES = Setting.listSetting( + "plugins.security.privileges_evaluation.actions.map_action_names", + Collections.emptyList(), + Function.identity(), + Setting.Property.NodeScope + ); + + /** + * A list of action names which will be always denied by PrivilegesEvaluator, regardless of any + * other setting. The only way to execute such actions will be using a super admin certificate. + */ + public static Setting> UNIVERSALLY_DENIED_ACTIONS = Setting.listSetting( + "plugins.security.privileges_evaluation.actions.universally_denied_actions", + Collections.emptyList(), + Function.identity(), + Setting.Property.NodeScope + ); + + private static final Logger log = LogManager.getLogger(ActionConfiguration.class); + + private final ImmutableMap actionToActionMap; + private final ImmutableSet explicitIndexActions; + private final ImmutableSet clusterActions; + private final ImmutableSet universallyDeniedActions; + + ActionConfiguration(Settings settings) { + this.actionToActionMap = buildActionToActionMap(settings); + this.explicitIndexActions = buildExplicitIndexActionSet(settings); + this.clusterActions = buildClusterActionSet(settings); + this.universallyDeniedActions = ImmutableSet.copyOf(UNIVERSALLY_DENIED_ACTIONS.get(settings)); + } + + /** + * Checks the action mapping and normalizes the given action name. In most cases, this will just return the + * original action name. + */ + String normalize(String action) { + String mapped = this.actionToActionMap.get(action); + if (mapped != null) { + return mapped; + } else { + return action; + } + } + + /** + * Returns true if the given action is supposed to be a cluster action according to the configuration. + */ + boolean isClusterPermission(String action) { + if (this.explicitIndexActions.contains(action)) { + return false; + } else if (this.clusterActions.contains(action)) { + return true; + } else { + // TODO maybe switch to index: + return action.startsWith("cluster:"); + } + } + + boolean isUniversallyDenied(String action) { + return this.universallyDeniedActions.contains(action); + } + + private static ImmutableMap buildActionToActionMap(Settings settings) { + ImmutableMap.Builder builder = ImmutableMap.builder(); + + // The following mappings were originally defined at + // https://github.com/opensearch-project/security/blob/eb7153d772e9e00d49d9cb5ffafb33b5f02399fc/src/main/java/org/opensearch/security/privileges/PrivilegesEvaluator.java#L392 + builder.put(UpgradeSettingsAction.NAME, UpgradeAction.NAME); + builder.put(AutoCreateAction.NAME, CreateIndexAction.NAME); + builder.put(AutoPutMappingAction.NAME, PutMappingAction.NAME); + + for (String entry : MAP_ACTION_NAMES.get(settings)) { + String[] parts = entry.split(">"); + if (parts.length == 2) { + builder.put(parts[0], parts[1]); + } else { + log.error("Invalid value for {}: {}", MAP_ACTION_NAMES.getKey(), entry); + } + } + + return builder.build(); + } + + private static ImmutableSet buildClusterActionSet(Settings settings) { + ImmutableSet.Builder builder = ImmutableSet.builder(); + + // A couple of "indices:" actions are considered as cluster level privileges for a number of different reasons. + // See below for details + builder.addAll(WellKnownActions.CLUSTER_ACTIONS); + + // The _msearch action triggers under the hood an additional _search action; thus it is sufficient to check index specific + // privileges on the _search level + builder.add(MultiSearchTemplateAction.NAME); + + // The _reindex action triggers under the hood _search and _bulk actions; thus, index privileges can be checked on these levels + builder.add(ReindexAction.NAME); + + // The _render/template action actually does not operate on indices at all + builder.add(RenderSearchTemplateAction.NAME); + + // The _search/point_in_time/_all action provides no possibility to specify/reduce indices. Thus, it should be a cluster action + builder.add(GetAllPitsAction.NAME); + + // The index template and composable template actions do not specify indices, but specify patterns for potentially non-existing + // indices. + // This makes it difficult (or rather impossible) to match these against the privilege definition index patterns. + // Thus, we treat these as cluster privileges + builder.add(PutIndexTemplateAction.NAME); + builder.add(DeleteIndexTemplateAction.NAME); + builder.add(GetIndexTemplatesAction.NAME); + builder.add(PutComposableIndexTemplateAction.NAME); + builder.add(DeleteComposableIndexTemplateAction.NAME); + builder.add(GetComposableIndexTemplateAction.NAME); + builder.add(SimulateIndexTemplateAction.NAME); + builder.add(SimulateTemplateAction.NAME); + + builder.addAll(FORCE_AS_CLUSTER_ACTIONS.get(settings)); + return builder.build(); + } + + ImmutableSet buildExplicitIndexActionSet(Settings settings) { + Set builder = new HashSet<>(WellKnownActions.INDEX_ACTIONS); + builder.removeAll(FORCE_AS_CLUSTER_ACTIONS.get(settings)); + return ImmutableSet.copyOf(builder); + } +} diff --git a/src/main/java/org/opensearch/security/privileges/actionlevel/nextgen/PrivilegesEvaluator.java b/src/main/java/org/opensearch/security/privileges/actionlevel/nextgen/PrivilegesEvaluator.java new file mode 100644 index 0000000000..f87589316a --- /dev/null +++ b/src/main/java/org/opensearch/security/privileges/actionlevel/nextgen/PrivilegesEvaluator.java @@ -0,0 +1,816 @@ +/* + * 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. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ +package org.opensearch.security.privileges.actionlevel.nextgen; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Supplier; + +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import org.opensearch.OpenSearchSecurityException; +import org.opensearch.action.ActionRequest; +import org.opensearch.action.AliasesRequest; +import org.opensearch.action.IndicesRequest; +import org.opensearch.action.admin.cluster.shards.ClusterSearchShardsRequest; +import org.opensearch.action.admin.cluster.snapshots.restore.RestoreSnapshotRequest; +import org.opensearch.action.admin.indices.alias.get.GetAliasesAction; +import org.opensearch.action.admin.indices.alias.get.GetAliasesRequest; +import org.opensearch.action.admin.indices.segments.PitSegmentsRequest; +import org.opensearch.action.bulk.BulkItemRequest; +import org.opensearch.action.bulk.BulkRequest; +import org.opensearch.action.bulk.BulkShardRequest; +import org.opensearch.action.delete.DeleteAction; +import org.opensearch.action.index.IndexAction; +import org.opensearch.action.search.CreatePitRequest; +import org.opensearch.action.search.SearchAction; +import org.opensearch.action.support.ActionRequestMetadata; +import org.opensearch.action.update.UpdateAction; +import org.opensearch.cluster.ClusterState; +import org.opensearch.cluster.metadata.IndexNameExpressionResolver; +import org.opensearch.cluster.metadata.Metadata; +import org.opensearch.cluster.metadata.OptionallyResolvedIndices; +import org.opensearch.cluster.metadata.ResolvedIndices; +import org.opensearch.cluster.service.ClusterService; +import org.opensearch.common.regex.Regex; +import org.opensearch.common.settings.Settings; +import org.opensearch.common.util.concurrent.ThreadContext; +import org.opensearch.core.common.Strings; +import org.opensearch.core.common.transport.TransportAddress; +import org.opensearch.security.privileges.ActionPrivileges; +import org.opensearch.security.privileges.DocumentAllowList; +import org.opensearch.security.privileges.IndicesRequestModifier; +import org.opensearch.security.privileges.IndicesRequestResolver; +import org.opensearch.security.privileges.PrivilegesEvaluationContext; +import org.opensearch.security.privileges.PrivilegesEvaluatorResponse; +import org.opensearch.security.privileges.PrivilegesInterceptor; +import org.opensearch.security.privileges.RoleMapper; +import org.opensearch.security.privileges.actionlevel.RoleBasedActionPrivileges; +import org.opensearch.security.privileges.actionlevel.RuntimeOptimizedActionPrivileges; +import org.opensearch.security.privileges.actionlevel.SubjectBasedActionPrivileges; +import org.opensearch.security.securityconf.FlattenedActionGroups; +import org.opensearch.security.securityconf.impl.SecurityDynamicConfiguration; +import org.opensearch.security.securityconf.impl.v7.ConfigV7; +import org.opensearch.security.securityconf.impl.v7.RoleV7; +import org.opensearch.security.support.ConfigConstants; +import org.opensearch.security.support.SnapshotRestoreHelper; +import org.opensearch.security.user.User; +import org.opensearch.tasks.Task; +import org.opensearch.threadpool.ThreadPool; + +/** + * A next generation implementation of PrivilegesEvaluator with the following properties: + *

      + *
    • By default, it tries to reduce the requested indices in IndicesRequests to the set of allowed indices (formerly known as do_not_fail_on_forbidden). + * This is done for requests with ignore_unavailable=true or requests using wildcards or patterns.
    • + *
    • For complex actions, are more fine-grained permission model is employed by using the sub-actions property of + * the ResolvedIndices class. For example, if an IndicesAliasesRequest contained a single item for + * deleting an index, the old privileges evaluator would require an indices:admin/delete privilege for + * all requested indices. The new implementation only requires privileges for the indices that are + * actually going to be deleted.
    • + *
    • No longer breaks apart search operations on aliases into member indices. Thus, for a search on an alias, + * you always need to have the privileges for all member indices. This preserves certain alias semantics, + * such as filtered aliases. The old implementation would just drop the filter semantics when the + * requested indices were reduced.
    • + *
    • Integrates the former SystemIndexAccessEvaluator and ProtectedIndexAccessEvaluator completely into + * the ActionPrivileges evaluation. This allows us to fully support the reduction of requested indices if such + * indices are requested.
    • + *
    • The direct support of index reduction also makes the former TermsAggregationEvaluator redundant.
    • + *
    • Adding an index to an alias now additionally requires privileges on the name of the alias.
    • + *
    • A number of config options is no longer supported in order to simplify the code and the configuration + * complexity (relevant for both UX and robustness reasons). The discontinued config options are: + *
        + *
      • "config.dynamic.filtered_alias_mode": Filtered alias checks are no longer performed because they served no actual purpose. See https://github.com/opensearch-project/security/issues/5599
      • + *
      • "config.dynamic.do_not_fail_on_forbidden": Reduction of indices is always performed when possible
      • + *
      • "config.dynamic.do_not_fail_on_forbidden_empty": Reduction to empty requests is always performed when possible.
      • + *
      • "config.dynamic.respect_request_indices_options": By using the ActionRequestMetadata, this is no longer necessary.
      • + *
      • "plugins.security.check_snapshot_restore_write_privileges": The write privileges are always checked for restoring snapshots.
      • + *
      • "plugins.security.enable_snapshot_restore_privilege": Normal users can use the restore API. If you want to forbid normal users to use this API, you can use "plugins.security.privileges_evaluation.actions.universally_denied_actions" instead.
      • + *
      • "plugins.security.unsupported.restore.securityindex.enabled": This is always disabled for normal users.
      • + *
      • "plugins.security.filter_securityindex_from_all_requests": The filtering of the security index has been integrated in the normal index filtering and is thus always available.
      • + *
      • "plugins.security.system_indices.enabled": System index handling is always enabled.
      • + *
      • "plugins.security.system_indices.permission.enabled": The ability to use the explicit system index permission is always enabled.
      • + *
      + *
    • + *
    • A few new config options have been introduced to allow some control over the behavior, mostly for emergency or + * mitigation purposes: + *
        + *
      • "plugins.security.privileges_evaluation.actions.force_as_cluster_actions": Allows to treat actions that are usually considered index privileges, explicitly as cluster privileges instead.
      • + *
      • "plugins.security.privileges_evaluation.actions.universally_denied_actions": Denies all requests of normal users for these actions. Only super admins can use these actions.
      • + *
      • "plugins.security.privileges_evaluation.actions.map_action_names": Allows remapping of action names to privilege names.
      • + *
      + *
    • + *
    + */ +public class PrivilegesEvaluator implements org.opensearch.security.privileges.PrivilegesEvaluator { + private static final Logger log = LogManager.getLogger(PrivilegesEvaluator.class); + + private final Supplier clusterStateSupplier; + private final IndexNameExpressionResolver indexNameExpressionResolver; + private final ThreadContext threadContext; + private final PrivilegesInterceptor privilegesInterceptor; + private final Settings settings; + private final AtomicReference actionPrivileges = new AtomicReference<>(); + private final ImmutableMap pluginIdToActionPrivileges; + private final IndicesRequestResolver indicesRequestResolver; + private final IndicesRequestModifier indicesRequestModifier = new IndicesRequestModifier(); + private final RoleMapper roleMapper; + private final ThreadPool threadPool; + private final RuntimeOptimizedActionPrivileges.SpecialIndexProtection specialIndexProtection; + private final ActionConfiguration actionConfiguration; + + public PrivilegesEvaluator( + Supplier clusterStateSupplier, + RoleMapper roleMapper, + ThreadPool threadPool, + ThreadContext threadContext, + IndexNameExpressionResolver indexNameExpressionResolver, + Settings settings, + PrivilegesInterceptor privilegesInterceptor, + FlattenedActionGroups actionGroups, + FlattenedActionGroups staticActionGroups, + SecurityDynamicConfiguration rolesConfiguration, + ConfigV7 generalConfiguration, + Map pluginIdToRolePrivileges, + RuntimeOptimizedActionPrivileges.SpecialIndexProtection specialIndexProtection + ) { + this.indexNameExpressionResolver = indexNameExpressionResolver; + this.roleMapper = roleMapper; + this.threadContext = threadContext; + this.threadPool = threadPool; + this.privilegesInterceptor = privilegesInterceptor; + this.clusterStateSupplier = clusterStateSupplier; + this.settings = settings; + this.specialIndexProtection = specialIndexProtection; + + this.actionConfiguration = new ActionConfiguration(settings); + this.indicesRequestResolver = new IndicesRequestResolver(indexNameExpressionResolver); + + this.pluginIdToActionPrivileges = SubjectBasedActionPrivileges.buildFromMap( + pluginIdToRolePrivileges, + staticActionGroups, + specialIndexProtection + ); + this.updateConfiguration(actionGroups, rolesConfiguration, generalConfiguration); + } + + @Override + public void updateConfiguration( + FlattenedActionGroups flattenedActionGroups, + SecurityDynamicConfiguration rolesConfiguration, + ConfigV7 generalConfiguration + ) { + + try { + RoleBasedActionPrivileges actionPrivileges = new RoleBasedActionPrivileges( + rolesConfiguration, + flattenedActionGroups, + this.specialIndexProtection, + this.settings, + false + ); + Metadata metadata = clusterStateSupplier.get().metadata(); + actionPrivileges.updateStatefulIndexPrivileges(metadata.getIndicesLookup(), metadata.version()); + RoleBasedActionPrivileges oldInstance = this.actionPrivileges.getAndSet(actionPrivileges); + + if (oldInstance != null) { + oldInstance.clusterStateMetadataDependentPrivileges().shutdown(); + } + } catch (Exception e) { + log.error("Error while updating ActionPrivileges", e); + } + } + + @Override + public boolean isInitialized() { + return true; + } + + @Override + public PrivilegesEvaluationContext createContext(User user, String action) { + return createContext(user, action, null, ActionRequestMetadata.empty(), null); + } + + @Override + public PrivilegesEvaluationContext createContext( + User user, + String action, + ActionRequest request, + ActionRequestMetadata actionRequestMetadata, + Task task + ) { + TransportAddress caller = threadContext.getTransient(ConfigConstants.OPENDISTRO_SECURITY_REMOTE_ADDRESS); + + ActionPrivileges actionPrivileges; + ImmutableSet mappedRoles; + + if (user.isPluginUser()) { + mappedRoles = ImmutableSet.of(); + actionPrivileges = this.pluginIdToActionPrivileges.getOrDefault(user.getName(), ActionPrivileges.EMPTY); + } else { + mappedRoles = this.roleMapper.map(user, caller); + actionPrivileges = this.actionPrivileges.get(); + } + + return new PrivilegesEvaluationContext( + user, + mappedRoles, + action, + request, + actionRequestMetadata, + task, + indexNameExpressionResolver, + indicesRequestResolver, + clusterStateSupplier, + actionPrivileges + ); + } + + @Override + public PrivilegesEvaluatorResponse evaluate(PrivilegesEvaluationContext context) { + String action = this.actionConfiguration.normalize(context.getAction()); + User user = context.getUser(); + ActionRequest request = context.getRequest(); + + if (request instanceof PitSegmentsRequest pitSegmentsRequest && isAllPitsRequest(pitSegmentsRequest)) { + // We treat this as a separate cluster action. This is because there is no way to reduce the requested + // indices in an _all pits request. + action = "cluster:monitor/point_in_time/segments/_all"; + } + + if (this.actionConfiguration.isUniversallyDenied(action)) { + return PrivilegesEvaluatorResponse.insufficient(action).reason("The action is universally denied"); + } + + ActionPrivileges actionPrivileges = context.getActionPrivileges(); + if (actionPrivileges == null) { + throw new OpenSearchSecurityException("OpenSearch Security is not initialized: roles configuration is missing"); + } + + if (request instanceof BulkRequest && Strings.isNullOrEmpty(user.getRequestedTenant())) { + // Shortcut for bulk actions. The details are checked on the lower level of the BulkShardRequests (Action + // indices:data/write/bulk[s]). + // This shortcut is only possible if the default tenant is selected, as we might need to rewrite the request for non-default + // tenants. + // No further access check for the default tenant is necessary, as access will be also checked on the TransportShardBulkAction + // level. + + PrivilegesEvaluatorResponse result = actionPrivileges.hasClusterPrivilege(context, action); + logPrivilegeEvaluationResult(context, result, "cluster"); + return result; + } + + if (isClusterPermission(action)) { + PrivilegesEvaluatorResponse result = checkClusterPermission(context, action, request); + logPrivilegeEvaluationResult(context, result, "cluster"); + return result; + } else { + PrivilegesEvaluatorResponse result = checkIndexPermission(context, action, request); + logPrivilegeEvaluationResult(context, result, "index"); + return result; + } + } + + PrivilegesEvaluatorResponse checkClusterPermission(PrivilegesEvaluationContext context, String action, ActionRequest request) { + if (context.getUser().isServiceAccount()) { + return PrivilegesEvaluatorResponse.insufficient(action) + .reason("User is a service account which does not have access to any cluster action"); + } + + PrivilegesEvaluatorResponse presponse = context.getActionPrivileges().hasClusterPrivilege(context, action); + if (!presponse.isAllowed()) { + return presponse; + } + + PrivilegesInterceptor.ReplaceResult replaceResult = privilegesInterceptor.replaceDashboardsIndex( + request, + action, + context.getUser(), + context + ); + + log.trace("Result from privileges interceptor for cluster perm: {}", replaceResult); + + if (!replaceResult.continueEvaluation) { + if (replaceResult.accessDenied) { + return PrivilegesEvaluatorResponse.insufficient(action).reason("Insufficient tenant privileges"); + } else { + return PrivilegesEvaluatorResponse.ok(replaceResult.createIndexRequestBuilder); + } + } + + if (request instanceof RestoreSnapshotRequest restoreSnapshotRequest) { + return handleRestoreSnapshot(context, restoreSnapshotRequest); + } + + return presponse; + } + + PrivilegesEvaluatorResponse checkIndexPermission(PrivilegesEvaluationContext context, String action, ActionRequest request) { + if (DocumentAllowList.isAllowed(request, threadContext)) { + return PrivilegesEvaluatorResponse.ok(); + } + + PrivilegesInterceptor.ReplaceResult replaceResult = privilegesInterceptor.replaceDashboardsIndex( + request, + action, + context.getUser(), + context + ); + + log.trace("Result from privileges interceptor: {}", replaceResult); + + if (!replaceResult.continueEvaluation) { + if (replaceResult.accessDenied) { + return PrivilegesEvaluatorResponse.insufficient(action).reason("Insufficient tenant privileges"); + } else { + return PrivilegesEvaluatorResponse.ok(replaceResult.createIndexRequestBuilder); + } + } + + OptionallyResolvedIndices optionallyResolvedIndices = context.getResolvedRequest(); + + if (request instanceof GetAliasesRequest getAliasesRequest + && optionallyResolvedIndices instanceof ResolvedIndices resolvedIndices) { + // The GetAliasesAction is such a special thing that we need a special case for it + return handleGetAliases(context, getAliasesRequest, resolvedIndices); + } + + return checkIndexPermissionBasic(context, requiredIndexPermissions(request, action), optionallyResolvedIndices, request); + } + + /** + * Checks whether the user has the necessary privileges for the given requiredIndexPermissions set and the given + * resolvedIndices. Reduces the requested indices to authorized indices if possible. This method contains the + * generic part of the privilege evaluation check; all special cases like Dashboards index handing and similar are + * in the checkIndexPermission() method. + */ + PrivilegesEvaluatorResponse checkIndexPermissionBasic( + PrivilegesEvaluationContext context, + Set requiredIndexPermissions, + OptionallyResolvedIndices optionallyResolvedIndices, + ActionRequest request + ) { + + ActionPrivileges actionPrivileges = context.getActionPrivileges(); + + if (optionallyResolvedIndices instanceof ResolvedIndices resolvedIndices && resolvedIndices.isEmpty()) { + // If the request is empty, the normal privilege checks would just pass because technically the question + // "are all indices authorized" is true if the set of indices is empty. This means that certain operations + // would be available to any users regardless of their privileges. Thus, we check first whether the user + // has *any* privilege for the given action. + // The main example for such actions is the _analyze action which can operate on indices, but also can + // operate on an empty set of indices. Without this check, it would be always allowed. + PrivilegesEvaluatorResponse anyPrivilegesResult = actionPrivileges.hasIndexPrivilegeForAnyIndex( + context, + requiredIndexPermissions + ); + if (!anyPrivilegesResult.isAllowed()) { + return anyPrivilegesResult; + } + } + + PrivilegesEvaluatorResponse presponse = actionPrivileges.hasIndexPrivilege( + context, + requiredIndexPermissions, + optionallyResolvedIndices + ); + + if (optionallyResolvedIndices instanceof ResolvedIndices resolvedIndices && !resolvedIndices.local().subActions().isEmpty()) { + // Sub-actions represent situations like a CreateIndexRequest which is configured to add the index also to an alias + // In these cases, we check also privileges for sub-actions. Sub-actions are not eligible for index reduction, + // i.e., they can be only successful or fail. + presponse = checkSubActionPermissions(context, resolvedIndices, presponse); + } + + if (presponse.isPartiallyOk()) { + // If the user has privileges only for a sub-set of indices, we try to scope the request only to these indices if the conditions + // allow. + // These are: + // - The action supports it + // - The index expression contains a pattern expression or ignore_unavailable is true + + if (isIndexReductionForIncompletePrivilegesPossible(request) + && optionallyResolvedIndices instanceof ResolvedIndices resolvedIndices) { + if (this.indicesRequestModifier.setLocalIndices(request, resolvedIndices, presponse.getAvailableIndices())) { + return PrivilegesEvaluatorResponse.ok().reason("Only allowed for a sub-set of indices").originalResult(presponse); + } + } + } else if (!presponse.isAllowed()) { + + if (isIndexReductionForIncompletePrivilegesPossible(request) + && optionallyResolvedIndices instanceof ResolvedIndices resolvedIndices + && !resolvedIndices.remote().isEmpty()) { + // If remote indices are requested, we reduce to these and let the request pass + if (this.indicesRequestModifier.setLocalIndicesToEmpty(request, resolvedIndices)) { + return PrivilegesEvaluatorResponse.ok().reason("Only allowed for remote indices").originalResult(presponse); + } + } else if (isIndexReductionForNoPrivilegesPossible(request) + && optionallyResolvedIndices instanceof ResolvedIndices resolvedIndices) { + // If the user has no privileges, there are certain conditions where we return an empty result instead of a 403 error + // These are: + // - The action supports it + // - The index expression contains a pattern expression or ignore_unavailable is true + // - The user has privileges for the given actions on some indices + + PrivilegesEvaluatorResponse allowedForAnyIndex = actionPrivileges.hasIndexPrivilegeForAnyIndex( + context, + requiredIndexPermissions + ); + + if (allowedForAnyIndex.isAllowed() && this.indicesRequestModifier.setLocalIndicesToEmpty(request, resolvedIndices)) { + return PrivilegesEvaluatorResponse.ok() + .reason("Not allowed for any indices; returning empty result") + .originalResult(presponse); + } + } + } + + return presponse; + } + + @Override + public boolean isClusterPermission(String action) { + return this.actionConfiguration.isClusterPermission(action); + } + + @Override + public void updateClusterStateMetadata(ClusterService clusterService) { + RoleBasedActionPrivileges actionPrivileges = this.actionPrivileges.get(); + if (actionPrivileges != null) { + actionPrivileges.clusterStateMetadataDependentPrivileges().updateClusterStateMetadataAsync(clusterService, threadPool); + } + } + + @Override + public void shutdown() { + RoleBasedActionPrivileges roleBasedActionPrivileges = this.actionPrivileges.get(); + if (roleBasedActionPrivileges != null) { + roleBasedActionPrivileges.clusterStateMetadataDependentPrivileges().shutdown(); + } + } + + @Override + public boolean notFailOnForbiddenEnabled() { + return true; + } + + void logPrivilegeEvaluationResult(PrivilegesEvaluationContext context, PrivilegesEvaluatorResponse result, String privilegeType) { + if (result.isAllowed()) { + if (log.isDebugEnabled()) { + String reason = result.getReason(); + if (result.hasEvaluationExceptions()) { + reason = "There were errors during privilege evaluation"; + } + String requestInfo = getRequestInfo(context.getRequest()); + + if (reason == null) { + log.debug(""" + Allowing {} action because all privileges are present. + Action: {} + Request: {} + Resolved indices: {} + User: {} + """, privilegeType, context.getAction(), requestInfo, context.getResolvedRequest(), context.getUser()); + } else if (result.privilegesAreComplete()) { + log.debug( + """ + Allowing {} action, but: {} + Action: {} + Request: {} + Resolved indices: {} + User: {} + Roles: {} + Errors: {} + """, + privilegeType, + reason, + context.getAction(), + requestInfo, + context.getResolvedRequest(), + context.getUser(), + context.getMappedRoles(), + result.getEvaluationExceptionInfo() + ); + } else { + log.debug( + """ + Allowing {} action, but: {} + Action: {} + Request: {} + Resolved indices: {} + User: {} + Roles: {} + Available privileges: + {} + Errors: {} + """, + privilegeType, + reason, + context.getAction(), + requestInfo, + context.getResolvedRequest(), + context.getUser(), + context.getMappedRoles(), + result.originalResult() != null ? result.originalResult().getPrivilegeMatrix() : result.getPrivilegeMatrix(), + result.getEvaluationExceptionInfo() + ); + } + } + } else { + log.info( + """ + Not allowing {} action: {} + Action: {} + Request: {} + Resolved indices: {} + User: {} + Roles: {} + Available privileges: + {} + Errors: {} + """, + privilegeType, + result.getReason(), + context.getAction(), + getRequestInfo(context.getRequest()), + context.getResolvedRequest(), + context.getUser(), + context.getMappedRoles(), + result.originalResult() != null ? result.originalResult().getPrivilegeMatrix() : result.getPrivilegeMatrix(), + result.getEvaluationExceptionInfo() + ); + } + } + + String getRequestInfo(ActionRequest request) { + StringBuilder result = new StringBuilder(request.getClass().getSimpleName()); + if (request instanceof IndicesRequest indicesRequest) { + String[] indices = indicesRequest.indices(); + result.append("; indices: ").append(indices != null ? Arrays.asList(indices) : "null"); + result.append("; indicesOptions: ").append(indicesRequest.indicesOptions()); + } + if (request instanceof AliasesRequest aliasesRequest) { + String[] aliases = aliasesRequest.aliases(); + result.append("; aliases: ").append(aliases != null ? Arrays.asList(aliases) : "null"); + } + return result.toString(); + } + + /** + * The GetAliasesRequest has such a complicated logic that we need to handle it with a special case. It has two dimensions: + * indices and aliases which can be independently specified; indices can be reduced, but reducing aliases is not really + * possible due to a special logic which exists in the RestGetAliasesAction (an unusual location for such logic): + * https://github.com/opensearch-project/OpenSearch/blob/1df543e04d7605b7ee37587ff5c635609ebdafbd/server/src/main/java/org/opensearch/rest/action/admin/indices/RestGetAliasesAction.java#L94 + * Another effect of this logic is that if there are explicitly specified aliases in the request which are not matched + * by any indices, the action fails with a 404 error. + * In order to avoid these 404 errors, we fail with an "insufficient" error whenever there are explicit aliases + * and there are no sufficient privileges for these aliases. + * If there are no explicit aliases, we can do index reduction, though. + */ + PrivilegesEvaluatorResponse handleGetAliases( + PrivilegesEvaluationContext context, + GetAliasesRequest request, + ResolvedIndices resolvedIndices + ) { + ActionPrivileges actionPrivileges = context.getActionPrivileges(); + String aliasesSubActionKey = GetAliasesAction.NAME + "[aliases]"; + Set indices = resolvedIndices.local().names(); + Set aliases = resolvedIndices.local().subActions().containsKey(aliasesSubActionKey) + ? resolvedIndices.local().subActions().get(aliasesSubActionKey).names() + : Collections.emptySet(); + + PrivilegesEvaluatorResponse indicesResult = actionPrivileges.hasIndexPrivilege( + context, + Set.of(context.getAction()), + ResolvedIndices.of(indices) + ); + PrivilegesEvaluatorResponse aliasesResult = actionPrivileges.hasIndexPrivilege( + context, + Set.of(context.getAction()), + ResolvedIndices.of(aliases) + ); + + if (!aliasesResult.isAllowed() && request.aliases().length != 0) { + // The RestGetAliasesAction does not allow reducing aliases (Even though the GetAliasesRequest has a method for + // setting aliases retroactively). Thus, if explicit aliases were specified, we will always fail with an + // "insufficient" error. + return indicesResult.insufficient(List.of(aliasesResult)) + .reason("No privileges for aliases while explicit aliases were specified in the request"); + } + + if (!indicesResult.isAllowed() && indicesResult.getAvailableIndices().isEmpty()) { + // If the user does not have privileges for any index, we deny the request here completely + PrivilegesEvaluatorResponse anyPrivilegesResult = actionPrivileges.hasIndexPrivilegeForAnyIndex( + context, + Set.of(context.getAction()) + ); + if (anyPrivilegesResult != null && !anyPrivilegesResult.isAllowed()) { + return indicesResult; + } + } + + if (!indicesResult.isAllowed()) { + // If we reached this block, the user has privileges for a sub-set of indices or at least for other indices. + // Then, we will return either a reduced or empty result + if (this.indicesRequestModifier.setLocalIndices(request, resolvedIndices, indicesResult.getAvailableIndices())) { + return PrivilegesEvaluatorResponse.ok().originalResult(indicesResult).reason("Only allowed for a subset of indices"); + } + } + + return indicesResult; + } + + /** + * Special handling for RestoreSnapshotRequests. This includes especially the check of the privileges for the restored + * indices. The check will be performed using the standard checkIndexPermission() method; thus, the standard restrictions + * on the security index and system indices apply (incl. system index permission handling). + */ + PrivilegesEvaluatorResponse handleRestoreSnapshot(PrivilegesEvaluationContext context, RestoreSnapshotRequest request) { + if (request.includeGlobalState()) { + return PrivilegesEvaluatorResponse.insufficient(context.getAction()) + .reason("Restoring snapshot with 'include_global_state' enabled is not allowed"); + } + + if (!clusterStateSupplier.get().nodes().isLocalNodeElectedClusterManager()) { + // We need to return ok here, because we can only retrieve the snapshot info on a cluster manager node. + // This is fine, as the next thing the TransportAction implementation will do, is forwarding the request to a + // cluster manager node. + return PrivilegesEvaluatorResponse.ok(); + } + + OptionallyResolvedIndices optionallyResolvedIndices = SnapshotRestoreHelper.resolveTargetIndices(request); + if (!(optionallyResolvedIndices instanceof ResolvedIndices resolvedIndices)) { + return PrivilegesEvaluatorResponse.insufficient(context.getAction()) + .reason("Could not retrieve information for snapshot " + request.repository() + "/" + request.snapshot()); + } + + return checkIndexPermissionBasic( + context, + ConfigConstants.SECURITY_SNAPSHOT_RESTORE_NEEDED_WRITE_PRIVILEGES, + resolvedIndices, + request + ); + } + + /** + * Checks the permissions for the sub-actions given in the ResolvedIndices object. Sub-actions describe complex + * action requests, which might do different things with different indices. One example is the IndicesAliasesRequest + * which can also just delete indices; in this case, the index to be deleted is contained in the sub-action + * with the key "indices:admin/delete". + *

    + * This will return the value given as the originalResult parameter if all sub-action privileges are present. If a + * privilege is missing, this returns an insufficient PrivilegesEvaluatorResponse. + *

    + * Reduction of requested indices is not possible for sub-actions, thus this only return "ok" or "insufficient", + * but never "partially sufficient". + */ + PrivilegesEvaluatorResponse checkSubActionPermissions( + PrivilegesEvaluationContext context, + ResolvedIndices resolvedIndices, + PrivilegesEvaluatorResponse originalResult + ) { + ActionPrivileges actionPrivileges = context.getActionPrivileges(); + List subActionResults = new ArrayList<>(resolvedIndices.local().subActions().size()); + boolean allowed = true; + + for (Map.Entry subAction : resolvedIndices.local().subActions().entrySet()) { + PrivilegesEvaluatorResponse subResponse = actionPrivileges.hasIndexPrivilege( + context, + Set.of(subAction.getKey()), + ResolvedIndices.of(subAction.getValue()) + ); + subActionResults.add(subResponse); + if (!subResponse.isAllowed()) { + allowed = false; + } + } + if (allowed) { + return originalResult; + } else { + return originalResult.insufficient(subActionResults); + } + } + + /** + * This returns the set of required privileges for a particular action. This is usually just the set containing + * exactly the given action name. There are some exceptions where more than one action privilege is required. + * See the implementation for these cases. + */ + Set requiredIndexPermissions(ActionRequest request, String originalAction) { + if (request instanceof ClusterSearchShardsRequest) { + return Set.of(originalAction, SearchAction.NAME); + } else if (request instanceof BulkShardRequest bulkShardRequest) { + ImmutableSet.Builder allRequiredPermissions = ImmutableSet.builderWithExpectedSize(2); + allRequiredPermissions.add(originalAction); + for (BulkItemRequest item : bulkShardRequest.items()) { + switch (item.request().opType()) { + case CREATE: + case INDEX: + allRequiredPermissions.add(IndexAction.NAME); + break; + case DELETE: + allRequiredPermissions.add(DeleteAction.NAME); + break; + case UPDATE: + allRequiredPermissions.add(UpdateAction.NAME); + break; + } + } + return allRequiredPermissions.build(); + } else { + return Set.of(originalAction); + } + } + + /** + * Returns true if it is possible to reduce the requested indices in the given request to allow its execution + * given the user's available privileges. + *

    + * This is the case when: + *

      + *
    • The request implements IndicesRequest.Replaceable
    • + *
    • AND, the ignore_unavailable index option has been specified or the request contains patterns (like "index_a*")
    • + *
    + */ + boolean isIndexReductionForIncompletePrivilegesPossible(ActionRequest request) { + if (!(request instanceof IndicesRequest.Replaceable indicesRequest)) { + return false; + } + + if (request instanceof PitSegmentsRequest) { + // PitSegmentsRequest implements IndicesRequest.Replaceable, but ignores all specified indices + return false; + } + + if (indicesRequest.indicesOptions().ignoreUnavailable()) { + return true; + } + + return indicesRequest.indicesOptions().expandWildcardsOpen() && containsPattern(indicesRequest); + } + + /** + * Returns true if it is possible to reduce the requested indices in the given request to NONE to allow its + * execution. The execution should just return an empty response then. + *

    + * This is the case when the conditions for isIndexReductionForIncompletePrivilegesPossible() hold and the index + * option allow_no_indices has been specified. + *

    + * Additionally, there might be exceptions for actions which just do not support an empty set of indices. + */ + boolean isIndexReductionForNoPrivilegesPossible(ActionRequest request) { + if (!isIndexReductionForIncompletePrivilegesPossible(request)) { + return false; + } + + if (request instanceof CreatePitRequest) { + // The creation of PIT search contexts is not possible for no indices + return false; + } + + return ((IndicesRequest) request).indicesOptions().allowNoIndices(); + } + + /** + * Returns if the given IndicesRequest contains a wildcard, index pattern or refers to all indices via "_all" or + * an empty index expression. + */ + boolean containsPattern(IndicesRequest indicesRequest) { + String[] indices = indicesRequest.indices(); + + if (indices == null + || indices.length == 0 + || (indices.length == 1 && (Metadata.ALL.equals(indices[0]) || Regex.isMatchAllPattern(indices[0])))) { + return true; + } + + for (String index : indices) { + if (Regex.isSimpleMatchPattern(index)) { + return true; + } + } + + return false; + } + + private boolean isAllPitsRequest(PitSegmentsRequest request) { + return request.getPitIds().size() == 1 && "_all".equals(request.getPitIds().get(0)); + } +} diff --git a/src/main/java/org/opensearch/security/privileges/dlsfls/AbstractRuleBasedPrivileges.java b/src/main/java/org/opensearch/security/privileges/dlsfls/AbstractRuleBasedPrivileges.java index f7ba4442c8..0ab44e4b55 100644 --- a/src/main/java/org/opensearch/security/privileges/dlsfls/AbstractRuleBasedPrivileges.java +++ b/src/main/java/org/opensearch/security/privileges/dlsfls/AbstractRuleBasedPrivileges.java @@ -25,13 +25,14 @@ import org.apache.logging.log4j.Logger; import org.opensearch.cluster.metadata.IndexAbstraction; +import org.opensearch.cluster.metadata.OptionallyResolvedIndices; +import org.opensearch.cluster.metadata.ResolvedIndices; import org.opensearch.common.settings.Settings; import org.opensearch.security.privileges.IndexPattern; import org.opensearch.security.privileges.PrivilegesConfigurationValidationException; import org.opensearch.security.privileges.PrivilegesEvaluationContext; import org.opensearch.security.privileges.PrivilegesEvaluationException; import org.opensearch.security.privileges.actionlevel.RoleBasedActionPrivileges; -import org.opensearch.security.resolver.IndexResolverReplacer; import org.opensearch.security.securityconf.impl.SecurityDynamicConfiguration; import org.opensearch.security.securityconf.impl.v7.RoleV7; import org.opensearch.security.support.ConfigConstants; @@ -131,7 +132,7 @@ public boolean isUniversallyUnrestricted(PrivilegesEvaluationContext context) { * @throws PrivilegesEvaluationException If something went wrong during privileges evaluation. In such cases, any * access should be denied to make sure that no unauthorized information is exposed. */ - public boolean isUnrestricted(PrivilegesEvaluationContext context, IndexResolverReplacer.Resolved resolved) + public boolean isUnrestricted(PrivilegesEvaluationContext context, OptionallyResolvedIndices optionallyResolvedIndices) throws PrivilegesEvaluationException { if (context.getMappedRoles().isEmpty()) { return false; @@ -142,11 +143,12 @@ public boolean isUnrestricted(PrivilegesEvaluationContext context, IndexResolver return true; } - if (resolved == null) { + if (this.hasRestrictedRulesWithIndexWildcard(context)) { return false; } - if (this.hasRestrictedRulesWithIndexWildcard(context)) { + if (!(optionallyResolvedIndices instanceof ResolvedIndices resolved)) { + // If we do not have resolved indices information, we can assume a restriction return false; } @@ -156,7 +158,7 @@ public boolean isUnrestricted(PrivilegesEvaluationContext context, IndexResolver // If we found an unrestricted role, we continue with the next index/alias/data stream. If we found a restricted role, we abort // early and return true. - for (String index : resolved.getAllIndicesResolved(context.getClusterStateSupplier(), context.getIndexNameExpressionResolver())) { + for (String index : resolved.local().names(context.clusterState())) { if (this.dfmEmptyOverridesAll) { // We assume that we have a restriction unless there are roles without restriction. // Thus, we only have to check the roles without restriction. @@ -617,7 +619,7 @@ static class StaticRules { } } else { SingleRule singleRule = this.roleToRule(rolePermissions); - IndexPattern indexPattern = IndexPattern.from(rolePermissions.getIndex_patterns()); + IndexPattern indexPattern = IndexPattern.from(rolePermissions.getIndex_patterns(), false); if (indexPattern.hasStaticPattern()) { if (singleRule == null) { @@ -781,7 +783,7 @@ static class StatefulRules { continue; } - WildcardMatcher indexMatcher = IndexPattern.from(indexPermissions.getIndex_patterns()).getStaticPattern(); + WildcardMatcher indexMatcher = IndexPattern.from(indexPermissions.getIndex_patterns(), false).getStaticPattern(); if (indexMatcher == WildcardMatcher.NONE) { // The pattern is likely blank because there are only dynamic patterns. @@ -840,5 +842,4 @@ static interface RoleToRuleFunction { static abstract class Rule { abstract boolean isUnrestricted(); } - } diff --git a/src/main/java/org/opensearch/security/privileges/dlsfls/DlsFlsBaseContext.java b/src/main/java/org/opensearch/security/privileges/dlsfls/DlsFlsBaseContext.java index 8a27fef255..682b0583b2 100644 --- a/src/main/java/org/opensearch/security/privileges/dlsfls/DlsFlsBaseContext.java +++ b/src/main/java/org/opensearch/security/privileges/dlsfls/DlsFlsBaseContext.java @@ -12,8 +12,8 @@ import org.opensearch.common.util.concurrent.ThreadContext; import org.opensearch.security.configuration.AdminDNs; +import org.opensearch.security.privileges.PrivilegesConfiguration; import org.opensearch.security.privileges.PrivilegesEvaluationContext; -import org.opensearch.security.privileges.PrivilegesEvaluator; import org.opensearch.security.support.ConfigConstants; import org.opensearch.security.support.HeaderHelper; import org.opensearch.security.user.User; @@ -22,12 +22,12 @@ * Node global context data for DLS/FLS. The lifecycle of an instance of this class is equal to the lifecycle of a running node. */ public class DlsFlsBaseContext { - private final PrivilegesEvaluator privilegesEvaluator; + private final PrivilegesConfiguration privilegesConfiguration; private final ThreadContext threadContext; private final AdminDNs adminDNs; - public DlsFlsBaseContext(PrivilegesEvaluator privilegesEvaluator, ThreadContext threadContext, AdminDNs adminDNs) { - this.privilegesEvaluator = privilegesEvaluator; + public DlsFlsBaseContext(PrivilegesConfiguration privilegesConfiguration, ThreadContext threadContext, AdminDNs adminDNs) { + this.privilegesConfiguration = privilegesConfiguration; this.threadContext = threadContext; this.adminDNs = adminDNs; } @@ -43,7 +43,7 @@ public PrivilegesEvaluationContext getPrivilegesEvaluationContext() { return null; } - return this.privilegesEvaluator.createContext(user, null); + return this.privilegesConfiguration.privilegesEvaluator().createContext(user, null); } public boolean isDlsDoneOnFilterLevel() { diff --git a/src/main/java/org/opensearch/security/resolver/IndexResolverReplacer.java b/src/main/java/org/opensearch/security/resolver/IndexResolverReplacer.java deleted file mode 100644 index 2239a0239a..0000000000 --- a/src/main/java/org/opensearch/security/resolver/IndexResolverReplacer.java +++ /dev/null @@ -1,863 +0,0 @@ -/* - * Copyright 2015-2018 _floragunn_ GmbH - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/* - * 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. - * - * Modifications Copyright OpenSearch Contributors. See - * GitHub history for details. - */ - -package org.opensearch.security.resolver; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; -import java.util.Collections; -import java.util.EnumSet; -import java.util.HashSet; -import java.util.Iterator; -import java.util.List; -import java.util.ListIterator; -import java.util.Map; -import java.util.Objects; -import java.util.Set; -import java.util.function.Supplier; -import java.util.regex.PatternSyntaxException; -import java.util.stream.Collectors; - -import com.google.common.collect.ImmutableSet; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -import org.opensearch.action.ActionRequest; -import org.opensearch.action.DocWriteRequest; -import org.opensearch.action.IndicesRequest; -import org.opensearch.action.IndicesRequest.Replaceable; -import org.opensearch.action.OriginalIndices; -import org.opensearch.action.admin.cluster.snapshots.restore.RestoreSnapshotRequest; -import org.opensearch.action.admin.indices.alias.IndicesAliasesRequest; -import org.opensearch.action.admin.indices.alias.IndicesAliasesRequest.AliasActions; -import org.opensearch.action.admin.indices.create.CreateIndexRequest; -import org.opensearch.action.admin.indices.datastream.CreateDataStreamAction; -import org.opensearch.action.admin.indices.mapping.put.PutMappingRequest; -import org.opensearch.action.admin.indices.resolve.ResolveIndexAction; -import org.opensearch.action.admin.indices.rollover.RolloverRequest; -import org.opensearch.action.admin.indices.shrink.ResizeRequest; -import org.opensearch.action.admin.indices.template.put.PutComponentTemplateAction; -import org.opensearch.action.bulk.BulkRequest; -import org.opensearch.action.bulk.BulkShardRequest; -import org.opensearch.action.delete.DeleteRequest; -import org.opensearch.action.fieldcaps.FieldCapabilitiesIndexRequest; -import org.opensearch.action.fieldcaps.FieldCapabilitiesRequest; -import org.opensearch.action.get.MultiGetRequest; -import org.opensearch.action.get.MultiGetRequest.Item; -import org.opensearch.action.index.IndexRequest; -import org.opensearch.action.main.MainRequest; -import org.opensearch.action.search.ClearScrollRequest; -import org.opensearch.action.search.MultiSearchRequest; -import org.opensearch.action.search.SearchRequest; -import org.opensearch.action.search.SearchScrollRequest; -import org.opensearch.action.support.IndicesOptions; -import org.opensearch.action.support.nodes.BaseNodesRequest; -import org.opensearch.action.support.replication.ReplicationRequest; -import org.opensearch.action.support.single.shard.SingleShardRequest; -import org.opensearch.action.termvectors.MultiTermVectorsRequest; -import org.opensearch.action.termvectors.TermVectorsRequest; -import org.opensearch.action.update.UpdateRequest; -import org.opensearch.cluster.ClusterState; -import org.opensearch.cluster.metadata.IndexAbstraction; -import org.opensearch.cluster.metadata.IndexNameExpressionResolver; -import org.opensearch.cluster.service.ClusterService; -import org.opensearch.common.util.IndexUtils; -import org.opensearch.core.index.Index; -import org.opensearch.index.IndexNotFoundException; -import org.opensearch.index.reindex.ReindexRequest; -import org.opensearch.security.OpenSearchSecurityPlugin; -import org.opensearch.security.configuration.ClusterInfoHolder; -import org.opensearch.security.securityconf.DynamicConfigModel; -import org.opensearch.security.support.SnapshotRestoreHelper; -import org.opensearch.security.support.WildcardMatcher; -import org.opensearch.snapshots.SnapshotInfo; -import org.opensearch.transport.RemoteClusterService; -import org.opensearch.transport.TransportRequest; - -import org.greenrobot.eventbus.Subscribe; - -import static org.opensearch.cluster.metadata.IndexAbstraction.Type.ALIAS; - -public class IndexResolverReplacer { - - private static final Set NULL_SET = new HashSet<>(Collections.singleton(null)); - private final Logger log = LogManager.getLogger(this.getClass()); - private final IndexNameExpressionResolver resolver; - private final Supplier clusterStateSupplier; - private final ClusterInfoHolder clusterInfoHolder; - private volatile boolean respectRequestIndicesOptions = false; - - public IndexResolverReplacer( - IndexNameExpressionResolver resolver, - Supplier clusterStateSupplier, - ClusterInfoHolder clusterInfoHolder - ) { - this.resolver = resolver; - this.clusterStateSupplier = clusterStateSupplier; - this.clusterInfoHolder = clusterInfoHolder; - } - - private static boolean isAllWithNoRemote(final String... requestedPatterns) { - - final List patterns = requestedPatterns == null ? null : Arrays.asList(requestedPatterns); - - if (IndexNameExpressionResolver.isAllIndices(patterns)) { - return true; - } - - if (patterns.size() == 1 && patterns.contains("*")) { - return true; - } - - if (new HashSet(patterns).equals(NULL_SET)) { - return true; - } - - return false; - } - - private static boolean isLocalAll(String... requestedPatterns) { - return isLocalAll(requestedPatterns == null ? null : Arrays.asList(requestedPatterns)); - } - - private static boolean isLocalAll(Collection patterns) { - if (IndexNameExpressionResolver.isAllIndices(patterns)) { - return true; - } - - if (patterns.contains("_all")) { - return true; - } - - if (new HashSet(patterns).equals(NULL_SET)) { - return true; - } - - return false; - } - - private class ResolvedIndicesProvider implements IndicesProvider { - private final ImmutableSet.Builder aliases; - private final ImmutableSet.Builder allIndices; - private final ImmutableSet.Builder originalRequested; - private final ImmutableSet.Builder remoteIndices; - // set of previously resolved index requests to avoid resolving - // the same index more than once while processing bulk requests - private final Set alreadyResolved; - private final String name; - - private final class AlreadyResolvedKey { - - private final IndicesOptions indicesOptions; - - private final boolean enableCrossClusterResolution; - - private final String[] original; - - private AlreadyResolvedKey(final IndicesOptions indicesOptions, final boolean enableCrossClusterResolution) { - this(indicesOptions, enableCrossClusterResolution, null); - } - - private AlreadyResolvedKey( - final IndicesOptions indicesOptions, - final boolean enableCrossClusterResolution, - final String[] original - ) { - this.indicesOptions = indicesOptions; - this.enableCrossClusterResolution = enableCrossClusterResolution; - this.original = original; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - AlreadyResolvedKey that = (AlreadyResolvedKey) o; - return enableCrossClusterResolution == that.enableCrossClusterResolution - && Objects.equals(indicesOptions, that.indicesOptions) - && Arrays.equals(original, that.original); - } - - @Override - public int hashCode() { - int result = Objects.hash(indicesOptions, enableCrossClusterResolution); - result = 31 * result + Arrays.hashCode(original); - return result; - } - } - - ResolvedIndicesProvider(Object request) { - aliases = ImmutableSet.builder(); - allIndices = ImmutableSet.builder(); - originalRequested = ImmutableSet.builder(); - remoteIndices = ImmutableSet.builder(); - alreadyResolved = new HashSet<>(); - name = request.getClass().getSimpleName(); - } - - private void resolveIndexPatterns( - final String name, - final IndicesOptions indicesOptions, - final boolean enableCrossClusterResolution, - final String[] original - ) { - final boolean isTraceEnabled = log.isTraceEnabled(); - if (isTraceEnabled) { - log.trace("resolve requestedPatterns: " + Arrays.toString(original)); - } - - if (isAllWithNoRemote(original)) { - if (isTraceEnabled) { - log.trace(Arrays.toString(original) + " is an ALL pattern without any remote indices"); - } - resolveToLocalAll(); - return; - } - - Set remoteIndices; - final List localRequestedPatterns = new ArrayList<>(Arrays.asList(original)); - - final RemoteClusterService remoteClusterService = OpenSearchSecurityPlugin.GuiceHolder.getRemoteClusterService(); - - if (remoteClusterService != null && remoteClusterService.isCrossClusterSearchEnabled() && enableCrossClusterResolution) { - remoteIndices = new HashSet<>(); - final Map remoteClusterIndices = OpenSearchSecurityPlugin.GuiceHolder.getRemoteClusterService() - .groupIndices(indicesOptions, original, idx -> resolver.hasIndexAbstraction(idx, clusterStateSupplier.get())); - final Set remoteClusters = remoteClusterIndices.keySet() - .stream() - .filter(k -> !RemoteClusterService.LOCAL_CLUSTER_GROUP_KEY.equals(k)) - .collect(Collectors.toSet()); - for (String remoteCluster : remoteClusters) { - for (String remoteIndex : remoteClusterIndices.get(remoteCluster).indices()) { - remoteIndices.add(RemoteClusterService.buildRemoteIndexName(remoteCluster, remoteIndex)); - } - } - - final Iterator iterator = localRequestedPatterns.iterator(); - while (iterator.hasNext()) { - final String[] split = iterator.next().split(String.valueOf(RemoteClusterService.REMOTE_CLUSTER_INDEX_SEPARATOR), 2); - final WildcardMatcher matcher = WildcardMatcher.from(split[0]); - if (split.length > 1 && matcher.matchAny(remoteClusters)) { - iterator.remove(); - } - } - - if (isTraceEnabled) { - log.trace( - "CCS is enabled, we found this local patterns " - + localRequestedPatterns - + " and this remote patterns: " - + remoteIndices - ); - } - - } else { - remoteIndices = Collections.emptySet(); - } - - final Collection matchingAliases; - Collection matchingAllIndices; - Collection matchingDataStreams = null; - - if (isLocalAll(original)) { - if (isTraceEnabled) { - log.trace(Arrays.toString(original) + " is an LOCAL ALL pattern"); - } - matchingAliases = Resolved.All_SET; - matchingAllIndices = Resolved.All_SET; - - } else if (!remoteIndices.isEmpty() && localRequestedPatterns.isEmpty()) { - if (isTraceEnabled) { - log.trace(Arrays.toString(original) + " is an LOCAL EMPTY request"); - } - matchingAllIndices = Collections.emptySet(); - matchingAliases = Collections.emptySet(); - } - - else { - final ClusterState state = clusterStateSupplier.get(); - final Set dateResolvedLocalRequestedPatterns = localRequestedPatterns.stream() - .map(resolver::resolveDateMathExpression) - .collect(Collectors.toSet()); - final WildcardMatcher dateResolvedMatcher = WildcardMatcher.from(dateResolvedLocalRequestedPatterns); - // fill matchingAliases - final Map lookup = state.metadata().getIndicesLookup(); - matchingAliases = lookup.entrySet() - .stream() - .filter(e -> e.getValue().getType() == ALIAS) - .map(Map.Entry::getKey) - .filter(dateResolvedMatcher) - .collect(Collectors.toSet()); - - final boolean isDebugEnabled = log.isDebugEnabled(); - try { - matchingAllIndices = Arrays.asList( - resolver.concreteIndexNames(state, indicesOptions, localRequestedPatterns.toArray(new String[0])) - ); - matchingDataStreams = resolver.dataStreamNames(state, indicesOptions, localRequestedPatterns.toArray(new String[0])); - - if (isDebugEnabled) { - log.debug( - "Resolved pattern {} to indices: {} and data-streams: {}", - localRequestedPatterns, - matchingAllIndices, - matchingDataStreams - ); - } - } catch (IndexNotFoundException e1) { - if (isDebugEnabled) { - log.debug("No such indices for pattern {}, use raw value", localRequestedPatterns); - } - - matchingAllIndices = dateResolvedLocalRequestedPatterns; - } - } - - if (matchingDataStreams == null || matchingDataStreams.size() == 0) { - matchingDataStreams = Arrays.asList(NOOP); - } - - if (isTraceEnabled) { - log.trace( - "Resolved patterns {} for {} ({}) to [aliases {}, allIndices {}, dataStreams {}, originalRequested{}, remote indices {}]", - original, - name, - this.name, - matchingAliases, - matchingAllIndices, - matchingDataStreams, - Arrays.toString(original), - remoteIndices - ); - } - - resolveTo(matchingAliases, matchingAllIndices, matchingDataStreams, original, remoteIndices); - } - - private void resolveToLocalAll() { - aliases.add(Resolved.ANY); - allIndices.add(Resolved.ANY); - originalRequested.add(Resolved.ANY); - } - - private void resolveTo( - Iterable matchingAliases, - Iterable matchingAllIndices, - Iterable matchingDataStreams, - String[] original, - Iterable remoteIndices - ) { - aliases.addAll(matchingAliases); - allIndices.addAll(matchingAllIndices); - allIndices.addAll(matchingDataStreams); - originalRequested.add(original); - this.remoteIndices.addAll(remoteIndices); - } - - @Override - public String[] provide(String[] original, Object localRequest, boolean supportsReplace) { - final IndicesOptions indicesOptions = indicesOptionsFrom(localRequest); - final boolean enableCrossClusterResolution = localRequest instanceof FieldCapabilitiesRequest - || localRequest instanceof SearchRequest - || localRequest instanceof ResolveIndexAction.Request; - // skip the whole thing if we have seen this exact resolveIndexPatterns request - final AlreadyResolvedKey alreadyResolvedKey; - if (original != null) { - alreadyResolvedKey = new AlreadyResolvedKey(indicesOptions, enableCrossClusterResolution, original); - } else { - alreadyResolvedKey = new AlreadyResolvedKey(indicesOptions, enableCrossClusterResolution); - } - if (alreadyResolved.add(alreadyResolvedKey)) { - resolveIndexPatterns(localRequest.getClass().getSimpleName(), indicesOptions, enableCrossClusterResolution, original); - } - return IndicesProvider.NOOP; - } - - Resolved resolved(IndicesOptions indicesOptions) { - final Resolved resolved = alreadyResolved.isEmpty() - ? Resolved._LOCAL_ALL - : new Resolved(aliases.build(), allIndices.build(), originalRequested.build(), remoteIndices.build(), indicesOptions); - - if (log.isTraceEnabled()) { - log.trace("Finally resolved for {}: {}", name, resolved); - } - - return resolved; - } - } - - // dnfof - public boolean replace(final TransportRequest request, boolean retainMode, String... replacements) { - return getOrReplaceAllIndices(request, new IndicesProvider() { - - @Override - public String[] provide(String[] original, Object request, boolean supportsReplace) { - if (supportsReplace) { - if (retainMode && !isAllWithNoRemote(original)) { - final Resolved resolved = resolveRequest(request); - final List retained = WildcardMatcher.from(resolved.getAllIndices()) - .getMatchAny(replacements, Collectors.toList()); - retained.addAll(resolved.getRemoteIndices()); - return retained.toArray(new String[0]); - } - return replacements; - } else { - return NOOP; - } - } - }, false); - } - - public boolean replace(final TransportRequest request, boolean retainMode, Collection replacements) { - return replace(request, retainMode, replacements.toArray(new String[replacements.size()])); - } - - public Resolved resolveRequest(final Object request) { - if (log.isDebugEnabled()) { - log.debug("Resolve aliases, indices and types from {}", request.getClass().getSimpleName()); - } - - final ResolvedIndicesProvider resolvedIndicesProvider = new ResolvedIndicesProvider(request); - - getOrReplaceAllIndices(request, resolvedIndicesProvider, false); - - return resolvedIndicesProvider.resolved(indicesOptionsFrom(request)); - } - - public final static class Resolved { - private static final String ANY = "*"; - private static final ImmutableSet All_SET = ImmutableSet.of(ANY); - private static final Set types = All_SET; - public static final Resolved _LOCAL_ALL = new Resolved( - All_SET, - All_SET, - All_SET, - ImmutableSet.of(), - SearchRequest.DEFAULT_INDICES_OPTIONS - ); - - private static final IndicesOptions EXACT_INDEX_OPTIONS = new IndicesOptions( - EnumSet.of(IndicesOptions.Option.FORBID_ALIASES_TO_MULTIPLE_INDICES), - EnumSet.noneOf(IndicesOptions.WildcardStates.class) - ); - - private final Set aliases; - private final Set allIndices; - private final Set originalRequested; - private final Set remoteIndices; - private final boolean isLocalAll; - private final IndicesOptions indicesOptions; - - public Resolved( - final ImmutableSet aliases, - final ImmutableSet allIndices, - final ImmutableSet originalRequested, - final ImmutableSet remoteIndices, - IndicesOptions indicesOptions - ) { - this.aliases = aliases; - this.allIndices = allIndices; - this.originalRequested = originalRequested; - this.remoteIndices = remoteIndices; - this.isLocalAll = IndexResolverReplacer.isLocalAll(originalRequested.toArray(new String[0])) - || (aliases.contains("*") && allIndices.contains("*")); - this.indicesOptions = indicesOptions; - } - - public boolean isLocalAll() { - return isLocalAll; - } - - public Set getAliases() { - return aliases; - } - - public Set getAllIndices() { - return allIndices; - } - - public Set getOriginalRequested() { - return originalRequested; - } - - public Set getAllIndicesResolved(ClusterService clusterService, IndexNameExpressionResolver resolver) { - return getAllIndicesResolved(clusterService::state, resolver); - } - - public Set getAllIndicesResolved(Supplier clusterStateSupplier, IndexNameExpressionResolver resolver) { - if (isLocalAll) { - return new HashSet<>(Arrays.asList(resolver.concreteIndexNames(clusterStateSupplier.get(), indicesOptions, "*"))); - } else { - return allIndices; - } - } - - public boolean isAllIndicesEmpty() { - return allIndices.isEmpty(); - } - - public Set getTypes() { - return types; - } - - public Set getRemoteIndices() { - return remoteIndices; - } - - @Override - public String toString() { - return "Resolved [aliases=" - + aliases - + ", allIndices=" - + allIndices - + ", types=" - + types - + ", originalRequested=" - + originalRequested - + ", remoteIndices=" - + remoteIndices - + "]"; - } - - @Override - public int hashCode() { - final int prime = 31; - int result = 1; - result = prime * result + ((aliases == null) ? 0 : aliases.hashCode()); - result = prime * result + ((allIndices == null) ? 0 : allIndices.hashCode()); - result = prime * result + ((originalRequested == null) ? 0 : originalRequested.hashCode()); - result = prime * result + ((remoteIndices == null) ? 0 : remoteIndices.hashCode()); - return result; - } - - @Override - public boolean equals(Object obj) { - if (this == obj) return true; - if (obj == null) return false; - if (getClass() != obj.getClass()) return false; - Resolved other = (Resolved) obj; - if (aliases == null) { - if (other.aliases != null) return false; - } else if (!aliases.equals(other.aliases)) return false; - if (allIndices == null) { - if (other.allIndices != null) return false; - } else if (!allIndices.equals(other.allIndices)) return false; - if (originalRequested == null) { - if (other.originalRequested != null) return false; - } else if (!originalRequested.equals(other.originalRequested)) return false; - if (remoteIndices == null) { - if (other.remoteIndices != null) return false; - } else if (!remoteIndices.equals(other.remoteIndices)) return false; - return true; - } - - public static Resolved ofIndex(String index) { - ImmutableSet indexSet = ImmutableSet.of(index); - return new Resolved(ImmutableSet.of(), indexSet, indexSet, ImmutableSet.of(), EXACT_INDEX_OPTIONS); - } - } - - private List renamedIndices(final RestoreSnapshotRequest request, final List filteredIndices) { - try { - final List renamedIndices = new ArrayList<>(); - for (final String index : filteredIndices) { - String renamedIndex = index; - if (request.renameReplacement() != null && request.renamePattern() != null) { - renamedIndex = index.replaceAll(request.renamePattern(), request.renameReplacement()); - } - renamedIndices.add(renamedIndex); - } - return renamedIndices; - } catch (PatternSyntaxException e) { - log.error("Unable to parse the regular expression denoted in 'rename_pattern'. Please correct the pattern an try again."); - throw e; - } - } - - // -- - - @FunctionalInterface - public interface IndicesProvider { - public static final String[] NOOP = new String[0]; - - String[] provide(String[] original, Object request, boolean supportsReplace); - } - - private boolean checkIndices(Object request, String[] indices, boolean needsToBeSizeOne, boolean allowEmpty) { - - if (indices == IndicesProvider.NOOP) { - return false; - } - - final boolean isTraceEnabled = log.isTraceEnabled(); - if (!allowEmpty && (indices == null || indices.length == 0)) { - if (isTraceEnabled && request != null) { - log.trace("Null or empty indices for " + request.getClass().getName()); - } - return false; - } - - if (!allowEmpty && needsToBeSizeOne && indices.length != 1) { - if (isTraceEnabled && request != null) { - log.trace("To much indices for " + request.getClass().getName()); - } - return false; - } - - for (int i = 0; i < indices.length; i++) { - final String index = indices[i]; - if (index == null || index.isEmpty()) { - // not allowed - if (isTraceEnabled && request != null) { - log.trace("At least one null or empty index for " + request.getClass().getName()); - } - return false; - } - } - - return true; - } - - /** - * new - * @param request - * @param allowEmptyIndices - * @return - */ - @SuppressWarnings("rawtypes") - private boolean getOrReplaceAllIndices(final Object request, final IndicesProvider provider, boolean allowEmptyIndices) { - final boolean isDebugEnabled = log.isDebugEnabled(); - final boolean isTraceEnabled = log.isTraceEnabled(); - if (isTraceEnabled) { - log.trace("getOrReplaceAllIndices() for " + request.getClass()); - } - - boolean result = true; - - if (request instanceof BulkRequest) { - - for (DocWriteRequest ar : ((BulkRequest) request).requests()) { - result = getOrReplaceAllIndices(ar, provider, false) && result; - } - - } else if (request instanceof MultiGetRequest) { - - for (ListIterator it = ((MultiGetRequest) request).getItems().listIterator(); it.hasNext();) { - Item item = it.next(); - result = getOrReplaceAllIndices(item, provider, false) && result; - /*if(item.index() == null || item.indices() == null || item.indices().length == 0) { - it.remove(); - }*/ - } - - } else if (request instanceof MultiSearchRequest) { - - for (ListIterator it = ((MultiSearchRequest) request).requests().listIterator(); it.hasNext();) { - SearchRequest ar = it.next(); - result = getOrReplaceAllIndices(ar, provider, false) && result; - /*if(ar.indices() == null || ar.indices().length == 0) { - it.remove(); - }*/ - } - - } else if (request instanceof MultiTermVectorsRequest) { - - for (ActionRequest ar : (Iterable) () -> ((MultiTermVectorsRequest) request).iterator()) { - result = getOrReplaceAllIndices(ar, provider, false) && result; - } - - } else if (request instanceof PutMappingRequest) { - PutMappingRequest pmr = (PutMappingRequest) request; - Index concreteIndex = pmr.getConcreteIndex(); - if (concreteIndex != null && (pmr.indices() == null || pmr.indices().length == 0)) { - String[] newIndices = provider.provide(new String[] { concreteIndex.getName() }, request, true); - if (checkIndices(request, newIndices, true, allowEmptyIndices) == false) { - return false; - } - - ((PutMappingRequest) request).indices(newIndices); - ((PutMappingRequest) request).setConcreteIndex(null); - } else { - String[] newIndices = provider.provide(((PutMappingRequest) request).indices(), request, true); - if (checkIndices(request, newIndices, false, allowEmptyIndices) == false) { - return false; - } - ((PutMappingRequest) request).indices(newIndices); - } - } else if (request instanceof RestoreSnapshotRequest) { - - if (clusterInfoHolder.isLocalNodeElectedClusterManager() == Boolean.FALSE) { - return true; - } - - final RestoreSnapshotRequest restoreRequest = (RestoreSnapshotRequest) request; - final SnapshotInfo snapshotInfo = SnapshotRestoreHelper.getSnapshotInfo(restoreRequest); - - if (snapshotInfo == null) { - log.warn( - "snapshot repository '" + restoreRequest.repository() + "', snapshot '" + restoreRequest.snapshot() + "' not found" - ); - provider.provide(new String[] { "*" }, request, false); - } else { - final List requestedResolvedIndices = IndexUtils.filterIndices( - snapshotInfo.indices(), - restoreRequest.indices(), - restoreRequest.indicesOptions() - ); - final List renamedTargetIndices = renamedIndices(restoreRequest, requestedResolvedIndices); - // final Set indices = new HashSet<>(requestedResolvedIndices); - // indices.addAll(renamedTargetIndices); - if (isDebugEnabled) { - log.debug("snapshot: {} contains this indices: {}", snapshotInfo.snapshotId().getName(), renamedTargetIndices); - } - provider.provide(renamedTargetIndices.toArray(new String[0]), request, false); - } - - } else if (request instanceof IndicesAliasesRequest) { - for (AliasActions ar : ((IndicesAliasesRequest) request).getAliasActions()) { - result = getOrReplaceAllIndices(ar, provider, false) && result; - } - } else if (request instanceof DeleteRequest) { - String[] newIndices = provider.provide(((DeleteRequest) request).indices(), request, true); - if (checkIndices(request, newIndices, true, allowEmptyIndices) == false) { - return false; - } - ((DeleteRequest) request).index(newIndices.length != 1 ? null : newIndices[0]); - } else if (request instanceof UpdateRequest) { - String[] newIndices = provider.provide(((UpdateRequest) request).indices(), request, true); - if (checkIndices(request, newIndices, true, allowEmptyIndices) == false) { - return false; - } - ((UpdateRequest) request).index(newIndices.length != 1 ? null : newIndices[0]); - } else if (request instanceof SingleShardRequest) { - final SingleShardRequest singleShardRequest = (SingleShardRequest) request; - final String index = singleShardRequest.index(); - String[] indices = provider.provide(index == null ? null : new String[] { index }, request, true); - if (!checkIndices(request, indices, true, allowEmptyIndices)) { - return false; - } - singleShardRequest.index(indices.length != 1 ? null : indices[0]); - } else if (request instanceof FieldCapabilitiesIndexRequest) { - // FieldCapabilitiesIndexRequest does not support replacing the indexes. - // However, the indexes are always determined by FieldCapabilitiesRequest which will be reduced below - // (implements Replaceable). So IF an index arrives here, we can be sure that we have - // at least privileges for indices:data/read/field_caps - FieldCapabilitiesIndexRequest fieldCapabilitiesRequest = (FieldCapabilitiesIndexRequest) request; - - String index = fieldCapabilitiesRequest.index(); - - String[] newIndices = provider.provide(new String[] { index }, request, true); - if (!checkIndices(request, newIndices, true, allowEmptyIndices)) { - return false; - } - } else if (request instanceof IndexRequest) { - String[] newIndices = provider.provide(((IndexRequest) request).indices(), request, true); - if (checkIndices(request, newIndices, true, allowEmptyIndices) == false) { - return false; - } - ((IndexRequest) request).index(newIndices.length != 1 ? null : newIndices[0]); - } else if (request instanceof Replaceable) { - String[] newIndices = provider.provide(((Replaceable) request).indices(), request, true); - if (checkIndices(request, newIndices, false, allowEmptyIndices) == false) { - return false; - } - ((Replaceable) request).indices(newIndices); - } else if (request instanceof RolloverRequest rolloverRequest) { - provider.provide(rolloverRequest.indices(), request, false); - if (rolloverRequest.getNewIndexName() != null) { // only when target index is explicitly provided - provider.provide(new String[] { rolloverRequest.getNewIndexName() }, request, false); - } - } else if (request instanceof BulkShardRequest) { - provider.provide(((ReplicationRequest) request).indices(), request, false); - // replace not supported? - } else if (request instanceof ReplicationRequest) { - String[] newIndices = provider.provide(((ReplicationRequest) request).indices(), request, true); - if (checkIndices(request, newIndices, true, allowEmptyIndices) == false) { - return false; - } - ((ReplicationRequest) request).index(newIndices.length != 1 ? null : newIndices[0]); - } else if (request instanceof MultiGetRequest.Item) { - String[] newIndices = provider.provide(((MultiGetRequest.Item) request).indices(), request, true); - if (checkIndices(request, newIndices, true, allowEmptyIndices) == false) { - return false; - } - ((MultiGetRequest.Item) request).index(newIndices.length != 1 ? null : newIndices[0]); - } else if (request instanceof CreateIndexRequest) { - String[] newIndices = provider.provide(((CreateIndexRequest) request).indices(), request, true); - if (checkIndices(request, newIndices, true, allowEmptyIndices) == false) { - return false; - } - ((CreateIndexRequest) request).index(newIndices.length != 1 ? null : newIndices[0]); - } else if (request instanceof ResizeRequest) { - // clone or shrink operations - provider.provide(((ResizeRequest) request).indices(), request, true); - provider.provide(((ResizeRequest) request).getTargetIndexRequest().indices(), request, true); - } else if (request instanceof CreateDataStreamAction.Request) { - provider.provide(((CreateDataStreamAction.Request) request).indices(), request, false); - } else if (request instanceof ReindexRequest) { - result = getOrReplaceAllIndices(((ReindexRequest) request).getDestination(), provider, false) && result; - result = getOrReplaceAllIndices(((ReindexRequest) request).getSearchRequest(), provider, false) && result; - } else if (request instanceof BaseNodesRequest) { - // do nothing - } else if (request instanceof MainRequest) { - // do nothing - } else if (request instanceof ClearScrollRequest) { - // do nothing - } else if (request instanceof SearchScrollRequest) { - // do nothing - } else if (request instanceof PutComponentTemplateAction.Request) { - // do nothing - } else { - if (isDebugEnabled) { - log.debug(request.getClass() + " not supported (It is likely not a indices related request)"); - } - result = false; - } - - return result; - } - - private IndicesOptions indicesOptionsFrom(Object localRequest) { - - if (!respectRequestIndicesOptions) { - return IndicesOptions.fromOptions(false, true, true, false, true); - } - - if (IndicesRequest.class.isInstance(localRequest)) { - return ((IndicesRequest) localRequest).indicesOptions(); - } else if (RestoreSnapshotRequest.class.isInstance(localRequest)) { - return ((RestoreSnapshotRequest) localRequest).indicesOptions(); - } else { - return IndicesOptions.fromOptions(false, true, true, false, true); - } - } - - @Subscribe - public void onDynamicConfigModelChanged(DynamicConfigModel dcm) { - respectRequestIndicesOptions = dcm.isRespectRequestIndicesEnabled(); - } -} diff --git a/src/main/java/org/opensearch/security/resources/ResourceAccessHandler.java b/src/main/java/org/opensearch/security/resources/ResourceAccessHandler.java index 9cf2c870a1..c3ab79639f 100644 --- a/src/main/java/org/opensearch/security/resources/ResourceAccessHandler.java +++ b/src/main/java/org/opensearch/security/resources/ResourceAccessHandler.java @@ -30,8 +30,8 @@ import org.opensearch.index.query.QueryBuilders; import org.opensearch.security.auth.UserSubjectImpl; import org.opensearch.security.configuration.AdminDNs; +import org.opensearch.security.privileges.PrivilegesConfiguration; import org.opensearch.security.privileges.PrivilegesEvaluationContext; -import org.opensearch.security.privileges.PrivilegesEvaluator; import org.opensearch.security.privileges.actionlevel.RoleBasedActionPrivileges; import org.opensearch.security.spi.resources.sharing.Recipient; import org.opensearch.security.spi.resources.sharing.ResourceSharing; @@ -56,19 +56,19 @@ public class ResourceAccessHandler { private final ThreadContext threadContext; private final ResourceSharingIndexHandler resourceSharingIndexHandler; private final AdminDNs adminDNs; - private final PrivilegesEvaluator privilegesEvaluator; + private final PrivilegesConfiguration privilegesConfiguration; @Inject public ResourceAccessHandler( final ThreadPool threadPool, final ResourceSharingIndexHandler resourceSharingIndexHandler, AdminDNs adminDns, - PrivilegesEvaluator evaluator + PrivilegesConfiguration privilegesConfiguration ) { this.threadContext = threadPool.getThreadContext(); this.resourceSharingIndexHandler = resourceSharingIndexHandler; this.adminDNs = adminDns; - this.privilegesEvaluator = evaluator; + this.privilegesConfiguration = privilegesConfiguration; } /** @@ -159,7 +159,9 @@ public void hasPermission( return; } - PrivilegesEvaluationContext effectiveContext = context != null ? context : privilegesEvaluator.createContext(user, action); + PrivilegesEvaluationContext effectiveContext = context != null + ? context + : privilegesConfiguration.privilegesEvaluator().createContext(user, action); Set userRoles = new HashSet<>(user.getSecurityRoles()); Set userBackendRoles = new HashSet<>(user.getRoles()); diff --git a/src/main/java/org/opensearch/security/rest/DashboardsInfoAction.java b/src/main/java/org/opensearch/security/rest/DashboardsInfoAction.java index 1249994dac..b97fe98010 100644 --- a/src/main/java/org/opensearch/security/rest/DashboardsInfoAction.java +++ b/src/main/java/org/opensearch/security/rest/DashboardsInfoAction.java @@ -42,7 +42,12 @@ import org.opensearch.rest.RestChannel; import org.opensearch.rest.RestController; import org.opensearch.rest.RestRequest; -import org.opensearch.security.privileges.PrivilegesEvaluator; +import org.opensearch.security.configuration.ConfigurationRepository; +import org.opensearch.security.privileges.DashboardsMultiTenancyConfiguration; +import org.opensearch.security.privileges.PrivilegesConfiguration; +import org.opensearch.security.securityconf.impl.CType; +import org.opensearch.security.securityconf.impl.DashboardSignInOption; +import org.opensearch.security.securityconf.impl.v7.ConfigV7; import org.opensearch.security.support.ConfigConstants; import org.opensearch.security.user.User; import org.opensearch.threadpool.ThreadPool; @@ -79,7 +84,8 @@ public class DashboardsInfoAction extends BaseRestHandler { .build(); private final Logger log = LogManager.getLogger(this.getClass()); - private final PrivilegesEvaluator evaluator; + private final PrivilegesConfiguration privilegesConfiguration; + private final ConfigurationRepository configurationRepository; private final ThreadContext threadContext; public static final String DEFAULT_PASSWORD_MESSAGE = "Password should be at least 8 characters long and contain at least one " @@ -90,12 +96,14 @@ public class DashboardsInfoAction extends BaseRestHandler { public DashboardsInfoAction( final Settings settings, final RestController controller, - final PrivilegesEvaluator evaluator, + final PrivilegesConfiguration privilegesConfiguration, + final ConfigurationRepository configurationRepository, final ThreadPool threadPool ) { super(); this.threadContext = threadPool.getThreadContext(); - this.evaluator = evaluator; + this.privilegesConfiguration = privilegesConfiguration; + this.configurationRepository = configurationRepository; } @Override @@ -121,16 +129,21 @@ public void accept(RestChannel channel) throws Exception { final User user = (User) threadContext.getTransient(ConfigConstants.OPENDISTRO_SECURITY_USER); + DashboardsMultiTenancyConfiguration multiTenancyConfiguration = privilegesConfiguration.multiTenancyConfiguration(); + builder.startObject(); builder.field("user_name", user == null ? null : user.getName()); - builder.field("not_fail_on_forbidden_enabled", evaluator.notFailOnForbiddenEnabled()); - builder.field("opensearch_dashboards_mt_enabled", evaluator.multitenancyEnabled()); - builder.field("opensearch_dashboards_index", evaluator.dashboardsIndex()); - builder.field("opensearch_dashboards_server_user", evaluator.dashboardsServerUsername()); - builder.field("multitenancy_enabled", evaluator.multitenancyEnabled()); - builder.field("private_tenant_enabled", evaluator.privateTenantEnabled()); - builder.field("default_tenant", evaluator.dashboardsDefaultTenant()); - builder.field("sign_in_options", evaluator.getSignInOptions()); + builder.field( + "not_fail_on_forbidden_enabled", + privilegesConfiguration.privilegesEvaluator().notFailOnForbiddenEnabled() + ); + builder.field("opensearch_dashboards_mt_enabled", multiTenancyConfiguration.multitenancyEnabled()); + builder.field("opensearch_dashboards_index", multiTenancyConfiguration.dashboardsIndex()); + builder.field("opensearch_dashboards_server_user", multiTenancyConfiguration.dashboardsServerUsername()); + builder.field("multitenancy_enabled", multiTenancyConfiguration.multitenancyEnabled()); + builder.field("private_tenant_enabled", multiTenancyConfiguration.privateTenantEnabled()); + builder.field("default_tenant", multiTenancyConfiguration.dashboardsDefaultTenant()); + builder.field("sign_in_options", getSignInOptions()); builder.field( "password_validation_error_message", client.settings().get(ConfigConstants.SECURITY_RESTAPI_PASSWORD_VALIDATION_ERROR_MESSAGE, DEFAULT_PASSWORD_MESSAGE) @@ -165,4 +178,13 @@ public String getName() { return "Kibana Info Action"; } + private List getSignInOptions() { + ConfigV7 generalConfig = configurationRepository.getConfiguration(CType.CONFIG).getCEntry(CType.CONFIG.name()); + if (generalConfig != null && generalConfig.dynamic != null && generalConfig.dynamic.kibana != null) { + return generalConfig.dynamic.kibana.sign_in_options; + } else { + return new ConfigV7.Kibana().sign_in_options; + } + } + } diff --git a/src/main/java/org/opensearch/security/rest/SecurityHealthAction.java b/src/main/java/org/opensearch/security/rest/SecurityHealthAction.java index 3de69a1a34..7319bfa8ca 100644 --- a/src/main/java/org/opensearch/security/rest/SecurityHealthAction.java +++ b/src/main/java/org/opensearch/security/rest/SecurityHealthAction.java @@ -40,7 +40,7 @@ import org.opensearch.rest.RestController; import org.opensearch.rest.RestRequest; import org.opensearch.security.auth.BackendRegistry; -import org.opensearch.security.privileges.PrivilegesEvaluator; +import org.opensearch.security.privileges.PrivilegesConfiguration; import org.opensearch.transport.client.node.NodeClient; import static org.opensearch.rest.RestRequest.Method.GET; @@ -67,17 +67,17 @@ public class SecurityHealthAction extends BaseRestHandler { ); private final BackendRegistry registry; - private final PrivilegesEvaluator privilegesEvaluator; + private final PrivilegesConfiguration privilegesConfiguration; public SecurityHealthAction( final Settings settings, final RestController controller, final BackendRegistry registry, - final PrivilegesEvaluator privilegesEvaluator + final PrivilegesConfiguration privilegesConfiguration ) { super(); this.registry = registry; - this.privilegesEvaluator = privilegesEvaluator; + this.privilegesConfiguration = privilegesConfiguration; } @Override @@ -108,7 +108,7 @@ public void accept(RestChannel channel) throws Exception { builder.startObject(); - if ("strict".equalsIgnoreCase(mode) && !(registry.isInitialized() && privilegesEvaluator.isInitialized())) { + if ("strict".equalsIgnoreCase(mode) && !(registry.isInitialized() && privilegesConfiguration.isInitialized())) { status = "DOWN"; message = "Not initialized"; restStatus = RestStatus.SERVICE_UNAVAILABLE; diff --git a/src/main/java/org/opensearch/security/rest/SecurityInfoAction.java b/src/main/java/org/opensearch/security/rest/SecurityInfoAction.java index 41b8cc98be..6932462f48 100644 --- a/src/main/java/org/opensearch/security/rest/SecurityInfoAction.java +++ b/src/main/java/org/opensearch/security/rest/SecurityInfoAction.java @@ -48,8 +48,8 @@ import org.opensearch.rest.RestChannel; import org.opensearch.rest.RestController; import org.opensearch.rest.RestRequest; +import org.opensearch.security.privileges.PrivilegesConfiguration; import org.opensearch.security.privileges.PrivilegesEvaluationContext; -import org.opensearch.security.privileges.PrivilegesEvaluator; import org.opensearch.security.support.Base64Helper; import org.opensearch.security.support.ConfigConstants; import org.opensearch.security.user.User; @@ -79,18 +79,18 @@ public class SecurityInfoAction extends BaseRestHandler { ); private final Logger log = LogManager.getLogger(this.getClass()); - private final PrivilegesEvaluator evaluator; + private final PrivilegesConfiguration privilegesConfiguration; private final ThreadContext threadContext; public SecurityInfoAction( final Settings settings, final RestController controller, - final PrivilegesEvaluator evaluator, + final PrivilegesConfiguration privilegesConfiguration, final ThreadPool threadPool ) { super(); this.threadContext = threadPool.getThreadContext(); - this.evaluator = evaluator; + this.privilegesConfiguration = privilegesConfiguration; } @Override @@ -122,7 +122,7 @@ public void accept(RestChannel channel) throws Exception { final User user = threadContext.getTransient(ConfigConstants.OPENDISTRO_SECURITY_USER); final TransportAddress remoteAddress = threadContext.getTransient(ConfigConstants.OPENDISTRO_SECURITY_REMOTE_ADDRESS); - PrivilegesEvaluationContext context = evaluator.createContext(user, null); + PrivilegesEvaluationContext context = privilegesConfiguration.privilegesEvaluator().createContext(user, null); builder.startObject(); builder.field("user", user == null ? null : user.toString()); @@ -132,7 +132,7 @@ public void accept(RestChannel channel) throws Exception { builder.field("backend_roles", user == null ? null : user.getRoles()); builder.field("custom_attribute_names", user == null ? null : user.getCustomAttributesMap().keySet()); builder.field("roles", context.getMappedRoles()); - builder.field("tenants", evaluator.tenantPrivileges().tenantMap(context)); + builder.field("tenants", privilegesConfiguration.tenantPrivileges().tenantMap(context)); builder.field("principal", (String) threadContext.getTransient(ConfigConstants.OPENDISTRO_SECURITY_SSL_PRINCIPAL)); builder.field("peer_certificates", certs != null && certs.length > 0 ? certs.length + "" : "0"); builder.field("sso_logout_url", (String) threadContext.getTransient(ConfigConstants.SSO_LOGOUT_URL)); diff --git a/src/main/java/org/opensearch/security/rest/TenantInfoAction.java b/src/main/java/org/opensearch/security/rest/TenantInfoAction.java index 47c4b61cc2..6628048546 100644 --- a/src/main/java/org/opensearch/security/rest/TenantInfoAction.java +++ b/src/main/java/org/opensearch/security/rest/TenantInfoAction.java @@ -49,7 +49,9 @@ import org.opensearch.rest.RestRequest; import org.opensearch.security.configuration.AdminDNs; import org.opensearch.security.configuration.ConfigurationRepository; -import org.opensearch.security.privileges.PrivilegesEvaluator; +import org.opensearch.security.privileges.DashboardsMultiTenancyConfiguration; +import org.opensearch.security.privileges.PrivilegesConfiguration; +import org.opensearch.security.privileges.TenantPrivileges; import org.opensearch.security.securityconf.DynamicConfigFactory; import org.opensearch.security.securityconf.RoleMappings; import org.opensearch.security.securityconf.impl.CType; @@ -82,7 +84,7 @@ public class TenantInfoAction extends BaseRestHandler { ); private final Logger log = LogManager.getLogger(this.getClass()); - private final PrivilegesEvaluator evaluator; + private final PrivilegesConfiguration privilegesConfiguration; private final ThreadContext threadContext; private final ClusterService clusterService; private final AdminDNs adminDns; @@ -91,7 +93,7 @@ public class TenantInfoAction extends BaseRestHandler { public TenantInfoAction( final Settings settings, final RestController controller, - final PrivilegesEvaluator evaluator, + final PrivilegesConfiguration privilegesConfiguration, final ThreadPool threadPool, final ClusterService clusterService, final AdminDNs adminDns, @@ -99,7 +101,7 @@ public TenantInfoAction( ) { super(); this.threadContext = threadPool.getThreadContext(); - this.evaluator = evaluator; + this.privilegesConfiguration = privilegesConfiguration; this.clusterService = clusterService; this.adminDns = adminDns; this.configurationRepository = configurationRepository; @@ -134,10 +136,12 @@ public void accept(RestChannel channel) throws Exception { } else { builder.startObject(); + DashboardsMultiTenancyConfiguration multiTenancyConfiguration = privilegesConfiguration.multiTenancyConfiguration(); + TenantPrivileges tenantPrivileges = privilegesConfiguration.tenantPrivileges(); final SortedMap lookup = clusterService.state().metadata().getIndicesLookup(); for (final String indexOrAlias : lookup.keySet()) { - final String tenant = tenantNameForIndex(indexOrAlias); + final String tenant = tenantNameForIndex(indexOrAlias, multiTenancyConfiguration, tenantPrivileges); if (tenant != null) { builder.field(indexOrAlias, tenant); } @@ -172,8 +176,10 @@ private boolean isAuthorized() { return false; } + DashboardsMultiTenancyConfiguration multiTenancyConfiguration = privilegesConfiguration.multiTenancyConfiguration(); + // check if the user is a kibanauser or super admin - if (user.getName().equals(evaluator.dashboardsServerUsername()) || adminDns.isAdmin(user)) { + if (user.getName().equals(multiTenancyConfiguration.dashboardsServerUsername()) || adminDns.isAdmin(user)) { return true; } @@ -182,7 +188,7 @@ private boolean isAuthorized() { // check if dashboardsOpenSearchRole is present in RolesMapping and if yes, check if user is a part of this role if (rolesMappingConfiguration != null) { - String dashboardsOpenSearchRole = evaluator.dashboardsOpenSearchRole(); + String dashboardsOpenSearchRole = multiTenancyConfiguration.dashboardsOpenSearchRole(); if (Strings.isNullOrEmpty(dashboardsOpenSearchRole)) { return false; } @@ -201,13 +207,17 @@ private final SecurityDynamicConfiguration load(final CType config, boolea return DynamicConfigFactory.addStatics(loaded); } - private String tenantNameForIndex(String index) { + private String tenantNameForIndex( + String index, + DashboardsMultiTenancyConfiguration multiTenancyConfiguration, + TenantPrivileges tenantPrivileges + ) { String[] indexParts; if (index == null || (indexParts = index.split("_")).length != 3) { return null; } - if (!indexParts[0].equals(evaluator.dashboardsIndex())) { + if (!indexParts[0].equals(multiTenancyConfiguration.dashboardsIndex())) { return null; } @@ -215,7 +225,7 @@ private String tenantNameForIndex(String index) { final int expectedHash = Integer.parseInt(indexParts[1]); final String sanitizedName = indexParts[2]; - for (String tenant : evaluator.tenantPrivileges().allTenantNames()) { + for (String tenant : tenantPrivileges.allTenantNames()) { if (tenant.hashCode() == expectedHash && sanitizedName.equals(tenant.toLowerCase().replaceAll("[^a-z0-9]+", ""))) { return tenant; } diff --git a/src/main/java/org/opensearch/security/securityconf/ConfigModel.java b/src/main/java/org/opensearch/security/securityconf/ConfigModel.java deleted file mode 100644 index a1546de0f4..0000000000 --- a/src/main/java/org/opensearch/security/securityconf/ConfigModel.java +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright 2015-2018 _floragunn_ GmbH - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/* - * 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. - * - * Modifications Copyright OpenSearch Contributors. See - * GitHub history for details. - */ - -package org.opensearch.security.securityconf; - -import java.util.Set; - -import org.opensearch.core.common.transport.TransportAddress; -import org.opensearch.security.user.User; - -public abstract class ConfigModel { - public abstract Set mapSecurityRoles(User user, TransportAddress caller); -} diff --git a/src/main/java/org/opensearch/security/securityconf/ConfigModelV7.java b/src/main/java/org/opensearch/security/securityconf/ConfigModelV7.java deleted file mode 100644 index e811a267a8..0000000000 --- a/src/main/java/org/opensearch/security/securityconf/ConfigModelV7.java +++ /dev/null @@ -1,193 +0,0 @@ -/* - * Copyright 2015-2018 floragunn GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package org.opensearch.security.securityconf; - -import java.util.Collections; -import java.util.HashSet; -import java.util.List; -import java.util.Map.Entry; -import java.util.Set; - -import com.google.common.collect.ArrayListMultimap; -import com.google.common.collect.ListMultimap; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -import org.opensearch.common.settings.Settings; -import org.opensearch.core.common.transport.TransportAddress; -import org.opensearch.security.securityconf.impl.SecurityDynamicConfiguration; -import org.opensearch.security.securityconf.impl.v7.RoleMappingsV7; -import org.opensearch.security.securityconf.impl.v7.RoleV7; -import org.opensearch.security.support.ConfigConstants; -import org.opensearch.security.support.HostResolverMode; -import org.opensearch.security.support.WildcardMatcher; -import org.opensearch.security.user.User; - -public class ConfigModelV7 extends ConfigModel { - - protected final Logger log = LogManager.getLogger(this.getClass()); - private ConfigConstants.RolesMappingResolution rolesMappingResolution; - private RoleMappingHolder roleMappingHolder; - private SecurityDynamicConfiguration roles; - - public ConfigModelV7( - SecurityDynamicConfiguration roles, - SecurityDynamicConfiguration rolemappings, - DynamicConfigModel dcm, - Settings opensearchSettings - ) { - - this.roles = roles; - - try { - rolesMappingResolution = ConfigConstants.RolesMappingResolution.valueOf( - opensearchSettings.get( - ConfigConstants.SECURITY_ROLES_MAPPING_RESOLUTION, - ConfigConstants.RolesMappingResolution.MAPPING_ONLY.toString() - ).toUpperCase() - ); - } catch (Exception e) { - log.error("Cannot apply roles mapping resolution", e); - rolesMappingResolution = ConfigConstants.RolesMappingResolution.MAPPING_ONLY; - } - - roleMappingHolder = new RoleMappingHolder(rolemappings, dcm.getHostsResolverMode()); - } - - private class RoleMappingHolder { - - private ListMultimap users; - private ListMultimap, String> abars; - private ListMultimap bars; - private ListMultimap hosts; - private final String hostResolverMode; - - private List userMatchers; - private List barMatchers; - private List hostMatchers; - - private RoleMappingHolder(final SecurityDynamicConfiguration rolemappings, final String hostResolverMode) { - - this.hostResolverMode = hostResolverMode; - - if (roles != null) { - - users = ArrayListMultimap.create(); - abars = ArrayListMultimap.create(); - bars = ArrayListMultimap.create(); - hosts = ArrayListMultimap.create(); - - for (final Entry roleMap : rolemappings.getCEntries().entrySet()) { - final String roleMapKey = roleMap.getKey(); - final RoleMappingsV7 roleMapValue = roleMap.getValue(); - - for (String u : roleMapValue.getUsers()) { - users.put(u, roleMapKey); - } - - final Set abar = new HashSet<>(roleMapValue.getAnd_backend_roles()); - - if (!abar.isEmpty()) { - abars.put(WildcardMatcher.matchers(abar), roleMapKey); - } - - for (String bar : roleMapValue.getBackend_roles()) { - bars.put(bar, roleMapKey); - } - - for (String host : roleMapValue.getHosts()) { - hosts.put(host, roleMapKey); - } - } - - userMatchers = WildcardMatcher.matchers(users.keySet()); - barMatchers = WildcardMatcher.matchers(bars.keySet()); - hostMatchers = WildcardMatcher.matchers(hosts.keySet()); - } - } - - private Set map(final User user, final TransportAddress caller) { - - if (user == null || users == null || abars == null || bars == null || hosts == null) { - return Collections.emptySet(); - } - - final Set securityRoles = new HashSet<>(user.getSecurityRoles()); - - if (rolesMappingResolution == ConfigConstants.RolesMappingResolution.BOTH - || rolesMappingResolution == ConfigConstants.RolesMappingResolution.BACKENDROLES_ONLY) { - if (log.isDebugEnabled()) { - log.debug("Pass backendroles from {}", user); - } - securityRoles.addAll(user.getRoles()); - } - - if (((rolesMappingResolution == ConfigConstants.RolesMappingResolution.BOTH - || rolesMappingResolution == ConfigConstants.RolesMappingResolution.MAPPING_ONLY))) { - - for (String p : WildcardMatcher.getAllMatchingPatterns(userMatchers, user.getName())) { - securityRoles.addAll(users.get(p)); - } - for (String p : WildcardMatcher.getAllMatchingPatterns(barMatchers, user.getRoles())) { - securityRoles.addAll(bars.get(p)); - } - - for (List patterns : abars.keySet()) { - if (patterns.stream().allMatch(p -> p.matchAny(user.getRoles()))) { - securityRoles.addAll(abars.get(patterns)); - } - } - - if (caller != null) { - // IPV4 or IPv6 (compressed and without scope identifiers) - final String ipAddress = caller.getAddress(); - - for (String p : WildcardMatcher.getAllMatchingPatterns(hostMatchers, ipAddress)) { - securityRoles.addAll(hosts.get(p)); - } - - if (caller.address() != null - && (hostResolverMode.equalsIgnoreCase(HostResolverMode.IP_HOSTNAME.getValue()) - || hostResolverMode.equalsIgnoreCase(HostResolverMode.IP_HOSTNAME_LOOKUP.getValue()))) { - final String hostName = caller.address().getHostString(); - - for (String p : WildcardMatcher.getAllMatchingPatterns(hostMatchers, hostName)) { - securityRoles.addAll(hosts.get(p)); - } - } - - if (caller.address() != null && hostResolverMode.equalsIgnoreCase(HostResolverMode.IP_HOSTNAME_LOOKUP.getValue())) { - - final String resolvedHostName = caller.address().getHostName(); - - for (String p : WildcardMatcher.getAllMatchingPatterns(hostMatchers, resolvedHostName)) { - securityRoles.addAll(hosts.get(p)); - } - } - } - } - - return Collections.unmodifiableSet(securityRoles); - - } - } - - public Set mapSecurityRoles(User user, TransportAddress caller) { - return roleMappingHolder.map(user, caller); - } -} diff --git a/src/main/java/org/opensearch/security/securityconf/DynamicConfigFactory.java b/src/main/java/org/opensearch/security/securityconf/DynamicConfigFactory.java index 249c1a8a15..cb124d8c51 100644 --- a/src/main/java/org/opensearch/security/securityconf/DynamicConfigFactory.java +++ b/src/main/java/org/opensearch/security/securityconf/DynamicConfigFactory.java @@ -236,7 +236,6 @@ public void onChange(ConfigurationMap typeToConfig) { final DynamicConfigModel dcm; final InternalUsersModel ium; - final ConfigModel cm; final NodesDnModel nm = new NodesDnModelImpl(nodesDn); final AllowlistingSettings allowlist = cr.getConfiguration(CType.ALLOWLIST).getCEntry("config"); final AuditConfig audit = cr.getConfiguration(CType.AUDIT).getCEntry("config"); @@ -278,10 +277,8 @@ public void onChange(ConfigurationMap typeToConfig) { // rebuild v7 Models dcm = new DynamicConfigModelV7(getConfigV7(config), opensearchSettings, configPath, iab, this.cih); ium = new InternalUsersModelV7(internalusers, roles, rolesmapping); - cm = new ConfigModelV7(roles, rolesmapping, dcm, opensearchSettings); // notify subscribers - eventBus.post(cm); eventBus.post(dcm); eventBus.post(ium); eventBus.post(nm); diff --git a/src/main/java/org/opensearch/security/securityconf/impl/v7/ConfigV7.java b/src/main/java/org/opensearch/security/securityconf/impl/v7/ConfigV7.java index def5247590..1659b532f0 100644 --- a/src/main/java/org/opensearch/security/securityconf/impl/v7/ConfigV7.java +++ b/src/main/java/org/opensearch/security/securityconf/impl/v7/ConfigV7.java @@ -86,6 +86,9 @@ public static class Dynamic { public String transport_userrname_attribute; public boolean do_not_fail_on_forbidden_empty; public OnBehalfOfSettings on_behalf_of = new OnBehalfOfSettings(); + @JsonInclude(JsonInclude.Include.NON_NULL) + @JsonProperty("privileges_evaluation_type") + public String privilegesEvaluationType = null; @Override public String toString() { diff --git a/src/main/java/org/opensearch/security/support/ConfigConstants.java b/src/main/java/org/opensearch/security/support/ConfigConstants.java index 81202e47fe..37ad3bd8b3 100644 --- a/src/main/java/org/opensearch/security/support/ConfigConstants.java +++ b/src/main/java/org/opensearch/security/support/ConfigConstants.java @@ -331,12 +331,6 @@ public class ConfigConstants { public static final String SECURITY_DISABLE_ENVVAR_REPLACEMENT = SECURITY_SETTINGS_PREFIX + "disable_envvar_replacement"; public static final String SECURITY_DFM_EMPTY_OVERRIDES_ALL = SECURITY_SETTINGS_PREFIX + "dfm_empty_overrides_all"; - public enum RolesMappingResolution { - MAPPING_ONLY, - BACKENDROLES_ONLY, - BOTH - } - public static final String SECURITY_FILTER_SECURITYINDEX_FROM_ALL_REQUESTS = SECURITY_SETTINGS_PREFIX + "filter_securityindex_from_all_requests"; public static final String SECURITY_DLS_MODE = SECURITY_SETTINGS_PREFIX + "dls.mode"; diff --git a/src/main/java/org/opensearch/security/support/HostResolverMode.java b/src/main/java/org/opensearch/security/support/HostResolverMode.java index 00ce6e9117..ef23381826 100644 --- a/src/main/java/org/opensearch/security/support/HostResolverMode.java +++ b/src/main/java/org/opensearch/security/support/HostResolverMode.java @@ -13,7 +13,8 @@ public enum HostResolverMode { IP_HOSTNAME("ip-hostname"), - IP_HOSTNAME_LOOKUP("ip-hostname-lookup"); + IP_HOSTNAME_LOOKUP("ip-hostname-lookup"), + DISABLED("disabled"); private final String value; @@ -24,4 +25,14 @@ public enum HostResolverMode { public String getValue() { return value; } + + public static HostResolverMode fromConfig(String hostResolverModeConfig) { + if (hostResolverModeConfig == null || hostResolverModeConfig.equalsIgnoreCase(IP_HOSTNAME.value)) { + return HostResolverMode.IP_HOSTNAME; + } else if (hostResolverModeConfig.equalsIgnoreCase(IP_HOSTNAME_LOOKUP.value)) { + return HostResolverMode.IP_HOSTNAME_LOOKUP; + } else { + return HostResolverMode.DISABLED; + } + } } diff --git a/src/main/java/org/opensearch/security/support/SnapshotRestoreHelper.java b/src/main/java/org/opensearch/security/support/SnapshotRestoreHelper.java index 1af2de0ffa..37d21dddad 100644 --- a/src/main/java/org/opensearch/security/support/SnapshotRestoreHelper.java +++ b/src/main/java/org/opensearch/security/support/SnapshotRestoreHelper.java @@ -30,6 +30,7 @@ import java.security.PrivilegedAction; import java.util.List; import java.util.Objects; +import java.util.stream.Collectors; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -39,6 +40,8 @@ import org.opensearch.action.support.PlainActionFuture; import org.opensearch.cluster.ClusterState; import org.opensearch.cluster.RestoreInProgress; +import org.opensearch.cluster.metadata.OptionallyResolvedIndices; +import org.opensearch.cluster.metadata.ResolvedIndices; import org.opensearch.cluster.service.ClusterService; import org.opensearch.common.util.IndexUtils; import org.opensearch.core.index.Index; @@ -62,7 +65,23 @@ public static List resolveOriginalIndices(RestoreSnapshotRequest restore } else { return IndexUtils.filterIndices(snapshotInfo.indices(), restoreRequest.indices(), restoreRequest.indicesOptions()); } + } + + public static OptionallyResolvedIndices resolveTargetIndices(RestoreSnapshotRequest request) { + List indices = resolveOriginalIndices(request); + if (indices == null) { + return ResolvedIndices.unknown(); + } + if (request.renameReplacement() != null && request.renamePattern() != null) { + return ResolvedIndices.of( + indices.stream() + .map(index -> index.replaceAll(request.renamePattern(), request.renameReplacement())) + .collect(Collectors.toSet()) + ); + } else { + return ResolvedIndices.of(indices); + } } public static SnapshotInfo getSnapshotInfo(RestoreSnapshotRequest restoreRequest) { diff --git a/src/main/java/org/opensearch/security/user/ThreadContextUserInfo.java b/src/main/java/org/opensearch/security/user/ThreadContextUserInfo.java new file mode 100644 index 0000000000..88a22ad5e6 --- /dev/null +++ b/src/main/java/org/opensearch/security/user/ThreadContextUserInfo.java @@ -0,0 +1,79 @@ +package org.opensearch.security.user; + +import java.util.HashMap; +import java.util.StringJoiner; + +import org.opensearch.common.settings.Settings; +import org.opensearch.common.util.concurrent.ThreadContext; +import org.opensearch.core.common.Strings; +import org.opensearch.security.privileges.PrivilegesConfiguration; +import org.opensearch.security.privileges.PrivilegesEvaluationContext; +import org.opensearch.security.privileges.TenantPrivileges; +import org.opensearch.security.support.Base64Helper; + +import static org.opensearch.security.support.ConfigConstants.OPENDISTRO_SECURITY_USER_INFO_THREAD_CONTEXT; +import static org.opensearch.security.support.ConfigConstants.USER_ATTRIBUTE_SERIALIZATION_ENABLED; +import static org.opensearch.security.support.ConfigConstants.USER_ATTRIBUTE_SERIALIZATION_ENABLED_DEFAULT; +import static org.opensearch.security.support.SecurityUtils.escapePipe; + +/** + * Functionality to add parseable information about the current user to the thread context. Usually called + * in the SecurityFilter. + *

    + * Moved from https://github.com/opensearch-project/security/blob/d29095f26dba1a26308c69b608dc926bd40c0f52/src/main/java/org/opensearch/security/privileges/PrivilegesEvaluator.java#L293 + */ +public class ThreadContextUserInfo { + private static final String READ_ACCESS = "READ"; + private static final String WRITE_ACCESS = "WRITE"; + private static final String NO_ACCESS = "NONE"; + private static final String GLOBAL_TENANT = "global_tenant"; + + private final boolean userAttributeSerializationEnabled; + private final ThreadContext threadContext; + private final PrivilegesConfiguration privilegesConfiguration; + + public ThreadContextUserInfo(ThreadContext threadContext, PrivilegesConfiguration privilegesConfiguration, Settings settings) { + this.threadContext = threadContext; + this.userAttributeSerializationEnabled = settings.getAsBoolean( + USER_ATTRIBUTE_SERIALIZATION_ENABLED, + USER_ATTRIBUTE_SERIALIZATION_ENABLED_DEFAULT + ); + this.privilegesConfiguration = privilegesConfiguration; + } + + public void setUserInfoInThreadContext(PrivilegesEvaluationContext context) { + if (threadContext.getTransient(OPENDISTRO_SECURITY_USER_INFO_THREAD_CONTEXT) == null) { + StringJoiner joiner = new StringJoiner("|"); + // Escape any pipe characters in the values before joining + joiner.add(escapePipe(context.getUser().getName())); + joiner.add(escapePipe(String.join(",", context.getUser().getRoles()))); + joiner.add(escapePipe(String.join(",", context.getMappedRoles()))); + + String requestedTenant = context.getUser().getRequestedTenant(); + joiner.add(requestedTenant); + + String tenantAccessToCheck = getTenancyAccess(context); + joiner.add(tenantAccessToCheck); + + if (userAttributeSerializationEnabled) { + joiner.add(Base64Helper.serializeObject(new HashMap<>(context.getUser().getCustomAttributesMap()))); + } + + threadContext.putTransient(OPENDISTRO_SECURITY_USER_INFO_THREAD_CONTEXT, joiner.toString()); + } + } + + private String getTenancyAccess(PrivilegesEvaluationContext context) { + String requestedTenant = context.getUser().getRequestedTenant(); + TenantPrivileges tenantPrivileges = privilegesConfiguration.tenantPrivileges(); + final String tenant = Strings.isNullOrEmpty(requestedTenant) ? GLOBAL_TENANT : requestedTenant; + if (tenantPrivileges.hasTenantPrivilege(context, tenant, TenantPrivileges.ActionType.WRITE)) { + return WRITE_ACCESS; + } else if (tenantPrivileges.hasTenantPrivilege(context, tenant, TenantPrivileges.ActionType.READ)) { + return READ_ACCESS; + } else { + return NO_ACCESS; + } + } + +} diff --git a/src/test/java/org/opensearch/security/IndexIntegrationTests.java b/src/test/java/org/opensearch/security/IndexIntegrationTests.java index fa996c0cd5..265a34450f 100644 --- a/src/test/java/org/opensearch/security/IndexIntegrationTests.java +++ b/src/test/java/org/opensearch/security/IndexIntegrationTests.java @@ -26,7 +26,6 @@ package org.opensearch.security; -import java.net.URLEncoder; import java.text.SimpleDateFormat; import java.util.Date; import java.util.TimeZone; @@ -595,6 +594,9 @@ public void testAliases() throws Exception { ); assertContains(res, "*\"hits\" : {*\"value\" : 0,*\"hits\" : [ ]*"); + res = rh.executePutRequest("/logstash-1/_alias/alog1", "", encodeBasicHeader("aliasmngt", "nagilum")); + System.out.println(res.getBody()); + // add alias to allowed index assertThat( HttpStatus.SC_OK, @@ -664,11 +666,17 @@ public void testIndexResolveInvalidIndexName() throws Exception { setup(); final RestHelper rh = nonSslRestHelper(); - // invalid_index_name_exception should be thrown and responded when invalid index name is mentioned in requests. - HttpResponse res = rh.executeGetRequest( - URLEncoder.encode("_##pdt_data/_search", "UTF-8"), - encodeBasicHeader("ccsresolv", "nagilum") - ); + // invalid_index_name_exception should be thrown and responded when invalid index name is mentioned in requests AND if the + // user has in theory privileges for the (invalid) index name. This is because the index name validation takes + // place in the transport action itself. + // The security plugin should not engage itself in any validation logic that is outside of its scope. + + // We do not have privileges for the index below, thus we get a 403 error + HttpResponse res = rh.executeGetRequest("/_pdt_data/_search", encodeBasicHeader("ccsresolv", "nagilum")); + assertThat(res.getStatusCode(), is(HttpStatus.SC_FORBIDDEN)); + + // We have privileges for the invalid index name below, thus we get through to the validation logic + res = rh.executeGetRequest("/_abcdata/_search", encodeBasicHeader("ccsresolv", "nagilum")); assertThat(res.getStatusCode(), is(HttpStatus.SC_BAD_REQUEST)); Assert.assertTrue(res.getBody().contains("invalid_index_name_exception")); } diff --git a/src/test/java/org/opensearch/security/TransportUserInjectorIntegTest.java b/src/test/java/org/opensearch/security/TransportUserInjectorIntegTest.java index ff37fad282..6dc1b84621 100644 --- a/src/test/java/org/opensearch/security/TransportUserInjectorIntegTest.java +++ b/src/test/java/org/opensearch/security/TransportUserInjectorIntegTest.java @@ -131,7 +131,10 @@ public void testSecurityUserInjection() throws Exception { exception = ex; log.debug(ex.toString()); Assert.assertNotNull(exception); - Assert.assertTrue(exception.getMessage().toString().contains("no permissions for [indices:admin/create]")); + Assert.assertTrue( + exception.getMessage(), + exception.getMessage().toString().contains("no permissions for [indices:admin/create]") + ); } // 3. with valid backend roles for injected user diff --git a/src/test/java/org/opensearch/security/dlic/rest/api/RestApiPrivilegesEvaluatorTest.java b/src/test/java/org/opensearch/security/dlic/rest/api/RestApiPrivilegesEvaluatorTest.java index bbe1bf90f8..e8172d7723 100644 --- a/src/test/java/org/opensearch/security/dlic/rest/api/RestApiPrivilegesEvaluatorTest.java +++ b/src/test/java/org/opensearch/security/dlic/rest/api/RestApiPrivilegesEvaluatorTest.java @@ -20,7 +20,6 @@ import org.opensearch.common.settings.Settings; import org.opensearch.rest.RestRequest; import org.opensearch.security.configuration.AdminDNs; -import org.opensearch.security.privileges.PrivilegesEvaluator; import org.opensearch.security.ssl.transport.PrincipalExtractor; import org.opensearch.threadpool.ThreadPool; @@ -37,7 +36,7 @@ public void setUp() { this.privilegesEvaluator = new RestApiPrivilegesEvaluator( Settings.EMPTY, mock(AdminDNs.class), - mock(PrivilegesEvaluator.class), + (user, caller) -> user.getSecurityRoles(), mock(PrincipalExtractor.class), mock(Path.class), mock(ThreadPool.class) diff --git a/src/test/java/org/opensearch/security/filter/SecurityFilterTests.java b/src/test/java/org/opensearch/security/filter/SecurityFilterTests.java index 3cf3a1c291..4b9b45f4e1 100644 --- a/src/test/java/org/opensearch/security/filter/SecurityFilterTests.java +++ b/src/test/java/org/opensearch/security/filter/SecurityFilterTests.java @@ -21,6 +21,7 @@ import org.junit.runners.Parameterized; import org.opensearch.OpenSearchSecurityException; +import org.opensearch.action.support.ActionRequestMetadata; import org.opensearch.cluster.service.ClusterService; import org.opensearch.common.settings.Settings; import org.opensearch.core.action.ActionListener; @@ -31,8 +32,8 @@ import org.opensearch.security.configuration.CompatConfig; import org.opensearch.security.configuration.DlsFlsRequestValve; import org.opensearch.security.http.XFFResolver; -import org.opensearch.security.privileges.PrivilegesEvaluator; -import org.opensearch.security.resolver.IndexResolverReplacer; +import org.opensearch.security.privileges.PrivilegesConfiguration; +import org.opensearch.security.privileges.RoleMapper; import org.opensearch.security.resources.ResourceAccessHandler; import org.opensearch.security.support.ConfigConstants; import org.opensearch.security.support.WildcardMatcher; @@ -81,7 +82,8 @@ public static Collection data() { public void testImmutableIndicesWildcardMatcher() { final SecurityFilter filter = new SecurityFilter( settings, - mock(PrivilegesEvaluator.class), + mock(PrivilegesConfiguration.class), + mock(RoleMapper.class), mock(AdminDNs.class), mock(DlsFlsRequestValve.class), mock(AuditLog.class), @@ -89,7 +91,6 @@ public void testImmutableIndicesWildcardMatcher() { mock(ClusterService.class), mock(ClusterInfoHolder.class), mock(CompatConfig.class), - mock(IndexResolverReplacer.class), mock(XFFResolver.class), Set.of(), mock(ResourceAccessHandler.class) @@ -107,7 +108,8 @@ public void testUnexepectedCausesAreNotSendToCallers() { final SecurityFilter filter = new SecurityFilter( settings, - mock(PrivilegesEvaluator.class), + mock(PrivilegesConfiguration.class), + mock(RoleMapper.class), mock(AdminDNs.class), mock(DlsFlsRequestValve.class), auditLog, @@ -115,14 +117,13 @@ public void testUnexepectedCausesAreNotSendToCallers() { mock(ClusterService.class), mock(ClusterInfoHolder.class), mock(CompatConfig.class), - mock(IndexResolverReplacer.class), mock(XFFResolver.class), Set.of(), mock(ResourceAccessHandler.class) ); // Act - filter.apply(null, null, null, listener, null); + filter.apply(null, null, null, ActionRequestMetadata.empty(), listener, null); // Verify verify(auditLog).getComplianceConfig(); // Make sure the exception was thrown diff --git a/src/test/java/org/opensearch/security/identity/SecurityTokenManagerTest.java b/src/test/java/org/opensearch/security/identity/SecurityTokenManagerTest.java index 7558533656..1507d77c73 100644 --- a/src/test/java/org/opensearch/security/identity/SecurityTokenManagerTest.java +++ b/src/test/java/org/opensearch/security/identity/SecurityTokenManagerTest.java @@ -13,7 +13,6 @@ import java.io.IOException; import java.nio.charset.StandardCharsets; -import java.util.Set; import com.google.common.io.BaseEncoding; import org.junit.After; @@ -31,7 +30,6 @@ import org.opensearch.identity.tokens.OnBehalfOfClaims; import org.opensearch.security.authtoken.jwt.ExpiringBearerAuthToken; import org.opensearch.security.authtoken.jwt.JwtVendor; -import org.opensearch.security.securityconf.ConfigModel; import org.opensearch.security.securityconf.DynamicConfigModel; import org.opensearch.security.support.ConfigConstants; import org.opensearch.security.user.User; @@ -71,7 +69,7 @@ public class SecurityTokenManagerTest { @Before public void setup() { - tokenManager = spy(new SecurityTokenManager(cs, threadPool, userService)); + tokenManager = spy(new SecurityTokenManager(cs, threadPool, userService, (user, caller) -> user.getSecurityRoles())); } @After @@ -83,26 +81,12 @@ public void after() { "This is my super safe signing key that no one will ever be able to guess. It's would take billions of years and the world's most powerful quantum computer to crack"; final static String signingKeyB64Encoded = BaseEncoding.base64().encode(signingKey.getBytes(StandardCharsets.UTF_8)); - @Test - public void onConfigModelChanged_oboNotSupported() { - final ConfigModel configModel = mock(ConfigModel.class); - - tokenManager.onConfigModelChanged(configModel); - - assertThat(tokenManager.issueOnBehalfOfTokenAllowed(), equalTo(false)); - verifyNoMoreInteractions(configModel); - } - @Test public void onDynamicConfigModelChanged_JwtVendorEnabled() { - final ConfigModel configModel = mock(ConfigModel.class); final DynamicConfigModel mockConfigModel = createMockJwtVendorInTokenManager(true); - tokenManager.onConfigModelChanged(configModel); - assertThat(tokenManager.issueOnBehalfOfTokenAllowed(), equalTo(true)); verify(mockConfigModel).getDynamicOnBehalfOfSettings(); - verifyNoMoreInteractions(configModel); } @Test @@ -211,9 +195,6 @@ public void issueOnBehalfOfToken_jwtGenerationFailure() throws Exception { final ThreadContext threadContext = new ThreadContext(Settings.EMPTY); threadContext.putTransient(ConfigConstants.OPENDISTRO_SECURITY_USER, new User("Jon")); when(threadPool.getThreadContext()).thenReturn(threadContext); - final ConfigModel configModel = mock(ConfigModel.class); - tokenManager.onConfigModelChanged(configModel); - when(configModel.mapSecurityRoles(any(), any())).thenReturn(Set.of()); createMockJwtVendorInTokenManager(true); @@ -235,9 +216,6 @@ public void issueOnBehalfOfToken_success() throws Exception { final ThreadContext threadContext = new ThreadContext(Settings.EMPTY); threadContext.putTransient(ConfigConstants.OPENDISTRO_SECURITY_USER, new User("Jon")); when(threadPool.getThreadContext()).thenReturn(threadContext); - final ConfigModel configModel = mock(ConfigModel.class); - tokenManager.onConfigModelChanged(configModel); - when(configModel.mapSecurityRoles(any(), any())).thenReturn(Set.of()); createMockJwtVendorInTokenManager(true); @@ -257,9 +235,6 @@ public void testCreateJwtWithNegativeExpiry() { final ThreadContext threadContext = new ThreadContext(Settings.EMPTY); threadContext.putTransient(ConfigConstants.OPENDISTRO_SECURITY_USER, new User("Jon")); when(threadPool.getThreadContext()).thenReturn(threadContext); - final ConfigModel configModel = mock(ConfigModel.class); - tokenManager.onConfigModelChanged(configModel); - when(configModel.mapSecurityRoles(any(), any())).thenReturn(Set.of()); createMockJwtVendorInTokenManager(true); @@ -280,9 +255,6 @@ public void testCreateJwtWithExceededExpiry() throws Exception { final ThreadContext threadContext = new ThreadContext(Settings.EMPTY); threadContext.putTransient(ConfigConstants.OPENDISTRO_SECURITY_USER, new User("Jon")); when(threadPool.getThreadContext()).thenReturn(threadContext); - final ConfigModel configModel = mock(ConfigModel.class); - tokenManager.onConfigModelChanged(configModel); - when(configModel.mapSecurityRoles(any(), any())).thenReturn(Set.of()); createMockJwtVendorInTokenManager(true); @@ -300,9 +272,6 @@ public void testCreateJwtWithBadEncryptionKey() { final ThreadContext threadContext = new ThreadContext(Settings.EMPTY); threadContext.putTransient(ConfigConstants.OPENDISTRO_SECURITY_USER, new User("Jon")); when(threadPool.getThreadContext()).thenReturn(threadContext); - final ConfigModel configModel = mock(ConfigModel.class); - tokenManager.onConfigModelChanged(configModel); - when(configModel.mapSecurityRoles(any(), any())).thenReturn(Set.of()); createMockJwtVendorInTokenManager(false); @@ -322,9 +291,6 @@ public void testCreateJwtWithBadRoles() { final ThreadContext threadContext = new ThreadContext(Settings.EMPTY); threadContext.putTransient(ConfigConstants.OPENDISTRO_SECURITY_USER, new User("Jon")); when(threadPool.getThreadContext()).thenReturn(threadContext); - final ConfigModel configModel = mock(ConfigModel.class); - tokenManager.onConfigModelChanged(configModel); - when(configModel.mapSecurityRoles(any(), any())).thenReturn(null); createMockJwtVendorInTokenManager(true); diff --git a/src/test/java/org/opensearch/security/privileges/ResourceAccessEvaluatorTest.java b/src/test/java/org/opensearch/security/privileges/ResourceAccessEvaluatorTest.java index 9030f24413..aea9b41314 100644 --- a/src/test/java/org/opensearch/security/privileges/ResourceAccessEvaluatorTest.java +++ b/src/test/java/org/opensearch/security/privileges/ResourceAccessEvaluatorTest.java @@ -84,7 +84,6 @@ private void assertEvaluateAsync(boolean hasPermission, boolean expectedAllowed) PrivilegesEvaluatorResponse out = captor.getValue(); assertThat(out.allowed, equalTo(expectedAllowed)); - assertThat(out.isComplete(), equalTo(true)); } @Test diff --git a/src/test/java/org/opensearch/security/privileges/RestLayerPrivilegesEvaluatorTest.java b/src/test/java/org/opensearch/security/privileges/RestLayerPrivilegesEvaluatorTest.java index 85d4a9cfa1..6b3718e81e 100644 --- a/src/test/java/org/opensearch/security/privileges/RestLayerPrivilegesEvaluatorTest.java +++ b/src/test/java/org/opensearch/security/privileges/RestLayerPrivilegesEvaluatorTest.java @@ -12,81 +12,33 @@ package org.opensearch.security.privileges; import java.util.Set; -import java.util.TreeMap; - -import org.apache.logging.log4j.Level; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; -import org.apache.logging.log4j.core.config.Configurator; -import org.junit.After; -import org.junit.Before; + import org.junit.Test; import org.junit.runner.RunWith; -import org.opensearch.OpenSearchSecurityException; -import org.opensearch.cluster.ClusterState; -import org.opensearch.cluster.metadata.IndexNameExpressionResolver; -import org.opensearch.cluster.metadata.Metadata; -import org.opensearch.cluster.node.DiscoveryNode; +import org.opensearch.action.ActionRequest; +import org.opensearch.action.support.ActionRequestMetadata; import org.opensearch.cluster.service.ClusterService; import org.opensearch.common.settings.Settings; -import org.opensearch.common.util.concurrent.ThreadContext; -import org.opensearch.security.auditlog.NullAuditLog; -import org.opensearch.security.configuration.ClusterInfoHolder; -import org.opensearch.security.securityconf.ConfigModel; -import org.opensearch.security.securityconf.DynamicConfigModel; +import org.opensearch.security.privileges.actionlevel.RoleBasedActionPrivileges; +import org.opensearch.security.privileges.actionlevel.RuntimeOptimizedActionPrivileges; +import org.opensearch.security.securityconf.FlattenedActionGroups; import org.opensearch.security.securityconf.impl.CType; import org.opensearch.security.securityconf.impl.SecurityDynamicConfiguration; +import org.opensearch.security.securityconf.impl.v7.ConfigV7; import org.opensearch.security.securityconf.impl.v7.RoleV7; import org.opensearch.security.user.User; -import org.opensearch.threadpool.ThreadPool; +import org.opensearch.tasks.Task; -import org.mockito.Mock; import org.mockito.junit.MockitoJUnitRunner; -import org.mockito.quality.Strictness; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; -import static org.junit.Assert.assertThrows; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; -import static org.mockito.Mockito.withSettings; @RunWith(MockitoJUnitRunner.class) public class RestLayerPrivilegesEvaluatorTest { - @Mock(strictness = Mock.Strictness.LENIENT) - private ClusterService clusterService; - @Mock - private ConfigModel configModel; - @Mock - private DynamicConfigModel dynamicConfigModel; - @Mock - private ClusterInfoHolder clusterInfoHolder; - - private static final User TEST_USER = new User("test_user"); - - private void setLoggingLevel(final Level level) { - final Logger restLayerPrivilegesEvaluatorLogger = LogManager.getLogger(RestLayerPrivilegesEvaluator.class); - Configurator.setLevel(restLayerPrivilegesEvaluatorLogger, level); - } - - @Before - public void setUp() { - when(clusterService.localNode()).thenReturn(mock(DiscoveryNode.class, withSettings().strictness(Strictness.LENIENT))); - when(configModel.mapSecurityRoles(TEST_USER, null)).thenReturn(Set.of("test_role")); - setLoggingLevel(Level.DEBUG); // Enable debug logging scenarios for verification - ClusterState clusterState = mock(ClusterState.class); - when(clusterService.state()).thenReturn(clusterState); - Metadata metadata = mock(Metadata.class); - when(clusterState.metadata()).thenReturn(metadata); - when(metadata.getIndicesLookup()).thenReturn(new TreeMap<>()); - } - - @After - public void after() { - setLoggingLevel(Level.INFO); - } + private static final User TEST_USER = new User("test_user").withSecurityRoles(Set.of("test_role")); @Test public void testEvaluate_Initialized_Success() throws Exception { @@ -95,8 +47,8 @@ public void testEvaluate_Initialized_Success() throws Exception { " cluster_permissions:\n" + // " - any", CType.ROLES); - PrivilegesEvaluator privilegesEvaluator = createPrivilegesEvaluator(roles); - RestLayerPrivilegesEvaluator restPrivilegesEvaluator = new RestLayerPrivilegesEvaluator(privilegesEvaluator); + PrivilegesConfiguration privilegesConfiguration = createPrivilegesConfiguration(roles); + RestLayerPrivilegesEvaluator restPrivilegesEvaluator = new RestLayerPrivilegesEvaluator(privilegesConfiguration); PrivilegesEvaluatorResponse response = restPrivilegesEvaluator.evaluate(TEST_USER, "route_name", Set.of(action)); @@ -104,30 +56,14 @@ public void testEvaluate_Initialized_Success() throws Exception { assertThat(response.getMissingPrivileges(), equalTo(Set.of(action))); } - @Test - public void testEvaluate_NotInitialized_NullModel_ExceptionThrown() { - PrivilegesEvaluator privilegesEvaluator = createPrivilegesEvaluator(null); - RestLayerPrivilegesEvaluator restPrivilegesEvaluator = new RestLayerPrivilegesEvaluator(privilegesEvaluator); - when(clusterInfoHolder.hasClusterManager()).thenReturn(true); - OpenSearchSecurityException exception = assertThrows( - OpenSearchSecurityException.class, - () -> restPrivilegesEvaluator.evaluate(TEST_USER, "route_name", null) - ); - assertThat(exception.getMessage(), equalTo("OpenSearch Security is not initialized.")); - - when(clusterInfoHolder.hasClusterManager()).thenReturn(false); - exception = assertThrows(OpenSearchSecurityException.class, () -> restPrivilegesEvaluator.evaluate(TEST_USER, "route_name", null)); - assertThat(exception.getMessage(), equalTo("OpenSearch Security is not initialized. Cluster manager not present")); - } - @Test public void testEvaluate_Successful_NewPermission() throws Exception { String action = "hw:greet"; SecurityDynamicConfiguration roles = SecurityDynamicConfiguration.fromYaml("test_role:\n" + // " cluster_permissions:\n" + // " - hw:greet", CType.ROLES); - PrivilegesEvaluator privilegesEvaluator = createPrivilegesEvaluator(roles); - RestLayerPrivilegesEvaluator restPrivilegesEvaluator = new RestLayerPrivilegesEvaluator(privilegesEvaluator); + PrivilegesConfiguration privilegesConfiguration = createPrivilegesConfiguration(roles); + RestLayerPrivilegesEvaluator restPrivilegesEvaluator = new RestLayerPrivilegesEvaluator(privilegesConfiguration); PrivilegesEvaluatorResponse response = restPrivilegesEvaluator.evaluate(TEST_USER, "route_name", Set.of(action)); assertThat(response.allowed, equalTo(true)); } @@ -138,8 +74,8 @@ public void testEvaluate_Successful_LegacyPermission() throws Exception { SecurityDynamicConfiguration roles = SecurityDynamicConfiguration.fromYaml("test_role:\n" + // " cluster_permissions:\n" + // " - cluster:admin/opensearch/hw/greet", CType.ROLES); - PrivilegesEvaluator privilegesEvaluator = createPrivilegesEvaluator(roles); - RestLayerPrivilegesEvaluator restPrivilegesEvaluator = new RestLayerPrivilegesEvaluator(privilegesEvaluator); + PrivilegesConfiguration privilegesConfiguration = createPrivilegesConfiguration(roles); + RestLayerPrivilegesEvaluator restPrivilegesEvaluator = new RestLayerPrivilegesEvaluator(privilegesConfiguration); PrivilegesEvaluatorResponse response = restPrivilegesEvaluator.evaluate(TEST_USER, "route_name", Set.of(action)); assertThat(response.allowed, equalTo(true)); } @@ -150,36 +86,89 @@ public void testEvaluate_Unsuccessful() throws Exception { SecurityDynamicConfiguration roles = SecurityDynamicConfiguration.fromYaml("test_role:\n" + // " cluster_permissions:\n" + // " - other_action", CType.ROLES); - PrivilegesEvaluator privilegesEvaluator = createPrivilegesEvaluator(roles); - RestLayerPrivilegesEvaluator restPrivilegesEvaluator = new RestLayerPrivilegesEvaluator(privilegesEvaluator); + PrivilegesConfiguration privilegesConfiguration = createPrivilegesConfiguration(roles); + RestLayerPrivilegesEvaluator restPrivilegesEvaluator = new RestLayerPrivilegesEvaluator(privilegesConfiguration); PrivilegesEvaluatorResponse response = restPrivilegesEvaluator.evaluate(TEST_USER, "route_name", Set.of(action)); assertThat(response.allowed, equalTo(false)); } + PrivilegesConfiguration createPrivilegesConfiguration(SecurityDynamicConfiguration roles) { + return new PrivilegesConfiguration(createPrivilegesEvaluator(roles)); + } + PrivilegesEvaluator createPrivilegesEvaluator(SecurityDynamicConfiguration roles) { - PrivilegesEvaluator privilegesEvaluator = new PrivilegesEvaluator( - clusterService, - () -> clusterService.state(), - mock(ThreadPool.class), - new ThreadContext(Settings.EMPTY), - null, - new IndexNameExpressionResolver(new ThreadContext(Settings.EMPTY)), - new NullAuditLog(), + ActionPrivileges actionPrivileges = new RoleBasedActionPrivileges( + roles, + FlattenedActionGroups.EMPTY, + RuntimeOptimizedActionPrivileges.SpecialIndexProtection.NONE, Settings.EMPTY, - null, - clusterInfoHolder, - null + false ); - privilegesEvaluator.onConfigModelChanged(configModel); // Defaults to the mocked config model - privilegesEvaluator.onDynamicConfigModelChanged(dynamicConfigModel); - - if (roles != null) { - privilegesEvaluator.updateConfiguration( - SecurityDynamicConfiguration.empty(CType.ACTIONGROUPS), - roles, - SecurityDynamicConfiguration.empty(CType.TENANTS) - ); - } - return privilegesEvaluator; + + return new PrivilegesEvaluator() { + + @Override + public PrivilegesEvaluationContext createContext( + User user, + String action, + ActionRequest actionRequest, + ActionRequestMetadata actionRequestMetadata, + Task task + ) { + return new PrivilegesEvaluationContext( + user, + user.getSecurityRoles(), + action, + actionRequest, + ActionRequestMetadata.empty(), + task, + null, + null, + null, + actionPrivileges + ); + } + + @Override + public PrivilegesEvaluatorResponse evaluate(PrivilegesEvaluationContext context) { + return null; + } + + @Override + public boolean isClusterPermission(String action) { + return false; + } + + @Override + public void updateConfiguration( + FlattenedActionGroups actionGroups, + SecurityDynamicConfiguration rolesConfiguration, + ConfigV7 generalConfiguration + ) { + + } + + @Override + public void updateClusterStateMetadata(ClusterService clusterService) { + + } + + @Override + public void shutdown() { + + } + + @Override + public boolean notFailOnForbiddenEnabled() { + return false; + } + + @Override + public boolean isInitialized() { + return true; + } + }; + } + } diff --git a/src/test/java/org/opensearch/security/privileges/UserAttributesUnitTest.java b/src/test/java/org/opensearch/security/privileges/UserAttributesUnitTest.java index 05da8d5419..e4538a6025 100644 --- a/src/test/java/org/opensearch/security/privileges/UserAttributesUnitTest.java +++ b/src/test/java/org/opensearch/security/privileges/UserAttributesUnitTest.java @@ -44,6 +44,7 @@ public void testReplaceProperties() { null, null, null, + null, null ); diff --git a/src/test/java/org/opensearch/security/privileges/PrivilegesEvaluatorUnitTest.java b/src/test/java/org/opensearch/security/privileges/actionlevel/legacy/PrivilegesEvaluatorUnitTest.java similarity index 56% rename from src/test/java/org/opensearch/security/privileges/PrivilegesEvaluatorUnitTest.java rename to src/test/java/org/opensearch/security/privileges/actionlevel/legacy/PrivilegesEvaluatorUnitTest.java index 787d35e285..cd5feff67f 100644 --- a/src/test/java/org/opensearch/security/privileges/PrivilegesEvaluatorUnitTest.java +++ b/src/test/java/org/opensearch/security/privileges/actionlevel/legacy/PrivilegesEvaluatorUnitTest.java @@ -6,40 +6,22 @@ * compatible open source license. */ -package org.opensearch.security.privileges; +package org.opensearch.security.privileges.actionlevel.legacy; import java.util.List; -import java.util.function.Supplier; import com.google.common.collect.ImmutableList; -import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; -import org.opensearch.OpenSearchSecurityException; -import org.opensearch.cluster.ClusterState; -import org.opensearch.cluster.metadata.IndexNameExpressionResolver; -import org.opensearch.cluster.service.ClusterService; -import org.opensearch.common.settings.Settings; -import org.opensearch.common.util.concurrent.ThreadContext; -import org.opensearch.core.xcontent.NamedXContentRegistry; -import org.opensearch.security.auditlog.AuditLog; -import org.opensearch.security.configuration.ClusterInfoHolder; -import org.opensearch.security.configuration.ConfigurationRepository; -import org.opensearch.security.resolver.IndexResolverReplacer; -import org.opensearch.threadpool.ThreadPool; - -import org.mockito.Mock; import org.mockito.junit.MockitoJUnitRunner; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; -import static org.opensearch.security.privileges.PrivilegesEvaluator.DNFOF_MATCHER; -import static org.opensearch.security.privileges.PrivilegesEvaluator.isClusterPerm; +import static org.opensearch.security.privileges.actionlevel.legacy.PrivilegesEvaluator.DNFOF_MATCHER; +import static org.opensearch.security.privileges.actionlevel.legacy.PrivilegesEvaluator.isClusterPermissionStatic; import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; -import static org.mockito.Mockito.when; @RunWith(MockitoJUnitRunner.class) public class PrivilegesEvaluatorUnitTest { @@ -119,62 +101,6 @@ public class PrivilegesEvaluatorUnitTest { "indices:monitor/upgrade" ); - @Mock - private ClusterService clusterService; - - @Mock - private ThreadPool threadPool; - - @Mock - private ConfigurationRepository configurationRepository; - - @Mock - private IndexNameExpressionResolver resolver; - - @Mock - private AuditLog auditLog; - - @Mock - private PrivilegesInterceptor privilegesInterceptor; - - @Mock - private ClusterInfoHolder clusterInfoHolder; - - @Mock - private IndexResolverReplacer irr; - - @Mock - private NamedXContentRegistry namedXContentRegistry; - - @Mock - private ClusterState clusterState; - - private Settings settings; - private Supplier clusterStateSupplier; - private ThreadContext threadContext; - private PrivilegesEvaluator privilegesEvaluator; - - @Before - public void setUp() { - settings = Settings.builder().build(); - clusterStateSupplier = () -> clusterState; - threadContext = new ThreadContext(Settings.EMPTY); - - privilegesEvaluator = new PrivilegesEvaluator( - clusterService, - clusterStateSupplier, - threadPool, - threadContext, - configurationRepository, - resolver, - auditLog, - settings, - privilegesInterceptor, - clusterInfoHolder, - irr - ); - } - @Test public void testClusterPerm() { String multiSearchTemplate = "indices:data/read/msearch/template"; @@ -184,13 +110,13 @@ public void testClusterPerm() { String monitorUpgrade = "indices:monitor/upgrade"; // Cluster Permissions - assertTrue(isClusterPerm(multiSearchTemplate)); - assertTrue(isClusterPerm(writeIndex)); - assertTrue(isClusterPerm(monitorHealth)); + assertTrue(isClusterPermissionStatic(multiSearchTemplate)); + assertTrue(isClusterPermissionStatic(writeIndex)); + assertTrue(isClusterPermissionStatic(monitorHealth)); // Index Permissions - assertFalse(isClusterPerm(adminClose)); - assertFalse(isClusterPerm(monitorUpgrade)); + assertFalse(isClusterPermissionStatic(adminClose)); + assertFalse(isClusterPermissionStatic(monitorUpgrade)); } @Test @@ -207,20 +133,4 @@ public void testDnfofPermissions_positive() { } } - @Test - public void testEvaluate_NotInitialized_ExceptionThrown() { - when(clusterInfoHolder.hasClusterManager()).thenReturn(true); - OpenSearchSecurityException exception = assertThrows( - OpenSearchSecurityException.class, - () -> privilegesEvaluator.evaluate(null) - ); - assertThat(exception.getMessage(), equalTo("OpenSearch Security is not initialized.")); - - when(clusterInfoHolder.hasClusterManager()).thenReturn(false); - exception = assertThrows( - OpenSearchSecurityException.class, - () -> privilegesEvaluator.evaluate(null) - ); - assertThat(exception.getMessage(), equalTo("OpenSearch Security is not initialized. Cluster manager not present")); - } } diff --git a/src/test/java/org/opensearch/security/privileges/SystemIndexAccessEvaluatorTest.java b/src/test/java/org/opensearch/security/privileges/actionlevel/legacy/SystemIndexAccessEvaluatorTest.java similarity index 80% rename from src/test/java/org/opensearch/security/privileges/SystemIndexAccessEvaluatorTest.java rename to src/test/java/org/opensearch/security/privileges/actionlevel/legacy/SystemIndexAccessEvaluatorTest.java index 17a78501c9..28db045a73 100644 --- a/src/test/java/org/opensearch/security/privileges/SystemIndexAccessEvaluatorTest.java +++ b/src/test/java/org/opensearch/security/privileges/actionlevel/legacy/SystemIndexAccessEvaluatorTest.java @@ -9,7 +9,7 @@ * GitHub history for details. */ -package org.opensearch.security.privileges; +package org.opensearch.security.privileges.actionlevel.legacy; import java.util.Arrays; import java.util.List; @@ -28,18 +28,20 @@ import org.opensearch.action.ActionRequest; import org.opensearch.action.get.MultiGetRequest; import org.opensearch.action.search.SearchRequest; -import org.opensearch.action.support.IndicesOptions; +import org.opensearch.action.support.ActionRequestMetadata; import org.opensearch.cluster.ClusterState; import org.opensearch.cluster.metadata.IndexAbstraction; import org.opensearch.cluster.metadata.IndexNameExpressionResolver; import org.opensearch.cluster.metadata.Metadata; +import org.opensearch.cluster.metadata.ResolvedIndices; import org.opensearch.cluster.service.ClusterService; import org.opensearch.common.settings.Settings; import org.opensearch.common.util.concurrent.ThreadContext; import org.opensearch.security.auditlog.AuditLog; +import org.opensearch.security.privileges.PrivilegesEvaluationContext; +import org.opensearch.security.privileges.PrivilegesEvaluatorResponse; import org.opensearch.security.privileges.actionlevel.RoleBasedActionPrivileges; -import org.opensearch.security.resolver.IndexResolverReplacer; -import org.opensearch.security.resolver.IndexResolverReplacer.Resolved; +import org.opensearch.security.privileges.actionlevel.RuntimeOptimizedActionPrivileges; import org.opensearch.security.securityconf.FlattenedActionGroups; import org.opensearch.security.securityconf.impl.CType; import org.opensearch.security.securityconf.impl.SecurityDynamicConfiguration; @@ -55,10 +57,10 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.is; import static org.opensearch.security.support.ConfigConstants.SYSTEM_INDEX_PERMISSION; +import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.Mockito.when; @@ -68,8 +70,6 @@ public class SystemIndexAccessEvaluatorTest { @Mock private AuditLog auditLog; @Mock - private IndexResolverReplacer irr; - @Mock private ActionRequest request; @Mock private Task task; @@ -138,7 +138,13 @@ public void setup( CType.ROLES ); - this.actionPrivileges = new RoleBasedActionPrivileges(rolesConfig, FlattenedActionGroups.EMPTY, Settings.EMPTY); + this.actionPrivileges = new RoleBasedActionPrivileges( + rolesConfig, + FlattenedActionGroups.EMPTY, + RuntimeOptimizedActionPrivileges.SpecialIndexProtection.NONE, + Settings.EMPTY, + false + ); } catch (JsonProcessingException e) { throw new RuntimeException(e); } @@ -154,8 +160,7 @@ public void setup( .put(ConfigConstants.SECURITY_SYSTEM_INDICES_ENABLED_KEY, isSystemIndexEnabled) .put(ConfigConstants.SECURITY_SYSTEM_INDICES_PERMISSIONS_ENABLED_KEY, isSystemIndexPermissionsEnabled) .build(), - auditLog, - irr + auditLog ); evaluator.log = log; @@ -171,9 +176,10 @@ PrivilegesEvaluationContext ctx(String action) { ImmutableSet.of("role_a"), action, request, - null, + ActionRequestMetadata.empty(), null, indexNameExpressionResolver, + null, () -> clusterState, actionPrivileges ); @@ -181,13 +187,13 @@ PrivilegesEvaluationContext ctx(String action) { @After public void after() { - verifyNoMoreInteractions(auditLog, irr, request, task, presponse, log); + verifyNoMoreInteractions(auditLog, request, task, presponse, log); } @Test public void testUnprotectedActionOnRegularIndex_systemIndexDisabled() { setup(false, false, TEST_INDEX, false); - final Resolved resolved = createResolved(TEST_INDEX); + final ResolvedIndices resolved = createResolved(TEST_INDEX); // Action final PrivilegesEvaluatorResponse response = evaluator.evaluate( @@ -195,20 +201,18 @@ public void testUnprotectedActionOnRegularIndex_systemIndexDisabled() { null, UNPROTECTED_ACTION, resolved, - presponse, ctx(UNPROTECTED_ACTION), actionPrivileges, user ); - verifyNoInteractions(presponse); - assertThat(response, is(presponse)); + assertThat(response, isNull()); } @Test public void testUnprotectedActionOnRegularIndex_systemIndexPermissionDisabled() { setup(true, false, TEST_INDEX, false); - final Resolved resolved = createResolved(TEST_INDEX); + final ResolvedIndices resolved = createResolved(TEST_INDEX); // Action final PrivilegesEvaluatorResponse response = evaluator.evaluate( @@ -216,19 +220,17 @@ public void testUnprotectedActionOnRegularIndex_systemIndexPermissionDisabled() null, UNPROTECTED_ACTION, resolved, - presponse, ctx(UNPROTECTED_ACTION), actionPrivileges, user ); - verifyNoInteractions(presponse); - assertThat(response, is(presponse)); + assertThat(response, isNull()); } @Test public void testUnprotectedActionOnRegularIndex_systemIndexPermissionEnabled() { setup(true, true, TEST_INDEX, false); - final Resolved resolved = createResolved(TEST_INDEX); + final ResolvedIndices resolved = createResolved(TEST_INDEX); // Action final PrivilegesEvaluatorResponse response = evaluator.evaluate( @@ -236,19 +238,17 @@ public void testUnprotectedActionOnRegularIndex_systemIndexPermissionEnabled() { null, UNPROTECTED_ACTION, resolved, - presponse, ctx(UNPROTECTED_ACTION), actionPrivileges, user ); - verifyNoInteractions(presponse); - assertThat(response, is(presponse)); + assertThat(response, isNull()); } @Test public void testUnprotectedActionOnSystemIndex_systemIndexDisabled() { setup(false, false, TEST_SYSTEM_INDEX, false); - final Resolved resolved = createResolved(TEST_SYSTEM_INDEX); + final ResolvedIndices resolved = createResolved(TEST_SYSTEM_INDEX); // Action final PrivilegesEvaluatorResponse response = evaluator.evaluate( @@ -256,19 +256,17 @@ public void testUnprotectedActionOnSystemIndex_systemIndexDisabled() { null, UNPROTECTED_ACTION, resolved, - presponse, ctx(UNPROTECTED_ACTION), actionPrivileges, user ); - verifyNoInteractions(presponse); - assertThat(response, is(presponse)); + assertThat(response, isNull()); } @Test public void testUnprotectedActionOnSystemIndex_systemIndexPermissionDisabled() { setup(true, false, TEST_SYSTEM_INDEX, false); - final Resolved resolved = createResolved(TEST_SYSTEM_INDEX); + final ResolvedIndices resolved = createResolved(TEST_SYSTEM_INDEX); // Action final PrivilegesEvaluatorResponse response = evaluator.evaluate( @@ -276,19 +274,17 @@ public void testUnprotectedActionOnSystemIndex_systemIndexPermissionDisabled() { null, UNPROTECTED_ACTION, resolved, - presponse, ctx(UNPROTECTED_ACTION), actionPrivileges, user ); - verifyNoInteractions(presponse); - assertThat(response, is(presponse)); + assertThat(response, isNull()); } @Test public void testUnprotectedActionOnSystemIndex_systemIndexPermissionEnabled_WithoutSystemIndexPermission() { setup(true, true, TEST_SYSTEM_INDEX, false); - final Resolved resolved = createResolved(TEST_SYSTEM_INDEX); + final ResolvedIndices resolved = createResolved(TEST_SYSTEM_INDEX); // Action final PrivilegesEvaluatorResponse response = evaluator.evaluate( @@ -296,13 +292,11 @@ public void testUnprotectedActionOnSystemIndex_systemIndexPermissionEnabled_With null, UNPROTECTED_ACTION, resolved, - presponse, ctx(UNPROTECTED_ACTION), actionPrivileges, user ); - verify(presponse).markComplete(); - assertThat(response, is(presponse)); + assertThat(presponse.isAllowed(), is(false)); verify(auditLog).logSecurityIndexAttempt(request, UNPROTECTED_ACTION, null); verify(log).isInfoEnabled(); @@ -317,7 +311,7 @@ public void testUnprotectedActionOnSystemIndex_systemIndexPermissionEnabled_With @Test public void testUnprotectedActionOnSystemIndex_systemIndexPermissionEnabled_WithSystemIndexPermission() { setup(true, true, TEST_SYSTEM_INDEX, true); - final Resolved resolved = createResolved(TEST_SYSTEM_INDEX); + final ResolvedIndices resolved = createResolved(TEST_SYSTEM_INDEX); // Action final PrivilegesEvaluatorResponse response = evaluator.evaluate( @@ -325,14 +319,12 @@ public void testUnprotectedActionOnSystemIndex_systemIndexPermissionEnabled_With null, UNPROTECTED_ACTION, resolved, - presponse, ctx(UNPROTECTED_ACTION), actionPrivileges, user ); - assertThat(response, is(presponse)); // unprotected action is not allowed on a system index - assertThat(presponse.allowed, is(false)); + assertThat(presponse.isAllowed(), is(false)); } @Test @@ -341,14 +333,19 @@ public void testDisableCacheOrRealtimeOnSystemIndex_systemIndexDisabled() { final SearchRequest searchRequest = mock(SearchRequest.class); final MultiGetRequest realtimeRequest = mock(MultiGetRequest.class); - final Resolved resolved = createResolved(TEST_SYSTEM_INDEX); + final ResolvedIndices resolved = createResolved(TEST_SYSTEM_INDEX); // Action - evaluator.evaluate(request, null, UNPROTECTED_ACTION, resolved, presponse, ctx(UNPROTECTED_ACTION), actionPrivileges, user); - evaluator.evaluate(searchRequest, null, UNPROTECTED_ACTION, resolved, presponse, ctx(UNPROTECTED_ACTION), actionPrivileges, user); - evaluator.evaluate(realtimeRequest, null, UNPROTECTED_ACTION, resolved, presponse, ctx(UNPROTECTED_ACTION), actionPrivileges, user); - - verifyNoInteractions(presponse); + PrivilegesEvaluatorResponse response = evaluator.evaluate( + request, + null, + UNPROTECTED_ACTION, + resolved, + ctx(UNPROTECTED_ACTION), + actionPrivileges, + user + ); + assertThat(response, isNull()); } @Test @@ -357,12 +354,12 @@ public void testDisableCacheOrRealtimeOnSystemIndex_systemIndexPermissionDisable final SearchRequest searchRequest = mock(SearchRequest.class); final MultiGetRequest realtimeRequest = mock(MultiGetRequest.class); - final Resolved resolved = createResolved(TEST_SYSTEM_INDEX); + final ResolvedIndices resolved = createResolved(TEST_SYSTEM_INDEX); // Action - evaluator.evaluate(request, null, UNPROTECTED_ACTION, resolved, presponse, ctx(UNPROTECTED_ACTION), actionPrivileges, user); - evaluator.evaluate(searchRequest, null, UNPROTECTED_ACTION, resolved, presponse, ctx(UNPROTECTED_ACTION), actionPrivileges, user); - evaluator.evaluate(realtimeRequest, null, UNPROTECTED_ACTION, resolved, presponse, ctx(UNPROTECTED_ACTION), actionPrivileges, user); + evaluator.evaluate(request, null, UNPROTECTED_ACTION, resolved, ctx(UNPROTECTED_ACTION), actionPrivileges, user); + evaluator.evaluate(searchRequest, null, UNPROTECTED_ACTION, resolved, ctx(UNPROTECTED_ACTION), actionPrivileges, user); + evaluator.evaluate(realtimeRequest, null, UNPROTECTED_ACTION, resolved, ctx(UNPROTECTED_ACTION), actionPrivileges, user); verify(searchRequest).requestCache(Boolean.FALSE); verify(realtimeRequest).realtime(Boolean.FALSE); @@ -378,12 +375,12 @@ public void testDisableCacheOrRealtimeOnSystemIndex_systemIndexPermissionEnabled final SearchRequest searchRequest = mock(SearchRequest.class); final MultiGetRequest realtimeRequest = mock(MultiGetRequest.class); - final Resolved resolved = createResolved(TEST_SYSTEM_INDEX); + final ResolvedIndices resolved = createResolved(TEST_SYSTEM_INDEX); // Action - evaluator.evaluate(request, null, UNPROTECTED_ACTION, resolved, presponse, ctx(UNPROTECTED_ACTION), actionPrivileges, user); - evaluator.evaluate(searchRequest, null, UNPROTECTED_ACTION, resolved, presponse, ctx(UNPROTECTED_ACTION), actionPrivileges, user); - evaluator.evaluate(realtimeRequest, null, UNPROTECTED_ACTION, resolved, presponse, ctx(UNPROTECTED_ACTION), actionPrivileges, user); + evaluator.evaluate(request, null, UNPROTECTED_ACTION, resolved, ctx(UNPROTECTED_ACTION), actionPrivileges, user); + evaluator.evaluate(searchRequest, null, UNPROTECTED_ACTION, resolved, ctx(UNPROTECTED_ACTION), actionPrivileges, user); + evaluator.evaluate(realtimeRequest, null, UNPROTECTED_ACTION, resolved, ctx(UNPROTECTED_ACTION), actionPrivileges, user); verify(searchRequest).requestCache(Boolean.FALSE); verify(realtimeRequest).realtime(Boolean.FALSE); @@ -394,7 +391,6 @@ public void testDisableCacheOrRealtimeOnSystemIndex_systemIndexPermissionEnabled verify(auditLog).logSecurityIndexAttempt(request, UNPROTECTED_ACTION, null); verify(auditLog).logSecurityIndexAttempt(searchRequest, UNPROTECTED_ACTION, null); verify(auditLog).logSecurityIndexAttempt(realtimeRequest, UNPROTECTED_ACTION, null); - verify(presponse, times(3)).markComplete(); verify(log, times(2)).isDebugEnabled(); verify(log, times(3)).isInfoEnabled(); verify(log, times(3)).info( @@ -413,12 +409,12 @@ public void testDisableCacheOrRealtimeOnSystemIndex_systemIndexPermissionEnabled final SearchRequest searchRequest = mock(SearchRequest.class); final MultiGetRequest realtimeRequest = mock(MultiGetRequest.class); - final Resolved resolved = createResolved(TEST_SYSTEM_INDEX); + final ResolvedIndices resolved = createResolved(TEST_SYSTEM_INDEX); // Action - evaluator.evaluate(request, null, UNPROTECTED_ACTION, resolved, presponse, ctx(UNPROTECTED_ACTION), actionPrivileges, user); - evaluator.evaluate(searchRequest, null, UNPROTECTED_ACTION, resolved, presponse, ctx(UNPROTECTED_ACTION), actionPrivileges, user); - evaluator.evaluate(realtimeRequest, null, UNPROTECTED_ACTION, resolved, presponse, ctx(UNPROTECTED_ACTION), actionPrivileges, user); + evaluator.evaluate(request, null, UNPROTECTED_ACTION, resolved, ctx(UNPROTECTED_ACTION), actionPrivileges, user); + evaluator.evaluate(searchRequest, null, UNPROTECTED_ACTION, resolved, ctx(UNPROTECTED_ACTION), actionPrivileges, user); + evaluator.evaluate(realtimeRequest, null, UNPROTECTED_ACTION, resolved, ctx(UNPROTECTED_ACTION), actionPrivileges, user); verify(searchRequest).requestCache(Boolean.FALSE); verify(realtimeRequest).realtime(Boolean.FALSE); @@ -427,11 +423,12 @@ public void testDisableCacheOrRealtimeOnSystemIndex_systemIndexPermissionEnabled verify(log).debug("Disable search request cache for this request"); verify(log).debug("Disable realtime for this request"); } + /* @Test public void testProtectedActionLocalAll_systemIndexDisabled() { setup(false, false, TEST_SYSTEM_INDEX, false); - final Resolved resolved = Resolved._LOCAL_ALL; + final ResolvedIndices resolved = ResolvedIndices.all(); // Action evaluator.evaluate(request, task, PROTECTED_ACTION, resolved, presponse, ctx(PROTECTED_ACTION), actionPrivileges, user); @@ -445,7 +442,7 @@ public void testProtectedActionLocalAll_systemIndexDisabled() { @Test public void testProtectedActionLocalAll_systemIndexPermissionDisabled() { setup(true, false, TEST_SYSTEM_INDEX, false); - final Resolved resolved = Resolved._LOCAL_ALL; + final ResolvedIndices resolved = ResolvedIndices.all(); // Action evaluator.evaluate(request, task, PROTECTED_ACTION, resolved, presponse, ctx(PROTECTED_ACTION), actionPrivileges, user); @@ -459,7 +456,7 @@ public void testProtectedActionLocalAll_systemIndexPermissionDisabled() { @Test public void testProtectedActionLocalAll_systemIndexPermissionEnabled() { setup(true, true, TEST_SYSTEM_INDEX, false); - final Resolved resolved = Resolved._LOCAL_ALL; + final ResolvedIndices resolved = ResolvedIndices.all(); // Action evaluator.evaluate(request, task, PROTECTED_ACTION, resolved, presponse, ctx(PROTECTED_ACTION), actionPrivileges, user); @@ -473,7 +470,7 @@ public void testProtectedActionLocalAll_systemIndexPermissionEnabled() { @Test public void testProtectedActionOnRegularIndex_systemIndexDisabled() { setup(false, false, TEST_INDEX, false); - final Resolved resolved = createResolved(TEST_INDEX); + final ResolvedIndices resolved = createResolved(TEST_INDEX); // Action evaluator.evaluate(request, task, PROTECTED_ACTION, resolved, presponse, ctx(PROTECTED_ACTION), actionPrivileges, user); @@ -484,7 +481,7 @@ public void testProtectedActionOnRegularIndex_systemIndexDisabled() { @Test public void testProtectedActionOnRegularIndex_systemIndexPermissionDisabled() { setup(true, false, TEST_INDEX, false); - final Resolved resolved = createResolved(TEST_INDEX); + final ResolvedIndices resolved = createResolved(TEST_INDEX); // Action evaluator.evaluate(request, task, PROTECTED_ACTION, resolved, presponse, ctx(PROTECTED_ACTION), actionPrivileges, user); @@ -495,7 +492,7 @@ public void testProtectedActionOnRegularIndex_systemIndexPermissionDisabled() { @Test public void testProtectedActionOnRegularIndex_systemIndexPermissionEnabled() { setup(true, true, TEST_INDEX, false); - final Resolved resolved = createResolved(TEST_INDEX); + final ResolvedIndices resolved = createResolved(TEST_INDEX); // Action evaluator.evaluate(request, task, PROTECTED_ACTION, resolved, presponse, ctx(PROTECTED_ACTION), actionPrivileges, user); @@ -506,7 +503,7 @@ public void testProtectedActionOnRegularIndex_systemIndexPermissionEnabled() { @Test public void testProtectedActionOnSystemIndex_systemIndexDisabled() { setup(false, false, TEST_SYSTEM_INDEX, false); - final Resolved resolved = createResolved(TEST_SYSTEM_INDEX); + final ResolvedIndices resolved = createResolved(TEST_SYSTEM_INDEX); // Action evaluator.evaluate(request, task, PROTECTED_ACTION, resolved, presponse, ctx(PROTECTED_ACTION), actionPrivileges, user); @@ -517,7 +514,7 @@ public void testProtectedActionOnSystemIndex_systemIndexDisabled() { @Test public void testProtectedActionOnSystemIndex_systemIndexPermissionDisabled() { setup(true, false, TEST_SYSTEM_INDEX, false); - final Resolved resolved = createResolved(TEST_SYSTEM_INDEX); + final ResolvedIndices resolved = createResolved(TEST_SYSTEM_INDEX); // Action evaluator.evaluate(request, task, PROTECTED_ACTION, resolved, presponse, ctx(PROTECTED_ACTION), actionPrivileges, user); @@ -531,7 +528,7 @@ public void testProtectedActionOnSystemIndex_systemIndexPermissionDisabled() { @Test public void testProtectedActionOnSystemIndex_systemIndexPermissionEnabled_withoutSystemIndexPermission() { setup(true, true, TEST_SYSTEM_INDEX, false); - final Resolved resolved = createResolved(TEST_SYSTEM_INDEX); + final ResolvedIndices resolved = createResolved(TEST_SYSTEM_INDEX); // Action evaluator.evaluate(request, task, PROTECTED_ACTION, resolved, presponse, ctx(PROTECTED_ACTION), actionPrivileges, user); @@ -552,7 +549,7 @@ public void testProtectedActionOnSystemIndex_systemIndexPermissionEnabled_withou public void testProtectedActionOnSystemIndex_systemIndexPermissionEnabled_withSystemIndexPermission() { setup(true, true, TEST_SYSTEM_INDEX, true); - final Resolved resolved = createResolved(TEST_SYSTEM_INDEX); + final ResolvedIndices resolved = createResolved(TEST_SYSTEM_INDEX); // Action evaluator.evaluate(request, task, PROTECTED_ACTION, resolved, presponse, ctx(PROTECTED_ACTION), actionPrivileges, user); @@ -563,7 +560,7 @@ public void testProtectedActionOnSystemIndex_systemIndexPermissionEnabled_withSy @Test public void testProtectedActionOnProtectedSystemIndex_systemIndexDisabled() { setup(false, false, SECURITY_INDEX, false); - final Resolved resolved = createResolved(SECURITY_INDEX); + final ResolvedIndices resolved = createResolved(SECURITY_INDEX); // Action evaluator.evaluate(request, task, PROTECTED_ACTION, resolved, presponse, ctx(PROTECTED_ACTION), actionPrivileges, user); @@ -578,7 +575,7 @@ public void testProtectedActionOnProtectedSystemIndex_systemIndexDisabled() { @Test public void testProtectedActionOnProtectedSystemIndex_systemIndexPermissionDisabled() { setup(true, false, SECURITY_INDEX, false); - final Resolved resolved = createResolved(SECURITY_INDEX); + final ResolvedIndices resolved = createResolved(SECURITY_INDEX); // Action evaluator.evaluate(request, task, PROTECTED_ACTION, resolved, presponse, ctx(PROTECTED_ACTION), actionPrivileges, user); @@ -608,36 +605,9 @@ public void testProtectedActionOnProtectedSystemIndex_systemIndexPermissionEnabl @Test public void testProtectedActionOnProtectedSystemIndex_systemIndexPermissionEnabled_withSystemIndexPermission() { testSecurityIndexAccess(PROTECTED_ACTION); - } + }*/ - private void testSecurityIndexAccess(String action) { - setup(true, true, SECURITY_INDEX, true); - - final Resolved resolved = createResolved(SECURITY_INDEX); - - // Action - evaluator.evaluate(request, task, action, resolved, presponse, ctx(action), actionPrivileges, user); - - verify(auditLog).logSecurityIndexAttempt(request, action, task); - assertThat(presponse.allowed, is(false)); - verify(presponse).markComplete(); - - verify(log).isInfoEnabled(); - verify(log).info( - "{} not permitted for a regular user {} on protected system indices {}", - action, - user.getSecurityRoles(), - SECURITY_INDEX - ); - } - - private Resolved createResolved(final String... indexes) { - return new Resolved( - ImmutableSet.of(), - ImmutableSet.copyOf(indexes), - ImmutableSet.copyOf(indexes), - ImmutableSet.of(), - IndicesOptions.STRICT_EXPAND_OPEN - ); + private ResolvedIndices createResolved(final String... indexes) { + return ResolvedIndices.of(indexes); } } diff --git a/src/test/java/org/opensearch/security/resources/ResourceAccessHandlerTest.java b/src/test/java/org/opensearch/security/resources/ResourceAccessHandlerTest.java index 5803c5e6f0..773f625209 100644 --- a/src/test/java/org/opensearch/security/resources/ResourceAccessHandlerTest.java +++ b/src/test/java/org/opensearch/security/resources/ResourceAccessHandlerTest.java @@ -23,6 +23,7 @@ import org.opensearch.core.action.ActionListener; import org.opensearch.security.auth.UserSubjectImpl; import org.opensearch.security.configuration.AdminDNs; +import org.opensearch.security.privileges.PrivilegesConfiguration; import org.opensearch.security.privileges.PrivilegesEvaluationContext; import org.opensearch.security.privileges.PrivilegesEvaluator; import org.opensearch.security.privileges.actionlevel.RoleBasedActionPrivileges; @@ -73,7 +74,7 @@ public class ResourceAccessHandlerTest { public void setup() { threadContext = new ThreadContext(Settings.EMPTY); when(threadPool.getThreadContext()).thenReturn(threadContext); - handler = new ResourceAccessHandler(threadPool, sharingIndexHandler, adminDNs, privilegesEvaluator); + handler = new ResourceAccessHandler(threadPool, sharingIndexHandler, adminDNs, new PrivilegesConfiguration(privilegesEvaluator)); } private void injectUser(User user) {