Skip to content

Commit 479e7a4

Browse files
mohityadav766claude
andcommitted
fix(search): scope alias lookups to cluster prefix on shared OpenSearch clusters (#27466)
* fix(search): scope alias and stats lookups to clusterAlias prefix On shared OpenSearch/Elasticsearch clusters where tenant roles only grant indices:admin/aliases/get on their own prefix, the orphaned index cleanup and metrics refresh were failing with 403 Forbidden because listIndicesByPrefix("") and getAllIndexStats() issued unscoped GET /*/_alias and stats("*") requests. Route both through a shared buildScopedPattern() that substitutes {clusterAlias}_* when the caller passes an empty prefix and a cluster alias is configured, so each deployment only reads its own indices. Explicit non-empty prefixes are already cluster-qualified by their callers and are left untouched. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(search): add IT for cluster-scoped orphaned index cleanup Verifies that on a shared search cluster where the app is configured with clusterAlias="openmetadata", the orphaned-index cleanup and index-listing paths only read / touch indices matching {clusterAlias}_*. The test provisions a "foreign tenant" directly against the real OpenSearch/Elasticsearch container by creating indices under a different prefix (foreigntenant_it_orphans_*), then asserts: 1. listIndicesByPrefix("") never returns foreign-prefixed indices 2. getAllIndexStats() never returns foreign-prefixed indices 3. OrphanedIndexCleaner.cleanupOrphanedIndices() only deletes orphans under the configured cluster prefix, leaving foreign tenant indices (both orphaned and live) intact Security plugin is disabled in the IT bootstrap, so the exact 403 cannot be reproduced — but the behavioral guarantee that prevents it (never issuing unscoped GET /*/_alias) is verified here. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(search): use IndexMapping.INDEX_NAME_SEPARATOR in scoped pattern Centralize the cluster-prefix separator so the scoped wildcard is built from the same constant used by getIndexName() / getAlias(). Addresses review feedback on #27466. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(search): address review feedback on scoped pattern IT - Use IndexMapping.INDEX_NAME_SEPARATOR in unit test assertions for parity with production code. - Rewrite the IT's cleanup test as a read-only discovery test via findOrphanedRebuildIndices(). cleanupOrphanedIndices() is a globally-scoped destructive op that could race with parallel ITs creating _rebuild_ indices under the same shared openmetadata_* namespace. Discovery-scope is the invariant that produces the 403 prevention; per-index deletion is already covered by unit tests. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(search): make scoped cleanup IT setup idempotent @BeforeAll was calling createIndex() directly, which returns 400 if the index already exists from a prior failed run (or a re-run against a reused container). Delete first, then create, so setUp is safe to rerun. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 958b33f)
1 parent 02169bf commit 479e7a4

5 files changed

Lines changed: 305 additions & 6 deletions

File tree

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
/*
2+
* Copyright 2024 Collate
3+
* Licensed under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License.
5+
* You may obtain a copy of the License at
6+
* http://www.apache.org/licenses/LICENSE-2.0
7+
*/
8+
9+
package org.openmetadata.it.tests;
10+
11+
import static org.junit.jupiter.api.Assertions.assertFalse;
12+
import static org.junit.jupiter.api.Assertions.assertTrue;
13+
14+
import es.co.elastic.clients.transport.rest5_client.low_level.Request;
15+
import es.co.elastic.clients.transport.rest5_client.low_level.Rest5Client;
16+
import java.util.List;
17+
import java.util.Set;
18+
import java.util.concurrent.TimeUnit;
19+
import org.junit.jupiter.api.AfterAll;
20+
import org.junit.jupiter.api.BeforeAll;
21+
import org.junit.jupiter.api.MethodOrderer.OrderAnnotation;
22+
import org.junit.jupiter.api.Order;
23+
import org.junit.jupiter.api.Test;
24+
import org.junit.jupiter.api.TestMethodOrder;
25+
import org.junit.jupiter.api.parallel.Execution;
26+
import org.junit.jupiter.api.parallel.ExecutionMode;
27+
import org.openmetadata.it.bootstrap.TestSuiteBootstrap;
28+
import org.openmetadata.service.Entity;
29+
import org.openmetadata.service.apps.bundles.searchIndex.OrphanedIndexCleaner;
30+
import org.openmetadata.service.search.IndexManagementClient.IndexStats;
31+
import org.openmetadata.service.search.SearchClient;
32+
import org.openmetadata.service.search.SearchRepository;
33+
34+
/**
35+
* Verifies that on a shared search cluster where the app is configured with a non-empty
36+
* {@code clusterAlias}, the orphaned-index cleanup and index-listing paths only read / touch
37+
* indices matching {@code {clusterAlias}_*}.
38+
*
39+
* <p>In production this prevents the {@code indices:admin/aliases/get} 403 reported in
40+
* openmetadata-collate#3557: if we never ask OpenSearch for foreign indices, the tenant role
41+
* never needs permission on them.
42+
*
43+
* <p>The test simulates a "foreign tenant" by creating indices with a different prefix directly
44+
* on the container (security plugin is disabled in the IT bootstrap, so the 403 itself cannot be
45+
* reproduced — but the behavioral guarantee that produces it is verified here).
46+
*/
47+
@Execution(ExecutionMode.SAME_THREAD)
48+
@TestMethodOrder(OrderAnnotation.class)
49+
public class OrphanedIndexCleanerScopedCleanupIT {
50+
51+
private static final String CLUSTER_ALIAS = "openmetadata";
52+
private static final String FOREIGN_PREFIX = "foreigntenant_it_orphans";
53+
private static final String OUR_PREFIX = CLUSTER_ALIAS + "_it_orphans";
54+
55+
private static final long OLD_TIMESTAMP =
56+
System.currentTimeMillis() - TimeUnit.MINUTES.toMillis(45);
57+
58+
private static final String OUR_ORPHAN = OUR_PREFIX + "_table_rebuild_" + OLD_TIMESTAMP;
59+
private static final String FOREIGN_ORPHAN = FOREIGN_PREFIX + "_table_rebuild_" + OLD_TIMESTAMP;
60+
private static final String FOREIGN_LIVE = FOREIGN_PREFIX + "_table_live";
61+
private static final String FOREIGN_LIVE_ALIAS = FOREIGN_PREFIX + "_alias";
62+
63+
private static Rest5Client lowLevelClient;
64+
65+
@BeforeAll
66+
static void setUp() throws Exception {
67+
// Sanity-check: the app under test must have the cluster alias configured, otherwise this
68+
// test is not exercising the scoping behavior at all.
69+
SearchRepository searchRepo = Entity.getSearchRepository();
70+
assertTrue(
71+
CLUSTER_ALIAS.equals(searchRepo.getClusterAlias()),
72+
"Test expects cluster alias '"
73+
+ CLUSTER_ALIAS
74+
+ "' but got '"
75+
+ searchRepo.getClusterAlias()
76+
+ "'");
77+
78+
lowLevelClient = TestSuiteBootstrap.createSearchClient();
79+
80+
// Idempotent: drop any residue from a prior failed run before creating.
81+
for (String index : List.of(OUR_ORPHAN, FOREIGN_ORPHAN, FOREIGN_LIVE)) {
82+
deleteIndexQuietly(index);
83+
}
84+
85+
createIndex(OUR_ORPHAN);
86+
createIndex(FOREIGN_ORPHAN);
87+
createIndex(FOREIGN_LIVE);
88+
addAlias(FOREIGN_LIVE, FOREIGN_LIVE_ALIAS);
89+
}
90+
91+
@AfterAll
92+
static void tearDown() throws Exception {
93+
if (lowLevelClient == null) {
94+
return;
95+
}
96+
// Best-effort cleanup — the cleaner may have already removed OUR_ORPHAN.
97+
for (String index : List.of(OUR_ORPHAN, FOREIGN_ORPHAN, FOREIGN_LIVE)) {
98+
deleteIndexQuietly(index);
99+
}
100+
lowLevelClient.close();
101+
}
102+
103+
@Test
104+
@Order(1)
105+
void listIndicesByPrefixWithEmptyPrefixOnlyReturnsClusterScopedIndices() {
106+
SearchClient client = Entity.getSearchRepository().getSearchClient();
107+
108+
Set<String> indices = client.listIndicesByPrefix("");
109+
110+
assertTrue(
111+
indices.contains(OUR_ORPHAN),
112+
"Expected our-prefix orphan " + OUR_ORPHAN + " to be listed, got " + indices);
113+
assertFalse(
114+
indices.contains(FOREIGN_ORPHAN),
115+
"Foreign orphan " + FOREIGN_ORPHAN + " must not be listed (cross-tenant leak)");
116+
assertFalse(
117+
indices.contains(FOREIGN_LIVE),
118+
"Foreign live index " + FOREIGN_LIVE + " must not be listed (cross-tenant leak)");
119+
for (String name : indices) {
120+
assertTrue(
121+
name.startsWith(CLUSTER_ALIAS + "_"),
122+
"Index " + name + " outside cluster prefix should not be returned");
123+
}
124+
}
125+
126+
@Test
127+
@Order(2)
128+
void getAllIndexStatsOnlyReturnsClusterScopedIndices() throws Exception {
129+
SearchClient client = Entity.getSearchRepository().getSearchClient();
130+
131+
List<IndexStats> stats = client.getAllIndexStats();
132+
133+
for (IndexStats stat : stats) {
134+
assertTrue(
135+
stat.name().startsWith(CLUSTER_ALIAS + "_"),
136+
"Stats for " + stat.name() + " returned from outside cluster prefix");
137+
}
138+
assertTrue(
139+
stats.stream().anyMatch(s -> s.name().equals(OUR_ORPHAN)),
140+
"Expected stats for our-prefix orphan " + OUR_ORPHAN);
141+
assertFalse(
142+
stats.stream().anyMatch(s -> s.name().equals(FOREIGN_ORPHAN)),
143+
"Foreign orphan " + FOREIGN_ORPHAN + " must not appear in stats");
144+
}
145+
146+
/**
147+
* Read-only assertion that orphan discovery only looks at indices under the cluster prefix.
148+
*
149+
* <p>We deliberately avoid calling {@link OrphanedIndexCleaner#cleanupOrphanedIndices} here:
150+
* that is a destructive, globally-scoped operation and would race with other ITs that may
151+
* create temporary {@code _rebuild_} indices under the same shared {@code openmetadata_*}
152+
* namespace. Since cleanup = discovery + per-index delete, verifying discovery is scoped is
153+
* sufficient for the 403-prevention guarantee; per-index deletion is covered by unit tests.
154+
*/
155+
@Test
156+
@Order(3)
157+
void findOrphanedRebuildIndicesOnlyDiscoversClusterScopedOrphans() {
158+
SearchClient client = Entity.getSearchRepository().getSearchClient();
159+
OrphanedIndexCleaner cleaner = new OrphanedIndexCleaner();
160+
161+
List<OrphanedIndexCleaner.OrphanedIndex> orphans = cleaner.findOrphanedRebuildIndices(client);
162+
163+
assertTrue(
164+
orphans.stream().anyMatch(o -> o.indexName().equals(OUR_ORPHAN)),
165+
"Expected our-prefix orphan "
166+
+ OUR_ORPHAN
167+
+ " to be discovered, got "
168+
+ orphans.stream().map(OrphanedIndexCleaner.OrphanedIndex::indexName).toList());
169+
assertFalse(
170+
orphans.stream().anyMatch(o -> o.indexName().equals(FOREIGN_ORPHAN)),
171+
"Foreign orphan " + FOREIGN_ORPHAN + " must not be discovered (cross-tenant leak)");
172+
for (OrphanedIndexCleaner.OrphanedIndex orphan : orphans) {
173+
assertTrue(
174+
orphan.indexName().startsWith(CLUSTER_ALIAS + "_"),
175+
"Discovered orphan " + orphan.indexName() + " is outside cluster prefix");
176+
}
177+
assertTrue(indexExists(FOREIGN_ORPHAN), "Foreign orphan must still exist (never touched)");
178+
assertTrue(indexExists(FOREIGN_LIVE), "Foreign live index must still exist (never touched)");
179+
}
180+
181+
private static void createIndex(String name) throws Exception {
182+
Request request = new Request("PUT", "/" + name);
183+
request.setJsonEntity(
184+
"{\"settings\":{\"index\":{\"number_of_shards\":1,\"number_of_replicas\":0}}}");
185+
lowLevelClient.performRequest(request);
186+
}
187+
188+
private static void addAlias(String index, String alias) throws Exception {
189+
Request request = new Request("POST", "/_aliases");
190+
request.setJsonEntity(
191+
String.format(
192+
"{\"actions\":[{\"add\":{\"index\":\"%s\",\"alias\":\"%s\"}}]}", index, alias));
193+
lowLevelClient.performRequest(request);
194+
}
195+
196+
private static boolean indexExists(String name) {
197+
try {
198+
Request request = new Request("HEAD", "/" + name);
199+
return lowLevelClient.performRequest(request).getStatusCode() == 200;
200+
} catch (Exception e) {
201+
return false;
202+
}
203+
}
204+
205+
private static void deleteIndexQuietly(String name) {
206+
try {
207+
lowLevelClient.performRequest(new Request("DELETE", "/" + name));
208+
} catch (Exception ignored) {
209+
// Best-effort cleanup.
210+
}
211+
}
212+
}

openmetadata-service/src/main/java/org/openmetadata/service/search/elasticsearch/ElasticSearchIndexManager.java

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -461,23 +461,36 @@ public Set<String> listIndicesByPrefix(String prefix) {
461461
return indices;
462462
}
463463
try {
464-
String pattern = prefix + "*";
464+
String pattern = buildScopedPattern(prefix);
465465
GetAliasRequest request = GetAliasRequest.of(g -> g.index(pattern));
466466
GetAliasResponse response = client.indices().getAlias(request);
467467

468468
indices.addAll(response.aliases().keySet());
469469

470-
LOG.info("Retrieved {} indices matching prefix '{}': {}", indices.size(), prefix, indices);
470+
LOG.info(
471+
"Retrieved {} indices matching pattern '{}' (prefix='{}'): {}",
472+
indices.size(),
473+
pattern,
474+
prefix,
475+
indices);
471476
} catch (Exception e) {
472477
LOG.error("Failed to list indices by prefix {} due to", prefix, e);
473478
}
474479
return indices;
475480
}
476481

482+
private String buildScopedPattern(String prefix) {
483+
if (prefix != null && !prefix.isEmpty()) {
484+
return prefix + "*";
485+
}
486+
return clusterAlias.isEmpty() ? "*" : clusterAlias + IndexMapping.INDEX_NAME_SEPARATOR + "*";
487+
}
488+
477489
@Override
478490
public List<IndexStats> getAllIndexStats() throws IOException {
479491
List<IndexStats> result = new ArrayList<>();
480-
var statsResponse = client.indices().stats(s -> s.index("*"));
492+
String statsPattern = buildScopedPattern(null);
493+
var statsResponse = client.indices().stats(s -> s.index(statsPattern));
481494
var indices = statsResponse.indices();
482495
for (var entry : indices.entrySet()) {
483496
String indexName = entry.getKey();

openmetadata-service/src/main/java/org/openmetadata/service/search/opensearch/OpenSearchIndexManager.java

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -541,23 +541,36 @@ public Set<String> listIndicesByPrefix(String prefix) {
541541
return indices;
542542
}
543543
try {
544-
String pattern = prefix + "*";
544+
String pattern = buildScopedPattern(prefix);
545545
GetAliasRequest request = GetAliasRequest.of(g -> g.index(pattern));
546546
GetAliasResponse response = client.indices().getAlias(request);
547547

548548
indices.addAll(response.result().keySet());
549549

550-
LOG.info("Retrieved {} indices matching prefix '{}': {}", indices.size(), prefix, indices);
550+
LOG.info(
551+
"Retrieved {} indices matching pattern '{}' (prefix='{}'): {}",
552+
indices.size(),
553+
pattern,
554+
prefix,
555+
indices);
551556
} catch (Exception e) {
552557
LOG.error("Failed to list indices by prefix {} due to", prefix, e);
553558
}
554559
return indices;
555560
}
556561

562+
private String buildScopedPattern(String prefix) {
563+
if (prefix != null && !prefix.isEmpty()) {
564+
return prefix + "*";
565+
}
566+
return clusterAlias.isEmpty() ? "*" : clusterAlias + IndexMapping.INDEX_NAME_SEPARATOR + "*";
567+
}
568+
557569
@Override
558570
public List<IndexStats> getAllIndexStats() throws IOException {
559571
List<IndexStats> result = new ArrayList<>();
560-
var statsResponse = client.indices().stats(s -> s.index("*"));
572+
String statsPattern = buildScopedPattern(null);
573+
var statsResponse = client.indices().stats(s -> s.index(statsPattern));
561574
var indices = statsResponse.indices();
562575
for (var entry : indices.entrySet()) {
563576
String indexName = entry.getKey();

openmetadata-service/src/test/java/org/openmetadata/service/search/elasticsearch/ElasticSearchIndexManagerTest.java

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
package org.openmetadata.service.search.elasticsearch;
22

33
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
4+
import static org.junit.jupiter.api.Assertions.assertEquals;
45
import static org.junit.jupiter.api.Assertions.assertFalse;
56
import static org.junit.jupiter.api.Assertions.assertNotNull;
67
import static org.junit.jupiter.api.Assertions.assertTrue;
8+
import static org.mockito.ArgumentCaptor.forClass;
79
import static org.mockito.ArgumentMatchers.any;
810
import static org.mockito.Mockito.lenient;
911
import static org.mockito.Mockito.verify;
@@ -27,6 +29,7 @@
2729
import java.io.IOException;
2830
import java.util.Arrays;
2931
import java.util.List;
32+
import java.util.Map;
3033
import java.util.Set;
3134
import org.junit.jupiter.api.BeforeEach;
3235
import org.junit.jupiter.api.Test;
@@ -432,6 +435,34 @@ void testGetIndicesByAlias_HandlesException() throws IOException {
432435
verify(indicesClient).getAlias(any(GetAliasRequest.class));
433436
}
434437

438+
@Test
439+
void testListIndicesByPrefix_EmptyPrefixScopesToClusterAlias() throws IOException {
440+
when(indicesClient.getAlias(any(GetAliasRequest.class))).thenReturn(getAliasResponse);
441+
when(getAliasResponse.aliases()).thenReturn(Map.of());
442+
443+
indexManager.listIndicesByPrefix("");
444+
445+
var captor = forClass(GetAliasRequest.class);
446+
verify(indicesClient).getAlias(captor.capture());
447+
assertEquals(
448+
List.of(CLUSTER_ALIAS + IndexMapping.INDEX_NAME_SEPARATOR + "*"),
449+
captor.getValue().index());
450+
}
451+
452+
@Test
453+
void testListIndicesByPrefix_EmptyPrefixWithoutClusterAliasUsesWildcard() throws IOException {
454+
ElasticSearchIndexManager unscopedManager =
455+
new ElasticSearchIndexManager(elasticsearchClient, "");
456+
when(indicesClient.getAlias(any(GetAliasRequest.class))).thenReturn(getAliasResponse);
457+
when(getAliasResponse.aliases()).thenReturn(Map.of());
458+
459+
unscopedManager.listIndicesByPrefix(null);
460+
461+
var captor = forClass(GetAliasRequest.class);
462+
verify(indicesClient).getAlias(captor.capture());
463+
assertEquals(List.of("*"), captor.getValue().index());
464+
}
465+
435466
@Test
436467
void testGetIndicesByAlias_ClientNotAvailable() {
437468
ElasticSearchIndexManager managerWithNullClient =

0 commit comments

Comments
 (0)