Skip to content

Commit 8919361

Browse files
authored
[Entity Resolution] unflatten dotted keys in bulkUpdateEntityDocs (elastic#260651)
## Summary Fixes elastic#260551 Entity Resolution's `bulkUpdateEntityDocs` sent flat dotted keys (for example `entity.relationships.resolution.resolved_to`) straight to Elasticsearch bulk `update`. The entity store latest index has a `dot_expander` ingest pipeline configured as `default_pipeline`, but **ES bulk partial `update` with `doc` does not run `default_pipeline`** — this is a known upstream ES bug ([elastic/elasticsearch#105804](elastic/elasticsearch#105804), fix PR [elastic#105805](elastic/elasticsearch#105805) targeted for ES v9.4.0). As a result, fields were stored as literal flat keys in `_source` next to the nested `entity` object. That dual shape could surface as `ChainResolutionError` when the automated resolution maintainer races with manual or CSV resolution. This change runs each bulk partial `doc` through `unflattenObject` from `@kbn/object-utils` — the same pattern used by entity store CRUD routes (e.g., `bulk_update.ts` line 23) — so updates merge into the nested `entity` tree correctly. Once the upstream ES fix lands, this becomes a harmless no-op. ### Checklist - [ ] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md) - [ ] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [ ] If a plugin configuration key changed, check if it needs to be allowlisted in the cloud and added to the [docker list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker) - [ ] This was checked for breaking HTTP API changes, and any breaking changes have been approved by the breaking-change committee. The `release_note:breaking` label should be applied in these situations. - [ ] [Flaky Test Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was used on any tests changed - [x] The PR description includes the appropriate Release Notes section, and the correct `release_note:*` label is applied per the [guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) - [x] Review the [backport guidelines](https://docs.google.com/document/d/1VyN5k91e5OVumlc0Gb9RPa3h1ewuPE705nRtioPiTvY/edit?usp=sharing) and apply applicable `backport:*` labels. ### Identify risks - **Regression for callers that intentionally pass flat keys:** Low severity. `unflattenObject` is the same helper used elsewhere in entity_store for API payloads; nested-only docs pass through unchanged. - **Bulk update shape:** Low severity. ES merge behavior improves (nested merge vs literal dotted field names).
1 parent d0c0abb commit 8919361

3 files changed

Lines changed: 54 additions & 6 deletions

File tree

x-pack/solutions/security/plugins/entity_store/server/domain/resolution/resolution_client.test.ts

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,44 @@ describe('ResolutionClient', () => {
111111
);
112112
});
113113

114+
it('should pass nested doc payloads to ES bulk (not flat dotted keys)', async () => {
115+
const targetDoc = createEntityDoc('target-1');
116+
const entity1Doc = createEntityDoc('entity-1');
117+
const entity2Doc = createEntityDoc('entity-2');
118+
119+
mockEsClient.search.mockResolvedValueOnce(
120+
createSearchResponse([targetDoc, entity1Doc, entity2Doc]) as never
121+
);
122+
mockEsClient.search.mockResolvedValueOnce(createSearchResponse([]) as never);
123+
mockEsClient.bulk.mockResolvedValueOnce({ errors: false, items: [] } as never);
124+
125+
await client.linkEntities('target-1', ['entity-1', 'entity-2']);
126+
127+
expect(mockEsClient.bulk).toHaveBeenCalledTimes(1);
128+
const bulkPayload = mockEsClient.bulk.mock.calls[0][0];
129+
const { operations } = bulkPayload;
130+
if (!operations) {
131+
throw new Error('expected bulk operations');
132+
}
133+
const docOperations = operations.filter(
134+
(op: unknown): op is { doc: unknown } =>
135+
op !== null && typeof op === 'object' && 'doc' in op
136+
);
137+
expect(docOperations).toHaveLength(2);
138+
const nestedDoc = {
139+
entity: {
140+
relationships: {
141+
resolution: {
142+
resolved_to: 'target-1',
143+
},
144+
},
145+
},
146+
};
147+
for (const { doc } of docOperations) {
148+
expect(doc).toEqual(nestedDoc);
149+
}
150+
});
151+
114152
it('should skip entities already linked to the same target', async () => {
115153
const targetDoc = createEntityDoc('target-1');
116154
const alreadyLinkedDoc = createEntityDoc('entity-1', 'user', 'target-1');
@@ -313,7 +351,15 @@ describe('ResolutionClient', () => {
313351
refresh: true,
314352
operations: expect.arrayContaining([
315353
expect.objectContaining({
316-
doc: { 'entity.relationships.resolution.resolved_to': null },
354+
doc: {
355+
entity: {
356+
relationships: {
357+
resolution: {
358+
resolved_to: null,
359+
},
360+
},
361+
},
362+
},
317363
}),
318364
]),
319365
})

x-pack/solutions/security/plugins/entity_store/server/infra/elasticsearch/resolution.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import type { ElasticsearchClient } from '@kbn/core/server';
99
import type { SearchResponse, BulkResponse } from '@elastic/elasticsearch/lib/api/types';
10+
import { unflattenObject } from '@kbn/object-utils';
1011

1112
const RETRY_ON_CONFLICT = 3;
1213

@@ -109,7 +110,7 @@ export const bulkUpdateEntityDocs = (
109110
retry_on_conflict: RETRY_ON_CONFLICT,
110111
},
111112
},
112-
{ doc },
113+
{ doc: unflattenObject(doc) },
113114
]);
114115

115116
return esClient.bulk({ operations, refresh: true });

x-pack/solutions/security/plugins/entity_store/test/scout/api/tests/resolution_api.spec.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import { apiTest } from '@kbn/scout-security';
99
import { expect } from '@kbn/scout-security/api';
1010
import type { Client } from '@elastic/elasticsearch';
11+
import { get } from 'lodash';
1112
import {
1213
COMMON_HEADERS,
1314
ENTITY_STORE_ROUTES,
@@ -79,10 +80,10 @@ apiTest.describe('Entity Store Resolution API tests', { tag: ENTITY_STORE_TAGS }
7980

8081
// Verify via ES that resolved_to is set on alias docs
8182
const alias1Doc = await getEntitySource(esClient, alias1);
82-
expect(alias1Doc[RESOLVED_TO_FIELD]).toBe(targetId);
83+
expect(get(alias1Doc, RESOLVED_TO_FIELD)).toBe(targetId);
8384

8485
const alias2Doc = await getEntitySource(esClient, alias2);
85-
expect(alias2Doc[RESOLVED_TO_FIELD]).toBe(targetId);
86+
expect(get(alias2Doc, RESOLVED_TO_FIELD)).toBe(targetId);
8687
});
8788

8889
apiTest('Link: should skip already-linked entities', async ({ apiClient }) => {
@@ -215,10 +216,10 @@ apiTest.describe('Entity Store Resolution API tests', { tag: ENTITY_STORE_TAGS }
215216

216217
// Verify via ES that resolved_to is null
217218
const alias1Doc = await getEntitySource(esClient, alias1);
218-
expect(alias1Doc[RESOLVED_TO_FIELD]).toBeNull();
219+
expect(get(alias1Doc, RESOLVED_TO_FIELD)).toBeNull();
219220

220221
const alias2Doc = await getEntitySource(esClient, alias2);
221-
expect(alias2Doc[RESOLVED_TO_FIELD]).toBeNull();
222+
expect(get(alias2Doc, RESOLVED_TO_FIELD)).toBeNull();
222223

223224
// Group should show standalone
224225
const group = await apiClient.get(

0 commit comments

Comments
 (0)