Skip to content

fix(search): enforce ContextMemory visibility at index time, not per-query - #29541

Merged
pmbrull merged 24 commits into
mainfrom
harshach/contextmemory-search-visibility-gap
Jul 27, 2026
Merged

fix(search): enforce ContextMemory visibility at index time, not per-query#29541
pmbrull merged 24 commits into
mainfrom
harshach/contextmemory-search-visibility-gap

Conversation

@harshach

@harshach harshach commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator

Describe your changes:

This addresses a security finding from the review of #29384: Private/Shared ContextMemory could still leak through search paths that the per-query applyContextMemoryVisibility filter didn't cover. Instead of filtering on every search path (fragile — the review found three uncovered ones), the rule is enforced once at index time: only org-wide (ENTITY) memories are written to the search index. ContextMemoryRepository.isSearchIndexable gates the live create/update/embedding paths and ContextMemoryRepository.getReindexFilter() applies the same visibility == ENTITY rule (a cross-DB JSON ListFilter condition) to the bulk-reindex reader and its entity counts, so partition totals stay balanced and the staged index promotes. The applyContextMemoryVisibility apparatus is removed from both the Elasticsearch and OpenSearch managers; the REST /contextCenter/memories visibility checks are unchanged.

Type of change:

  • Bug fix

High-level design:

Two indexing pipelines need the rule, so it lives entirely in ContextMemoryRepository (overriding trivial EntityRepository base hooks): isSearchIndexable(entity) for the live single-entity path (SearchRepository.createEntityIndex/updateEntityIndex, VectorEmbeddingHandler), and getReindexFilter() (a shareConfig.visibility = 'Entity' JSON condition on ListFilter) for the bulk reindex — applied at the reader (PaginatedEntitiesSource) and every entity-count site (PartitionCalculator / ReindexingOrchestrator / DistributedIndexingStrategy). Count and read must share the same filter because reindex promotion compares reader-total vs sink-success (StatsReconciler / EntityPromotionContext); excluding from the read but not the count would collapse the success ratio and block index promotion. No data migration is needed — ContextMemory search indexing is unreleased (added post-1.13, only on 2.0.0-SNAPSHOT), so the feature ships with this fix.

Tests:

Use cases covered

  • Creating a PRIVATE or SHARED memory: never written to the search index (live path).
  • An ENTITY (org-wide) memory: searchable.
  • Flipping a memory ENTITY→PRIVATE removes it from the index live; PRIVATE→ENTITY re-indexes it.
  • A full reindex rebuilds only ENTITY memories, with balanced counts so the staged index promotes.

Unit tests

  • I added unit tests for the new/changed logic.
  • ContextMemoryRepositoryTestisSearchIndexable across ENTITY/PRIVATE/SHARED/missing-shareConfig + getReindexFilter.
  • ListFilterTest — the generated memorySearchVisibility SQL condition (MySQL + Postgres forms).

Backend integration tests

  • I added integration tests in openmetadata-integration-tests/.
  • ContextMemoryIT.privateAndSharedMemoriesAreExcludedFromSearchIndex and ContextMemoryIT.memoryVisibilityFlipAddsAndRemovesFromSearchIndex.

Ingestion integration tests

  • Not applicable (no ingestion changes).

Playwright (UI) tests

  • Not applicable (no UI changes).

Manual testing performed

  1. mvn compile + spotless:check on openmetadata-service and openmetadata-integration-tests: pass.
  2. Unit tests green: ContextMemoryRepositoryTest, ListFilterTest, ReindexingUtilTest. The two ContextMemory ITs require the Docker stack and were not run locally.

UI screen recording / screenshots:

Not applicable.

Checklist:

  • I have read the CONTRIBUTING document.
  • I have commented on my code, particularly in hard-to-understand areas.
  • I have added tests (unit / integration as applicable) and listed them above.

🤖 Generated with Claude Code

Greptile Summary

This PR enforces ContextMemory search visibility at index time rather than per-query, addressing a security finding from #29384 where PRIVATE/SHARED memories could leak through search paths not covered by the runtime filter. The fix is applied consistently across all five indexing pipelines: live create/update (via Entity.isSearchIndexable guarding SearchRepository and VectorEmbeddingHandler), bulk reindex reader and all entity-count sites (via EntityRepository.getReindexFilter() overridden in ContextMemoryRepository), ensuring reader totals and count totals stay balanced so staged-index promotion succeeds.

  • Index-time gate: ContextMemoryRepository.isSearchIndexable blocks PRIVATE/SHARED memories from all live write paths; getReindexFilter() applies the same visibility = ENTITY SQL condition to bulk reindex reads and every count call (PartitionCalculator, ReindexingOrchestrator, DistributedIndexingStrategy, DistributedSearchIndexCoordinator, PaginatedEntitiesSource).
  • Visibility flip handling: SearchRepository.createEntityIndex/updateEntityIndex/updateEntitiesBulk call deleteEntityIndex for newly non-indexable entities; ES/OS delete-on-NotFound is downgraded to DEBUG to reduce noise; VectorEmbeddingHandler.onEntityUpdated calls deleteChunks on the flip-to-PRIVATE path.
  • Defense-in-depth retained: ContextMemorySearchVisibility query-time filter is kept for documents written before this policy was introduced, with updated Javadoc explaining its residual role.

Confidence Score: 5/5

Safe to merge. The security fix is applied consistently to all five indexing pipelines with no logic gaps found; all count and read sites share the same filter, so index promotion ratios remain balanced.

Every live and bulk-reindex path is correctly gated: createEntityIndex, updateEntityIndex, updateEntitiesBulk, VectorEmbeddingHandler, and all five entity-count sites now use the same getReindexFilter() or isSearchIndexable check. The deleteEntityIndex path handles the unavailable-client case via the existing retry queue. ES/OS NotFound handling is downgraded to DEBUG.

Files Needing Attention: No files require special attention.

Important Files Changed

Filename Overview
openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/ContextMemoryRepository.java Adds isSearchIndexable (live-path gate) and getReindexFilter (bulk reindex gate); correctly restricts both to ENTITY-visibility memories only
openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/ListFilter.java Adds MEMORY_SEARCH_VISIBILITY_PARAM constant and getMemorySearchVisibilityCondition() to global condition chain; parameter is no-op unless explicitly set, with clear documentation warning against misuse on non-ContextMemory filters
openmetadata-service/src/main/java/org/openmetadata/service/search/SearchRepository.java Guards createEntityIndex, updateEntityIndex, createEntitiesIndex, and updateEntitiesBulk with Entity.isSearchIndexable; non-indexable entities trigger deleteEntityIndex (has internal retry queue) on all live paths; empty-docs guard prevents no-op bulk calls
openmetadata-service/src/main/java/org/openmetadata/service/search/vector/VectorEmbeddingHandler.java Adds isSearchIndexable guard in onEntityUpdated with explicit deleteChunks for the ENTITY-to-PRIVATE flip; onEntityCreated is protected by inner guard in updateEmbedding, but lacks a dedicated test for non-indexable create path
openmetadata-service/src/main/java/org/openmetadata/service/Entity.java Adds Entity.isSearchIndexable(entity) facade; defaults to true for types without a regular repository (index-only/time-series) to avoid NPE; null-safe throughout
openmetadata-service/src/main/java/org/openmetadata/service/workflows/searchIndex/PaginatedEntitiesSource.java Both constructors now use getReindexFilter() for both the list filter and the cached total count, keeping reader and counter in sync
openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/ContextMemoryIT.java Adds two integration tests: exclusion of PRIVATE/SHARED at index time (with a stability window via during()) and ENTITY-PRIVATE flip adds/removes from index; createMinimalRequest updated to use ENTITY visibility for generic base tests

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Entity Lifecycle Event] --> B{Entity Created or Updated?}
    B -->|Create| C[SearchRepository.createEntityIndex]
    B -->|Update| D[SearchRepository.updateEntityIndex]
    B -->|Bulk Update| E[SearchRepository.updateEntitiesBulk]
    C --> F{Entity.isSearchIndexable?}
    D --> F
    E --> G[Filter list: isSearchIndexable per entity]
    F -->|false| H[deleteEntityIndex ES/OS NotFound to DEBUG log]
    F -->|true| I[Write to search index]
    G -->|non-indexable| H
    G -->|indexable| I
    A --> J[VectorEmbeddingHandler.onEntityUpdated]
    J --> K{Entity.isSearchIndexable?}
    K -->|false| L[deleteChunks remove vector chunks]
    K -->|true| M[updateEmbedding write vector chunks]
    A --> N[VectorEmbeddingHandler.onEntityCreated]
    N --> O[updateEmbedding inner guard]
    O --> P{Entity.isSearchIndexable?}
    P -->|false| Q[return early no chunks written]
    P -->|true| M
    subgraph Bulk Reindex
        R[PaginatedEntitiesSource] --> S[getReindexFilter visibility=ENTITY SQL]
        T[PartitionCalculator] --> S
        U[ReindexingOrchestrator] --> S
        V[DistributedIndexingStrategy] --> S
        W[DistributedSearchIndexCoordinator] --> S
        S --> X[Only ENTITY memories read and counted]
    end
Loading

Reviews (22): Last reviewed commit: "Merge branch 'main' into harshach/contex..." | Re-trigger Greptile

…query

Private/Shared ContextMemory could leak through search paths the per-query applyContextMemoryVisibility filter (#29384) did not cover. Instead of filtering on every search path, only org-wide (ENTITY) memories are written to the index: ContextMemoryRepository.isSearchIndexable gates the live create/update/embedding paths and getReindexFilter applies the same rule to the bulk reindex reader and counts (so partition totals stay balanced and the staged index promotes). Removes the applyContextMemoryVisibility apparatus from both search managers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

❌ PR checklist incomplete

This PR cannot be merged until the following are addressed on its linked issue:

  • No GitHub issue is linked. Link an issue in the Development section of the PR (or add Fixes #12345 to the description). For a same-org cross-repo issue, add Fixes open-metadata/<repo>#123 to the description.

The fields live on the linked issue in the Shipping project (open the issue → right sidebar → Projects). After you set them, re-run this check (or push a commit) — issue/project changes do not re-trigger it automatically.

Maintainers can bypass this check by adding the skip-pr-checks label.

@github-actions github-actions Bot added backend safe to test Add this label to run secure Github workflows on PRs labels Jun 26, 2026
Comment on lines +69 to +71
if (!Entity.getEntityRepository(entityType).isSearchIndexable(entity)) {
return;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Silent early-return on update gives no vector-side cleanup — When a memory is flipped ENTITY→PRIVATE, SearchRepository.updateEntityIndex calls deleteEntityIndex to remove the entity document. Because vector embeddings are written via partialUpdateEntity as fields on that same document, the entity-doc deletion implicitly cleans up the embedding too. The VectorEmbeddingHandler returning early here is therefore safe today. However, the asymmetry vs. SearchRepository (which explicitly deletes) is easy to miss: if the embedding is ever moved to its own child documents (the chunkIndex/chunkCount/parentId fields already hint at chunking), this silent return would become a real leak with no compiler or test warning. A brief inline comment explaining the implicit-cleanup assumption would protect future readers.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

harshach and others added 2 commits June 26, 2026 12:37
createEntitiesIndex and updateEntitiesIndex (the bulk live paths reached via SearchIndexHandler.onEntitiesCreated/onEntitiesUpdated) bypassed the isSearchIndexable gate, so a PRIVATE/SHARED ContextMemory created or updated through a bulk lifecycle dispatch (import/batch extraction) could still be indexed. Apply the same gate: skip non-indexable entities on bulk create; on bulk update drop them and delete their stale docs, mirroring updateEntityIndex's ENTITY->PRIVATE flip handling.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

✅ Playwright Results — workflow succeeded

Validated commit e9d68929f66c6416c01be7b7efc970c9f0f1f6ad in Playwright run 30261536127, attempt 1.

✅ 1012 passed · ❌ 0 failed · 🟡 1 flaky · ⏭️ 5 skipped · 🧰 0 lifecycle flaky

Performance

Blocking targets: ✅ met · Optimization targets: 🟡 in progress

Shard-job maxima below are not the full workflow wall time; the linked run includes build, fixture, planning, and reporting.

🕒 Full workflow signal wall (to summary) 50m 12s

⏱️ Max setup 2m 55s · max shard execution 16m 36s · max shard-job elapsed before upload 19m 51s · reporting 6s

🌐 189.09 requests/attempt · 2.22 app boots/UI scenario · 27.61% common-shard skew

Optimization targets still in progress:

  • Common shard skew was 27.61% (convergence target: at most 15%).
  • Application boot ratio was 2.22 per UI scenario (2289 boots / 1030 scenarios; convergence target: at most 1).
Shard Passed Failed Flaky Skipped Lifecycle failed Lifecycle flaky
✅ Shard chromium-01 130 0 0 0 0 0
✅ Shard chromium-02 149 0 0 0 0 0
🟡 Shard chromium-03 132 0 1 0 0 0
✅ Shard chromium-04 145 0 0 3 0 0
✅ Shard chromium-05 146 0 0 0 0 0
✅ Shard chromium-06 144 0 0 0 0 0
✅ Shard data-asset-rules-01 61 0 0 0 0 0
✅ Shard domain-isolation-01 14 0 0 0 0 0
✅ Shard global-state-01 23 0 0 0 0 0
✅ Shard ingestion-01 2 0 0 0 0 0
✅ Shard reindex-01 28 0 0 0 0 0
✅ Shard search-01 11 0 0 0 0 0
✅ Shard search-rbac-01 27 0 0 2 0 0
🟡 1 flaky test(s) (passed on retry)
  • Features/SearchExport.spec.tsExport queues a background job and downloads from the jobs tray (shard chromium-03, 1 retry)

📦 Download artifacts

How to debug locally
# Download playwright-test-results-<shard> artifact and unzip
npx playwright show-trace path/to/trace.zip    # view trace

checkIfIndexingIsSupported gates on the index mapping, not repository existence, so an indexable but repository-less type (e.g. an index-only/time-series sub-entity) reaching createEntityIndex/updateEntityIndex would throw EntityNotFoundException at the getEntityRepository().isSearchIndexable call added in the prior commits. Route all live index-path gates through a new Entity.isSearchIndexable facade that defaults to indexable when no repository is registered, so the rule still lives in ContextMemoryRepository but repository-less types index exactly as before.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…_PARAM

Warn that the filter param is set only by ContextMemoryRepository.getReindexFilter and must not be applied to the REST /contextCenter/memories listing (owners must still read their own PRIVATE/SHARED memories there).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 24, 2026 22:23

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 24 out of 24 changed files in this pull request and generated 3 comments.

Comment on lines +749 to +753
public static boolean isSearchIndexable(EntityInterface entity) {
String entityType = entity.getEntityReference().getType();
EntityRepository<? extends EntityInterface> repository = ENTITY_REPOSITORY_MAP.get(entityType);
return repository == null || repository.isSearchIndexable(entity);
}
Comment on lines +24 to +42
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.openmetadata.schema.EntityInterface;
import org.openmetadata.schema.entity.context.ContextMemory;
import org.openmetadata.schema.entity.context.MemoryShareConfig;
import org.openmetadata.schema.entity.context.MemoryVisibility;
import org.openmetadata.schema.type.EntityReference;
import org.openmetadata.service.Entity;

/**
* The ContextMemory search-visibility rule lives entirely in {@link ContextMemoryRepository}: only
* org-wide ({@link MemoryVisibility#ENTITY}) memories are indexed. {@link
* ContextMemoryRepository#isSearchIndexable} gates the live create/update path and {@link
* ContextMemoryRepository#getReindexFilter} carries the same rule into the bulk reindex as a DB
* query. These tests pin both halves across every visibility state.
*/
class ContextMemoryRepositoryTest {

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 25 out of 25 changed files in this pull request and generated 4 comments.

Comments suppressed due to low confidence (3)

openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/search/ReindexStatsIT.java:39

  • In this test’s invariants, processStats.successRecords is the count of successful docs built, but the sink balance is success + failed + warning. That equality only holds when failures/warnings are always zero, which makes the invariant misleading.

The invariant description should be processStats.totalRecords == sinkStats.successRecords + sinkStats.failedRecords + sinkStats.warningRecords (processed docs are fully accounted for at the sink).

 *   <li>{@code readerStats.totalRecords == processStats.totalRecords}
 *       (everything read is processed);
 *   <li>{@code processStats.successRecords == sinkStats.successRecords +
 *       sinkStats.failedRecords + sinkStats.warningRecords} (everything
 *       successfully processed is accounted for at the sink);

openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/search/ReindexStatsIT.java:76

  • The assertion should validate the same invariant described above: processStats.totalRecords (success+failed+warning) should equal sinkStats.success + failed + warning. Using processStats.successRecords makes the check pass only when there are no warnings/failures and can hide accounting regressions.
    assertThat(sumOrZero(processed.getSuccessRecords()))
        .as("successfully processed records must equal sum of sink success/failure/warning")

openmetadata-service/src/main/java/org/openmetadata/service/workflows/searchIndex/PaginatedEntitiesSource.java:74

  • Minor: same as the other constructor — caching the repository avoids repeated lookups and keeps the filter derivation centralized.
    this.filter = Entity.getEntityRepository(entityType).getReindexFilter();
    this.cachedTotalCount = knownTotal;

Comment on lines +1154 to +1160
if (!Entity.isSearchIndexable(entity)) {
LOG.debug(
"Skipping search index create for non-indexable {} [{}]",
entity.getEntityReference().getType(),
entity.getId());
return;
}
Comment on lines 391 to 394
}
}
}
applyContextMemoryVisibility(subjectContext, requestBuilder);
}

/**
* Enforces ContextMemory shareConfig visibility on search results for non-admin subjects. Applied
* independently of {@code shouldApplyRbacConditions} because memory visibility is a per-memory
* privacy guarantee, not an RBAC policy — disabling RBAC search filtering must not expose private
* memories. Non-memory documents are left untouched.
*/
private void applyContextMemoryVisibility(
SubjectContext subjectContext, ElasticSearchRequestBuilder requestBuilder) {
OMQueryBuilder visibilityBuilder =
contextMemoryVisibility.buildVisibilityFilter(subjectContext);
if (visibilityBuilder != null) {
Query visibilityQuery = ((ElasticQueryBuilder) visibilityBuilder).buildV2();
Query existingQuery = requestBuilder.query();
if (existingQuery != null) {
Query combinedQuery =
Query.of(
qb ->
qb.bool(
b -> {
b.must(existingQuery);
b.filter(visibilityQuery);
return b;
}));
requestBuilder.query(combinedQuery);
} else {
requestBuilder.query(visibilityQuery);
}
}
}
Comment on lines 406 to 410
}
}

applyContextMemoryVisibility(subjectContext, requestBuilder);

return doListWithOffset(limit, offset, index, searchSortFilter, requestBuilder);
}

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 27 out of 27 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (1)

openmetadata-service/src/main/java/org/openmetadata/service/Entity.java:758

  • Entity.isSearchIndexable(...) is used as a safety gate in several live indexing paths (e.g., VectorEmbeddingHandler.onEntityUpdated), but the current implementation can still throw if entity.getEntityReference() throws or if the type is blank. That makes the gate itself unsafe and can break indexing/embedding on malformed entities; it should fail closed (return false) instead of propagating exceptions.
    EntityReference entityReference = entity.getEntityReference();
    if (entityReference == null) {
      return false;
    }
    String entityType = entityReference.getType();

Comment on lines 1449 to 1450
try {
if (!getSearchClient().isClientAvailable()) {

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 24 out of 24 changed files in this pull request and generated 1 comment.

Comment on lines 36 to +42
* <p>Memory visibility is driven by the per-memory shareConfig, not by the OSS RBAC/policy model,
* so it is applied for every non-admin subject regardless of the RBAC access-control toggle.
* Disabling RBAC search filtering must never expose another user's private memories.
*
* <p>Current live writes and reindexing exclude restricted memories at index time. This filter
* remains as defense-in-depth for documents written before that policy was introduced and for
* mixed-version deployments during an upgrade.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 24 out of 24 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (1)

openmetadata-service/src/main/java/org/openmetadata/service/search/security/ContextMemorySearchVisibility.java:42

  • PR description says the per-query ContextMemory visibility apparatus was removed from Elasticsearch/OpenSearch managers, but the codebase still applies it (e.g., ElasticSearchSearchManager.applyContextMemoryVisibility at openmetadata-service/.../ElasticSearchSearchManager.java:398 and OpenSearchSearchManager.java:413). Either update the PR description to reflect that the query-time filter remains as defense-in-depth, or remove the manager wiring if the intent is to enforce visibility only at index time.
 * <p>Current live writes and reindexing exclude restricted memories at index time. This filter
 * remains as defense-in-depth for documents written before that policy was introduced and for
 * mixed-version deployments during an upgrade.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 23 out of 23 changed files in this pull request and generated 1 comment.

Comment on lines 261 to 264
private long getRegularEntityCount(String entityType) {
EntityRepository<?> repository = Entity.getEntityRepository(entityType);
return repository.getDao().listCount(new ListFilter(Include.ALL));
return repository.getDao().listCount(repository.getReindexFilter());
}

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 27 out of 27 changed files in this pull request and generated 1 comment.

Comment on lines +754 to +764
EntityReference entityReference = entity.getEntityReference();
if (entityReference == null) {
return false;
}
String entityType = entityReference.getType();
if (entityType == null) {
return false;
}
EntityRepository<? extends EntityInterface> repository = ENTITY_REPOSITORY_MAP.get(entityType);
return repository == null || repository.isSearchIndexable(entity);
}

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 27 out of 27 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (2)

openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/search/ReindexStatsIT.java:80

  • The invariant being asserted is incorrect: sink stats should account for all processed successes and processed failures (warnings are derived from the delta between processed and sink outcomes). Comparing only processStats.successRecords to sink totals will fail (or mask issues) when the processor records failures.
    assertThat(sumOrZero(processed.getSuccessRecords()))
        .as("successfully processed records must equal sum of sink success/failure/warning")
        .isEqualTo(
            sumOrZero(sink.getSuccessRecords())
                + sumOrZero(sink.getFailedRecords())
                + sumOrZero(sink.getWarningRecords()));

openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/search/ReindexStatsIT.java:39

  • This Javadoc invariant no longer matches how process/sink stats are reconciled: sink totals correspond to processStats.successRecords + processStats.failedRecords (not just successRecords). Keeping the doc accurate helps future debugging when failures are present.
 *   <li>{@code processStats.successRecords == sinkStats.successRecords +
 *       sinkStats.failedRecords + sinkStats.warningRecords} (everything
 *       successfully processed is accounted for at the sink);

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 27 out of 27 changed files in this pull request and generated 1 comment.

Comment on lines 1967 to +1978
for (Map.Entry<String, List<EntityInterface>> entry : entitiesByType.entrySet()) {
String entityType = entry.getKey();
List<EntityInterface> typeEntities = entry.getValue();
List<EntityInterface> typeEntities = new ArrayList<>();
for (EntityInterface entity : entry.getValue()) {
if (Entity.isSearchIndexable(entity)) {
typeEntities.add(entity);
} else {
// Mirror updateEntityIndex: a now-non-indexable entity (e.g. a memory flipped to
// PRIVATE/SHARED) must have its stale document removed from the index.
deleteEntityIndex(entity);
}
}

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 27 out of 27 changed files in this pull request and generated 1 comment.

Comment on lines +1442 to +1444
if (nullOrEmpty(entityType)) {
return;
}
@pmbrull

pmbrull commented Jul 27, 2026

Copy link
Copy Markdown
Member

picked to 2.0

@gitar-bot

gitar-bot Bot commented Jul 27, 2026

Copy link
Copy Markdown
Code Review 👍 Approved with suggestions 1 resolved / 2 findings

Enforces ContextMemory visibility at index time by gating search indexing to ENTITY-only records, resolving the risk of data leakage via uncovered search paths. Consider refining integration test assertions and adding documentation to the visibility parameter to prevent future misuse.

💡 Edge Case: Deterministic tiebreak now sorts on name.keyword/id.keyword for all queries

📄 openmetadata-service/src/main/java/org/openmetadata/service/search/opensearch/OpenSearchSearchManager.java:1114-1122 📄 openmetadata-service/src/main/java/org/openmetadata/service/search/opensearch/OpenSearchSearchManager.java:1254-1268 📄 openmetadata-service/src/main/java/org/openmetadata/service/search/elasticsearch/ElasticSearchSearchManager.java:1073-1081 📄 openmetadata-service/src/main/java/org/openmetadata/service/search/elasticsearch/ElasticSearchSearchManager.java:1211-1225

In both OpenSearchSearchManager.buildSearchRequestBuilder (~lines 1254-1278) and ElasticSearchSearchManager.buildSearchRequestBuilder (~lines 1211-1235), the deterministic tiebreaker (name.keyword + id.keyword) is now appended on EVERY search path. Previously it was only appended when the sort field was _score or when isExport=true. For explicit non-_score sort fields on non-export requests, no id.keyword/name.keyword sort was added before; it now always is.

This is a deliberate determinism improvement, but it broadens the set of queries that sort on name.keyword/id.keyword. If any searchable index lacks those keyword sub-fields in its mapping, the sort clause can fail with "No mapping found for [name.keyword]". Risk is low because OpenMetadata entity indexes universally map name.keyword and id.keyword, and the _score/export paths already relied on this. Worth a quick confirmation that no specialized index (e.g. data-quality/relationship-only indexes) is queried through this builder without those fields. If such an index exists, set unmapped_type on the tiebreaker sorts to keep them harmless.

✅ 1 resolved
Security: Bulk live-index paths bypass isSearchIndexable visibility gate

📄 openmetadata-service/src/main/java/org/openmetadata/service/search/SearchRepository.java:1193-1207 📄 openmetadata-service/src/main/java/org/openmetadata/service/search/SearchRepository.java:1630-1644
The new visibility-at-index-time enforcement only gates the single-entity live paths: SearchRepository.createEntityIndex (SearchRepository.java:961) and updateEntityIndex (SearchRepository.java:1399), plus VectorEmbeddingHandler. However the bulk live-index paths do NOT call isSearchIndexable:

  • createEntitiesIndex(List) builds and writes docs directly (SearchRepository.java:1209-1227) with no indexability check. It is reached on the live (non-reindex) path via SearchIndexHandler.onEntitiesCreated -> createEntitiesIndex (SearchIndexHandler.java:170).
  • updateEntitiesIndex(List) writes through bulkSink.write(typeEntities, ...) (SearchRepository.java:1692) without an indexability filter; it only falls back to the gated updateEntityIndex on bulk failure (line 1698). It is reached via SearchIndexHandler.onEntitiesUpdated (SearchIndexHandler.java:197).

The EntityLifecycleEventDispatcher invokes these bulk handler methods for bulk create/update operations. If any ContextMemory create/update ever flows through a bulk lifecycle dispatch (e.g. a batch extraction/import), a PRIVATE or SHARED memory would be written to the search index, re-introducing exactly the leak this PR set out to eliminate at index time. This is the same fragility (uncovered paths) the PR cites for moving the rule to index time, so the bulk live paths should be covered too.

Suggested fix: apply isSearchIndexable filtering inside createEntitiesIndex and updateEntitiesIndex (drop non-indexable entities before building/writing docs), so all live write paths share the gate. Note the asymmetry vs. updateEntityIndex, which deletes the doc when not indexable — the bulk update path should similarly remove now-non-indexable docs to handle a bulk ENTITY->PRIVATE flip.

🤖 Prompt for agents
Code Review: Enforces ContextMemory visibility at index time by gating search indexing to ENTITY-only records, resolving the risk of data leakage via uncovered search paths. Consider refining integration test assertions and adding documentation to the visibility parameter to prevent future misuse.

1. 💡 Edge Case: Deterministic tiebreak now sorts on name.keyword/id.keyword for all queries
   Files: openmetadata-service/src/main/java/org/openmetadata/service/search/opensearch/OpenSearchSearchManager.java:1114-1122, openmetadata-service/src/main/java/org/openmetadata/service/search/opensearch/OpenSearchSearchManager.java:1254-1268, openmetadata-service/src/main/java/org/openmetadata/service/search/elasticsearch/ElasticSearchSearchManager.java:1073-1081, openmetadata-service/src/main/java/org/openmetadata/service/search/elasticsearch/ElasticSearchSearchManager.java:1211-1225

   In both OpenSearchSearchManager.buildSearchRequestBuilder (~lines 1254-1278) and ElasticSearchSearchManager.buildSearchRequestBuilder (~lines 1211-1235), the deterministic tiebreaker (`name.keyword` + `id.keyword`) is now appended on EVERY search path. Previously it was only appended when the sort field was `_score` or when `isExport=true`. For explicit non-`_score` sort fields on non-export requests, no `id.keyword`/`name.keyword` sort was added before; it now always is.
   
   This is a deliberate determinism improvement, but it broadens the set of queries that sort on `name.keyword`/`id.keyword`. If any searchable index lacks those keyword sub-fields in its mapping, the sort clause can fail with "No mapping found for [name.keyword]". Risk is low because OpenMetadata entity indexes universally map `name.keyword` and `id.keyword`, and the `_score`/export paths already relied on this. Worth a quick confirmation that no specialized index (e.g. data-quality/relationship-only indexes) is queried through this builder without those fields. If such an index exists, set `unmapped_type` on the tiebreaker sorts to keep them harmless.

Options

Display: compact → Showing less information.

Comment with these commands to change the behavior for this request:

Compact
gitar display:verbose         

Was this helpful? React with 👍 / 👎 | Gitar

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backend safe to test Add this label to run secure Github workflows on PRs skip-pr-checks Bypass PR metadata validation check

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants