From 0731629d13fec2ff21f80d9561d6548c2b1f39fc Mon Sep 17 00:00:00 2001 From: Craig Perkins Date: Sat, 21 Mar 2026 17:53:20 -0400 Subject: [PATCH 1/8] Increment version to 3.6.0-SNAPSHOT and change bwc from 2.20 to 2.19.5 Signed-off-by: Craig Perkins --- .github/workflows/draft-release-notes-workflow.yml | 2 +- build.gradle | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/draft-release-notes-workflow.yml b/.github/workflows/draft-release-notes-workflow.yml index f2d3f746..f4c4a678 100644 --- a/.github/workflows/draft-release-notes-workflow.yml +++ b/.github/workflows/draft-release-notes-workflow.yml @@ -16,6 +16,6 @@ jobs: with: config-name: draft-release-notes-config.yml tag: (None) - version: 3.5.0.0 + version: 3.6.0.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/build.gradle b/build.gradle index e45414c0..0999b59a 100644 --- a/build.gradle +++ b/build.gradle @@ -28,7 +28,7 @@ buildscript { opensearch_group = "org.opensearch" isSnapshot = "true" == System.getProperty("build.snapshot", "true") - opensearch_version = System.getProperty("opensearch.version", "3.5.0-SNAPSHOT") + opensearch_version = System.getProperty("opensearch.version", "3.6.0-SNAPSHOT") buildVersionQualifier = System.getProperty("build.version_qualifier", "") // 2.0.0-rc1-SNAPSHOT -> 2.0.0.0-rc1-SNAPSHOT version_tokens = opensearch_version.tokenize('-') @@ -597,7 +597,7 @@ testClusters.integTest { } // For job-scheduler and reports-scheduler, the latest opensearch releases appear to be 1.1.0.0. -String baseVersion = "2.20.0" +String baseVersion = "2.19.5" String bwcVersion = baseVersion + ".0" String baseName = "reportsSchedulerBwcCluster" String bwcFilePath = "src/test/resources/bwc" From b21b004ab83ea08723ea9f5829d0a76526ba441d Mon Sep 17 00:00:00 2001 From: Craig Perkins Date: Sat, 21 Mar 2026 18:10:35 -0400 Subject: [PATCH 2/8] Fix bwc Signed-off-by: Craig Perkins --- build.gradle | 32 ++++++++++++++++++++++++++------ 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/build.gradle b/build.gradle index 0999b59a..6bc168bd 100644 --- a/build.gradle +++ b/build.gradle @@ -596,14 +596,34 @@ testClusters.integTest { } } -// For job-scheduler and reports-scheduler, the latest opensearch releases appear to be 1.1.0.0. String baseVersion = "2.19.5" String bwcVersion = baseVersion + ".0" String baseName = "reportsSchedulerBwcCluster" String bwcFilePath = "src/test/resources/bwc" -String bwcJobSchedulerURL = "https://aws.oss.sonatype.org/service/local/artifact/maven/redirect?r=snapshots&g=org.opensearch.plugin&a=opensearch-job-scheduler&v=$bwcVersion-SNAPSHOT&p=zip" -String bwcReportsSchedulerURL = "https://aws.oss.sonatype.org/service/local/artifact/maven/redirect?r=snapshots&g=org.opensearch.plugin&a=opensearch-reports-scheduler&v=$bwcVersion-SNAPSHOT&p=zip" -String bwcSnapshotVersion = baseVersion + "-SNAPSHOT" +String bwcSnapshotVersion = bwcVersion + "-SNAPSHOT" +String groupPath = "org/opensearch/plugin" +String base = "https://ci.opensearch.org/ci/dbc/snapshots/maven" + +def resolveZipSnapshotValue = { String metadataXmlUrl -> + def xml = new URL(metadataXmlUrl).text + def m = (xml =~ /[\s\S]*?zip<\/extension>[\s\S]*?([^<]+)<\/value>[\s\S]*?<\/snapshotVersion>/) + if (!m.find()) { + throw new GradleException("Could not find zip snapshot in ${metadataXmlUrl}") + } + return m.group(1) +} + +String jobSchedulerArtifact = "opensearch-job-scheduler" +String jobSchedulerMetadataUrl = "${base}/${groupPath}/${jobSchedulerArtifact}/${bwcSnapshotVersion}/maven-metadata.xml" +String jobSchedulerSnapshotValue = resolveZipSnapshotValue(jobSchedulerMetadataUrl) +String bwcJobSchedulerFileName = "${jobSchedulerArtifact}-${jobSchedulerSnapshotValue}.zip" +String bwcJobSchedulerURL = "${base}/${groupPath}/${jobSchedulerArtifact}/${bwcSnapshotVersion}/${bwcJobSchedulerFileName}" + +String reportsSchedulerArtifact = "opensearch-reports-scheduler" +String reportsSchedulerMetadataUrl = "${base}/${groupPath}/${reportsSchedulerArtifact}/${bwcSnapshotVersion}/maven-metadata.xml" +String reportsSchedulerSnapshotValue = resolveZipSnapshotValue(reportsSchedulerMetadataUrl) +String bwcReportsSchedulerFileName = "${reportsSchedulerArtifact}-${reportsSchedulerSnapshotValue}.zip" +String bwcReportsSchedulerURL = "${base}/${groupPath}/${reportsSchedulerArtifact}/${bwcSnapshotVersion}/${bwcReportsSchedulerFileName}" 2.times {i -> testClusters { @@ -621,7 +641,7 @@ String bwcSnapshotVersion = baseVersion + "-SNAPSHOT" if (!dir.exists()) { dir.mkdirs() } - File file = new File(dir, "opensearch-job-scheduler-" + bwcVersion + ".zip") + File file = new File(dir, bwcJobSchedulerFileName) if (!file.exists()) { new URL(bwcJobSchedulerURL).withInputStream{ ins -> file.withOutputStream{ it << ins }} } @@ -640,7 +660,7 @@ String bwcSnapshotVersion = baseVersion + "-SNAPSHOT" if (!dir.exists()) { dir.mkdirs() } - File file = new File(dir, "opensearch-reports-scheduler-" + bwcVersion + ".zip") + File file = new File(dir, bwcReportsSchedulerFileName) if (!file.exists()) { new URL(bwcReportsSchedulerURL).withInputStream{ ins -> file.withOutputStream{ it << ins }} } From 3a1eb623f3cfbc3ea8066959b3b0060a5019bac7 Mon Sep 17 00:00:00 2001 From: Craig Perkins Date: Sat, 21 Mar 2026 18:21:22 -0400 Subject: [PATCH 3/8] Lazily resolve Signed-off-by: Craig Perkins --- build.gradle | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/build.gradle b/build.gradle index 6bc168bd..e62c5fa8 100644 --- a/build.gradle +++ b/build.gradle @@ -614,16 +614,7 @@ def resolveZipSnapshotValue = { String metadataXmlUrl -> } String jobSchedulerArtifact = "opensearch-job-scheduler" -String jobSchedulerMetadataUrl = "${base}/${groupPath}/${jobSchedulerArtifact}/${bwcSnapshotVersion}/maven-metadata.xml" -String jobSchedulerSnapshotValue = resolveZipSnapshotValue(jobSchedulerMetadataUrl) -String bwcJobSchedulerFileName = "${jobSchedulerArtifact}-${jobSchedulerSnapshotValue}.zip" -String bwcJobSchedulerURL = "${base}/${groupPath}/${jobSchedulerArtifact}/${bwcSnapshotVersion}/${bwcJobSchedulerFileName}" - String reportsSchedulerArtifact = "opensearch-reports-scheduler" -String reportsSchedulerMetadataUrl = "${base}/${groupPath}/${reportsSchedulerArtifact}/${bwcSnapshotVersion}/maven-metadata.xml" -String reportsSchedulerSnapshotValue = resolveZipSnapshotValue(reportsSchedulerMetadataUrl) -String bwcReportsSchedulerFileName = "${reportsSchedulerArtifact}-${reportsSchedulerSnapshotValue}.zip" -String bwcReportsSchedulerURL = "${base}/${groupPath}/${reportsSchedulerArtifact}/${bwcSnapshotVersion}/${bwcReportsSchedulerFileName}" 2.times {i -> testClusters { @@ -637,13 +628,17 @@ String bwcReportsSchedulerURL = "${base}/${groupPath}/${reportsSchedulerArtifact return new RegularFile() { @Override File getAsFile() { + String metadataUrl = "${base}/${groupPath}/${jobSchedulerArtifact}/${bwcSnapshotVersion}/maven-metadata.xml" + String snapshotValue = resolveZipSnapshotValue(metadataUrl) + String fileName = "${jobSchedulerArtifact}-${snapshotValue}.zip" + String downloadUrl = "${base}/${groupPath}/${jobSchedulerArtifact}/${bwcSnapshotVersion}/${fileName}" File dir = new File(bwcFilePath + "/job-scheduler/" + bwcVersion) if (!dir.exists()) { dir.mkdirs() } - File file = new File(dir, bwcJobSchedulerFileName) + File file = new File(dir, fileName) if (!file.exists()) { - new URL(bwcJobSchedulerURL).withInputStream{ ins -> file.withOutputStream{ it << ins }} + new URL(downloadUrl).withInputStream{ ins -> file.withOutputStream{ it << ins }} } return fileTree(bwcFilePath + "/job-scheduler/" + bwcVersion).getSingleFile() } @@ -656,13 +651,17 @@ String bwcReportsSchedulerURL = "${base}/${groupPath}/${reportsSchedulerArtifact return new RegularFile() { @Override File getAsFile() { + String metadataUrl = "${base}/${groupPath}/${reportsSchedulerArtifact}/${bwcSnapshotVersion}/maven-metadata.xml" + String snapshotValue = resolveZipSnapshotValue(metadataUrl) + String fileName = "${reportsSchedulerArtifact}-${snapshotValue}.zip" + String downloadUrl = "${base}/${groupPath}/${reportsSchedulerArtifact}/${bwcSnapshotVersion}/${fileName}" File dir = new File(bwcFilePath + "/reports-scheduler/" + bwcVersion) if (!dir.exists()) { dir.mkdirs() } - File file = new File(dir, bwcReportsSchedulerFileName) + File file = new File(dir, fileName) if (!file.exists()) { - new URL(bwcReportsSchedulerURL).withInputStream{ ins -> file.withOutputStream{ it << ins }} + new URL(downloadUrl).withInputStream{ ins -> file.withOutputStream{ it << ins }} } return fileTree(bwcFilePath + "/reports-scheduler/" + bwcVersion).getSingleFile() } From 613980642cf8d80e3195b51d734c0c484576c29e Mon Sep 17 00:00:00 2001 From: Craig Perkins Date: Sat, 21 Mar 2026 18:23:01 -0400 Subject: [PATCH 4/8] Fix base version Signed-off-by: Craig Perkins --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index e62c5fa8..749cde87 100644 --- a/build.gradle +++ b/build.gradle @@ -600,7 +600,7 @@ String baseVersion = "2.19.5" String bwcVersion = baseVersion + ".0" String baseName = "reportsSchedulerBwcCluster" String bwcFilePath = "src/test/resources/bwc" -String bwcSnapshotVersion = bwcVersion + "-SNAPSHOT" +String bwcSnapshotVersion = baseVersion + "-SNAPSHOT" String groupPath = "org/opensearch/plugin" String base = "https://ci.opensearch.org/ci/dbc/snapshots/maven" From 04876ee20dcf17b7f241423f0c8affab94440ff8 Mon Sep 17 00:00:00 2001 From: Craig Perkins Date: Sat, 21 Mar 2026 18:37:19 -0400 Subject: [PATCH 5/8] Use correct version Signed-off-by: Craig Perkins --- build.gradle | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/build.gradle b/build.gradle index 749cde87..f5ce9c74 100644 --- a/build.gradle +++ b/build.gradle @@ -628,10 +628,10 @@ String reportsSchedulerArtifact = "opensearch-reports-scheduler" return new RegularFile() { @Override File getAsFile() { - String metadataUrl = "${base}/${groupPath}/${jobSchedulerArtifact}/${bwcSnapshotVersion}/maven-metadata.xml" + String metadataUrl = "${base}/${groupPath}/${jobSchedulerArtifact}/${bwcVersion}-SNAPSHOT/maven-metadata.xml" String snapshotValue = resolveZipSnapshotValue(metadataUrl) String fileName = "${jobSchedulerArtifact}-${snapshotValue}.zip" - String downloadUrl = "${base}/${groupPath}/${jobSchedulerArtifact}/${bwcSnapshotVersion}/${fileName}" + String downloadUrl = "${base}/${groupPath}/${jobSchedulerArtifact}/${bwcVersion}-SNAPSHOT/${fileName}" File dir = new File(bwcFilePath + "/job-scheduler/" + bwcVersion) if (!dir.exists()) { dir.mkdirs() @@ -651,10 +651,10 @@ String reportsSchedulerArtifact = "opensearch-reports-scheduler" return new RegularFile() { @Override File getAsFile() { - String metadataUrl = "${base}/${groupPath}/${reportsSchedulerArtifact}/${bwcSnapshotVersion}/maven-metadata.xml" + String metadataUrl = "${base}/${groupPath}/${reportsSchedulerArtifact}/${bwcVersion}-SNAPSHOT/maven-metadata.xml" String snapshotValue = resolveZipSnapshotValue(metadataUrl) String fileName = "${reportsSchedulerArtifact}-${snapshotValue}.zip" - String downloadUrl = "${base}/${groupPath}/${reportsSchedulerArtifact}/${bwcSnapshotVersion}/${fileName}" + String downloadUrl = "${base}/${groupPath}/${reportsSchedulerArtifact}/${bwcVersion}-SNAPSHOT/${fileName}" File dir = new File(bwcFilePath + "/reports-scheduler/" + bwcVersion) if (!dir.exists()) { dir.mkdirs() From 28af793b49c38251d03abf9a1e2c6a7ccf122044 Mon Sep 17 00:00:00 2001 From: Craig Perkins Date: Sat, 21 Mar 2026 19:50:24 -0400 Subject: [PATCH 6/8] WIP on more extensive resource sharing tests Signed-off-by: Craig Perkins --- .github/workflows/test_security.yml | 2 +- .../ReportsSchedulerExtension.kt | 2 + src/main/resources/resource-action-groups.yml | 2 +- .../integTest/rest/ResourceSharingIT.kt | 150 ++++++++++++++++++ 4 files changed, 154 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test_security.yml b/.github/workflows/test_security.yml index cfad2920..a2b0aa7b 100644 --- a/.github/workflows/test_security.yml +++ b/.github/workflows/test_security.yml @@ -53,4 +53,4 @@ jobs: # switching the user, as OpenSearch cluster can only be started as root/Administrator on linux-deb/linux-rpm/windows-zip. run: | chown -R 1000:1000 `pwd` - su `id -un 1000` -c "whoami && java -version && ./gradlew integTest -Dhttps=true -Dtests.opensearch.username=admin -Dtests.opensearch.password=admin -Dusername=admin -Dpassword=admin -Dtests.opensearch.secure=true -Dsecurity=true ${{ matrix.resource_sharing_flag }}" \ No newline at end of file + su `id -un 1000` -c "whoami && java -version && ./gradlew integTest -Dhttps=true -Dtests.opensearch.username=admin -Dtests.opensearch.password=admin -Dusername=admin -Dpassword=admin -Dtests.opensearch.secure=true -Dsecurity=true ${{ matrix.resource_sharing_flag }}" diff --git a/src/main/kotlin/org/opensearch/reportsscheduler/ReportsSchedulerExtension.kt b/src/main/kotlin/org/opensearch/reportsscheduler/ReportsSchedulerExtension.kt index dfe8523e..e2678334 100644 --- a/src/main/kotlin/org/opensearch/reportsscheduler/ReportsSchedulerExtension.kt +++ b/src/main/kotlin/org/opensearch/reportsscheduler/ReportsSchedulerExtension.kt @@ -24,6 +24,8 @@ class ReportsSchedulerExtension : ResourceSharingExtension { object : ResourceProvider { override fun resourceType(): String = Utils.REPORT_INSTANCE_TYPE override fun resourceIndexName(): String = ReportInstancesIndex.REPORT_INSTANCES_INDEX_NAME + override fun parentType(): String = Utils.REPORT_DEFINITION_TYPE + override fun parentIdField(): String = "reportDefinitionDetails.id" } ) } diff --git a/src/main/resources/resource-action-groups.yml b/src/main/resources/resource-action-groups.yml index cec48274..4863ec4b 100644 --- a/src/main/resources/resource-action-groups.yml +++ b/src/main/resources/resource-action-groups.yml @@ -39,4 +39,4 @@ resource_types: allowed_actions: - 'cluster:admin/opendistro/reports/instance/*' - 'cluster:admin/opendistro/reports/menu/download' - - 'cluster:admin/security/resource/share' \ No newline at end of file + - 'cluster:admin/security/resource/share' diff --git a/src/test/kotlin/org/opensearch/integTest/rest/ResourceSharingIT.kt b/src/test/kotlin/org/opensearch/integTest/rest/ResourceSharingIT.kt index 5a37dc3a..c8bd06ff 100644 --- a/src/test/kotlin/org/opensearch/integTest/rest/ResourceSharingIT.kt +++ b/src/test/kotlin/org/opensearch/integTest/rest/ResourceSharingIT.kt @@ -704,4 +704,154 @@ class ResourceSharingIT : PluginRestTestCase() { fun `test legacy list report instances with resource sharing`() { testListReportInstancesWithResourceSharing(LEGACY_BASE_REPORTS_URI) } + + /** + * Test that a user with access to a report definition (parent) can list and get its + * report instances (children) via parent-inherited access, without being directly + * shared on the instances themselves. + * + * This mirrors the dashboards UI scenario where instances are shown on page load + * for a definition the user has access to. + */ + private fun testListInstancesViaParentDefinitionAccess(baseUri: String) { + if (!isHttps()) return + if (!isResourceSharingFeatureEnabled()) return + + // Create a report definition as the full-access user (owner) + val defRequest = constructReportDefinitionRequest(name = "parent_hierarchy_def") + val defResponse = executeRequest( + reportsFullClient, + RestRequest.Method.POST.name, + "$baseUri/definition", + defRequest, + RestStatus.OK.status + ) + val reportDefinitionId = defResponse.get("reportDefinitionId").asString + waitForSharingVisibility( + RestRequest.Method.GET.name, + "$baseUri/definition/$reportDefinitionId", + null, + reportsFullClient + ) + + // Generate two instances from that definition + val instance1Response = executeRequest( + reportsFullClient, + RestRequest.Method.POST.name, + "$baseUri/on_demand/$reportDefinitionId", + "{}", + RestStatus.OK.status + ) + val instance1Id = instance1Response.get("reportInstance").asJsonObject.get("id").asString + + val instance2Response = executeRequest( + reportsFullClient, + RestRequest.Method.POST.name, + "$baseUri/on_demand/$reportDefinitionId", + "{}", + RestStatus.OK.status + ) + val instance2Id = instance2Response.get("reportInstance").asJsonObject.get("id").asString + + // Wait for sharing records to be visible for the owner + waitForSharingVisibility( + RestRequest.Method.GET.name, + "$baseUri/instance/$instance1Id", + null, + reportsFullClient + ) + waitForSharingVisibility( + RestRequest.Method.GET.name, + "$baseUri/instance/$instance2Id", + null, + reportsFullClient + ) + + // Read user has no access yet — instances should be invisible + val readListBefore = executeRequest( + reportsReadClient, + RestRequest.Method.GET.name, + "$baseUri/instances", + "", + RestStatus.OK.status + ) + assertEquals(0, readListBefore.get("totalHits").asInt) + + val readGetBefore = executeRequest( + reportsReadClient, + RestRequest.Method.GET.name, + "$baseUri/instance/$instance1Id", + "", + RestStatus.FORBIDDEN.status + ) + validateErrorResponse(readGetBefore, RestStatus.FORBIDDEN.status, "security_exception") + + // Share the DEFINITION (parent) with the read user — NOT the instances directly + val shareDefPayload = shareWithUserPayload( + reportDefinitionId, + Utils.REPORT_DEFINITION_TYPE, + reportReadOnlyAccessLevel, + reportReadUser + ) + shareConfig(reportsFullClient, shareDefPayload) + + // Wait for parent-inherited access to propagate to the instances + waitForSharingVisibility( + RestRequest.Method.GET.name, + "$baseUri/instance/$instance1Id", + null, + reportsReadClient + ) + + // Read user should now see both instances via parent-inherited access + val readListAfter = executeRequest( + reportsReadClient, + RestRequest.Method.GET.name, + "$baseUri/instances", + "", + RestStatus.OK.status + ) + assertEquals(2, readListAfter.get("totalHits").asInt) + val instanceIds = readListAfter.get("reportInstanceList").asJsonArray + .map { it.asJsonObject.get("id").asString } + .toSet() + assertTrue(instanceIds.contains(instance1Id)) + assertTrue(instanceIds.contains(instance2Id)) + + // Read user should also be able to GET each instance individually + val readGet1 = executeRequest( + reportsReadClient, + RestRequest.Method.GET.name, + "$baseUri/instance/$instance1Id", + "", + RestStatus.OK.status + ) + assertEquals(instance1Id, readGet1.get("reportInstance").asJsonObject.get("id").asString) + + val readGet2 = executeRequest( + reportsReadClient, + RestRequest.Method.GET.name, + "$baseUri/instance/$instance2Id", + "", + RestStatus.OK.status + ) + assertEquals(instance2Id, readGet2.get("reportInstance").asJsonObject.get("id").asString) + + // Cleanup + executeRequest( + reportsFullClient, + RestRequest.Method.DELETE.name, + "$baseUri/definition/$reportDefinitionId", + "", + RestStatus.OK.status + ) + } + + fun `test list instances via parent definition access`() { + testListInstancesViaParentDefinitionAccess(BASE_REPORTS_URI) + } + + fun `test legacy list instances via parent definition access`() { + testListInstancesViaParentDefinitionAccess(LEGACY_BASE_REPORTS_URI) + } } From 50f921c7387a01ecbb6f6927d566f6b0976a6325 Mon Sep 17 00:00:00 2001 From: Craig Perkins Date: Sat, 21 Mar 2026 22:08:57 -0400 Subject: [PATCH 7/8] WIP Signed-off-by: Craig Perkins --- .../integTest/rest/ResourceSharingIT.kt | 1050 +++++------------ 1 file changed, 324 insertions(+), 726 deletions(-) diff --git a/src/test/kotlin/org/opensearch/integTest/rest/ResourceSharingIT.kt b/src/test/kotlin/org/opensearch/integTest/rest/ResourceSharingIT.kt index c8bd06ff..3abc4a58 100644 --- a/src/test/kotlin/org/opensearch/integTest/rest/ResourceSharingIT.kt +++ b/src/test/kotlin/org/opensearch/integTest/rest/ResourceSharingIT.kt @@ -5,853 +5,451 @@ package org.opensearch.integTest.rest +import java.time.Duration +import org.apache.logging.log4j.LogManager +import org.awaitility.Awaitility import org.opensearch.core.rest.RestStatus import org.opensearch.integTest.PluginRestTestCase import org.opensearch.integTest.constructReportDefinitionRequest import org.opensearch.integTest.validateErrorResponse import org.opensearch.reportsscheduler.ReportsSchedulerPlugin.Companion.BASE_REPORTS_URI -import org.opensearch.reportsscheduler.ReportsSchedulerPlugin.Companion.LEGACY_BASE_REPORTS_URI import org.opensearch.reportsscheduler.resources.Utils import org.opensearch.rest.RestRequest /** * Integration tests for resource sharing feature. - * * Tests the behavior difference between backend_roles filtering and resource-sharing: + * Tests the behavior difference between backend_roles filtering and resource-sharing: * - Without resource-sharing: Users with same backend_role have default access * - With resource-sharing: Users need explicit access grants, regardless of roles */ class ResourceSharingIT : PluginRestTestCase() { + private val log = LogManager.getLogger(ResourceSharingIT::class.java) + + /** Logs a labelled step so the test output shows exactly where time is spent. */ + private fun step(msg: String) = log.info("STEP: $msg") + + // ------------------------------------------------------------------ + // Helpers + // ------------------------------------------------------------------ + /** - * Test report definition CRUD operations with resource sharing enabled. - * Verifies that full access user cannot access resources until explicitly shared. + * Waits until [predicate] returns true, polling every 200 ms for up to 15 s. + * Shorter than the default 30 s — if something is going to work it usually does within a few seconds. */ + private fun awaitCondition(description: String, predicate: () -> Boolean) { + step("waiting for: $description") + val start = System.currentTimeMillis() + Awaitility.await(description) + .atMost(Duration.ofSeconds(15)) + .pollInterval(Duration.ofMillis(200)) + .until { predicate() } + log.info("'$description' satisfied in ${System.currentTimeMillis() - start} ms") + } + + /** Waits until the given endpoint returns 200 for [client]. */ + private fun awaitVisible(baseUri: String, path: String, client: org.opensearch.client.RestClient) { + awaitCondition("$path visible") { + try { + waitForSharingVisibility(RestRequest.Method.GET.name, "$baseUri/$path", null, client) != null + } catch (_: Exception) { false } + } + } + + /** Waits until the definitions list for [client] returns exactly [count] hits. */ + private fun awaitDefinitionCount(baseUri: String, client: org.opensearch.client.RestClient, count: Int) { + awaitCondition("definitions list == $count") { + try { + executeRequest(client, RestRequest.Method.GET.name, "$baseUri/definitions", "", RestStatus.OK.status) + .get("totalHits").asInt == count + } catch (_: Exception) { false } + } + } + + /** Waits until the instances list for [client] returns at least [minCount] hits. */ + private fun awaitInstanceCount(baseUri: String, client: org.opensearch.client.RestClient, minCount: Int) { + awaitCondition("instances list >= $minCount") { + try { + executeRequest(client, RestRequest.Method.GET.name, "$baseUri/instances", "", RestStatus.OK.status) + .get("totalHits").asInt >= minCount + } catch (_: Exception) { false } + } + } + + // ------------------------------------------------------------------ + // Report definition CRUD + // ------------------------------------------------------------------ + @Suppress("LongMethod") private fun testReportDefinitionCRUDWithResourceSharing(baseUri: String) { if (!isHttps()) return if (!isResourceSharingFeatureEnabled()) return + step("create definition and verify owner access") val reportDefinitionId = createReportDefinitionAndVerifyOwnerAccess(baseUri) + + step("verify no access without sharing") verifyNoAccessWithoutSharing(baseUri, reportDefinitionId) + + step("test read-only access sharing") testReadOnlyAccessSharing(baseUri, reportDefinitionId) + + step("test full access sharing") testFullAccessSharing(baseUri, reportDefinitionId) + + step("cleanup") cleanupReportDefinition(baseUri, reportDefinitionId) } private fun createReportDefinitionAndVerifyOwnerAccess(baseUri: String): String { - val createRequest = constructReportDefinitionRequest() val createResponse = executeRequest( - reportsFullClient, - RestRequest.Method.POST.name, - "$baseUri/definition", - createRequest, - RestStatus.OK.status - ) - val reportDefinitionId = createResponse.get("reportDefinitionId").asString - assertNotNull("reportDefinitionId should be generated", reportDefinitionId) - - waitForSharingVisibility( - RestRequest.Method.GET.name, - "$baseUri/definition/$reportDefinitionId", - null, - reportsFullClient + reportsFullClient, RestRequest.Method.POST.name, "$baseUri/definition", + constructReportDefinitionRequest(), RestStatus.OK.status ) + val id = createResponse.get("reportDefinitionId").asString + assertNotNull("reportDefinitionId should be generated", id) + log.info("created definition $id") - val ownerGet = executeRequest( - reportsFullClient, - RestRequest.Method.GET.name, - "$baseUri/definition/$reportDefinitionId", - "", - RestStatus.OK.status - ) - assertEquals( - reportDefinitionId, - ownerGet.get("reportDefinitionDetails").asJsonObject.get("id").asString - ) - return reportDefinitionId + // Wait for the sharing record + all_shared_principals to be written before asserting + step("wait for owner visibility of definition $id") + awaitVisible(baseUri, "definition/$id", reportsFullClient) + + val ownerGet = executeRequest(reportsFullClient, RestRequest.Method.GET.name, "$baseUri/definition/$id", "", RestStatus.OK.status) + assertEquals(id, ownerGet.get("reportDefinitionDetails").asJsonObject.get("id").asString) + return id } - private fun verifyNoAccessWithoutSharing(baseUri: String, reportDefinitionId: String) { - val readGetBefore = executeRequest( - reportsReadClient, - RestRequest.Method.GET.name, - "$baseUri/definition/$reportDefinitionId", - "", - RestStatus.FORBIDDEN.status + private fun verifyNoAccessWithoutSharing(baseUri: String, id: String) { + validateErrorResponse( + executeRequest(reportsReadClient, RestRequest.Method.GET.name, "$baseUri/definition/$id", "", RestStatus.FORBIDDEN.status), + RestStatus.FORBIDDEN.status, "security_exception" ) - validateErrorResponse(readGetBefore, RestStatus.FORBIDDEN.status, "security_exception") - - val noAccessGetBefore = executeRequest( - reportsNoAccessClient, - RestRequest.Method.GET.name, - "$baseUri/definition/$reportDefinitionId", - "", - RestStatus.FORBIDDEN.status + validateErrorResponse( + executeRequest(reportsNoAccessClient, RestRequest.Method.GET.name, "$baseUri/definition/$id", "", RestStatus.FORBIDDEN.status), + RestStatus.FORBIDDEN.status, "security_exception" ) - validateErrorResponse(noAccessGetBefore, RestStatus.FORBIDDEN.status, "security_exception") } - private fun testReadOnlyAccessSharing(baseUri: String, reportDefinitionId: String) { - val shareReadPayload = shareWithUserPayload( - reportDefinitionId, - Utils.REPORT_DEFINITION_TYPE, - reportReadOnlyAccessLevel, - reportReadUser - ) - shareConfig(reportsFullClient, shareReadPayload) + private fun testReadOnlyAccessSharing(baseUri: String, id: String) { + shareConfig(reportsFullClient, shareWithUserPayload(id, Utils.REPORT_DEFINITION_TYPE, reportReadOnlyAccessLevel, reportReadUser)) + awaitVisible(baseUri, "definition/$id", reportsReadClient) - waitForSharingVisibility( - RestRequest.Method.GET.name, - "$baseUri/definition/$reportDefinitionId", - null, - reportsReadClient - ) + val readGet = executeRequest(reportsReadClient, RestRequest.Method.GET.name, "$baseUri/definition/$id", "", RestStatus.OK.status) + assertEquals(id, readGet.get("reportDefinitionDetails").asJsonObject.get("id").asString) - val readGetAfter = executeRequest( - reportsReadClient, - RestRequest.Method.GET.name, - "$baseUri/definition/$reportDefinitionId", - "", - RestStatus.OK.status + validateErrorResponse( + executeRequest(reportsReadClient, RestRequest.Method.PUT.name, "$baseUri/definition/$id", + constructReportDefinitionRequest(name = "read_user_update_attempt"), RestStatus.FORBIDDEN.status), + RestStatus.FORBIDDEN.status, "security_exception" ) - assertEquals( - reportDefinitionId, - readGetAfter.get("reportDefinitionDetails").asJsonObject.get("id").asString + validateErrorResponse( + executeRequest(reportsReadClient, RestRequest.Method.DELETE.name, "$baseUri/definition/$id", "", RestStatus.FORBIDDEN.status), + RestStatus.FORBIDDEN.status, "security_exception" ) - - val updateRequest = constructReportDefinitionRequest(name = "read_user_update_attempt") - val readUpdateForbidden = executeRequest( - reportsReadClient, - RestRequest.Method.PUT.name, - "$baseUri/definition/$reportDefinitionId", - updateRequest, - RestStatus.FORBIDDEN.status - ) - validateErrorResponse(readUpdateForbidden, RestStatus.FORBIDDEN.status, "security_exception") - - val readDeleteForbidden = executeRequest( - reportsReadClient, - RestRequest.Method.DELETE.name, - "$baseUri/definition/$reportDefinitionId", - "", - RestStatus.FORBIDDEN.status - ) - validateErrorResponse(readDeleteForbidden, RestStatus.FORBIDDEN.status, "security_exception") } - private fun testFullAccessSharing(baseUri: String, reportDefinitionId: String) { - val shareNoAccessPayload = shareWithUserPayload( - reportDefinitionId, - Utils.REPORT_DEFINITION_TYPE, - reportFullAccessLevel, - reportNoAccessUser - ) - shareConfig(reportsFullClient, shareNoAccessPayload) + private fun testFullAccessSharing(baseUri: String, id: String) { + shareConfig(reportsFullClient, shareWithUserPayload(id, Utils.REPORT_DEFINITION_TYPE, reportFullAccessLevel, reportNoAccessUser)) + awaitVisible(baseUri, "definition/$id", reportsNoAccessClient) - waitForSharingVisibility( - RestRequest.Method.GET.name, - "$baseUri/definition/$reportDefinitionId", - null, - reportsNoAccessClient - ) - - val noAccessGetAfter = executeRequest( - reportsNoAccessClient, - RestRequest.Method.GET.name, - "$baseUri/definition/$reportDefinitionId", - "", - RestStatus.OK.status - ) - assertEquals( - reportDefinitionId, - noAccessGetAfter.get("reportDefinitionDetails").asJsonObject.get("id").asString - ) + val noAccessGet = executeRequest(reportsNoAccessClient, RestRequest.Method.GET.name, "$baseUri/definition/$id", "", RestStatus.OK.status) + assertEquals(id, noAccessGet.get("reportDefinitionDetails").asJsonObject.get("id").asString) - val noAccessUpdateRequest = constructReportDefinitionRequest(name = "no_access_user_updated") - val noAccessUpdate = executeRequest( - reportsNoAccessClient, - RestRequest.Method.PUT.name, - "$baseUri/definition/$reportDefinitionId", - noAccessUpdateRequest, - RestStatus.OK.status - ) - assertEquals( - reportDefinitionId, - noAccessUpdate.get("reportDefinitionId").asString - ) + val update = executeRequest(reportsNoAccessClient, RestRequest.Method.PUT.name, "$baseUri/definition/$id", + constructReportDefinitionRequest(name = "no_access_user_updated"), RestStatus.OK.status) + assertEquals(id, update.get("reportDefinitionId").asString) - val verifyUpdate = executeRequest( - reportsFullClient, - RestRequest.Method.GET.name, - "$baseUri/definition/$reportDefinitionId", - "", - RestStatus.OK.status - ) - assertEquals( - "no_access_user_updated", - verifyUpdate.get("reportDefinitionDetails").asJsonObject - .get("reportDefinition").asJsonObject.get("name").asString - ) + val verify = executeRequest(reportsFullClient, RestRequest.Method.GET.name, "$baseUri/definition/$id", "", RestStatus.OK.status) + assertEquals("no_access_user_updated", + verify.get("reportDefinitionDetails").asJsonObject.get("reportDefinition").asJsonObject.get("name").asString) } - private fun cleanupReportDefinition(baseUri: String, reportDefinitionId: String) { - val deleteResponse = executeRequest( - reportsFullClient, - RestRequest.Method.DELETE.name, - "$baseUri/definition/$reportDefinitionId", - "", - RestStatus.OK.status - ) - assertEquals( - reportDefinitionId, - deleteResponse.get("reportDefinitionId").asString - ) + private fun cleanupReportDefinition(baseUri: String, id: String) { + val del = executeRequest(reportsFullClient, RestRequest.Method.DELETE.name, "$baseUri/definition/$id", "", RestStatus.OK.status) + assertEquals(id, del.get("reportDefinitionId").asString) } fun `test report definition CRUD with resource sharing`() { testReportDefinitionCRUDWithResourceSharing(BASE_REPORTS_URI) } - fun `test legacy report definition CRUD with resource sharing`() { - testReportDefinitionCRUDWithResourceSharing(LEGACY_BASE_REPORTS_URI) - } + // ------------------------------------------------------------------ + // List report definitions + // ------------------------------------------------------------------ - /** - * Test listing report definitions with resource sharing. - * Verifies that users only see resources they own or have been granted access to. - */ @Suppress("LongMethod") private fun testListReportDefinitionsWithResourceSharing(baseUri: String) { if (!isHttps()) return if (!isResourceSharingFeatureEnabled()) return - // Create multiple report definitions with full access user - val def1Request = constructReportDefinitionRequest(name = "definition_1") - val def1Response = executeRequest( - reportsFullClient, - RestRequest.Method.POST.name, - "$baseUri/definition", - def1Request, - RestStatus.OK.status - ) - val def1Id = def1Response.get("reportDefinitionId").asString - waitForSharingVisibility( - RestRequest.Method.GET.name, - "$baseUri/definition/$def1Id", - null, - reportsFullClient - ) - - val def2Request = constructReportDefinitionRequest(name = "definition_2") - val def2Response = executeRequest( - reportsFullClient, - RestRequest.Method.POST.name, - "$baseUri/definition", - def2Request, - RestStatus.OK.status - ) - val def2Id = def2Response.get("reportDefinitionId").asString - waitForSharingVisibility( - RestRequest.Method.GET.name, - "$baseUri/definition/$def2Id", - null, - reportsFullClient - ) - - val def3Request = constructReportDefinitionRequest(name = "definition_3") - val def3Response = executeRequest( - reportsFullClient, - RestRequest.Method.POST.name, - "$baseUri/definition", - def3Request, - RestStatus.OK.status - ) - val def3Id = def3Response.get("reportDefinitionId").asString - waitForSharingVisibility( - RestRequest.Method.GET.name, - "$baseUri/definition/$def3Id", - null, - reportsFullClient - ) - - // Owner sees all their definitions - val ownerList = executeRequest( - reportsFullClient, - RestRequest.Method.GET.name, - "$baseUri/definitions", - "", - RestStatus.OK.status - ) - assertEquals(3, ownerList.get("totalHits").asInt) - - // Read user sees none (no sharing yet) - val readListBefore = executeRequest( - reportsReadClient, - RestRequest.Method.GET.name, - "$baseUri/definitions", - "", - RestStatus.OK.status - ) - assertEquals(0, readListBefore.get("totalHits").asInt) - - // No-access user: forbidden - executeRequest( - reportsNoAccessClient, - RestRequest.Method.GET.name, - "$baseUri/definitions", - "", - RestStatus.FORBIDDEN.status - ) - - // Share def1 with read user - val shareDef1 = shareWithUserPayload( - def1Id, - Utils.REPORT_DEFINITION_TYPE, - reportReadOnlyAccessLevel, - reportReadUser - ) - shareConfig(reportsFullClient, shareDef1) - - // Share def2 with no-access user - val shareDef2 = shareWithUserPayload( - def2Id, - Utils.REPORT_DEFINITION_TYPE, - reportFullAccessLevel, - reportNoAccessUser - ) - shareConfig(reportsFullClient, shareDef2) - - waitForSharingVisibility( - RestRequest.Method.GET.name, - "$baseUri/definition/$def2Id", - null, - reportsNoAccessClient - ) - - // Read user sees only def1 - val readListAfter = executeRequest( - reportsReadClient, - RestRequest.Method.GET.name, - "$baseUri/definitions", - "", - RestStatus.OK.status - ) - assertEquals(1, readListAfter.get("totalHits").asInt) - val readList = readListAfter.get("reportDefinitionDetailsList").asJsonArray - assertEquals(def1Id, readList[0].asJsonObject.get("id").asString) - - // No-access user can now see def2 but still cannot call list all resources since that endpoint is not permissioned - executeRequest( - reportsNoAccessClient, - RestRequest.Method.GET.name, - "$baseUri/definitions", - "", - RestStatus.FORBIDDEN.status - ) - val noAccessDefAfter = executeRequest( - reportsNoAccessClient, - RestRequest.Method.GET.name, - "$baseUri/definition/$def2Id", - "", - RestStatus.OK.status - ) - assertEquals(def2Id, noAccessDefAfter.get("reportDefinitionDetails").asJsonObject.get("id").asString) - - // Cleanup - executeRequest(reportsFullClient, RestRequest.Method.DELETE.name, "$baseUri/definition/$def1Id", "", RestStatus.OK.status) - executeRequest(reportsFullClient, RestRequest.Method.DELETE.name, "$baseUri/definition/$def2Id", "", RestStatus.OK.status) - executeRequest(reportsFullClient, RestRequest.Method.DELETE.name, "$baseUri/definition/$def3Id", "", RestStatus.OK.status) + step("create 3 definitions") + fun createDef(name: String): String { + val resp = executeRequest(reportsFullClient, RestRequest.Method.POST.name, "$baseUri/definition", + constructReportDefinitionRequest(name = name), RestStatus.OK.status) + return resp.get("reportDefinitionId").asString.also { log.info("created definition '$name' -> $it") } + } + val def1Id = createDef("definition_1") + val def2Id = createDef("definition_2") + val def3Id = createDef("definition_3") + + step("wait for owner to see all 3") + awaitDefinitionCount(baseUri, reportsFullClient, 3) + + step("verify read user sees 0 before sharing") + assertEquals(0, executeRequest(reportsReadClient, RestRequest.Method.GET.name, "$baseUri/definitions", "", RestStatus.OK.status) + .get("totalHits").asInt) + + step("no-access user gets forbidden on list") + executeRequest(reportsNoAccessClient, RestRequest.Method.GET.name, "$baseUri/definitions", "", RestStatus.FORBIDDEN.status) + + step("share def1 with read user, def2 with no-access user") + shareConfig(reportsFullClient, shareWithUserPayload(def1Id, Utils.REPORT_DEFINITION_TYPE, reportReadOnlyAccessLevel, reportReadUser)) + shareConfig(reportsFullClient, shareWithUserPayload(def2Id, Utils.REPORT_DEFINITION_TYPE, reportFullAccessLevel, reportNoAccessUser)) + + step("wait for read user to see def1") + awaitDefinitionCount(baseUri, reportsReadClient, 1) + + step("wait for no-access user to see def2 via GET") + awaitVisible(baseUri, "definition/$def2Id", reportsNoAccessClient) + + step("assert read user sees exactly def1") + val readList = executeRequest(reportsReadClient, RestRequest.Method.GET.name, "$baseUri/definitions", "", RestStatus.OK.status) + assertEquals(1, readList.get("totalHits").asInt) + assertEquals(def1Id, readList.get("reportDefinitionDetailsList").asJsonArray[0].asJsonObject.get("id").asString) + + step("assert no-access user still forbidden on list but can GET def2") + executeRequest(reportsNoAccessClient, RestRequest.Method.GET.name, "$baseUri/definitions", "", RestStatus.FORBIDDEN.status) + val noAccessDef = executeRequest(reportsNoAccessClient, RestRequest.Method.GET.name, "$baseUri/definition/$def2Id", "", RestStatus.OK.status) + assertEquals(def2Id, noAccessDef.get("reportDefinitionDetails").asJsonObject.get("id").asString) + + step("cleanup") + listOf(def1Id, def2Id, def3Id).forEach { + executeRequest(reportsFullClient, RestRequest.Method.DELETE.name, "$baseUri/definition/$it", "", RestStatus.OK.status) + } } fun `test list report definitions with resource sharing`() { testListReportDefinitionsWithResourceSharing(BASE_REPORTS_URI) } - fun `test legacy list report definitions with resource sharing`() { - testListReportDefinitionsWithResourceSharing(LEGACY_BASE_REPORTS_URI) - } + // ------------------------------------------------------------------ + // Patch sharing operations + // ------------------------------------------------------------------ - /** - * Test patch operations for sharing (add/revoke access). - */ private fun testPatchSharingOperations(baseUri: String) { if (!isHttps()) return if (!isResourceSharingFeatureEnabled()) return - val createRequest = constructReportDefinitionRequest(name = "patch_test") - val createResponse = executeRequest( - reportsFullClient, - RestRequest.Method.POST.name, - "$baseUri/definition", - createRequest, - RestStatus.OK.status - ) - val reportDefinitionId = createResponse.get("reportDefinitionId").asString - waitForSharingVisibility( - RestRequest.Method.GET.name, - "$baseUri/definition/$reportDefinitionId", - null, - reportsFullClient - ) + step("create definition for patch test") + val createResponse = executeRequest(reportsFullClient, RestRequest.Method.POST.name, "$baseUri/definition", + constructReportDefinitionRequest(name = "patch_test"), RestStatus.OK.status) + val id = createResponse.get("reportDefinitionId").asString + log.info("created definition $id") - // Share with read user using patch - val patchSharePayload = PatchSharingInfoPayloadBuilder() - .configId(reportDefinitionId) - .configType(Utils.REPORT_DEFINITION_TYPE) - .apply { - share( - mutableMapOf(Recipient.USERS to mutableSetOf(reportReadUser)), - reportReadOnlyAccessLevel - ) - } - .build() - - patchSharingInfo(reportsFullClient, patchSharePayload) - - // Wait for sharing to propagate - waitForSharingVisibility( - RestRequest.Method.GET.name, - "$baseUri/definition/$reportDefinitionId", - null, - reportsReadClient - ) + step("wait for sharing record to be created before patching") + awaitVisible(baseUri, "definition/$id", reportsFullClient) - // Read user can access - val readGet = executeRequest( - reportsReadClient, - RestRequest.Method.GET.name, - "$baseUri/definition/$reportDefinitionId", - "", - RestStatus.OK.status - ) - assertNotNull(readGet) - - // Revoke access using patch - val patchRevokePayload = PatchSharingInfoPayloadBuilder() - .configId(reportDefinitionId) - .configType(Utils.REPORT_DEFINITION_TYPE) - .apply { - revoke( - mutableMapOf(Recipient.USERS to mutableSetOf(reportReadUser)), - reportReadOnlyAccessLevel - ) - } - .build() - - patchSharingInfo(reportsFullClient, patchRevokePayload) - - // Wait for revocation to propagate - waitForRevokeNonVisibility( - RestRequest.Method.GET.name, - "$baseUri/definition/$reportDefinitionId", - null, - reportsReadClient - ) + step("patch share with read user") + patchSharingInfo(reportsFullClient, PatchSharingInfoPayloadBuilder() + .configId(id).configType(Utils.REPORT_DEFINITION_TYPE) + .apply { share(mutableMapOf(Recipient.USERS to mutableSetOf(reportReadUser)), reportReadOnlyAccessLevel) } + .build()) - // Read user cannot access anymore - val readGetAfterRevoke = executeRequest( - reportsReadClient, - RestRequest.Method.GET.name, - "$baseUri/definition/$reportDefinitionId", - "", - RestStatus.FORBIDDEN.status - ) - validateErrorResponse(readGetAfterRevoke, RestStatus.FORBIDDEN.status, "security_exception") - - // Cleanup - executeRequest( - reportsFullClient, - RestRequest.Method.DELETE.name, - "$baseUri/definition/$reportDefinitionId", - "", - RestStatus.OK.status + step("wait for read user to see definition") + awaitVisible(baseUri, "definition/$id", reportsReadClient) + assertNotNull(executeRequest(reportsReadClient, RestRequest.Method.GET.name, "$baseUri/definition/$id", "", RestStatus.OK.status)) + + step("patch revoke from read user") + patchSharingInfo(reportsFullClient, PatchSharingInfoPayloadBuilder() + .configId(id).configType(Utils.REPORT_DEFINITION_TYPE) + .apply { revoke(mutableMapOf(Recipient.USERS to mutableSetOf(reportReadUser)), reportReadOnlyAccessLevel) } + .build()) + + step("wait for revocation to propagate") + waitForRevokeNonVisibility(RestRequest.Method.GET.name, "$baseUri/definition/$id", null, reportsReadClient) + + validateErrorResponse( + executeRequest(reportsReadClient, RestRequest.Method.GET.name, "$baseUri/definition/$id", "", RestStatus.FORBIDDEN.status), + RestStatus.FORBIDDEN.status, "security_exception" ) + + step("cleanup") + executeRequest(reportsFullClient, RestRequest.Method.DELETE.name, "$baseUri/definition/$id", "", RestStatus.OK.status) } fun `test patch sharing operations`() { testPatchSharingOperations(BASE_REPORTS_URI) } - fun `test legacy patch sharing operations`() { - testPatchSharingOperations(LEGACY_BASE_REPORTS_URI) - } + // ------------------------------------------------------------------ + // Report instance with resource sharing + // ------------------------------------------------------------------ - /** - * Test report instance operations with resource sharing. - */ private fun testReportInstanceWithResourceSharing(baseUri: String) { if (!isHttps()) return if (!isResourceSharingFeatureEnabled()) return - // Create report definition and generate instance - val defRequest = constructReportDefinitionRequest() - val defResponse = executeRequest( - reportsFullClient, - RestRequest.Method.POST.name, - "$baseUri/definition", - defRequest, - RestStatus.OK.status - ) - val reportDefinitionId = defResponse.get("reportDefinitionId").asString - waitForSharingVisibility( - RestRequest.Method.GET.name, - "$baseUri/definition/$reportDefinitionId", - null, - reportsFullClient - ) + step("create definition") + val defId = executeRequest(reportsFullClient, RestRequest.Method.POST.name, "$baseUri/definition", + constructReportDefinitionRequest(), RestStatus.OK.status).get("reportDefinitionId").asString + log.info("created definition $defId") - val onDemandRequest = "{}" - val onDemandResponse = executeRequest( - reportsFullClient, - RestRequest.Method.POST.name, - "$baseUri/on_demand/$reportDefinitionId", - onDemandRequest, - RestStatus.OK.status - ) - val reportInstance = onDemandResponse.get("reportInstance").asJsonObject - val reportInstanceId = reportInstance.get("id").asString - waitForSharingVisibility( - RestRequest.Method.GET.name, - "$baseUri/instance/$reportInstanceId", - null, - reportsFullClient - ) + step("wait for owner visibility of definition") + awaitVisible(baseUri, "definition/$defId", reportsFullClient) - // Owner can access instance - val ownerGetInstance = executeRequest( - reportsFullClient, - RestRequest.Method.GET.name, - "$baseUri/instance/$reportInstanceId", - "", - RestStatus.OK.status - ) - assertNotNull(ownerGetInstance) - - // Read user cannot access instance - val readGetInstanceBefore = executeRequest( - reportsReadClient, - RestRequest.Method.GET.name, - "$baseUri/instance/$reportInstanceId", - "", - RestStatus.FORBIDDEN.status - ) - validateErrorResponse(readGetInstanceBefore, RestStatus.FORBIDDEN.status, "security_exception") - - // Share instance with read user - val shareInstancePayload = shareWithUserPayload( - reportInstanceId, - Utils.REPORT_INSTANCE_TYPE, - reportInstanceReadOnlyAccessLevel, - reportReadUser - ) - shareConfig(reportsFullClient, shareInstancePayload) - - // Wait for sharing to propagate - waitForSharingVisibility( - RestRequest.Method.GET.name, - "$baseUri/instance/$reportInstanceId", - null, - reportsReadClient - ) + step("generate instance") + val instanceId = executeRequest(reportsFullClient, RestRequest.Method.POST.name, "$baseUri/on_demand/$defId", + "{}", RestStatus.OK.status).get("reportInstance").asJsonObject.get("id").asString + log.info("created instance $instanceId") - // Read user can now access instance - val readGetInstanceAfter = executeRequest( - reportsReadClient, - RestRequest.Method.GET.name, - "$baseUri/instance/$reportInstanceId", - "", - RestStatus.OK.status - ) - assertEquals( - reportInstanceId, - readGetInstanceAfter.get("reportInstance").asJsonObject.get("id").asString - ) + step("wait for owner visibility of instance") + awaitVisible(baseUri, "instance/$instanceId", reportsFullClient) - // Cleanup - executeRequest( - reportsFullClient, - RestRequest.Method.DELETE.name, - "$baseUri/definition/$reportDefinitionId", - "", - RestStatus.OK.status + step("read user cannot access instance before sharing") + validateErrorResponse( + executeRequest(reportsReadClient, RestRequest.Method.GET.name, "$baseUri/instance/$instanceId", "", RestStatus.FORBIDDEN.status), + RestStatus.FORBIDDEN.status, "security_exception" ) + + step("share instance with read user") + shareConfig(reportsFullClient, shareWithUserPayload(instanceId, Utils.REPORT_INSTANCE_TYPE, reportInstanceReadOnlyAccessLevel, reportReadUser)) + + step("wait for read user to see instance") + awaitVisible(baseUri, "instance/$instanceId", reportsReadClient) + + step("assert read user can GET instance") + val readGet = executeRequest(reportsReadClient, RestRequest.Method.GET.name, "$baseUri/instance/$instanceId", "", RestStatus.OK.status) + assertEquals(instanceId, readGet.get("reportInstance").asJsonObject.get("id").asString) + + step("cleanup") + executeRequest(reportsFullClient, RestRequest.Method.DELETE.name, "$baseUri/definition/$defId", "", RestStatus.OK.status) } fun `test report instance with resource sharing`() { testReportInstanceWithResourceSharing(BASE_REPORTS_URI) } - fun `test legacy report instance with resource sharing`() { - testReportInstanceWithResourceSharing(LEGACY_BASE_REPORTS_URI) - } + // ------------------------------------------------------------------ + // List report instances with resource sharing + // ------------------------------------------------------------------ - /** - * Test listing report instances with resource sharing. - */ private fun testListReportInstancesWithResourceSharing(baseUri: String) { if (!isHttps()) return if (!isResourceSharingFeatureEnabled()) return - // Create report definition and generate instances - val defRequest = constructReportDefinitionRequest() - val defResponse = executeRequest( - reportsFullClient, - RestRequest.Method.POST.name, - "$baseUri/definition", - defRequest, - RestStatus.OK.status - ) - val reportDefinitionId = defResponse.get("reportDefinitionId").asString - waitForSharingVisibility( - RestRequest.Method.GET.name, - "$baseUri/definition/$reportDefinitionId", - null, - reportsFullClient - ) + step("create definition") + val defId = executeRequest(reportsFullClient, RestRequest.Method.POST.name, "$baseUri/definition", + constructReportDefinitionRequest(), RestStatus.OK.status).get("reportDefinitionId").asString + log.info("created definition $defId") - val onDemandRequest = "{}" - val instance1Response = executeRequest( - reportsFullClient, - RestRequest.Method.POST.name, - "$baseUri/on_demand/$reportDefinitionId", - onDemandRequest, - RestStatus.OK.status - ) - val instance1Id = instance1Response.get("reportInstance").asJsonObject.get("id").asString - - val instance2Response = executeRequest( - reportsFullClient, - RestRequest.Method.POST.name, - "$baseUri/on_demand/$reportDefinitionId", - onDemandRequest, - RestStatus.OK.status - ) - val instance2Id = instance2Response.get("reportInstance").asJsonObject.get("id").asString - waitForSharingVisibility( - RestRequest.Method.GET.name, - "$baseUri/instance/$instance1Id", - null, - reportsFullClient - ) - waitForSharingVisibility( - RestRequest.Method.GET.name, - "$baseUri/instance/$instance2Id", - null, - reportsFullClient - ) + step("wait for owner visibility of definition") + awaitVisible(baseUri, "definition/$defId", reportsFullClient) - // Owner sees all instances - val ownerList = executeRequest( - reportsFullClient, - RestRequest.Method.GET.name, - "$baseUri/instances", - "", - RestStatus.OK.status - ) - assertTrue(ownerList.get("totalHits").asInt >= 2) - - // Read user sees none - val readListBefore = executeRequest( - reportsReadClient, - RestRequest.Method.GET.name, - "$baseUri/instances", - "", - RestStatus.OK.status - ) - assertEquals(0, readListBefore.get("totalHits").asInt) - - // Share instance1 with read user - val sharePayload = shareWithUserPayload( - instance1Id, - Utils.REPORT_INSTANCE_TYPE, - reportInstanceReadOnlyAccessLevel, - reportReadUser - ) - shareConfig(reportsFullClient, sharePayload) - waitForSharingVisibility( - RestRequest.Method.GET.name, - "$baseUri/instance/$instance1Id", - null, - reportsReadClient - ) + step("generate 2 instances") + val instance1Id = executeRequest(reportsFullClient, RestRequest.Method.POST.name, "$baseUri/on_demand/$defId", + "{}", RestStatus.OK.status).get("reportInstance").asJsonObject.get("id").asString + val instance2Id = executeRequest(reportsFullClient, RestRequest.Method.POST.name, "$baseUri/on_demand/$defId", + "{}", RestStatus.OK.status).get("reportInstance").asJsonObject.get("id").asString + log.info("created instances $instance1Id, $instance2Id") - // Read user sees only instance1 - val readListAfter = executeRequest( - reportsReadClient, - RestRequest.Method.GET.name, - "$baseUri/instances", - "", - RestStatus.OK.status - ) - assertEquals(1, readListAfter.get("totalHits").asInt) - val instanceList = readListAfter.get("reportInstanceList").asJsonArray - assertEquals(instance1Id, instanceList[0].asJsonObject.get("id").asString) - - // Cleanup - executeRequest( - reportsFullClient, - RestRequest.Method.DELETE.name, - "$baseUri/definition/$reportDefinitionId", - "", - RestStatus.OK.status - ) + step("wait for owner to see both instances") + awaitInstanceCount(baseUri, reportsFullClient, 2) + + step("read user sees 0 before sharing") + assertEquals(0, executeRequest(reportsReadClient, RestRequest.Method.GET.name, "$baseUri/instances", "", RestStatus.OK.status) + .get("totalHits").asInt) + + step("share instance1 with read user") + shareConfig(reportsFullClient, shareWithUserPayload(instance1Id, Utils.REPORT_INSTANCE_TYPE, reportInstanceReadOnlyAccessLevel, reportReadUser)) + + step("wait for read user to see instance1 in list") + awaitInstanceCount(baseUri, reportsReadClient, 1) + + step("assert read user sees only instance1") + val readList = executeRequest(reportsReadClient, RestRequest.Method.GET.name, "$baseUri/instances", "", RestStatus.OK.status) + assertEquals(1, readList.get("totalHits").asInt) + assertEquals(instance1Id, readList.get("reportInstanceList").asJsonArray[0].asJsonObject.get("id").asString) + + step("cleanup") + executeRequest(reportsFullClient, RestRequest.Method.DELETE.name, "$baseUri/definition/$defId", "", RestStatus.OK.status) } fun `test list report instances with resource sharing`() { testListReportInstancesWithResourceSharing(BASE_REPORTS_URI) } - fun `test legacy list report instances with resource sharing`() { - testListReportInstancesWithResourceSharing(LEGACY_BASE_REPORTS_URI) - } + // ------------------------------------------------------------------ + // Parent-hierarchy access: instances visible via definition sharing + // ------------------------------------------------------------------ - /** - * Test that a user with access to a report definition (parent) can list and get its - * report instances (children) via parent-inherited access, without being directly - * shared on the instances themselves. - * - * This mirrors the dashboards UI scenario where instances are shown on page load - * for a definition the user has access to. - */ private fun testListInstancesViaParentDefinitionAccess(baseUri: String) { if (!isHttps()) return if (!isResourceSharingFeatureEnabled()) return - // Create a report definition as the full-access user (owner) - val defRequest = constructReportDefinitionRequest(name = "parent_hierarchy_def") - val defResponse = executeRequest( - reportsFullClient, - RestRequest.Method.POST.name, - "$baseUri/definition", - defRequest, - RestStatus.OK.status - ) - val reportDefinitionId = defResponse.get("reportDefinitionId").asString - waitForSharingVisibility( - RestRequest.Method.GET.name, - "$baseUri/definition/$reportDefinitionId", - null, - reportsFullClient - ) - - // Generate two instances from that definition - val instance1Response = executeRequest( - reportsFullClient, - RestRequest.Method.POST.name, - "$baseUri/on_demand/$reportDefinitionId", - "{}", - RestStatus.OK.status - ) - val instance1Id = instance1Response.get("reportInstance").asJsonObject.get("id").asString - - val instance2Response = executeRequest( - reportsFullClient, - RestRequest.Method.POST.name, - "$baseUri/on_demand/$reportDefinitionId", - "{}", - RestStatus.OK.status - ) - val instance2Id = instance2Response.get("reportInstance").asJsonObject.get("id").asString - - // Wait for sharing records to be visible for the owner - waitForSharingVisibility( - RestRequest.Method.GET.name, - "$baseUri/instance/$instance1Id", - null, - reportsFullClient - ) - waitForSharingVisibility( - RestRequest.Method.GET.name, - "$baseUri/instance/$instance2Id", - null, - reportsFullClient - ) - - // Read user has no access yet — instances should be invisible - val readListBefore = executeRequest( - reportsReadClient, - RestRequest.Method.GET.name, - "$baseUri/instances", - "", - RestStatus.OK.status - ) - assertEquals(0, readListBefore.get("totalHits").asInt) - - val readGetBefore = executeRequest( - reportsReadClient, - RestRequest.Method.GET.name, - "$baseUri/instance/$instance1Id", - "", - RestStatus.FORBIDDEN.status - ) - validateErrorResponse(readGetBefore, RestStatus.FORBIDDEN.status, "security_exception") - - // Share the DEFINITION (parent) with the read user — NOT the instances directly - val shareDefPayload = shareWithUserPayload( - reportDefinitionId, - Utils.REPORT_DEFINITION_TYPE, - reportReadOnlyAccessLevel, - reportReadUser - ) - shareConfig(reportsFullClient, shareDefPayload) - - // Wait for parent-inherited access to propagate to the instances - waitForSharingVisibility( - RestRequest.Method.GET.name, - "$baseUri/instance/$instance1Id", - null, - reportsReadClient - ) - - // Read user should now see both instances via parent-inherited access - val readListAfter = executeRequest( - reportsReadClient, - RestRequest.Method.GET.name, - "$baseUri/instances", - "", - RestStatus.OK.status - ) - assertEquals(2, readListAfter.get("totalHits").asInt) - val instanceIds = readListAfter.get("reportInstanceList").asJsonArray - .map { it.asJsonObject.get("id").asString } - .toSet() - assertTrue(instanceIds.contains(instance1Id)) - assertTrue(instanceIds.contains(instance2Id)) - - // Read user should also be able to GET each instance individually - val readGet1 = executeRequest( - reportsReadClient, - RestRequest.Method.GET.name, - "$baseUri/instance/$instance1Id", - "", - RestStatus.OK.status - ) - assertEquals(instance1Id, readGet1.get("reportInstance").asJsonObject.get("id").asString) - - val readGet2 = executeRequest( - reportsReadClient, - RestRequest.Method.GET.name, - "$baseUri/instance/$instance2Id", - "", - RestStatus.OK.status - ) - assertEquals(instance2Id, readGet2.get("reportInstance").asJsonObject.get("id").asString) - - // Cleanup - executeRequest( - reportsFullClient, - RestRequest.Method.DELETE.name, - "$baseUri/definition/$reportDefinitionId", - "", - RestStatus.OK.status - ) + step("create definition") + val defId = executeRequest(reportsFullClient, RestRequest.Method.POST.name, "$baseUri/definition", + constructReportDefinitionRequest(name = "parent_hierarchy_def"), RestStatus.OK.status) + .get("reportDefinitionId").asString + log.info("created definition $defId") + + // Wait for sharing record to be written before generating instances (on_demand checks definition access) + step("wait for owner visibility of definition") + awaitVisible(baseUri, "definition/$defId", reportsFullClient) + + step("generate 2 instances") + val instance1Id = executeRequest(reportsFullClient, RestRequest.Method.POST.name, "$baseUri/on_demand/$defId", + "{}", RestStatus.OK.status).get("reportInstance").asJsonObject.get("id").asString + val instance2Id = executeRequest(reportsFullClient, RestRequest.Method.POST.name, "$baseUri/on_demand/$defId", + "{}", RestStatus.OK.status).get("reportInstance").asJsonObject.get("id").asString + log.info("created instances $instance1Id, $instance2Id") + + step("wait for owner to see both instances") + awaitInstanceCount(baseUri, reportsFullClient, 2) + + // Wait for instance sharing records to exist (needed for direct GET-by-ID via hasPermission) + awaitVisible(baseUri, "instance/$instance1Id", reportsFullClient) + awaitVisible(baseUri, "instance/$instance2Id", reportsFullClient) + + step("read user sees 0 instances and gets 403 on direct GET before sharing") + assertEquals(0, executeRequest(reportsReadClient, RestRequest.Method.GET.name, "$baseUri/instances", "", RestStatus.OK.status) + .get("totalHits").asInt) + validateErrorResponse( + executeRequest(reportsReadClient, RestRequest.Method.GET.name, "$baseUri/instance/$instance1Id", "", RestStatus.FORBIDDEN.status), + RestStatus.FORBIDDEN.status, "security_exception" + ) + + step("share DEFINITION (parent) with read user — instances are NOT directly shared") + shareConfig(reportsFullClient, shareWithUserPayload(defId, Utils.REPORT_DEFINITION_TYPE, reportReadOnlyAccessLevel, reportReadUser)) + + // Wait for the definition's all_shared_principals to include the read user. + // This is the prerequisite for the two-phase instance query to work: + // phase 1 queries all_shared_principals on the definition doc, so we must + // wait until that field is visible to the read user before asserting the list. + step("wait for read user to see definition (confirms all_shared_principals is updated)") + awaitVisible(baseUri, "definition/$defId", reportsReadClient) + + step("assert read user sees both instances via parent-inherited access") + val readList = executeRequest(reportsReadClient, RestRequest.Method.GET.name, "$baseUri/instances", "", RestStatus.OK.status) + assertEquals(2, readList.get("totalHits").asInt) + val ids = readList.get("reportInstanceList").asJsonArray.map { it.asJsonObject.get("id").asString }.toSet() + assertTrue(ids.contains(instance1Id)) + assertTrue(ids.contains(instance2Id)) + + step("assert read user can GET each instance individually") + awaitVisible(baseUri, "instance/$instance1Id", reportsReadClient) + awaitVisible(baseUri, "instance/$instance2Id", reportsReadClient) + assertEquals(instance1Id, + executeRequest(reportsReadClient, RestRequest.Method.GET.name, "$baseUri/instance/$instance1Id", "", RestStatus.OK.status) + .get("reportInstance").asJsonObject.get("id").asString) + assertEquals(instance2Id, + executeRequest(reportsReadClient, RestRequest.Method.GET.name, "$baseUri/instance/$instance2Id", "", RestStatus.OK.status) + .get("reportInstance").asJsonObject.get("id").asString) + + step("cleanup") + executeRequest(reportsFullClient, RestRequest.Method.DELETE.name, "$baseUri/definition/$defId", "", RestStatus.OK.status) } fun `test list instances via parent definition access`() { testListInstancesViaParentDefinitionAccess(BASE_REPORTS_URI) } - - fun `test legacy list instances via parent definition access`() { - testListInstancesViaParentDefinitionAccess(LEGACY_BASE_REPORTS_URI) - } } From b01e84f6a87033a5514ae285e2580fbba014a471 Mon Sep 17 00:00:00 2001 From: Craig Perkins Date: Sat, 21 Mar 2026 22:45:29 -0400 Subject: [PATCH 8/8] Use multi-phase approach Signed-off-by: Craig Perkins --- .../index/ReportInstancesIndex.kt | 129 +++++++++++++++--- .../resources/report-instances-mapping.yml | 7 +- .../integTest/rest/ResourceSharingIT.kt | 47 +++++++ 3 files changed, 165 insertions(+), 18 deletions(-) diff --git a/src/main/kotlin/org/opensearch/reportsscheduler/index/ReportInstancesIndex.kt b/src/main/kotlin/org/opensearch/reportsscheduler/index/ReportInstancesIndex.kt index 60f5610e..77c5ffaf 100644 --- a/src/main/kotlin/org/opensearch/reportsscheduler/index/ReportInstancesIndex.kt +++ b/src/main/kotlin/org/opensearch/reportsscheduler/index/ReportInstancesIndex.kt @@ -28,6 +28,7 @@ import org.opensearch.reportsscheduler.model.RestTag.TENANT_FIELD import org.opensearch.reportsscheduler.model.RestTag.UPDATED_TIME_FIELD import org.opensearch.reportsscheduler.resources.Utils import org.opensearch.reportsscheduler.resources.Utils.shouldUseResourceAuthz +import org.apache.lucene.search.TotalHits import org.opensearch.reportsscheduler.settings.PluginSettings import org.opensearch.reportsscheduler.util.PluginClient import org.opensearch.reportsscheduler.util.SecureIndexClient @@ -160,31 +161,75 @@ internal object ReportInstancesIndex { pluginClient: PluginClient? ): ReportInstanceSearchResults { createIndex() + val tenantQuery = QueryBuilders.termsQuery(TENANT_FIELD, tenant) + + if (pluginClient != null && shouldUseResourceAuthz(Utils.REPORT_INSTANCE_TYPE)) { + // Resource-sharing path: resolve accessible instance IDs in the plugin layer. + // + // Step 1 — directly visible instances (DLS-filtered via pluginClient) + val directInstanceIds = searchInstanceIds( + QueryBuilders.boolQuery().filter(tenantQuery), + pluginClient + ) + + // Step 2 — accessible definition IDs (DLS-filtered via pluginClient) + val definitionIds = searchDefinitionIds(tenant, pluginClient) + + // Step 3 — instances whose parent definition is accessible (admin client, no DLS) + val inheritedInstanceIds: Set = if (definitionIds.isEmpty()) { + emptySet() + } else { + searchInstanceIdsByDefinitionIds(tenantQuery, definitionIds) + } + + val allIds = directInstanceIds + inheritedInstanceIds + log.info( + "$LOG_PREFIX:getAllReportInstances directIds=${directInstanceIds.size}" + + " definitionIds=${definitionIds.size} inheritedIds=${inheritedInstanceIds.size}" + + " total=${allIds.size}" + ) + + if (allIds.isEmpty()) { + return ReportInstanceSearchResults(from.toLong(), 0L, TotalHits.Relation.EQUAL_TO, emptyList()) + } + + // Step 4 — fetch full docs for the union, with pagination applied + val query = QueryBuilders.boolQuery() + .filter(tenantQuery) + .filter(QueryBuilders.idsQuery().addIds(*allIds.toTypedArray())) + val sourceBuilder = SearchSourceBuilder() + .timeout(TimeValue(PluginSettings.operationTimeoutMs, TimeUnit.MILLISECONDS)) + .sort(UPDATED_TIME_FIELD) + .size(maxItems) + .from(from) + .query(query) + val searchRequest = SearchRequest().indices(REPORT_INSTANCES_INDEX_NAME).source(sourceBuilder) + val response = client.search(searchRequest).actionGet(PluginSettings.operationTimeoutMs) + val result = ReportInstanceSearchResults(from.toLong(), response) + log.info( + "$LOG_PREFIX:getAllReportInstances from:$from maxItems:$maxItems" + + " retCount:${result.objectList.size} totalCount:${result.totalHits}" + ) + return result + } + + // Legacy path (resource-sharing disabled) val sourceBuilder = SearchSourceBuilder() .timeout(TimeValue(PluginSettings.operationTimeoutMs, TimeUnit.MILLISECONDS)) .sort(UPDATED_TIME_FIELD) .size(maxItems) .from(from) - val tenantQuery = QueryBuilders.termsQuery(TENANT_FIELD, tenant) if (access.isNotEmpty()) { - val accessQuery = QueryBuilders.termsQuery(ACCESS_LIST_FIELD, access) - val query = QueryBuilders.boolQuery() - query.filter(tenantQuery) - query.filter(accessQuery) - sourceBuilder.query(query) + sourceBuilder.query( + QueryBuilders.boolQuery() + .filter(tenantQuery) + .filter(QueryBuilders.termsQuery(ACCESS_LIST_FIELD, access)) + ) } else { sourceBuilder.query(tenantQuery) } - val searchRequest = SearchRequest() - .indices(REPORT_INSTANCES_INDEX_NAME) - .source(sourceBuilder) - val actionFuture = - if (pluginClient != null && shouldUseResourceAuthz(Utils.REPORT_INSTANCE_TYPE)) { - pluginClient.search(searchRequest) - } else { - client.search(searchRequest) - } - val response = actionFuture.actionGet(PluginSettings.operationTimeoutMs) + val searchRequest = SearchRequest().indices(REPORT_INSTANCES_INDEX_NAME).source(sourceBuilder) + val response = client.search(searchRequest).actionGet(PluginSettings.operationTimeoutMs) val result = ReportInstanceSearchResults(from.toLong(), response) log.info( "$LOG_PREFIX:getAllReportInstances from:$from, maxItems:$maxItems," + @@ -193,6 +238,58 @@ internal object ReportInstancesIndex { return result } + /** + * Returns the set of instance document IDs matching [query] via [pluginClient] (DLS-filtered). + * Fetches only `_id` — no source needed. + */ + private fun searchInstanceIds(query: org.opensearch.index.query.QueryBuilder, pluginClient: PluginClient): Set { + val sourceBuilder = SearchSourceBuilder() + .timeout(TimeValue(PluginSettings.operationTimeoutMs, TimeUnit.MILLISECONDS)) + .size(10_000) + .fetchSource(false) + .query(query) + val request = SearchRequest().indices(REPORT_INSTANCES_INDEX_NAME).source(sourceBuilder) + val response = pluginClient.search(request).actionGet(PluginSettings.operationTimeoutMs) + return response.hits.hits.map { it.id }.toSet() + } + + /** + * Returns the set of definition document IDs accessible to the current user via [pluginClient] (DLS-filtered). + */ + private fun searchDefinitionIds(tenant: String, pluginClient: PluginClient): Set { + val query = QueryBuilders.termsQuery(TENANT_FIELD, tenant) + val sourceBuilder = SearchSourceBuilder() + .timeout(TimeValue(PluginSettings.operationTimeoutMs, TimeUnit.MILLISECONDS)) + .size(10_000) + .fetchSource(false) + .query(query) + val request = SearchRequest() + .indices(ReportDefinitionsIndex.REPORT_DEFINITIONS_INDEX_NAME) + .source(sourceBuilder) + val response = pluginClient.search(request).actionGet(PluginSettings.operationTimeoutMs) + return response.hits.hits.map { it.id }.toSet() + } + + /** + * Returns instance IDs (via admin client, no DLS) whose `reportDefinitionDetails.id` is in [definitionIds]. + */ + private fun searchInstanceIdsByDefinitionIds( + tenantQuery: org.opensearch.index.query.QueryBuilder, + definitionIds: Set + ): Set { + val query = QueryBuilders.boolQuery() + .filter(tenantQuery) + .filter(QueryBuilders.termsQuery("reportDefinitionDetails.id", definitionIds.toList())) + val sourceBuilder = SearchSourceBuilder() + .timeout(TimeValue(PluginSettings.operationTimeoutMs, TimeUnit.MILLISECONDS)) + .size(10_000) + .fetchSource(false) + .query(query) + val request = SearchRequest().indices(REPORT_INSTANCES_INDEX_NAME).source(sourceBuilder) + val response = client.search(request).actionGet(PluginSettings.operationTimeoutMs) + return response.hits.hits.map { it.id }.toSet() + } + /** * update Report instance details for given id * @param reportInstance the Report instance details data diff --git a/src/main/resources/report-instances-mapping.yml b/src/main/resources/report-instances-mapping.yml index 4a182dcd..97ba881d 100644 --- a/src/main/resources/report-instances-mapping.yml +++ b/src/main/resources/report-instances-mapping.yml @@ -22,7 +22,10 @@ properties: type: keyword status: type: keyword - reportDefinitionDetails.id: - type: keyword + reportDefinitionDetails: + type: object + properties: + id: + type: keyword all_shared_principals: type: keyword diff --git a/src/test/kotlin/org/opensearch/integTest/rest/ResourceSharingIT.kt b/src/test/kotlin/org/opensearch/integTest/rest/ResourceSharingIT.kt index 3abc4a58..a1890fcf 100644 --- a/src/test/kotlin/org/opensearch/integTest/rest/ResourceSharingIT.kt +++ b/src/test/kotlin/org/opensearch/integTest/rest/ResourceSharingIT.kt @@ -76,6 +76,52 @@ class ResourceSharingIT : PluginRestTestCase() { } } + /** + * Dumps the full contents of the definition index, instance index, and both sharing indices + * to the log using the super-admin client. Call this before a failing assertion to diagnose + * what state the cluster is actually in. + */ + private fun dumpIndexState(defId: String? = null, instanceIds: List = emptyList()) { + val indices = listOf( + ".opendistro-reports-definitions", + ".opendistro-reports-definitions-sharing", + ".opendistro-reports-instances", + ".opendistro-reports-instances-sharing" + ) + for (idx in indices) { + try { + val req = org.opensearch.client.Request("GET", "/$idx/_search") + req.setJsonEntity("""{"query":{"match_all":{}},"size":50}""") + val resp = adminClient().performRequest(req) + val body = resp.entity.content.bufferedReader().readText() + log.info("INDEX DUMP [$idx]: $body") + } catch (e: Exception) { + log.info("INDEX DUMP [$idx]: unavailable (${e.message})") + } + } + // Also dump specific sharing records by ID if provided + if (defId != null) { + for (sharingIdx in listOf(".opendistro-reports-definitions-sharing", ".opendistro-reports-instances-sharing")) { + try { + val req = org.opensearch.client.Request("GET", "/$sharingIdx/_doc/$defId") + val resp = adminClient().performRequest(req) + log.info("SHARING DOC [$sharingIdx/$defId]: ${resp.entity.content.bufferedReader().readText()}") + } catch (e: Exception) { + log.info("SHARING DOC [$sharingIdx/$defId]: ${e.message}") + } + } + } + for (instanceId in instanceIds) { + try { + val req = org.opensearch.client.Request("GET", "/.opendistro-reports-instances-sharing/_doc/$instanceId") + val resp = adminClient().performRequest(req) + log.info("SHARING DOC [instances-sharing/$instanceId]: ${resp.entity.content.bufferedReader().readText()}") + } catch (e: Exception) { + log.info("SHARING DOC [instances-sharing/$instanceId]: ${e.message}") + } + } + } + // ------------------------------------------------------------------ // Report definition CRUD // ------------------------------------------------------------------ @@ -429,6 +475,7 @@ class ResourceSharingIT : PluginRestTestCase() { awaitVisible(baseUri, "definition/$defId", reportsReadClient) step("assert read user sees both instances via parent-inherited access") + dumpIndexState(defId = defId, instanceIds = listOf(instance1Id, instance2Id)) val readList = executeRequest(reportsReadClient, RestRequest.Method.GET.name, "$baseUri/instances", "", RestStatus.OK.status) assertEquals(2, readList.get("totalHits").asInt) val ids = readList.get("reportInstanceList").asJsonArray.map { it.asJsonObject.get("id").asString }.toSet()