From c1a758c16e92f80b74b357e29b1d7a4c6fc82e66 Mon Sep 17 00:00:00 2001 From: Craig Perkins Date: Thu, 18 Jun 2026 13:52:40 -0400 Subject: [PATCH] Remove .kibana_* and unnecessary index permissions from kibana_user role The kibana_user role previously granted index-level permissions on .kibana_*, .kibana-6, .opensearch_dashboards_*, .opensearch_dashboards-6, .tasks, and .management-beats. These are unnecessary for the standard Dashboards UI flow. The PrivilegesInterceptor (legacy) and DashboardsMultitenancySystemIndexHandler (nextgen) handle tenant access entirely through the .kibana alias rewrite and DocumentAllowList thread context mechanism: 1. Requests go through the .kibana alias 2. The interceptor validates tenant_permissions 3. The request is rewritten to the concrete tenant index 4. A DocumentAllowList is stashed in the thread context for shard-level access No additional index permissions are needed. The .tasks and .management-beats entries are a legacy artifact (.tasks is now a system index, .management-beats is unused by OpenSearch Dashboards). Changes: - static_roles.yml: Narrow kibana_user to only .kibana and .opensearch_dashboards alias permissions - DashboardMultiTenancyIntTests: Add tests verifying users cannot access other users concrete tenant indices without the securitytenant header Signed-off-by: Craig Perkins --- .../DashboardMultiTenancyIntTests.java | 81 +++++++++++++++++++ .../resources/static_config/static_roles.yml | 11 --- 2 files changed, 81 insertions(+), 11 deletions(-) diff --git a/src/integrationTest/java/org/opensearch/security/privileges/int_tests/DashboardMultiTenancyIntTests.java b/src/integrationTest/java/org/opensearch/security/privileges/int_tests/DashboardMultiTenancyIntTests.java index 4da2324cfa..adb540c9e5 100644 --- a/src/integrationTest/java/org/opensearch/security/privileges/int_tests/DashboardMultiTenancyIntTests.java +++ b/src/integrationTest/java/org/opensearch/security/privileges/int_tests/DashboardMultiTenancyIntTests.java @@ -41,6 +41,8 @@ import static org.opensearch.test.framework.matcher.RestMatchers.isForbidden; import static org.opensearch.test.framework.matcher.RestMatchers.isOk; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; /** * An integration test matrix for Dashboards multi-tenancy. Verifies both read and write operations @@ -959,6 +961,85 @@ public DashboardMultiTenancyIntTests( this.clusterConfig = clusterConfig; } + /** + * Verifies that a user with kibana_user role cannot directly read another user's concrete + * tenant index via _mget without the securitytenant header. The kibana_user role should not + * grant index-level access to .kibana_* indices. + */ + @Test + public void mget_concreteIndexWithoutTenantHeader_shouldBeDenied() { + if (user.reference(READ).covers(dashboards_index_human_resources)) { + return; + } + try (TestRestClient restClient = cluster.getRestClient(user)) { + String docId = dashboards_index_human_resources.anyDocument().id(); + + TestRestClient.HttpResponse response = restClient.postJson("_mget/?pretty", """ + { + "docs": [ + { + "_index": "%s", + "_id": "%s" + } + ] + } + """.formatted(dashboards_index_human_resources.name(), docId)); + + // _mget returns 200 at top level but each doc has an error + if (response.getStatusCode() == 200) { + String body = response.getBody(); + assertTrue("Expected security_exception in mget doc response but got: " + body, body.contains("security_exception")); + assertFalse("Expected no found documents but got: " + body, body.contains("\"found\" : true")); + } else { + assertThat(response, isForbidden()); + } + } + } + + /** + * Verifies that a user with kibana_user role cannot directly search another user's concrete + * tenant index without the securitytenant header. + */ + @Test + public void search_concreteIndexWithoutTenantHeader_shouldBeDenied() { + if (user.reference(READ).covers(dashboards_index_human_resources)) { + return; + } + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse response = restClient.get(dashboards_index_human_resources.name() + "/_search?size=10"); + + assertThat(response, isForbidden()); + } + } + + /** + * Verifies that a user with kibana_user role cannot directly write to another user's concrete + * tenant index via _bulk without the securitytenant header. + */ + @Test + public void bulk_concreteIndexWithoutTenantHeader_shouldBeDenied() { + if (user.reference(WRITE).covers(dashboards_index_human_resources)) { + return; + } + try (TestRestClient restClient = cluster.getRestClient(user)) { + TestRestClient.HttpResponse response = restClient.postJson("_bulk", """ + { "index" : { "_index" : "%s", "_id" : "test_no_header_1" } } + { "type": "dashboard", "title": "test" } + """.formatted(dashboards_index_human_resources.name())); + + // _bulk returns 200 at top level but each item has a 403 status + if (response.getStatusCode() == 200) { + String body = response.getBody(); + assertTrue( + "Expected security_exception in bulk item response but got: " + body, + body.contains("security_exception") || body.contains("\"status\":403") + ); + } else { + assertThat(response, isForbidden()); + } + } + } + private void delete(String... paths) { try (TestRestClient adminRestClient = cluster.getAdminCertRestClient()) { for (String path : paths) { diff --git a/src/main/resources/static_config/static_roles.yml b/src/main/resources/static_config/static_roles.yml index c7820ab627..a07b17f15f 100644 --- a/src/main/resources/static_config/static_roles.yml +++ b/src/main/resources/static_config/static_roles.yml @@ -31,23 +31,12 @@ kibana_user: index_permissions: - index_patterns: - ".kibana" - - ".kibana-6" - - ".kibana_*" - ".opensearch_dashboards" - - ".opensearch_dashboards-6" - - ".opensearch_dashboards_*" allowed_actions: - "delete" - "index" - "manage" - "read" - - index_patterns: - - ".tasks" - - ".management-beats" - - "*:.tasks" - - "*:.management-beats" - allowed_actions: - - "indices_all" own_index: reserved: true