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/.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/build.gradle b/build.gradle index e45414c0..f5ce9c74 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('-') @@ -596,14 +596,25 @@ 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" -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 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 reportsSchedulerArtifact = "opensearch-reports-scheduler" 2.times {i -> testClusters { @@ -617,13 +628,17 @@ String bwcSnapshotVersion = baseVersion + "-SNAPSHOT" return new RegularFile() { @Override File getAsFile() { + String metadataUrl = "${base}/${groupPath}/${jobSchedulerArtifact}/${bwcVersion}-SNAPSHOT/maven-metadata.xml" + String snapshotValue = resolveZipSnapshotValue(metadataUrl) + String fileName = "${jobSchedulerArtifact}-${snapshotValue}.zip" + String downloadUrl = "${base}/${groupPath}/${jobSchedulerArtifact}/${bwcVersion}-SNAPSHOT/${fileName}" File dir = new File(bwcFilePath + "/job-scheduler/" + bwcVersion) if (!dir.exists()) { dir.mkdirs() } - File file = new File(dir, "opensearch-job-scheduler-" + bwcVersion + ".zip") + 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() } @@ -636,13 +651,17 @@ String bwcSnapshotVersion = baseVersion + "-SNAPSHOT" return new RegularFile() { @Override File getAsFile() { + String metadataUrl = "${base}/${groupPath}/${reportsSchedulerArtifact}/${bwcVersion}-SNAPSHOT/maven-metadata.xml" + String snapshotValue = resolveZipSnapshotValue(metadataUrl) + String fileName = "${reportsSchedulerArtifact}-${snapshotValue}.zip" + String downloadUrl = "${base}/${groupPath}/${reportsSchedulerArtifact}/${bwcVersion}-SNAPSHOT/${fileName}" File dir = new File(bwcFilePath + "/reports-scheduler/" + bwcVersion) if (!dir.exists()) { dir.mkdirs() } - File file = new File(dir, "opensearch-reports-scheduler-" + bwcVersion + ".zip") + 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() } 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/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/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..a1890fcf 100644 --- a/src/test/kotlin/org/opensearch/integTest/rest/ResourceSharingIT.kt +++ b/src/test/kotlin/org/opensearch/integTest/rest/ResourceSharingIT.kt @@ -5,703 +5,498 @@ 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 + // ------------------------------------------------------------------ + + /** + * 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 } + } + } + /** - * Test report definition CRUD operations with resource sharing enabled. - * Verifies that full access user cannot access resources until explicitly shared. + * 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 + // ------------------------------------------------------------------ + @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 readGetAfter = executeRequest( - reportsReadClient, - RestRequest.Method.GET.name, - "$baseUri/definition/$reportDefinitionId", - "", - RestStatus.OK.status - ) - assertEquals( - reportDefinitionId, - readGetAfter.get("reportDefinitionDetails").asJsonObject.get("id").asString - ) + val readGet = executeRequest(reportsReadClient, RestRequest.Method.GET.name, "$baseUri/definition/$id", "", RestStatus.OK.status) + assertEquals(id, readGet.get("reportDefinitionDetails").asJsonObject.get("id").asString) - val updateRequest = constructReportDefinitionRequest(name = "read_user_update_attempt") - val readUpdateForbidden = executeRequest( - reportsReadClient, - RestRequest.Method.PUT.name, - "$baseUri/definition/$reportDefinitionId", - updateRequest, - RestStatus.FORBIDDEN.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" ) - validateErrorResponse(readUpdateForbidden, RestStatus.FORBIDDEN.status, "security_exception") - - val readDeleteForbidden = executeRequest( - reportsReadClient, - RestRequest.Method.DELETE.name, - "$baseUri/definition/$reportDefinitionId", - "", - RestStatus.FORBIDDEN.status + validateErrorResponse( + executeRequest(reportsReadClient, RestRequest.Method.DELETE.name, "$baseUri/definition/$id", "", RestStatus.FORBIDDEN.status), + RestStatus.FORBIDDEN.status, "security_exception" ) - 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) - - waitForSharingVisibility( - RestRequest.Method.GET.name, - "$baseUri/definition/$reportDefinitionId", - null, - reportsNoAccessClient - ) + private fun testFullAccessSharing(baseUri: String, id: String) { + shareConfig(reportsFullClient, shareWithUserPayload(id, Utils.REPORT_DEFINITION_TYPE, reportFullAccessLevel, reportNoAccessUser)) + awaitVisible(baseUri, "definition/$id", 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() + step("wait for sharing record to be created before patching") + awaitVisible(baseUri, "definition/$id", reportsFullClient) - patchSharingInfo(reportsFullClient, patchSharePayload) + 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()) - // Wait for sharing to propagate - waitForSharingVisibility( - RestRequest.Method.GET.name, - "$baseUri/definition/$reportDefinitionId", - null, - reportsReadClient - ) + 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)) - // 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() + 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()) - patchSharingInfo(reportsFullClient, patchRevokePayload) + step("wait for revocation to propagate") + waitForRevokeNonVisibility(RestRequest.Method.GET.name, "$baseUri/definition/$id", null, reportsReadClient) - // Wait for revocation to propagate - waitForRevokeNonVisibility( - RestRequest.Method.GET.name, - "$baseUri/definition/$reportDefinitionId", - null, - reportsReadClient + validateErrorResponse( + executeRequest(reportsReadClient, RestRequest.Method.GET.name, "$baseUri/definition/$id", "", RestStatus.FORBIDDEN.status), + RestStatus.FORBIDDEN.status, "security_exception" ) - // 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("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 + // ------------------------------------------------------------------ + + private fun testListInstancesViaParentDefinitionAccess(baseUri: String) { + if (!isHttps()) return + if (!isResourceSharingFeatureEnabled()) return + + 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") + 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() + 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) } }