diff --git a/CHANGELOG.md b/CHANGELOG.md index a7f99722dd584..db47c394bc7aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - Introduce a setting to disable download of full cluster state from remote on term mismatch([#16798](https://github.com/opensearch-project/OpenSearch/pull/16798/)) - Added ability to retrieve value from DocValues in a flat_object filed([#16802](https://github.com/opensearch-project/OpenSearch/pull/16802)) - Introduce framework for auxiliary transports and an experimental gRPC transport plugin ([#16534](https://github.com/opensearch-project/OpenSearch/pull/16534)) +- Add resource-level access control and sharing ([#16030](https://github.com/opensearch-project/OpenSearch/pull/16030)) ### Dependencies - Bump `com.google.cloud:google-cloud-core-http` from 2.23.0 to 2.47.0 ([#16504](https://github.com/opensearch-project/OpenSearch/pull/16504)) diff --git a/distribution/tools/upgrade-cli/licenses/jackson-LICENSE b/distribution/tools/upgrade-cli/licenses/jackson-LICENSE deleted file mode 100644 index f5f45d26a49d6..0000000000000 --- a/distribution/tools/upgrade-cli/licenses/jackson-LICENSE +++ /dev/null @@ -1,8 +0,0 @@ -This copy of Jackson JSON processor streaming parser/generator is licensed under the -Apache (Software) License, version 2.0 ("the License"). -See the License for details about distribution rights, and the -specific rights regarding derivate works. - -You may obtain a copy of the License at: - -http://www.apache.org/licenses/LICENSE-2.0 diff --git a/distribution/tools/upgrade-cli/licenses/jackson-NOTICE b/distribution/tools/upgrade-cli/licenses/jackson-NOTICE deleted file mode 100644 index 4c976b7b4cc58..0000000000000 --- a/distribution/tools/upgrade-cli/licenses/jackson-NOTICE +++ /dev/null @@ -1,20 +0,0 @@ -# Jackson JSON processor - -Jackson is a high-performance, Free/Open Source JSON processing library. -It was originally written by Tatu Saloranta (tatu.saloranta@iki.fi), and has -been in development since 2007. -It is currently developed by a community of developers, as well as supported -commercially by FasterXML.com. - -## Licensing - -Jackson core and extension components may licensed under different licenses. -To find the details that apply to this artifact see the accompanying LICENSE file. -For more information, including possible other licensing options, contact -FasterXML.com (http://fasterxml.com). - -## Credits - -A list of contributors may be found from CREDITS file, which is included -in some artifacts (usually source distributions); but is always available -from the source code management (SCM) system project uses. diff --git a/modules/ingest-geoip/licenses/jackson-annotations-2.18.2.jar.sha1 b/modules/ingest-geoip/licenses/jackson-annotations-2.18.2.jar.sha1 deleted file mode 100644 index a06e1d5f28425..0000000000000 --- a/modules/ingest-geoip/licenses/jackson-annotations-2.18.2.jar.sha1 +++ /dev/null @@ -1 +0,0 @@ -985d77751ebc7fce5db115a986bc9aa82f973f4a \ No newline at end of file diff --git a/modules/ingest-geoip/licenses/jackson-annotations-LICENSE b/modules/ingest-geoip/licenses/jackson-annotations-LICENSE deleted file mode 100644 index f5f45d26a49d6..0000000000000 --- a/modules/ingest-geoip/licenses/jackson-annotations-LICENSE +++ /dev/null @@ -1,8 +0,0 @@ -This copy of Jackson JSON processor streaming parser/generator is licensed under the -Apache (Software) License, version 2.0 ("the License"). -See the License for details about distribution rights, and the -specific rights regarding derivate works. - -You may obtain a copy of the License at: - -http://www.apache.org/licenses/LICENSE-2.0 diff --git a/modules/ingest-geoip/licenses/jackson-annotations-NOTICE b/modules/ingest-geoip/licenses/jackson-annotations-NOTICE deleted file mode 100644 index 4c976b7b4cc58..0000000000000 --- a/modules/ingest-geoip/licenses/jackson-annotations-NOTICE +++ /dev/null @@ -1,20 +0,0 @@ -# Jackson JSON processor - -Jackson is a high-performance, Free/Open Source JSON processing library. -It was originally written by Tatu Saloranta (tatu.saloranta@iki.fi), and has -been in development since 2007. -It is currently developed by a community of developers, as well as supported -commercially by FasterXML.com. - -## Licensing - -Jackson core and extension components may licensed under different licenses. -To find the details that apply to this artifact see the accompanying LICENSE file. -For more information, including possible other licensing options, contact -FasterXML.com (http://fasterxml.com). - -## Credits - -A list of contributors may be found from CREDITS file, which is included -in some artifacts (usually source distributions); but is always available -from the source code management (SCM) system project uses. diff --git a/modules/ingest-geoip/licenses/jackson-databind-2.18.2.jar.sha1 b/modules/ingest-geoip/licenses/jackson-databind-2.18.2.jar.sha1 deleted file mode 100644 index eedbfff66c705..0000000000000 --- a/modules/ingest-geoip/licenses/jackson-databind-2.18.2.jar.sha1 +++ /dev/null @@ -1 +0,0 @@ -deef8697b92141fb6caf7aa86966cff4eec9b04f \ No newline at end of file diff --git a/modules/ingest-geoip/licenses/jackson-databind-LICENSE b/modules/ingest-geoip/licenses/jackson-databind-LICENSE deleted file mode 100644 index f5f45d26a49d6..0000000000000 --- a/modules/ingest-geoip/licenses/jackson-databind-LICENSE +++ /dev/null @@ -1,8 +0,0 @@ -This copy of Jackson JSON processor streaming parser/generator is licensed under the -Apache (Software) License, version 2.0 ("the License"). -See the License for details about distribution rights, and the -specific rights regarding derivate works. - -You may obtain a copy of the License at: - -http://www.apache.org/licenses/LICENSE-2.0 diff --git a/modules/ingest-geoip/licenses/jackson-databind-NOTICE b/modules/ingest-geoip/licenses/jackson-databind-NOTICE deleted file mode 100644 index 4c976b7b4cc58..0000000000000 --- a/modules/ingest-geoip/licenses/jackson-databind-NOTICE +++ /dev/null @@ -1,20 +0,0 @@ -# Jackson JSON processor - -Jackson is a high-performance, Free/Open Source JSON processing library. -It was originally written by Tatu Saloranta (tatu.saloranta@iki.fi), and has -been in development since 2007. -It is currently developed by a community of developers, as well as supported -commercially by FasterXML.com. - -## Licensing - -Jackson core and extension components may licensed under different licenses. -To find the details that apply to this artifact see the accompanying LICENSE file. -For more information, including possible other licensing options, contact -FasterXML.com (http://fasterxml.com). - -## Credits - -A list of contributors may be found from CREDITS file, which is included -in some artifacts (usually source distributions); but is always available -from the source code management (SCM) system project uses. diff --git a/plugins/crypto-kms/licenses/jackson-LICENSE b/plugins/crypto-kms/licenses/jackson-LICENSE deleted file mode 100644 index f5f45d26a49d6..0000000000000 --- a/plugins/crypto-kms/licenses/jackson-LICENSE +++ /dev/null @@ -1,8 +0,0 @@ -This copy of Jackson JSON processor streaming parser/generator is licensed under the -Apache (Software) License, version 2.0 ("the License"). -See the License for details about distribution rights, and the -specific rights regarding derivate works. - -You may obtain a copy of the License at: - -http://www.apache.org/licenses/LICENSE-2.0 diff --git a/plugins/crypto-kms/licenses/jackson-NOTICE b/plugins/crypto-kms/licenses/jackson-NOTICE deleted file mode 100644 index 4c976b7b4cc58..0000000000000 --- a/plugins/crypto-kms/licenses/jackson-NOTICE +++ /dev/null @@ -1,20 +0,0 @@ -# Jackson JSON processor - -Jackson is a high-performance, Free/Open Source JSON processing library. -It was originally written by Tatu Saloranta (tatu.saloranta@iki.fi), and has -been in development since 2007. -It is currently developed by a community of developers, as well as supported -commercially by FasterXML.com. - -## Licensing - -Jackson core and extension components may licensed under different licenses. -To find the details that apply to this artifact see the accompanying LICENSE file. -For more information, including possible other licensing options, contact -FasterXML.com (http://fasterxml.com). - -## Credits - -A list of contributors may be found from CREDITS file, which is included -in some artifacts (usually source distributions); but is always available -from the source code management (SCM) system project uses. diff --git a/plugins/crypto-kms/licenses/jackson-annotations-2.18.2.jar.sha1 b/plugins/crypto-kms/licenses/jackson-annotations-2.18.2.jar.sha1 deleted file mode 100644 index a06e1d5f28425..0000000000000 --- a/plugins/crypto-kms/licenses/jackson-annotations-2.18.2.jar.sha1 +++ /dev/null @@ -1 +0,0 @@ -985d77751ebc7fce5db115a986bc9aa82f973f4a \ No newline at end of file diff --git a/plugins/crypto-kms/licenses/jackson-databind-2.18.2.jar.sha1 b/plugins/crypto-kms/licenses/jackson-databind-2.18.2.jar.sha1 deleted file mode 100644 index eedbfff66c705..0000000000000 --- a/plugins/crypto-kms/licenses/jackson-databind-2.18.2.jar.sha1 +++ /dev/null @@ -1 +0,0 @@ -deef8697b92141fb6caf7aa86966cff4eec9b04f \ No newline at end of file diff --git a/plugins/discovery-ec2/licenses/jackson-LICENSE b/plugins/discovery-ec2/licenses/jackson-LICENSE deleted file mode 100644 index f5f45d26a49d6..0000000000000 --- a/plugins/discovery-ec2/licenses/jackson-LICENSE +++ /dev/null @@ -1,8 +0,0 @@ -This copy of Jackson JSON processor streaming parser/generator is licensed under the -Apache (Software) License, version 2.0 ("the License"). -See the License for details about distribution rights, and the -specific rights regarding derivate works. - -You may obtain a copy of the License at: - -http://www.apache.org/licenses/LICENSE-2.0 diff --git a/plugins/discovery-ec2/licenses/jackson-NOTICE b/plugins/discovery-ec2/licenses/jackson-NOTICE deleted file mode 100644 index 4c976b7b4cc58..0000000000000 --- a/plugins/discovery-ec2/licenses/jackson-NOTICE +++ /dev/null @@ -1,20 +0,0 @@ -# Jackson JSON processor - -Jackson is a high-performance, Free/Open Source JSON processing library. -It was originally written by Tatu Saloranta (tatu.saloranta@iki.fi), and has -been in development since 2007. -It is currently developed by a community of developers, as well as supported -commercially by FasterXML.com. - -## Licensing - -Jackson core and extension components may licensed under different licenses. -To find the details that apply to this artifact see the accompanying LICENSE file. -For more information, including possible other licensing options, contact -FasterXML.com (http://fasterxml.com). - -## Credits - -A list of contributors may be found from CREDITS file, which is included -in some artifacts (usually source distributions); but is always available -from the source code management (SCM) system project uses. diff --git a/plugins/discovery-ec2/licenses/jackson-annotations-2.18.2.jar.sha1 b/plugins/discovery-ec2/licenses/jackson-annotations-2.18.2.jar.sha1 deleted file mode 100644 index a06e1d5f28425..0000000000000 --- a/plugins/discovery-ec2/licenses/jackson-annotations-2.18.2.jar.sha1 +++ /dev/null @@ -1 +0,0 @@ -985d77751ebc7fce5db115a986bc9aa82f973f4a \ No newline at end of file diff --git a/plugins/discovery-ec2/licenses/jackson-databind-2.18.2.jar.sha1 b/plugins/discovery-ec2/licenses/jackson-databind-2.18.2.jar.sha1 deleted file mode 100644 index eedbfff66c705..0000000000000 --- a/plugins/discovery-ec2/licenses/jackson-databind-2.18.2.jar.sha1 +++ /dev/null @@ -1 +0,0 @@ -deef8697b92141fb6caf7aa86966cff4eec9b04f \ No newline at end of file diff --git a/plugins/repository-azure/licenses/jackson-annotations-2.18.2.jar.sha1 b/plugins/repository-azure/licenses/jackson-annotations-2.18.2.jar.sha1 deleted file mode 100644 index a06e1d5f28425..0000000000000 --- a/plugins/repository-azure/licenses/jackson-annotations-2.18.2.jar.sha1 +++ /dev/null @@ -1 +0,0 @@ -985d77751ebc7fce5db115a986bc9aa82f973f4a \ No newline at end of file diff --git a/plugins/repository-azure/licenses/jackson-databind-2.18.2.jar.sha1 b/plugins/repository-azure/licenses/jackson-databind-2.18.2.jar.sha1 deleted file mode 100644 index eedbfff66c705..0000000000000 --- a/plugins/repository-azure/licenses/jackson-databind-2.18.2.jar.sha1 +++ /dev/null @@ -1 +0,0 @@ -deef8697b92141fb6caf7aa86966cff4eec9b04f \ No newline at end of file diff --git a/plugins/repository-s3/licenses/jackson-LICENSE b/plugins/repository-s3/licenses/jackson-LICENSE deleted file mode 100644 index f5f45d26a49d6..0000000000000 --- a/plugins/repository-s3/licenses/jackson-LICENSE +++ /dev/null @@ -1,8 +0,0 @@ -This copy of Jackson JSON processor streaming parser/generator is licensed under the -Apache (Software) License, version 2.0 ("the License"). -See the License for details about distribution rights, and the -specific rights regarding derivate works. - -You may obtain a copy of the License at: - -http://www.apache.org/licenses/LICENSE-2.0 diff --git a/plugins/repository-s3/licenses/jackson-NOTICE b/plugins/repository-s3/licenses/jackson-NOTICE deleted file mode 100644 index 4c976b7b4cc58..0000000000000 --- a/plugins/repository-s3/licenses/jackson-NOTICE +++ /dev/null @@ -1,20 +0,0 @@ -# Jackson JSON processor - -Jackson is a high-performance, Free/Open Source JSON processing library. -It was originally written by Tatu Saloranta (tatu.saloranta@iki.fi), and has -been in development since 2007. -It is currently developed by a community of developers, as well as supported -commercially by FasterXML.com. - -## Licensing - -Jackson core and extension components may licensed under different licenses. -To find the details that apply to this artifact see the accompanying LICENSE file. -For more information, including possible other licensing options, contact -FasterXML.com (http://fasterxml.com). - -## Credits - -A list of contributors may be found from CREDITS file, which is included -in some artifacts (usually source distributions); but is always available -from the source code management (SCM) system project uses. diff --git a/plugins/repository-s3/licenses/jackson-annotations-2.18.2.jar.sha1 b/plugins/repository-s3/licenses/jackson-annotations-2.18.2.jar.sha1 deleted file mode 100644 index a06e1d5f28425..0000000000000 --- a/plugins/repository-s3/licenses/jackson-annotations-2.18.2.jar.sha1 +++ /dev/null @@ -1 +0,0 @@ -985d77751ebc7fce5db115a986bc9aa82f973f4a \ No newline at end of file diff --git a/plugins/repository-s3/licenses/jackson-databind-2.18.2.jar.sha1 b/plugins/repository-s3/licenses/jackson-databind-2.18.2.jar.sha1 deleted file mode 100644 index eedbfff66c705..0000000000000 --- a/plugins/repository-s3/licenses/jackson-databind-2.18.2.jar.sha1 +++ /dev/null @@ -1 +0,0 @@ -deef8697b92141fb6caf7aa86966cff4eec9b04f \ No newline at end of file diff --git a/server/build.gradle b/server/build.gradle index 8dd23491ccd69..57ff6f2619817 100644 --- a/server/build.gradle +++ b/server/build.gradle @@ -70,6 +70,9 @@ dependencies { api project(":libs:opensearch-telemetry") api project(":libs:opensearch-task-commons") + api "com.fasterxml.jackson.core:jackson-databind:${versions.jackson_databind}" + api "com.fasterxml.jackson.core:jackson-annotations:${versions.jackson}" + compileOnly project(':libs:opensearch-plugin-classloader') testRuntimeOnly project(':libs:opensearch-plugin-classloader') diff --git a/distribution/tools/upgrade-cli/licenses/jackson-annotations-2.18.2.jar.sha1 b/server/licenses/jackson-annotations-2.18.2.jar.sha1 similarity index 100% rename from distribution/tools/upgrade-cli/licenses/jackson-annotations-2.18.2.jar.sha1 rename to server/licenses/jackson-annotations-2.18.2.jar.sha1 diff --git a/distribution/tools/upgrade-cli/licenses/jackson-databind-2.18.2.jar.sha1 b/server/licenses/jackson-databind-2.18.2.jar.sha1 similarity index 100% rename from distribution/tools/upgrade-cli/licenses/jackson-databind-2.18.2.jar.sha1 rename to server/licenses/jackson-databind-2.18.2.jar.sha1 diff --git a/server/src/internalClusterTest/java/org/opensearch/accesscontrol/resources/NonDefaultResourceAccessControlPluginIT.java b/server/src/internalClusterTest/java/org/opensearch/accesscontrol/resources/NonDefaultResourceAccessControlPluginIT.java new file mode 100644 index 0000000000000..a69935e753d4f --- /dev/null +++ b/server/src/internalClusterTest/java/org/opensearch/accesscontrol/resources/NonDefaultResourceAccessControlPluginIT.java @@ -0,0 +1,32 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.accesscontrol.resources; + +import org.opensearch.accesscontrol.resources.testplugins.TestRACPlugin; +import org.opensearch.accesscontrol.resources.testplugins.TestResourcePlugin; +import org.opensearch.plugins.Plugin; +import org.opensearch.plugins.ResourceAccessControlPlugin; +import org.opensearch.test.OpenSearchIntegTestCase; +import org.hamcrest.MatcherAssert; +import org.hamcrest.Matchers; + +import java.util.Collection; +import java.util.List; + +public class NonDefaultResourceAccessControlPluginIT extends OpenSearchIntegTestCase { + @Override + protected Collection> nodePlugins() { + return List.of(TestResourcePlugin.class, TestRACPlugin.class); + } + + public void testSampleResourcePluginCallsTestRACPluginToManageResourceAccess() { + ResourceAccessControlPlugin racPlugin = TestResourcePlugin.GuiceHolder.getResourceService().getResourceAccessControlPlugin(); + MatcherAssert.assertThat(racPlugin.getClass(), Matchers.is(TestRACPlugin.class)); + } +} diff --git a/server/src/internalClusterTest/java/org/opensearch/accesscontrol/resources/fallback/DefaultResourceAccessControlPluginIT.java b/server/src/internalClusterTest/java/org/opensearch/accesscontrol/resources/fallback/DefaultResourceAccessControlPluginIT.java new file mode 100644 index 0000000000000..9885df3d9d42e --- /dev/null +++ b/server/src/internalClusterTest/java/org/opensearch/accesscontrol/resources/fallback/DefaultResourceAccessControlPluginIT.java @@ -0,0 +1,148 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.accesscontrol.resources.fallback; + +import org.opensearch.accesscontrol.resources.ResourceSharing; +import org.opensearch.accesscontrol.resources.ShareWith; +import org.opensearch.accesscontrol.resources.testplugins.TestResourcePlugin; +import org.opensearch.client.Client; +import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.plugins.Plugin; +import org.opensearch.plugins.ResourceAccessControlPlugin; +import org.opensearch.test.OpenSearchIntegTestCase; +import org.opensearch.threadpool.ThreadPool; +import org.hamcrest.MatcherAssert; + +import java.io.IOException; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.opensearch.accesscontrol.resources.testplugins.TestResourcePlugin.SAMPLE_TEST_INDEX; +import static org.opensearch.common.xcontent.XContentFactory.jsonBuilder; +import static org.hamcrest.Matchers.hasItem; +import static org.hamcrest.Matchers.hasProperty; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.nullValue; + +public class DefaultResourceAccessControlPluginIT extends OpenSearchIntegTestCase { + @Override + protected Collection> nodePlugins() { + return List.of(TestResourcePlugin.class); + } + + public void testGetResources() throws IOException { + final Client client = client(); + + createIndex(SAMPLE_TEST_INDEX); + indexSampleDocuments(); + + Set resources; + try ( + DefaultResourceAccessControlPlugin plugin = new DefaultResourceAccessControlPlugin( + client, + internalCluster().getInstance(ThreadPool.class) + ) + ) { + resources = plugin.getAccessibleResourcesForCurrentUser(SAMPLE_TEST_INDEX, TestResourcePlugin.TestResource.class); + + assertNotNull(resources); + MatcherAssert.assertThat(resources, hasSize(2)); + + MatcherAssert.assertThat(resources, hasItem(hasProperty("id", is("1")))); + MatcherAssert.assertThat(resources, hasItem(hasProperty("id", is("2")))); + } + } + + public void testSampleResourcePluginListResources() throws IOException { + createIndex(SAMPLE_TEST_INDEX); + indexSampleDocuments(); + + ResourceAccessControlPlugin racPlugin = TestResourcePlugin.GuiceHolder.getResourceService().getResourceAccessControlPlugin(); + MatcherAssert.assertThat(racPlugin.getClass(), is(DefaultResourceAccessControlPlugin.class)); + + Set resources = racPlugin.getAccessibleResourcesForCurrentUser( + SAMPLE_TEST_INDEX, + TestResourcePlugin.TestResource.class + ); + + assertNotNull(resources); + MatcherAssert.assertThat(resources, hasSize(2)); + MatcherAssert.assertThat(resources, hasItem(hasProperty("id", is("1")))); + MatcherAssert.assertThat(resources, hasItem(hasProperty("id", is("2")))); + } + + public void testSampleResourcePluginCallsHasPermission() { + + ResourceAccessControlPlugin racPlugin = TestResourcePlugin.GuiceHolder.getResourceService().getResourceAccessControlPlugin(); + MatcherAssert.assertThat(racPlugin.getClass(), is(DefaultResourceAccessControlPlugin.class)); + + boolean canAccess = racPlugin.hasPermission("1", SAMPLE_TEST_INDEX, "some_scope"); + + MatcherAssert.assertThat(canAccess, is(true)); + + } + + public void testSampleResourcePluginCallsShareWith() { + + ResourceAccessControlPlugin racPlugin = TestResourcePlugin.GuiceHolder.getResourceService().getResourceAccessControlPlugin(); + MatcherAssert.assertThat(racPlugin.getClass(), is(DefaultResourceAccessControlPlugin.class)); + + ResourceSharing sharingInfo = racPlugin.shareWith("1", SAMPLE_TEST_INDEX, new ShareWith(Set.of())); + + MatcherAssert.assertThat(sharingInfo, is(nullValue())); + } + + public void testSampleResourcePluginCallsRevokeAccess() { + + ResourceAccessControlPlugin racPlugin = TestResourcePlugin.GuiceHolder.getResourceService().getResourceAccessControlPlugin(); + MatcherAssert.assertThat(racPlugin.getClass(), is(DefaultResourceAccessControlPlugin.class)); + + ResourceSharing sharingInfo = racPlugin.revokeAccess("1", SAMPLE_TEST_INDEX, Map.of(), Set.of("some_scope")); + + MatcherAssert.assertThat(sharingInfo, is(nullValue())); + } + + public void testSampleResourcePluginCallsDeleteResourceSharingRecord() { + ResourceAccessControlPlugin racPlugin = TestResourcePlugin.GuiceHolder.getResourceService().getResourceAccessControlPlugin(); + MatcherAssert.assertThat(racPlugin.getClass(), is(DefaultResourceAccessControlPlugin.class)); + + boolean recordDeleted = racPlugin.deleteResourceSharingRecord("1", SAMPLE_TEST_INDEX); + + // no record to delete + MatcherAssert.assertThat(recordDeleted, is(false)); + } + + public void testSampleResourcePluginCallsDeleteAllResourceSharingRecordsForCurrentUser() { + ResourceAccessControlPlugin racPlugin = TestResourcePlugin.GuiceHolder.getResourceService().getResourceAccessControlPlugin(); + MatcherAssert.assertThat(racPlugin.getClass(), is(DefaultResourceAccessControlPlugin.class)); + + boolean recordDeleted = racPlugin.deleteAllResourceSharingRecordsForCurrentUser(); + + // no records to delete + MatcherAssert.assertThat(recordDeleted, is(false)); + } + + private void indexSampleDocuments() throws IOException { + XContentBuilder doc1 = jsonBuilder().startObject().field("id", "1").field("name", "Test Document 1").endObject(); + + XContentBuilder doc2 = jsonBuilder().startObject().field("id", "2").field("name", "Test Document 2").endObject(); + + try (Client client = client()) { + + client.prepareIndex(SAMPLE_TEST_INDEX).setId("1").setSource(doc1).get(); + + client.prepareIndex(SAMPLE_TEST_INDEX).setId("2").setSource(doc2).get(); + + client.admin().indices().prepareRefresh(SAMPLE_TEST_INDEX).get(); + } + } +} diff --git a/server/src/internalClusterTest/java/org/opensearch/accesscontrol/resources/testplugins/TestRACPlugin.java b/server/src/internalClusterTest/java/org/opensearch/accesscontrol/resources/testplugins/TestRACPlugin.java new file mode 100644 index 0000000000000..e9cde63cc6aa5 --- /dev/null +++ b/server/src/internalClusterTest/java/org/opensearch/accesscontrol/resources/testplugins/TestRACPlugin.java @@ -0,0 +1,14 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.accesscontrol.resources.testplugins; + +import org.opensearch.plugins.Plugin; +import org.opensearch.plugins.ResourceAccessControlPlugin; + +public class TestRACPlugin extends Plugin implements ResourceAccessControlPlugin {} diff --git a/server/src/internalClusterTest/java/org/opensearch/accesscontrol/resources/testplugins/TestResourcePlugin.java b/server/src/internalClusterTest/java/org/opensearch/accesscontrol/resources/testplugins/TestResourcePlugin.java new file mode 100644 index 0000000000000..7c0214fbcc000 --- /dev/null +++ b/server/src/internalClusterTest/java/org/opensearch/accesscontrol/resources/testplugins/TestResourcePlugin.java @@ -0,0 +1,116 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.accesscontrol.resources.testplugins; + +import org.opensearch.accesscontrol.resources.Resource; +import org.opensearch.accesscontrol.resources.ResourceService; +import org.opensearch.common.inject.Inject; +import org.opensearch.common.lifecycle.Lifecycle; +import org.opensearch.common.lifecycle.LifecycleComponent; +import org.opensearch.common.lifecycle.LifecycleListener; +import org.opensearch.core.common.io.stream.StreamOutput; +import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.plugins.Plugin; +import org.opensearch.plugins.ResourcePlugin; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +// Sample test resource plugin +public class TestResourcePlugin extends Plugin implements ResourcePlugin { + + public static final String SAMPLE_TEST_INDEX = ".sample_test_resource"; + + @Override + public String getResourceType() { + return ""; + } + + @Override + public String getResourceIndex() { + return SAMPLE_TEST_INDEX; + } + + @Override + public Collection> getGuiceServiceClasses() { + final List> services = new ArrayList<>(1); + services.add(GuiceHolder.class); + return services; + } + + public static class GuiceHolder implements LifecycleComponent { + + private static ResourceService resourceService; + + @Inject + public GuiceHolder(final ResourceService resourceService) { + GuiceHolder.resourceService = resourceService; + } + + public static ResourceService getResourceService() { + return resourceService; + } + + @Override + public void close() {} + + @Override + public Lifecycle.State lifecycleState() { + return null; + } + + @Override + public void addLifecycleListener(LifecycleListener listener) {} + + @Override + public void removeLifecycleListener(LifecycleListener listener) {} + + @Override + public void start() {} + + @Override + public void stop() {} + + } + + public static class TestResource implements Resource { + public String id; + public String name; + + public TestResource() {} + + public String getId() { + return id; + } + + public String getName() { + return name; + } + + @Override + public String getWriteableName() { + return "test_resource"; + } + + @Override + public void writeTo(StreamOutput out) {} + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + return null; + } + + @Override + public String getResourceName() { + return name; + } + } +} diff --git a/server/src/main/java/org/opensearch/accesscontrol/resources/CreatedBy.java b/server/src/main/java/org/opensearch/accesscontrol/resources/CreatedBy.java new file mode 100644 index 0000000000000..889952b3b8a69 --- /dev/null +++ b/server/src/main/java/org/opensearch/accesscontrol/resources/CreatedBy.java @@ -0,0 +1,89 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.accesscontrol.resources; + +import org.opensearch.core.common.io.stream.NamedWriteable; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; +import org.opensearch.core.xcontent.ToXContentFragment; +import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.core.xcontent.XContentParser; + +import java.io.IOException; + +/** + * This class is used to store information about the creator of a resource. + * Concrete implementation will be provided by security plugin + * + * @opensearch.experimental + */ +public class CreatedBy implements ToXContentFragment, NamedWriteable { + + private final String creatorType; + private final String creator; + + public CreatedBy(String creatorType, String creator) { + this.creatorType = creatorType; + this.creator = creator; + } + + public CreatedBy(StreamInput in) throws IOException { + this.creatorType = in.readString(); + this.creator = in.readString(); + } + + public String getCreator() { + return creator; + } + + public String getCreatorType() { + return creatorType; + } + + @Override + public String toString() { + return "CreatedBy {" + this.creatorType + "='" + this.creator + '\'' + '}'; + } + + @Override + public String getWriteableName() { + return "created_by"; + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + out.writeString(creatorType); + out.writeString(creator); + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + return builder.startObject().field(creatorType, creator).endObject(); + } + + public static CreatedBy fromXContent(XContentParser parser) throws IOException { + String creator = null; + String creatorType = null; + XContentParser.Token token; + + while ((token = parser.nextToken()) != XContentParser.Token.END_OBJECT) { + if (token == XContentParser.Token.FIELD_NAME) { + creatorType = parser.currentName(); + } else if (token == XContentParser.Token.VALUE_STRING) { + creator = parser.text(); + } + } + + if (creator == null) { + throw new IllegalArgumentException(creatorType + " is required"); + } + + return new CreatedBy(creatorType, creator); + } +} diff --git a/server/src/main/java/org/opensearch/accesscontrol/resources/RecipientType.java b/server/src/main/java/org/opensearch/accesscontrol/resources/RecipientType.java new file mode 100644 index 0000000000000..944e5d0e06106 --- /dev/null +++ b/server/src/main/java/org/opensearch/accesscontrol/resources/RecipientType.java @@ -0,0 +1,32 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.accesscontrol.resources; + +/** + * This class determines a type of recipient a resource can be shared with. + * An example type would be a user or a role. + * This class is used to determine the type of recipient a resource can be shared with. + * @opensearch.experimental + */ +public class RecipientType { + private final String type; + + public RecipientType(String type) { + this.type = type; + } + + public String getType() { + return type; + } + + @Override + public String toString() { + return type; + } +} diff --git a/server/src/main/java/org/opensearch/accesscontrol/resources/RecipientTypeRegistry.java b/server/src/main/java/org/opensearch/accesscontrol/resources/RecipientTypeRegistry.java new file mode 100644 index 0000000000000..e0c56d6f7c2c1 --- /dev/null +++ b/server/src/main/java/org/opensearch/accesscontrol/resources/RecipientTypeRegistry.java @@ -0,0 +1,33 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.accesscontrol.resources; + +import java.util.HashMap; +import java.util.Map; + +/** + * This class determines a collection of recipient types a resource can be shared with. + * + * @opensearch.experimental + */ +public class RecipientTypeRegistry { + private static final Map REGISTRY = new HashMap<>(); + + public static void registerRecipientType(String key, RecipientType recipientType) { + REGISTRY.put(key, recipientType); + } + + public static RecipientType fromValue(String value) { + RecipientType type = REGISTRY.get(value); + if (type == null) { + throw new IllegalArgumentException("Unknown RecipientType: " + value + ". Must be 1 of these: " + REGISTRY.values()); + } + return type; + } +} diff --git a/server/src/main/java/org/opensearch/accesscontrol/resources/Resource.java b/server/src/main/java/org/opensearch/accesscontrol/resources/Resource.java new file mode 100644 index 0000000000000..97f031515b526 --- /dev/null +++ b/server/src/main/java/org/opensearch/accesscontrol/resources/Resource.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. + */ + +package org.opensearch.accesscontrol.resources; + +import org.opensearch.core.common.io.stream.NamedWriteable; +import org.opensearch.core.xcontent.ToXContentFragment; + +/** + * Marker interface for all resources + */ +public interface Resource extends NamedWriteable, ToXContentFragment { + /** + * Get the resource name + * @return resource name + */ + String getResourceName(); + + // TODO: Next iteration, check if getResourceType() should be implemented +} diff --git a/server/src/main/java/org/opensearch/accesscontrol/resources/ResourceAccessScope.java b/server/src/main/java/org/opensearch/accesscontrol/resources/ResourceAccessScope.java new file mode 100644 index 0000000000000..ac27945b637a5 --- /dev/null +++ b/server/src/main/java/org/opensearch/accesscontrol/resources/ResourceAccessScope.java @@ -0,0 +1,21 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.accesscontrol.resources; + +/** + * This interface defines the two basic access scopes for resource-access. + * Each plugin must implement their own scopes and manage them. + * These access scopes will then be used to verify the type of access being requested. + * + * @opensearch.experimental + */ +public interface ResourceAccessScope { + String READ_ONLY = "read_only"; + String READ_WRITE = "read_write"; +} diff --git a/server/src/main/java/org/opensearch/accesscontrol/resources/ResourceService.java b/server/src/main/java/org/opensearch/accesscontrol/resources/ResourceService.java new file mode 100644 index 0000000000000..d58ec06fd34ac --- /dev/null +++ b/server/src/main/java/org/opensearch/accesscontrol/resources/ResourceService.java @@ -0,0 +1,71 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.accesscontrol.resources; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.opensearch.OpenSearchException; +import org.opensearch.accesscontrol.resources.fallback.DefaultResourceAccessControlPlugin; +import org.opensearch.client.Client; +import org.opensearch.common.inject.Inject; +import org.opensearch.plugins.ResourceAccessControlPlugin; +import org.opensearch.plugins.ResourcePlugin; +import org.opensearch.threadpool.ThreadPool; + +import java.util.List; +import java.util.stream.Collectors; + +/** + * Service to get the current ResourcePlugin to perform authorization. + * + * @opensearch.experimental + */ +public class ResourceService { + private static final Logger log = LogManager.getLogger(ResourceService.class); + + private final ResourceAccessControlPlugin resourceACPlugin; + private final List resourcePlugins; + + @Inject + public ResourceService( + final List resourceACPlugins, + List resourcePlugins, + Client client, + ThreadPool threadPool + ) { + this.resourcePlugins = resourcePlugins; + + if (resourceACPlugins.isEmpty()) { + log.info("Security plugin disabled: Using DefaultResourceAccessControlPlugin"); + resourceACPlugin = new DefaultResourceAccessControlPlugin(client, threadPool); + } else if (resourceACPlugins.size() == 1) { + log.info("Security plugin enabled: Using OpenSearchSecurityPlugin"); + resourceACPlugin = resourceACPlugins.get(0); + } else { + throw new OpenSearchException( + "Multiple resource access control plugins are not supported, found: " + + resourceACPlugins.stream().map(Object::getClass).map(Class::getName).collect(Collectors.joining(",")) + ); + } + } + + /** + * Gets the ResourceAccessControlPlugin in-effect to perform authorization + */ + public ResourceAccessControlPlugin getResourceAccessControlPlugin() { + return resourceACPlugin; + } + + /** + * Gets the list of ResourcePlugins + */ + public List listResourcePlugins() { + return List.copyOf(resourcePlugins); + } +} diff --git a/server/src/main/java/org/opensearch/accesscontrol/resources/ResourceSharing.java b/server/src/main/java/org/opensearch/accesscontrol/resources/ResourceSharing.java new file mode 100644 index 0000000000000..cabfa20189618 --- /dev/null +++ b/server/src/main/java/org/opensearch/accesscontrol/resources/ResourceSharing.java @@ -0,0 +1,207 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.accesscontrol.resources; + +import org.opensearch.core.common.io.stream.NamedWriteable; +import org.opensearch.core.common.io.stream.StreamOutput; +import org.opensearch.core.xcontent.ToXContentFragment; +import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.core.xcontent.XContentParser; + +import java.io.IOException; +import java.util.Objects; + +/** + * Represents a resource sharing configuration that manages access control for OpenSearch resources. + * This class holds information about shared resources including their source, creator, and sharing permissions. + * + *

This class implements {@link ToXContentFragment} for JSON serialization and {@link NamedWriteable} + * for stream-based serialization.

+ * + * The class maintains information about: + *
    + *
  • The source index where the resource is defined
  • + *
  • The unique identifier of the resource
  • + *
  • The creator's information
  • + *
  • The sharing permissions and recipients
  • + *
+ * + * + * @see CreatedBy + * @see ShareWith + * @opensearch.experimental + */ +public class ResourceSharing implements ToXContentFragment, NamedWriteable { + + /** + * The index where the resource is defined + */ + private String sourceIdx; + + /** + * The unique identifier of the resource + */ + private String resourceId; + + /** + * Information about who created the resource + */ + private CreatedBy createdBy; + + /** + * Information about with whom the resource is shared with + */ + private ShareWith shareWith; + + public ResourceSharing(String sourceIdx, String resourceId, CreatedBy createdBy, ShareWith shareWith) { + this.sourceIdx = sourceIdx; + this.resourceId = resourceId; + this.createdBy = createdBy; + this.shareWith = shareWith; + } + + public String getSourceIdx() { + return sourceIdx; + } + + public void setSourceIdx(String sourceIdx) { + this.sourceIdx = sourceIdx; + } + + public String getResourceId() { + return resourceId; + } + + public void setResourceId(String resourceId) { + this.resourceId = resourceId; + } + + public CreatedBy getCreatedBy() { + return createdBy; + } + + public void setCreatedBy(CreatedBy createdBy) { + this.createdBy = createdBy; + } + + public ShareWith getShareWith() { + return shareWith; + } + + public void setShareWith(ShareWith shareWith) { + this.shareWith = shareWith; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + ResourceSharing resourceSharing = (ResourceSharing) o; + return Objects.equals(getSourceIdx(), resourceSharing.getSourceIdx()) + && Objects.equals(getResourceId(), resourceSharing.getResourceId()) + && Objects.equals(getCreatedBy(), resourceSharing.getCreatedBy()) + && Objects.equals(getShareWith(), resourceSharing.getShareWith()); + } + + @Override + public int hashCode() { + return Objects.hash(getSourceIdx(), getResourceId(), getCreatedBy(), getShareWith()); + } + + @Override + public String toString() { + return "Resource {" + + "sourceIdx='" + + sourceIdx + + '\'' + + ", resourceId='" + + resourceId + + '\'' + + ", createdBy=" + + createdBy + + ", sharedWith=" + + shareWith + + '}'; + } + + @Override + public String getWriteableName() { + return "resource_sharing"; + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + out.writeString(sourceIdx); + out.writeString(resourceId); + createdBy.writeTo(out); + if (shareWith != null) { + out.writeBoolean(true); + shareWith.writeTo(out); + } else { + out.writeBoolean(false); + } + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + builder.startObject().field("source_idx", sourceIdx).field("resource_id", resourceId).field("created_by"); + createdBy.toXContent(builder, params); + if (shareWith != null && !shareWith.getSharedWithScopes().isEmpty()) { + builder.field("share_with"); + shareWith.toXContent(builder, params); + } + return builder.endObject(); + } + + public static ResourceSharing fromXContent(XContentParser parser) throws IOException { + String sourceIdx = null; + String resourceId = null; + CreatedBy createdBy = null; + ShareWith shareWith = null; + + String currentFieldName = null; + XContentParser.Token token; + + while ((token = parser.nextToken()) != XContentParser.Token.END_OBJECT) { + if (token == XContentParser.Token.FIELD_NAME) { + currentFieldName = parser.currentName(); + } else { + switch (Objects.requireNonNull(currentFieldName)) { + case "source_idx": + sourceIdx = parser.text(); + break; + case "resource_id": + resourceId = parser.text(); + break; + case "created_by": + createdBy = CreatedBy.fromXContent(parser); + break; + case "share_with": + shareWith = ShareWith.fromXContent(parser); + break; + default: + parser.skipChildren(); + break; + } + } + } + + validateRequiredField("source_idx", sourceIdx); + validateRequiredField("resource_id", resourceId); + validateRequiredField("created_by", createdBy); + + return new ResourceSharing(sourceIdx, resourceId, createdBy, shareWith); + } + + private static void validateRequiredField(String field, T value) { + if (value == null) { + throw new IllegalArgumentException(field + " is required"); + } + } +} diff --git a/server/src/main/java/org/opensearch/accesscontrol/resources/ShareWith.java b/server/src/main/java/org/opensearch/accesscontrol/resources/ShareWith.java new file mode 100644 index 0000000000000..e3f003f12728b --- /dev/null +++ b/server/src/main/java/org/opensearch/accesscontrol/resources/ShareWith.java @@ -0,0 +1,104 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.accesscontrol.resources; + +import org.opensearch.core.common.io.stream.NamedWriteable; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; +import org.opensearch.core.xcontent.ToXContentFragment; +import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.core.xcontent.XContentParser; + +import java.io.IOException; +import java.util.HashSet; +import java.util.Set; + +/** + * + * This class contains information about whom a resource is shared with and at what scope. + * Example: + * "share_with": { + * "read_only": { + * "users": [], + * "roles": [], + * "backend_roles": [] + * }, + * "read_write": { + * "users": [], + * "roles": [], + * "backend_roles": [] + * } + * } + * + * @opensearch.experimental + */ +public class ShareWith implements ToXContentFragment, NamedWriteable { + + /** + * A set of objects representing the scopes and their associated users, roles, and backend roles. + */ + private final Set sharedWithScopes; + + public ShareWith(Set sharedWithScopes) { + this.sharedWithScopes = sharedWithScopes; + } + + public ShareWith(StreamInput in) throws IOException { + this.sharedWithScopes = in.readSet(SharedWithScope::new); + } + + public Set getSharedWithScopes() { + return sharedWithScopes; + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + builder.startObject(); + + for (SharedWithScope scope : sharedWithScopes) { + scope.toXContent(builder, params); + } + + return builder.endObject(); + } + + public static ShareWith fromXContent(XContentParser parser) throws IOException { + Set sharedWithScopes = new HashSet<>(); + + if (parser.currentToken() != XContentParser.Token.START_OBJECT) { + parser.nextToken(); + } + + XContentParser.Token token; + while ((token = parser.nextToken()) != XContentParser.Token.END_OBJECT) { + // Each field in the object represents a SharedWithScope + if (token == XContentParser.Token.FIELD_NAME) { + SharedWithScope scope = SharedWithScope.fromXContent(parser); + sharedWithScopes.add(scope); + } + } + + return new ShareWith(sharedWithScopes); + } + + @Override + public String getWriteableName() { + return "share_with"; + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + out.writeCollection(sharedWithScopes); + } + + @Override + public String toString() { + return "ShareWith " + sharedWithScopes; + } +} diff --git a/server/src/main/java/org/opensearch/accesscontrol/resources/SharedWithScope.java b/server/src/main/java/org/opensearch/accesscontrol/resources/SharedWithScope.java new file mode 100644 index 0000000000000..01f0982f1ca76 --- /dev/null +++ b/server/src/main/java/org/opensearch/accesscontrol/resources/SharedWithScope.java @@ -0,0 +1,169 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.accesscontrol.resources; + +import org.opensearch.core.common.io.stream.NamedWriteable; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; +import org.opensearch.core.xcontent.ToXContentFragment; +import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.core.xcontent.XContentParser; + +import java.io.IOException; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +/** + * This class represents the scope at which a resource is shared with. + * Example: + * "read_only": { + * "users": [], + * "roles": [], + * "backend_roles": [] + * } + * where "users", "roles" and "backend_roles" are the recipient entities + * + * @opensearch.experimental + */ +public class SharedWithScope implements ToXContentFragment, NamedWriteable { + + private final String scope; + + private final ScopeRecipients scopeRecipients; + + public SharedWithScope(String scope, ScopeRecipients scopeRecipients) { + this.scope = scope; + this.scopeRecipients = scopeRecipients; + } + + public SharedWithScope(StreamInput in) throws IOException { + this.scope = in.readString(); + this.scopeRecipients = new ScopeRecipients(in); + } + + public String getScope() { + return scope; + } + + public ScopeRecipients getSharedWithPerScope() { + return scopeRecipients; + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + builder.field(scope); + builder.startObject(); + + scopeRecipients.toXContent(builder, params); + + return builder.endObject(); + } + + public static SharedWithScope fromXContent(XContentParser parser) throws IOException { + String scope = parser.currentName(); + + parser.nextToken(); + + ScopeRecipients scopeRecipients = ScopeRecipients.fromXContent(parser); + + return new SharedWithScope(scope, scopeRecipients); + } + + @Override + public String toString() { + return "{" + scope + ": " + scopeRecipients + '}'; + } + + @Override + public String getWriteableName() { + return "shared_with_scope"; + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + out.writeString(scope); + out.writeNamedWriteable(scopeRecipients); + } + + /** + * This class represents the entities with whom a resource is shared with for a given scope. + * + * @opensearch.experimental + */ + public static class ScopeRecipients implements ToXContentFragment, NamedWriteable { + + private final Map> recipients; + + public ScopeRecipients(Map> recipients) { + if (recipients == null) { + throw new IllegalArgumentException("Recipients map cannot be null"); + } + this.recipients = recipients; + } + + public ScopeRecipients(StreamInput in) throws IOException { + this.recipients = in.readMap( + key -> RecipientTypeRegistry.fromValue(key.readString()), + input -> input.readSet(StreamInput::readString) + ); + } + + public Map> getRecipients() { + return recipients; + } + + @Override + public String getWriteableName() { + return "scope_recipients"; + } + + public static ScopeRecipients fromXContent(XContentParser parser) throws IOException { + Map> recipients = new HashMap<>(); + + XContentParser.Token token; + while ((token = parser.nextToken()) != XContentParser.Token.END_OBJECT) { + if (token == XContentParser.Token.FIELD_NAME) { + String fieldName = parser.currentName(); + RecipientType recipientType = RecipientTypeRegistry.fromValue(fieldName); + + parser.nextToken(); + Set values = new HashSet<>(); + while (parser.nextToken() != XContentParser.Token.END_ARRAY) { + values.add(parser.text()); + } + recipients.put(recipientType, values); + } + } + + return new ScopeRecipients(recipients); + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + out.writeMap( + recipients, + (streamOutput, recipientType) -> streamOutput.writeString(recipientType.getType()), + (streamOutput, strings) -> streamOutput.writeCollection(strings, StreamOutput::writeString) + ); + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + if (recipients.isEmpty()) { + return builder; + } + for (Map.Entry> entry : recipients.entrySet()) { + builder.array(entry.getKey().getType(), entry.getValue().toArray()); + } + return builder; + } + } +} diff --git a/server/src/main/java/org/opensearch/accesscontrol/resources/fallback/DefaultResourceAccessControlPlugin.java b/server/src/main/java/org/opensearch/accesscontrol/resources/fallback/DefaultResourceAccessControlPlugin.java new file mode 100644 index 0000000000000..2efc519e05852 --- /dev/null +++ b/server/src/main/java/org/opensearch/accesscontrol/resources/fallback/DefaultResourceAccessControlPlugin.java @@ -0,0 +1,131 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.accesscontrol.resources.fallback; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.opensearch.OpenSearchException; +import org.opensearch.accesscontrol.resources.Resource; +import org.opensearch.action.search.ClearScrollRequest; +import org.opensearch.action.search.SearchRequest; +import org.opensearch.action.search.SearchResponse; +import org.opensearch.action.search.SearchScrollAction; +import org.opensearch.action.search.SearchScrollRequest; +import org.opensearch.client.Client; +import org.opensearch.common.inject.Inject; +import org.opensearch.common.unit.TimeValue; +import org.opensearch.common.util.concurrent.ThreadContext; +import org.opensearch.index.query.QueryBuilders; +import org.opensearch.plugins.Plugin; +import org.opensearch.plugins.ResourceAccessControlPlugin; +import org.opensearch.search.SearchHit; +import org.opensearch.search.builder.SearchSourceBuilder; +import org.opensearch.threadpool.ThreadPool; + +import java.security.AccessController; +import java.security.PrivilegedActionException; +import java.security.PrivilegedExceptionAction; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; +import java.util.stream.Stream; + +/** + * This plugin class defines a pass-through implementation of ResourceAccessControlPlugin. + * This plugin will come into effect when security plugin is disabled in the cluster. + * + * @opensearch.experimental + */ +public class DefaultResourceAccessControlPlugin extends Plugin implements ResourceAccessControlPlugin { + + private static final Logger log = LogManager.getLogger(DefaultResourceAccessControlPlugin.class); + + private final Client client; + + private final ThreadPool threadPool; + private static final ObjectMapper objectMapper = new ObjectMapper(); + + @Inject + public DefaultResourceAccessControlPlugin(Client client, ThreadPool threadPool) { + this.client = client; + this.threadPool = threadPool; + } + + /** + * Returns a list of all resource ids in provided resourceIndex + * @param resourceIndex index where resources are stored + * + * @return Set of resource ids + */ + @Override + public Set getAccessibleResourcesForCurrentUser(String resourceIndex, Class clazz) { + final Set documents = new HashSet<>(); + final TimeValue scrollTimeout = TimeValue.timeValueMinutes(1); + String scrollId; + + log.info("Searching for accessible resources in index {}", resourceIndex); + // stashContext is required in case resourceIndex is a system index + try (ThreadContext.StoredContext ctx = threadPool.getThreadContext().stashContext()) { + final SearchRequest searchRequest = buildSearchRequest(resourceIndex, scrollTimeout); + + SearchResponse searchResponse = client.search(searchRequest).actionGet(); + scrollId = searchResponse.getScrollId(); + SearchHit[] searchHits = searchResponse.getHits().getHits(); + + while (searchHits != null && searchHits.length > 0) { + parseAndAddToDocuments(Arrays.stream(searchHits), clazz, documents); + + final SearchScrollRequest scrollRequest = new SearchScrollRequest(scrollId); + scrollRequest.scroll(scrollTimeout); + searchResponse = client.execute(SearchScrollAction.INSTANCE, scrollRequest).actionGet(); + scrollId = searchResponse.getScrollId(); + searchHits = searchResponse.getHits().getHits(); + } + + final ClearScrollRequest clearScrollRequest = new ClearScrollRequest(); + clearScrollRequest.addScrollId(scrollId); + client.clearScroll(clearScrollRequest).actionGet(); + } catch (Exception e) { + log.error("Error fetching resources : {}", e.getMessage()); + return Set.of(); + } + log.info("Found {} documents", documents.size()); + return Collections.unmodifiableSet(documents); + } + + private SearchRequest buildSearchRequest(String resourceIndex, TimeValue scrollTimeout) { + final SearchRequest searchRequest = new SearchRequest(resourceIndex); + final SearchSourceBuilder sourceBuilder = new SearchSourceBuilder(); + sourceBuilder.query(QueryBuilders.matchAllQuery()); + sourceBuilder.fetchSource(true); + sourceBuilder.size(10000); + + searchRequest.scroll(scrollTimeout); + searchRequest.source(sourceBuilder); + + return searchRequest; + } + + private static T parse(String source, Class clazz) { + try { + return AccessController.doPrivileged((PrivilegedExceptionAction) () -> objectMapper.readValue(source, clazz)); + } catch (PrivilegedActionException e) { + log.error("Error parsing source: {}", e.toString()); + throw new OpenSearchException("Error parsing source into : " + clazz.getName(), e.getException()); + } + } + + private static void parseAndAddToDocuments(Stream searchHits, Class clazz, Set documents) { + searchHits.map(hit -> parse(hit.getSourceAsString(), clazz)).forEach(documents::add); + } + +} diff --git a/server/src/main/java/org/opensearch/accesscontrol/resources/fallback/package-info.java b/server/src/main/java/org/opensearch/accesscontrol/resources/fallback/package-info.java new file mode 100644 index 0000000000000..82c60fe3406b4 --- /dev/null +++ b/server/src/main/java/org/opensearch/accesscontrol/resources/fallback/package-info.java @@ -0,0 +1,14 @@ +/* + * 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. + */ + +/** + * This package defines a pass-through implementation of ResourceAccessControlPlugin. + * + * @opensearch.experimental + */ +package org.opensearch.accesscontrol.resources.fallback; diff --git a/server/src/main/java/org/opensearch/accesscontrol/resources/package-info.java b/server/src/main/java/org/opensearch/accesscontrol/resources/package-info.java new file mode 100644 index 0000000000000..61ccd7d879b05 --- /dev/null +++ b/server/src/main/java/org/opensearch/accesscontrol/resources/package-info.java @@ -0,0 +1,14 @@ +/* + * 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. + */ + +/** + * This package defines class required to implement resource access control in OpenSearch. + * + * @opensearch.experimental + */ +package org.opensearch.accesscontrol.resources; diff --git a/server/src/main/java/org/opensearch/common/util/concurrent/ThreadContext.java b/server/src/main/java/org/opensearch/common/util/concurrent/ThreadContext.java index 75a7ef94978d4..081e2afe936ef 100644 --- a/server/src/main/java/org/opensearch/common/util/concurrent/ThreadContext.java +++ b/server/src/main/java/org/opensearch/common/util/concurrent/ThreadContext.java @@ -411,8 +411,9 @@ public String getHeader(String key) { /** * Returns the persistent header for the given key or null if not present - persistent headers cannot be stashed */ - public Object getPersistent(String key) { - return threadLocal.get().persistentHeaders.get(key); + @SuppressWarnings("unchecked") // (T)object + public T getPersistent(String key) { + return (T) threadLocal.get().persistentHeaders.get(key); } /** diff --git a/server/src/main/java/org/opensearch/node/Node.java b/server/src/main/java/org/opensearch/node/Node.java index 704a23890b07a..85724c3f7a070 100644 --- a/server/src/main/java/org/opensearch/node/Node.java +++ b/server/src/main/java/org/opensearch/node/Node.java @@ -41,6 +41,7 @@ import org.opensearch.OpenSearchParseException; import org.opensearch.OpenSearchTimeoutException; import org.opensearch.Version; +import org.opensearch.accesscontrol.resources.ResourceService; import org.opensearch.action.ActionModule; import org.opensearch.action.ActionModule.DynamicActionRegistry; import org.opensearch.action.ActionType; @@ -214,6 +215,8 @@ import org.opensearch.plugins.Plugin; import org.opensearch.plugins.PluginsService; import org.opensearch.plugins.RepositoryPlugin; +import org.opensearch.plugins.ResourceAccessControlPlugin; +import org.opensearch.plugins.ResourcePlugin; import org.opensearch.plugins.ScriptPlugin; import org.opensearch.plugins.SearchPipelinePlugin; import org.opensearch.plugins.SearchPlugin; @@ -1108,6 +1111,12 @@ protected Node( ); modules.add(actionModule); + final List resourceAccessControlPlugins = pluginsService.filterPlugins( + ResourceAccessControlPlugin.class + ); + final List resourcePlugins = pluginsService.filterPlugins(ResourcePlugin.class); + ResourceService resourceService = new ResourceService(resourceAccessControlPlugins, resourcePlugins, client, threadPool); + final RestController restController = actionModule.getRestController(); final NodeResourceUsageTracker nodeResourceUsageTracker = new NodeResourceUsageTracker( @@ -1513,6 +1522,7 @@ protected Node( b.bind(ResourceUsageCollectorService.class).toInstance(resourceUsageCollectorService); b.bind(SystemIndices.class).toInstance(systemIndices); b.bind(IdentityService.class).toInstance(identityService); + b.bind(ResourceService.class).toInstance(resourceService); b.bind(Tracer.class).toInstance(tracer); b.bind(SearchRequestStats.class).toInstance(searchRequestStats); b.bind(SearchRequestSlowLog.class).toInstance(searchRequestSlowLog); diff --git a/server/src/main/java/org/opensearch/plugins/ResourceAccessControlPlugin.java b/server/src/main/java/org/opensearch/plugins/ResourceAccessControlPlugin.java new file mode 100644 index 0000000000000..d13ba9787cdea --- /dev/null +++ b/server/src/main/java/org/opensearch/plugins/ResourceAccessControlPlugin.java @@ -0,0 +1,99 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.plugins; + +import org.opensearch.accesscontrol.resources.RecipientType; +import org.opensearch.accesscontrol.resources.Resource; +import org.opensearch.accesscontrol.resources.ResourceSharing; +import org.opensearch.accesscontrol.resources.ShareWith; + +import java.util.Map; +import java.util.Set; + +/** + * This plugin allows to control access to resources. It is used by the ResourcePlugins to check whether a user has access to a resource defined by that plugin. + * It also defines java APIs to list, share or revoke resources with other users. + * User information will be fetched from the ThreadContext. + * + * @opensearch.experimental + */ +public interface ResourceAccessControlPlugin { + + /** + * Returns all accessible resources for current user for a given plugin index. + * @param resourceIndex index where the resource exists + * @param clazz class of the resource. Required to parse the resource object retrieved from resourceIndex. Must be a type of {@link Resource} + * @return set of {@link ResourceSharing} items accessible by current user. + */ + default Set getAccessibleResourcesForCurrentUser(String resourceIndex, Class clazz) { + return Set.of(); + } + + /** + * Checks whether current user has permission to given resource. + * + * @param resourceId the resource on which access is to be checked + * @param resourceIndex where the resource exists + * @param scope the scope being requested + * @return true if current user has access, false otherwise + */ + default boolean hasPermission(String resourceId, String resourceIndex, String scope) { + return true; + } + + /** + * Adds an entity to the share-with. + * Creates a resource sharing record if one doesn't exist. + * @param resourceId id of the resource to be updated + * @param resourceIndex index where this resource is stored + * @param shareWith an object that contains entries of entities with whom the resource should be shared with + * @return updated resource sharing record + */ + default ResourceSharing shareWith(String resourceId, String resourceIndex, ShareWith shareWith) { + return null; + } + + /** + * Revokes given scope permission to a resource + * + * @param resourceId if of the resource to be updated + * @param resourceIndex index where this resource is stored + * @param revokeAccess a map that contains entries of entities whose access should be revoked + * @param scopes Scopes to be checked for revoking access. If empty, all scopes will be checked. + * @return the updated ResourceSharing record + */ + default ResourceSharing revokeAccess( + String resourceId, + String resourceIndex, + Map> revokeAccess, + Set scopes + ) { + return null; + } + + /** + * Deletes a resource sharing record + * + * @param resourceId if of the resource to delete the resource sharing record + * @param resourceIndex index where this resource is stored + * @return true if resource record was deleted, false otherwise + */ + default boolean deleteResourceSharingRecord(String resourceId, String resourceIndex) { + return false; + } + + /** + * Deletes all resource sharing records for current user + * @return true if resource records were deleted, false otherwise + */ + default boolean deleteAllResourceSharingRecordsForCurrentUser() { + return false; + } + +} diff --git a/server/src/main/java/org/opensearch/plugins/ResourcePlugin.java b/server/src/main/java/org/opensearch/plugins/ResourcePlugin.java new file mode 100644 index 0000000000000..4fbbbf35fc367 --- /dev/null +++ b/server/src/main/java/org/opensearch/plugins/ResourcePlugin.java @@ -0,0 +1,29 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.plugins; + +/** + * This interface should be implemented by all the plugins that define one or more resources. + * + * @opensearch.experimental + */ +public interface ResourcePlugin { + + /** + * Type of the resource + * @return a string containing the type of the resource + */ + String getResourceType(); + + /** + * The index where resource meta-data is stored + * @return the name of the parent index where resource meta-data is stored + */ + String getResourceIndex(); +} diff --git a/server/src/main/resources/org/opensearch/bootstrap/security.policy b/server/src/main/resources/org/opensearch/bootstrap/security.policy index 22e445f7d9022..9e794bf76ccae 100644 --- a/server/src/main/resources/org/opensearch/bootstrap/security.policy +++ b/server/src/main/resources/org/opensearch/bootstrap/security.policy @@ -48,10 +48,18 @@ grant codeBase "${codebase.opensearch}" { permission java.lang.RuntimePermission "setContextClassLoader"; // needed for SPI class loading permission java.lang.RuntimePermission "accessDeclaredMembers"; + // needed for DefaultResourceAccessPlugin + permission java.lang.reflect.ReflectPermission "suppressAccessChecks"; permission org.opensearch.secure_sm.ThreadContextPermission "markAsSystemContext"; permission org.opensearch.secure_sm.ThreadContextPermission "stashWithOrigin"; }; +//// Permission specific to jackson-databind used in DefaultResourceAccessPlugin +grant codeBase "${codebase.jackson-databind}" { + permission java.lang.reflect.ReflectPermission "suppressAccessChecks"; + permission java.lang.RuntimePermission "accessDeclaredMembers"; +}; + //// Very special jar permissions: //// These are dangerous permissions that we don't want to grant to everything. diff --git a/server/src/test/java/org/opensearch/accesscontrol/resources/CreatedByTests.java b/server/src/test/java/org/opensearch/accesscontrol/resources/CreatedByTests.java new file mode 100644 index 0000000000000..bd86cb36fcfa2 --- /dev/null +++ b/server/src/test/java/org/opensearch/accesscontrol/resources/CreatedByTests.java @@ -0,0 +1,285 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.accesscontrol.resources; + +import org.opensearch.common.io.stream.BytesStreamOutput; +import org.opensearch.common.xcontent.XContentFactory; +import org.opensearch.common.xcontent.json.JsonXContent; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; +import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.core.xcontent.XContentParser; +import org.opensearch.test.OpenSearchTestCase; +import org.hamcrest.MatcherAssert; + +import java.io.IOException; + +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.greaterThan; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.Matchers.nullValue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class CreatedByTests extends OpenSearchTestCase { + + private static final String CREATOR_TYPE = "user"; + + public void testCreatedByConstructorWithValidUser() { + String expectedUser = "testUser"; + CreatedBy createdBy = new CreatedBy(CREATOR_TYPE, expectedUser); + + MatcherAssert.assertThat(expectedUser, is(equalTo(createdBy.getCreator()))); + } + + public void testCreatedByFromStreamInput() throws IOException { + String expectedUser = "testUser"; + + try (BytesStreamOutput out = new BytesStreamOutput()) { + out.writeString(CREATOR_TYPE); + out.writeString(expectedUser); + + StreamInput in = out.bytes().streamInput(); + + CreatedBy createdBy = new CreatedBy(in); + + MatcherAssert.assertThat(expectedUser, is(equalTo(createdBy.getCreator()))); + } + } + + public void testCreatedByWithEmptyStreamInput() throws IOException { + + try (StreamInput mockStreamInput = mock(StreamInput.class)) { + when(mockStreamInput.readString()).thenThrow(new IOException("EOF")); + + assertThrows(IOException.class, () -> new CreatedBy(mockStreamInput)); + } + } + + public void testCreatedByWithEmptyUser() { + + CreatedBy createdBy = new CreatedBy(CREATOR_TYPE, ""); + MatcherAssert.assertThat("", equalTo(createdBy.getCreator())); + } + + public void testCreatedByWithIOException() throws IOException { + + try (StreamInput mockStreamInput = mock(StreamInput.class)) { + when(mockStreamInput.readString()).thenThrow(new IOException("Test IOException")); + + assertThrows(IOException.class, () -> new CreatedBy(mockStreamInput)); + } + } + + public void testCreatedByWithLongUsername() { + String longUsername = "a".repeat(10000); + CreatedBy createdBy = new CreatedBy(CREATOR_TYPE, longUsername); + MatcherAssert.assertThat(longUsername, equalTo(createdBy.getCreator())); + } + + public void testCreatedByWithUnicodeCharacters() { + String unicodeUsername = "用户こんにちは"; + CreatedBy createdBy = new CreatedBy(CREATOR_TYPE, unicodeUsername); + MatcherAssert.assertThat(unicodeUsername, equalTo(createdBy.getCreator())); + } + + public void testFromXContentThrowsExceptionWhenUserFieldIsMissing() throws IOException { + String emptyJson = "{}"; + IllegalArgumentException exception; + try (XContentParser parser = JsonXContent.jsonXContent.createParser(null, null, emptyJson)) { + + exception = assertThrows(IllegalArgumentException.class, () -> CreatedBy.fromXContent(parser)); + } + + MatcherAssert.assertThat("null is required", equalTo(exception.getMessage())); + } + + public void testFromXContentWithEmptyInput() throws IOException { + String emptyJson = "{}"; + try (XContentParser parser = JsonXContent.jsonXContent.createParser(null, null, emptyJson)) { + + assertThrows(IllegalArgumentException.class, () -> CreatedBy.fromXContent(parser)); + } + } + + public void testFromXContentWithExtraFields() throws IOException { + String jsonWithExtraFields = "{\"user\": \"testUser\", \"extraField\": \"value\"}"; + XContentParser parser = JsonXContent.jsonXContent.createParser(null, null, jsonWithExtraFields); + + CreatedBy.fromXContent(parser); + } + + public void testFromXContentWithIncorrectFieldType() throws IOException { + String jsonWithIncorrectType = "{\"user\": 12345}"; + try (XContentParser parser = JsonXContent.jsonXContent.createParser(null, null, jsonWithIncorrectType)) { + + assertThrows(IllegalArgumentException.class, () -> CreatedBy.fromXContent(parser)); + } + } + + public void testFromXContentWithEmptyUser() throws IOException { + String emptyJson = "{\"" + CREATOR_TYPE + "\": \"\" }"; + CreatedBy createdBy; + try (XContentParser parser = JsonXContent.jsonXContent.createParser(null, null, emptyJson)) { + parser.nextToken(); + + createdBy = CreatedBy.fromXContent(parser); + } + + MatcherAssert.assertThat(CREATOR_TYPE, equalTo(createdBy.getCreatorType())); + MatcherAssert.assertThat("", equalTo(createdBy.getCreator())); + } + + public void testFromXContentWithNullUserValue() throws IOException { + String jsonWithNullUser = "{\"user\": null}"; + try (XContentParser parser = JsonXContent.jsonXContent.createParser(null, null, jsonWithNullUser)) { + + assertThrows(IllegalArgumentException.class, () -> CreatedBy.fromXContent(parser)); + } + } + + public void testFromXContentWithValidUser() throws IOException { + String json = "{\"user\":\"testUser\"}"; + XContentParser parser = JsonXContent.jsonXContent.createParser(null, null, json); + + CreatedBy createdBy = CreatedBy.fromXContent(parser); + + MatcherAssert.assertThat(createdBy, notNullValue()); + MatcherAssert.assertThat("testUser", equalTo(createdBy.getCreator())); + } + + public void testGetCreatorReturnsCorrectValue() { + String expectedUser = "testUser"; + CreatedBy createdBy = new CreatedBy(CREATOR_TYPE, expectedUser); + + String actualUser = createdBy.getCreator(); + + MatcherAssert.assertThat(expectedUser, equalTo(actualUser)); + } + + public void testGetCreatorWithNullString() { + + CreatedBy createdBy = new CreatedBy(CREATOR_TYPE, null); + MatcherAssert.assertThat(createdBy.getCreator(), nullValue()); + } + + public void testGetWriteableNameReturnsCorrectString() { + CreatedBy createdBy = new CreatedBy(CREATOR_TYPE, "testUser"); + MatcherAssert.assertThat("created_by", equalTo(createdBy.getWriteableName())); + } + + public void testToStringWithEmptyUser() { + CreatedBy createdBy = new CreatedBy(CREATOR_TYPE, ""); + String result = createdBy.toString(); + MatcherAssert.assertThat("CreatedBy {user=''}", equalTo(result)); + } + + public void testToStringWithNullUser() { + CreatedBy createdBy = new CreatedBy(CREATOR_TYPE, (String) null); + String result = createdBy.toString(); + MatcherAssert.assertThat("CreatedBy {user='null'}", equalTo(result)); + } + + public void testToStringWithLongUserName() { + + String longUserName = "a".repeat(1000); + CreatedBy createdBy = new CreatedBy(CREATOR_TYPE, longUserName); + String result = createdBy.toString(); + MatcherAssert.assertThat(result.startsWith("CreatedBy {user='"), is(true)); + MatcherAssert.assertThat(result.endsWith("'}"), is(true)); + MatcherAssert.assertThat(1019, equalTo(result.length())); + } + + public void testToXContentWithEmptyUser() throws IOException { + CreatedBy createdBy = new CreatedBy(CREATOR_TYPE, ""); + XContentBuilder builder = JsonXContent.contentBuilder(); + + createdBy.toXContent(builder, null); + String result = builder.toString(); + MatcherAssert.assertThat("{\"user\":\"\"}", equalTo(result)); + } + + public void testWriteToWithExceptionInStreamOutput() throws IOException { + CreatedBy createdBy = new CreatedBy(CREATOR_TYPE, "user1"); + try (StreamOutput failingOutput = new StreamOutput() { + @Override + public void writeByte(byte b) throws IOException { + throw new IOException("Simulated IO exception"); + } + + @Override + public void writeBytes(byte[] b, int offset, int length) throws IOException { + throw new IOException("Simulated IO exception"); + } + + @Override + public void flush() throws IOException { + + } + + @Override + public void close() throws IOException { + + } + + @Override + public void reset() throws IOException { + + } + }) { + + assertThrows(IOException.class, () -> createdBy.writeTo(failingOutput)); + } + } + + public void testWriteToWithLongUserName() throws IOException { + String longUserName = "a".repeat(65536); + CreatedBy createdBy = new CreatedBy(CREATOR_TYPE, longUserName); + BytesStreamOutput out = new BytesStreamOutput(); + createdBy.writeTo(out); + MatcherAssert.assertThat(out.size(), greaterThan(65536)); + } + + public void test_createdByToStringReturnsCorrectFormat() { + String testUser = "testUser"; + CreatedBy createdBy = new CreatedBy(CREATOR_TYPE, testUser); + + String expected = "CreatedBy {user='" + testUser + "'}"; + String actual = createdBy.toString(); + + MatcherAssert.assertThat(expected, equalTo(actual)); + } + + public void test_toXContent_serializesCorrectly() throws IOException { + String expectedUser = "testUser"; + CreatedBy createdBy = new CreatedBy(CREATOR_TYPE, expectedUser); + XContentBuilder builder = XContentFactory.jsonBuilder(); + + createdBy.toXContent(builder, null); + + String expectedJson = "{\"user\":\"testUser\"}"; + MatcherAssert.assertThat(expectedJson, equalTo(builder.toString())); + } + + public void test_writeTo_writesUserCorrectly() throws IOException { + String expectedUser = "testUser"; + CreatedBy createdBy = new CreatedBy(CREATOR_TYPE, expectedUser); + + BytesStreamOutput out = new BytesStreamOutput(); + createdBy.writeTo(out); + + StreamInput in = out.bytes().streamInput(); + in.readString(); + String actualUser = in.readString(); + + MatcherAssert.assertThat(expectedUser, equalTo(actualUser)); + } + +} diff --git a/server/src/test/java/org/opensearch/accesscontrol/resources/RecipientTypeRegistryTests.java b/server/src/test/java/org/opensearch/accesscontrol/resources/RecipientTypeRegistryTests.java new file mode 100644 index 0000000000000..8ed5ebaba2e2d --- /dev/null +++ b/server/src/test/java/org/opensearch/accesscontrol/resources/RecipientTypeRegistryTests.java @@ -0,0 +1,32 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.accesscontrol.resources; + +import org.opensearch.test.OpenSearchTestCase; +import org.hamcrest.MatcherAssert; + +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; + +public class RecipientTypeRegistryTests extends OpenSearchTestCase { + + public void testFromValue() { + RecipientTypeRegistry.registerRecipientType("ble1", new RecipientType("ble1")); + RecipientTypeRegistry.registerRecipientType("ble2", new RecipientType("ble2")); + + // Valid Value + RecipientType type = RecipientTypeRegistry.fromValue("ble1"); + assertNotNull(type); + assertEquals("ble1", type.getType()); + + // Invalid Value + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> RecipientTypeRegistry.fromValue("bleble")); + MatcherAssert.assertThat("Unknown RecipientType: bleble. Must be 1 of these: [ble1, ble2]", is(equalTo(exception.getMessage()))); + } +} diff --git a/server/src/test/java/org/opensearch/accesscontrol/resources/ResourceServiceTests.java b/server/src/test/java/org/opensearch/accesscontrol/resources/ResourceServiceTests.java new file mode 100644 index 0000000000000..48e08806ea2da --- /dev/null +++ b/server/src/test/java/org/opensearch/accesscontrol/resources/ResourceServiceTests.java @@ -0,0 +1,224 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.accesscontrol.resources; + +import org.opensearch.OpenSearchException; +import org.opensearch.accesscontrol.resources.fallback.DefaultResourceAccessControlPlugin; +import org.opensearch.client.Client; +import org.opensearch.plugins.ResourceAccessControlPlugin; +import org.opensearch.plugins.ResourcePlugin; +import org.opensearch.test.OpenSearchTestCase; +import org.opensearch.threadpool.ThreadPool; +import org.hamcrest.MatcherAssert; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.instanceOf; +import static org.hamcrest.Matchers.is; +import static org.mockito.Mockito.mock; + +public class ResourceServiceTests extends OpenSearchTestCase { + + @Mock + private Client client; + + @Mock + private ThreadPool threadPool; + + public void setup() { + MockitoAnnotations.openMocks(this); + } + + public void testGetResourceAccessControlPluginReturnsInitializedPlugin() { + setup(); + Client mockClient = mock(Client.class); + ThreadPool mockThreadPool = mock(ThreadPool.class); + + ResourceAccessControlPlugin mockPlugin = mock(ResourceAccessControlPlugin.class); + List plugins = new ArrayList<>(); + plugins.add(mockPlugin); + + List resourcePlugins = new ArrayList<>(); + + ResourceService resourceService = new ResourceService(plugins, resourcePlugins, mockClient, mockThreadPool); + + ResourceAccessControlPlugin result = resourceService.getResourceAccessControlPlugin(); + + MatcherAssert.assertThat(mockPlugin, equalTo(result)); + } + + public void testGetResourceAccessControlPlugin_NoPlugins() { + setup(); + List emptyPlugins = new ArrayList<>(); + List resourcePlugins = new ArrayList<>(); + + ResourceService resourceService = new ResourceService(emptyPlugins, resourcePlugins, client, threadPool); + + ResourceAccessControlPlugin result = resourceService.getResourceAccessControlPlugin(); + + assertNotNull(result); + MatcherAssert.assertThat(result, instanceOf(DefaultResourceAccessControlPlugin.class)); + } + + public void testGetResourceAccessControlPlugin_SinglePlugin() { + setup(); + ResourceAccessControlPlugin mockPlugin = mock(ResourceAccessControlPlugin.class); + List singlePlugin = Arrays.asList(mockPlugin); + List resourcePlugins = new ArrayList<>(); + + ResourceService resourceService = new ResourceService(singlePlugin, resourcePlugins, client, threadPool); + + ResourceAccessControlPlugin result = resourceService.getResourceAccessControlPlugin(); + + assertNotNull(result); + assertSame(mockPlugin, result); + } + + public void testListResourcePluginsReturnsPluginList() { + setup(); + List resourceACPlugins = new ArrayList<>(); + List expectedResourcePlugins = new ArrayList<>(); + expectedResourcePlugins.add(mock(ResourcePlugin.class)); + expectedResourcePlugins.add(mock(ResourcePlugin.class)); + + ResourceService resourceService = new ResourceService(resourceACPlugins, expectedResourcePlugins, client, threadPool); + + List actualResourcePlugins = resourceService.listResourcePlugins(); + + MatcherAssert.assertThat(expectedResourcePlugins, equalTo(actualResourcePlugins)); + } + + public void testListResourcePlugins_concurrentModification() { + setup(); + List emptyACPlugins = Collections.emptyList(); + List resourcePlugins = new ArrayList<>(); + resourcePlugins.add(mock(ResourcePlugin.class)); + + ResourceService resourceService = new ResourceService(emptyACPlugins, resourcePlugins, client, threadPool); + + Thread modifierThread = new Thread(() -> { resourcePlugins.add(mock(ResourcePlugin.class)); }); + + modifierThread.start(); + + List result = resourceService.listResourcePlugins(); + + assertNotNull(result); + // The size could be either 1 or 2 depending on the timing of the concurrent modification + assertTrue(result.size() == 1 || result.size() == 2); + } + + public void testListResourcePlugins_emptyList() { + setup(); + List emptyACPlugins = Collections.emptyList(); + List emptyResourcePlugins = Collections.emptyList(); + + ResourceService resourceService = new ResourceService(emptyACPlugins, emptyResourcePlugins, client, threadPool); + + List result = resourceService.listResourcePlugins(); + + assertNotNull(result); + MatcherAssert.assertThat(result, is(empty())); + } + + public void testListResourcePlugins_immutability() { + setup(); + List emptyACPlugins = Collections.emptyList(); + List resourcePlugins = new ArrayList<>(); + resourcePlugins.add(mock(ResourcePlugin.class)); + + ResourceService resourceService = new ResourceService(emptyACPlugins, resourcePlugins, client, threadPool); + + List result = resourceService.listResourcePlugins(); + + assertThrows(UnsupportedOperationException.class, () -> { result.add(mock(ResourcePlugin.class)); }); + } + + public void testResourceServiceConstructorWithMultiplePlugins() { + setup(); + ResourceAccessControlPlugin plugin1 = mock(ResourceAccessControlPlugin.class); + ResourceAccessControlPlugin plugin2 = mock(ResourceAccessControlPlugin.class); + List resourceACPlugins = Arrays.asList(plugin1, plugin2); + List resourcePlugins = Arrays.asList(mock(ResourcePlugin.class)); + + assertThrows(OpenSearchException.class, () -> { new ResourceService(resourceACPlugins, resourcePlugins, client, threadPool); }); + } + + public void testResourceServiceConstructor_MultiplePlugins() { + setup(); + ResourceAccessControlPlugin mockPlugin1 = mock(ResourceAccessControlPlugin.class); + ResourceAccessControlPlugin mockPlugin2 = mock(ResourceAccessControlPlugin.class); + List multiplePlugins = Arrays.asList(mockPlugin1, mockPlugin2); + List resourcePlugins = new ArrayList<>(); + + assertThrows( + org.opensearch.OpenSearchException.class, + () -> { new ResourceService(multiplePlugins, resourcePlugins, client, threadPool); } + ); + } + + public void testResourceServiceWithMultipleResourceACPlugins() { + setup(); + List multipleResourceACPlugins = Arrays.asList( + mock(ResourceAccessControlPlugin.class), + mock(ResourceAccessControlPlugin.class) + ); + List resourcePlugins = new ArrayList<>(); + + assertThrows( + OpenSearchException.class, + () -> { new ResourceService(multipleResourceACPlugins, resourcePlugins, client, threadPool); } + ); + } + + public void testResourceServiceWithNoAccessControlPlugin() { + setup(); + List resourceACPlugins = new ArrayList<>(); + List resourcePlugins = new ArrayList<>(); + Client client = mock(Client.class); + ThreadPool threadPool = mock(ThreadPool.class); + + ResourceService resourceService = new ResourceService(resourceACPlugins, resourcePlugins, client, threadPool); + + MatcherAssert.assertThat(resourceService.getResourceAccessControlPlugin(), instanceOf(DefaultResourceAccessControlPlugin.class)); + MatcherAssert.assertThat(resourcePlugins, equalTo(resourceService.listResourcePlugins())); + } + + public void testResourceServiceWithNoResourceACPlugins() { + setup(); + List emptyResourceACPlugins = new ArrayList<>(); + List resourcePlugins = new ArrayList<>(); + + ResourceService resourceService = new ResourceService(emptyResourceACPlugins, resourcePlugins, client, threadPool); + + assertNotNull(resourceService.getResourceAccessControlPlugin()); + } + + public void testResourceServiceWithSingleResourceAccessControlPlugin() { + setup(); + List resourceACPlugins = new ArrayList<>(); + ResourceAccessControlPlugin mockPlugin = mock(ResourceAccessControlPlugin.class); + resourceACPlugins.add(mockPlugin); + + List resourcePlugins = new ArrayList<>(); + + ResourceService resourceService = new ResourceService(resourceACPlugins, resourcePlugins, client, threadPool); + + assertNotNull(resourceService); + MatcherAssert.assertThat(mockPlugin, equalTo(resourceService.getResourceAccessControlPlugin())); + MatcherAssert.assertThat(resourcePlugins, equalTo(resourceService.listResourcePlugins())); + } +} diff --git a/server/src/test/java/org/opensearch/accesscontrol/resources/ShareWithTests.java b/server/src/test/java/org/opensearch/accesscontrol/resources/ShareWithTests.java new file mode 100644 index 0000000000000..e1af5a0900e2d --- /dev/null +++ b/server/src/test/java/org/opensearch/accesscontrol/resources/ShareWithTests.java @@ -0,0 +1,261 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.accesscontrol.resources; + +import org.opensearch.common.xcontent.XContentFactory; +import org.opensearch.common.xcontent.XContentType; +import org.opensearch.common.xcontent.json.JsonXContent; +import org.opensearch.core.common.io.stream.StreamOutput; +import org.opensearch.core.xcontent.ToXContent; +import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.core.xcontent.XContentParser; +import org.opensearch.test.OpenSearchTestCase; +import org.hamcrest.MatcherAssert; +import org.junit.Before; + +import java.io.IOException; +import java.util.Collections; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import org.mockito.Mockito; + +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class ShareWithTests extends OpenSearchTestCase { + + @Before + public void setupResourceRecipientTypes() { + initializeRecipientTypes(); + } + + public void testFromXContentWhenCurrentTokenIsNotStartObject() throws IOException { + String json = "{\"read_only\": {\"users\": [\"user1\"], \"roles\": [], \"backend_roles\": []}}"; + XContentParser parser = JsonXContent.jsonXContent.createParser(null, null, json); + + parser.nextToken(); + + ShareWith shareWith = ShareWith.fromXContent(parser); + + assertNotNull(shareWith); + Set sharedWithScopes = shareWith.getSharedWithScopes(); + assertNotNull(sharedWithScopes); + MatcherAssert.assertThat(1, equalTo(sharedWithScopes.size())); + + SharedWithScope scope = sharedWithScopes.iterator().next(); + MatcherAssert.assertThat("read_only", equalTo(scope.getScope())); + + SharedWithScope.ScopeRecipients scopeRecipients = scope.getSharedWithPerScope(); + assertNotNull(scopeRecipients); + Map> recipients = scopeRecipients.getRecipients(); + MatcherAssert.assertThat(recipients.get(RecipientTypeRegistry.fromValue(DefaultRecipientType.USERS.getName())).size(), is(1)); + MatcherAssert.assertThat(recipients.get(RecipientTypeRegistry.fromValue(DefaultRecipientType.USERS.getName())), contains("user1")); + MatcherAssert.assertThat(recipients.get(RecipientTypeRegistry.fromValue(DefaultRecipientType.ROLES.getName())).size(), is(0)); + MatcherAssert.assertThat( + recipients.get(RecipientTypeRegistry.fromValue(DefaultRecipientType.BACKEND_ROLES.getName())).size(), + is(0) + ); + } + + public void testFromXContentWithEmptyInput() throws IOException { + String emptyJson = "{}"; + XContentParser parser = XContentType.JSON.xContent().createParser(xContentRegistry(), null, emptyJson); + + ShareWith result = ShareWith.fromXContent(parser); + + assertNotNull(result); + MatcherAssert.assertThat(result.getSharedWithScopes(), is(empty())); + } + + public void testFromXContentWithStartObject() throws IOException { + XContentParser parser; + try (XContentBuilder builder = XContentFactory.jsonBuilder()) { + builder.startObject() + .startObject(ResourceAccessScope.READ_ONLY) + .array("users", "user1", "user2") + .array("roles", "role1") + .array("backend_roles", "backend_role1") + .endObject() + .startObject(ResourceAccessScope.READ_WRITE) + .array("users", "user3") + .array("roles", "role2", "role3") + .array("backend_roles") + .endObject() + .endObject(); + + parser = JsonXContent.jsonXContent.createParser(null, null, builder.toString()); + } + + parser.nextToken(); + + ShareWith shareWith = ShareWith.fromXContent(parser); + + assertNotNull(shareWith); + Set scopes = shareWith.getSharedWithScopes(); + MatcherAssert.assertThat(scopes.size(), equalTo(2)); + + for (SharedWithScope scope : scopes) { + SharedWithScope.ScopeRecipients perScope = scope.getSharedWithPerScope(); + Map> recipients = perScope.getRecipients(); + if (scope.getScope().equals(ResourceAccessScope.READ_ONLY)) { + MatcherAssert.assertThat( + recipients.get(RecipientTypeRegistry.fromValue(DefaultRecipientType.USERS.getName())).size(), + is(2) + ); + MatcherAssert.assertThat( + recipients.get(RecipientTypeRegistry.fromValue(DefaultRecipientType.ROLES.getName())).size(), + is(1) + ); + MatcherAssert.assertThat( + recipients.get(RecipientTypeRegistry.fromValue(DefaultRecipientType.BACKEND_ROLES.getName())).size(), + is(1) + ); + } else if (scope.getScope().equals(ResourceAccessScope.READ_WRITE)) { + MatcherAssert.assertThat( + recipients.get(RecipientTypeRegistry.fromValue(DefaultRecipientType.USERS.getName())).size(), + is(1) + ); + MatcherAssert.assertThat( + recipients.get(RecipientTypeRegistry.fromValue(DefaultRecipientType.ROLES.getName())).size(), + is(2) + ); + MatcherAssert.assertThat( + recipients.get(RecipientTypeRegistry.fromValue(DefaultRecipientType.BACKEND_ROLES.getName())).size(), + is(0) + ); + } + } + } + + public void testFromXContentWithUnexpectedEndOfInput() throws IOException { + XContentParser mockParser = mock(XContentParser.class); + when(mockParser.currentToken()).thenReturn(XContentParser.Token.START_OBJECT); + when(mockParser.nextToken()).thenReturn(XContentParser.Token.END_OBJECT, (XContentParser.Token) null); + + ShareWith result = ShareWith.fromXContent(mockParser); + + assertNotNull(result); + MatcherAssert.assertThat(result.getSharedWithScopes(), is(empty())); + } + + public void testToXContentBuildsCorrectly() throws IOException { + SharedWithScope scope = new SharedWithScope( + "scope1", + new SharedWithScope.ScopeRecipients(Map.of(new RecipientType("users"), Set.of("bleh"))) + ); + + Set scopes = new HashSet<>(); + scopes.add(scope); + + ShareWith shareWith = new ShareWith(scopes); + + XContentBuilder builder = JsonXContent.contentBuilder(); + + shareWith.toXContent(builder, ToXContent.EMPTY_PARAMS); + + String result = builder.toString(); + + String expected = "{\"scope1\":{\"users\":[\"bleh\"]}}"; + + MatcherAssert.assertThat(expected.length(), equalTo(result.length())); + MatcherAssert.assertThat(expected, equalTo(result)); + } + + public void testWriteToWithEmptySet() throws IOException { + Set emptySet = Collections.emptySet(); + ShareWith shareWith = new ShareWith(emptySet); + StreamOutput mockOutput = Mockito.mock(StreamOutput.class); + + shareWith.writeTo(mockOutput); + + verify(mockOutput).writeCollection(emptySet); + } + + public void testWriteToWithIOException() throws IOException { + Set set = new HashSet<>(); + set.add(new SharedWithScope("test", new SharedWithScope.ScopeRecipients(Map.of()))); + ShareWith shareWith = new ShareWith(set); + StreamOutput mockOutput = Mockito.mock(StreamOutput.class); + + doThrow(new IOException("Simulated IO exception")).when(mockOutput).writeCollection(set); + + assertThrows(IOException.class, () -> shareWith.writeTo(mockOutput)); + } + + public void testWriteToWithLargeSet() throws IOException { + Set largeSet = new HashSet<>(); + for (int i = 0; i < 10000; i++) { + largeSet.add(new SharedWithScope("scope" + i, new SharedWithScope.ScopeRecipients(Map.of()))); + } + ShareWith shareWith = new ShareWith(largeSet); + StreamOutput mockOutput = Mockito.mock(StreamOutput.class); + + shareWith.writeTo(mockOutput); + + verify(mockOutput).writeCollection(largeSet); + } + + public void test_fromXContent_emptyObject() throws IOException { + XContentParser parser; + try (XContentBuilder builder = XContentFactory.jsonBuilder()) { + builder.startObject().endObject(); + parser = XContentType.JSON.xContent().createParser(null, null, builder.toString()); + } + + ShareWith shareWith = ShareWith.fromXContent(parser); + + MatcherAssert.assertThat(shareWith.getSharedWithScopes(), is(empty())); + } + + public void test_writeSharedWithScopesToStream() throws IOException { + StreamOutput mockStreamOutput = Mockito.mock(StreamOutput.class); + + Set sharedWithScopes = new HashSet<>(); + sharedWithScopes.add(new SharedWithScope(ResourceAccessScope.READ_ONLY, new SharedWithScope.ScopeRecipients(Map.of()))); + sharedWithScopes.add(new SharedWithScope(ResourceAccessScope.READ_WRITE, new SharedWithScope.ScopeRecipients(Map.of()))); + + ShareWith shareWith = new ShareWith(sharedWithScopes); + + shareWith.writeTo(mockStreamOutput); + + verify(mockStreamOutput, times(1)).writeCollection(eq(sharedWithScopes)); + } + + private void initializeRecipientTypes() { + RecipientTypeRegistry.registerRecipientType("users", new RecipientType("users")); + RecipientTypeRegistry.registerRecipientType("roles", new RecipientType("roles")); + RecipientTypeRegistry.registerRecipientType("backend_roles", new RecipientType("backend_roles")); + } +} + +enum DefaultRecipientType { + USERS("users"), + ROLES("roles"), + BACKEND_ROLES("backend_roles"); + + private final String name; + + DefaultRecipientType(String name) { + this.name = name; + } + + public String getName() { + return name; + } +}