Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions oas_docs/output/kibana.serverless.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -76103,7 +76103,7 @@ paths:

Refer to [Spaces](https://www.elastic.co/docs/deploy-manage/manage-spaces) for more information.

Link one or more entities to a target entity, creating a resolution group. Requires an enterprise license.<br/><br/>[Required authorization] Route required privileges: securitySolution AND securitySolution-entity-analytics.
Link one or more entities to a target entity, creating a resolution group. Changes become visible on subsequent reads after the next index refresh (typically <1s). Requires an enterprise license.<br/><br/>[Required authorization] Route required privileges: securitySolution AND securitySolution-entity-analytics.
operationId: post-security-entity-store-resolution-link
parameters:
- description: A required header to protect against CSRF attacks
Expand Down Expand Up @@ -76217,7 +76217,7 @@ paths:

Refer to [Spaces](https://www.elastic.co/docs/deploy-manage/manage-spaces) for more information.

Remove one or more entities from their resolution group. Requires an enterprise license.<br/><br/>[Required authorization] Route required privileges: securitySolution AND securitySolution-entity-analytics.
Remove one or more entities from their resolution group. Changes become visible on subsequent reads after the next index refresh (typically <1s). Requires an enterprise license.<br/><br/>[Required authorization] Route required privileges: securitySolution AND securitySolution-entity-analytics.
operationId: post-security-entity-store-resolution-unlink
parameters:
- description: A required header to protect against CSRF attacks
Expand Down
4 changes: 2 additions & 2 deletions oas_docs/output/kibana.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -81312,7 +81312,7 @@ paths:

Refer to [Spaces](https://www.elastic.co/docs/deploy-manage/manage-spaces) for more information.

Link one or more entities to a target entity, creating a resolution group. Requires an enterprise license.<br/><br/>[Required authorization] Route required privileges: securitySolution AND securitySolution-entity-analytics.
Link one or more entities to a target entity, creating a resolution group. Changes become visible on subsequent reads after the next index refresh (typically <1s). Requires an enterprise license.<br/><br/>[Required authorization] Route required privileges: securitySolution AND securitySolution-entity-analytics.
operationId: post-security-entity-store-resolution-link
parameters:
- description: A required header to protect against CSRF attacks
Expand Down Expand Up @@ -81426,7 +81426,7 @@ paths:

Refer to [Spaces](https://www.elastic.co/docs/deploy-manage/manage-spaces) for more information.

Remove one or more entities from their resolution group. Requires an enterprise license.<br/><br/>[Required authorization] Route required privileges: securitySolution AND securitySolution-entity-analytics.
Remove one or more entities from their resolution group. Changes become visible on subsequent reads after the next index refresh (typically <1s). Requires an enterprise license.<br/><br/>[Required authorization] Route required privileges: securitySolution AND securitySolution-entity-analytics.
operationId: post-security-entity-store-resolution-unlink
parameters:
- description: A required header to protect against CSRF attacks
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ describe('ResolutionClient', () => {
});
expect(mockEsClient.bulk).toHaveBeenCalledWith(
expect.objectContaining({
refresh: true,
refresh: 'wait_for',
operations: expect.arrayContaining([
expect.objectContaining({
update: expect.objectContaining({
Expand All @@ -111,6 +111,21 @@ describe('ResolutionClient', () => {
);
});

it('should forward refresh: false to the bulk call when caller passes it', async () => {
const targetDoc = createEntityDoc('target-1');
const entity1Doc = createEntityDoc('entity-1');

mockEsClient.search.mockResolvedValueOnce(
createSearchResponse([targetDoc, entity1Doc]) as never
);
mockEsClient.search.mockResolvedValueOnce(createSearchResponse([]) as never);
mockEsClient.bulk.mockResolvedValueOnce({ errors: false, items: [] } as never);

await client.linkEntities('target-1', ['entity-1'], { refresh: false });

expect(mockEsClient.bulk).toHaveBeenCalledWith(expect.objectContaining({ refresh: false }));
});

it('should pass nested doc payloads to ES bulk (not flat dotted keys)', async () => {
const targetDoc = createEntityDoc('target-1');
const entity1Doc = createEntityDoc('entity-1');
Expand Down Expand Up @@ -348,7 +363,7 @@ describe('ResolutionClient', () => {
expect(result).toEqual({ unlinked: ['alias-1'], skipped: [] });
expect(mockEsClient.bulk).toHaveBeenCalledWith(
expect.objectContaining({
refresh: true,
refresh: 'wait_for',
operations: expect.arrayContaining([
expect.objectContaining({
doc: {
Expand All @@ -366,6 +381,17 @@ describe('ResolutionClient', () => {
);
});

it('should forward refresh: false to the bulk call when caller passes it', async () => {
const aliasDoc = createEntityDoc('alias-1', 'user', 'target-1');

mockEsClient.search.mockResolvedValueOnce(createSearchResponse([aliasDoc]) as never);
mockEsClient.bulk.mockResolvedValueOnce({ errors: false, items: [] } as never);

await client.unlinkEntities(['alias-1'], { refresh: false });

expect(mockEsClient.bulk).toHaveBeenCalledWith(expect.objectContaining({ refresh: false }));
});

it('should throw EntitiesNotFoundError when entities are missing', async () => {
mockEsClient.search.mockResolvedValueOnce(createSearchResponse([]) as never);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
ResolutionUpdateError,
SelfLinkError,
} from '../errors';
import type { RefreshOption } from '../../infra/elasticsearch/resolution';
import {
searchEntitiesByIds,
searchByResolvedToField,
Expand Down Expand Up @@ -54,6 +55,11 @@ export interface ResolutionGroup {
group_size: number;
}

/** Options forwarded to the underlying bulk write. */
export interface ResolutionWriteOptions {
refresh?: RefreshOption;
}

interface FetchedEntities {
sources: Map<string, Record<string, unknown>>;
docIds: Map<string, string>;
Expand All @@ -75,7 +81,12 @@ export class ResolutionClient {
* Validates chain prevention (can't link an alias) and has-aliases prevention
* (can't link an entity that has aliases pointing to it).
*/
public async linkEntities(targetId: string, rawEntityIds: string[]): Promise<LinkResult> {
public async linkEntities(
targetId: string,
rawEntityIds: string[],
options: ResolutionWriteOptions = {}
): Promise<LinkResult> {
const { refresh = 'wait_for' } = options;
const index = getLatestEntitiesIndexName(this.namespace);

// 1. Deduplicate entity_ids
Expand Down Expand Up @@ -133,7 +144,7 @@ export class ResolutionClient {
docId: docIds.get(entityId)!,
doc: { [RESOLVED_TO_FIELD]: targetId },
}));
const linkResult = await bulkUpdateEntityDocs(this.esClient, { index, updates });
const linkResult = await bulkUpdateEntityDocs(this.esClient, { index, updates, refresh });

this.throwOnBulkErrors(linkResult, `linking entities to '${targetId}'`);

Expand All @@ -144,7 +155,11 @@ export class ResolutionClient {
* Unlinks alias entities by removing their resolved_to field.
* Unlinked entities become standalone.
*/
public async unlinkEntities(rawEntityIds: string[]): Promise<UnlinkResult> {
public async unlinkEntities(
rawEntityIds: string[],
options: ResolutionWriteOptions = {}
): Promise<UnlinkResult> {
const { refresh = 'wait_for' } = options;
const index = getLatestEntitiesIndexName(this.namespace);

// 1. Deduplicate and fetch all entities
Expand Down Expand Up @@ -176,7 +191,7 @@ export class ResolutionClient {
docId: docIds.get(entityId)!,
doc: { [RESOLVED_TO_FIELD]: null },
}));
const unlinkResult = await bulkUpdateEntityDocs(this.esClient, { index, updates });
const unlinkResult = await bulkUpdateEntityDocs(this.esClient, { index, updates, refresh });

this.throwOnBulkErrors(unlinkResult, 'unlinking entities');

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,24 @@ interface BulkFieldUpdate {
doc: Record<string, unknown>;
}

/**
* Refresh option for bulk writes against the latest entities index.
*
* - `'wait_for'` (default): block until the next scheduled refresh fires
* (typically <1s). Read-your-writes guaranteed on subsequent searches.
* Only requires `write` privilege.
* - `false`: return immediately; the doc becomes searchable on the next
* natural refresh. Stale reads possible for ~1s. Only requires `write`.
*
* `true` is intentionally not supported: forcing a refresh requires the
* `indices:admin/refresh/unpromotable` action.
*
* Caller guidance: UI routes rely on the default so the immediate refetch
* sees the new state. Bulk callers (CSV upload, automated maintainer) pass
* `false` to avoid the per-write refresh wait.
*/
export type RefreshOption = boolean | 'wait_for';

/**
* Bulk updates entity documents by pre-computed _id.
* Uses retry_on_conflict to handle concurrent modifications.
Expand All @@ -100,18 +118,20 @@ export const bulkUpdateEntityDocs = (
params: {
index: string;
updates: BulkFieldUpdate[];
refresh?: RefreshOption;
}
): Promise<BulkResponse> => {
const operations = params.updates.flatMap(({ docId, doc }) => [
const { index, updates, refresh = 'wait_for' } = params;
const operations = updates.flatMap(({ docId, doc }) => [
{
update: {
_index: params.index,
_index: index,
_id: docId,
retry_on_conflict: RETRY_ON_CONFLICT,
},
},
{ doc: unflattenObject(doc) },
]);

return esClient.bulk({ operations, refresh: true });
return esClient.bulk({ operations, refresh });
};
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,9 @@ describe('Automated Resolution', () => {
createDeps(state, mockEsClient, mockResolutionClient)
);

expect(mockLinkEntities).toHaveBeenCalledWith('user-okta', ['user-entra']);
expect(mockLinkEntities).toHaveBeenCalledWith('user-okta', ['user-entra'], {
refresh: false,
});
expect(result.lastRun?.resolutionsCreated).toBe(1);
});

Expand Down Expand Up @@ -236,7 +238,9 @@ describe('Automated Resolution', () => {
createDeps(state, mockEsClient, mockResolutionClient)
);

expect(mockLinkEntities).toHaveBeenCalledWith('user-existing-target', ['user-new']);
expect(mockLinkEntities).toHaveBeenCalledWith('user-existing-target', ['user-new'], {
refresh: false,
});
expect(result.lastRun?.resolutionsCreated).toBe(1);
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,7 @@ async function resolveMatchBuckets(
.map((e) => e.entityId);
if (aliasIds.length === 0) continue;

const result = await resolutionClient.linkEntities(targetId, aliasIds);
const result = await resolutionClient.linkEntities(targetId, aliasIds, { refresh: false });
resolutionsCreated += result.linked.length;
} else if (unresolvedEntities.length >= 2) {
// New group: pick target via namespace priority, link the rest
Expand All @@ -311,7 +311,9 @@ async function resolveMatchBuckets(
.filter((e) => e.entityId !== targetEntity.entityId)
.map((e) => e.entityId);

const result = await resolutionClient.linkEntities(targetEntity.entityId, aliasIds);
const result = await resolutionClient.linkEntities(targetEntity.entityId, aliasIds, {
refresh: false,
});
resolutionsCreated += result.linked.length;
}
// else: only 1 unresolved, no existing targets → no match, skip
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,9 @@ export function registerResolutionLink(router: EntityStorePluginRouter) {
access: 'public',
summary: 'Link entities',
description:
'Link one or more entities to a target entity, creating a resolution group. Requires an enterprise license.',
'Link one or more entities to a target entity, creating a resolution group. ' +
'Changes become visible on subsequent reads after the next index refresh ' +
'(typically <1s). Requires an enterprise license.',
options: {
tags: ['oas-tag:Security entity store'],
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ export function registerResolutionUnlink(router: EntityStorePluginRouter) {
access: 'public',
summary: 'Unlink entities',
description:
'Remove one or more entities from their resolution group. Requires an enterprise license.',
'Remove one or more entities from their resolution group. Changes become ' +
'visible on subsequent reads after the next index refresh (typically <1s). ' +
'Requires an enterprise license.',
options: {
tags: ['oas-tag:Security entity store'],
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -340,7 +340,7 @@ describe('processResolutionCsvUpload', () => {
});
});

it('should call linkEntities with correct args', async () => {
it('should call linkEntities with correct args and refresh: false', async () => {
mockResolutionClient.linkEntities.mockResolvedValue({
linked: ['alias:1'],
skipped: [],
Expand All @@ -350,7 +350,9 @@ describe('processResolutionCsvUpload', () => {
const csv = 'type,user.email,resolved_to\nuser,alias@test.com,target:golden';
await processResolutionCsvUpload(createMockStream(csv), deps());

expect(mockResolutionClient.linkEntities).toHaveBeenCalledWith('target:golden', ['alias:1']);
expect(mockResolutionClient.linkEntities).toHaveBeenCalledWith('target:golden', ['alias:1'], {
refresh: false,
});
});

it('should report success with linked and skipped counts', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,8 @@ async function processRow(
try {
const { linked, skipped } = await deps.resolutionClient.linkEntities(
resolvedTo,
matchedEntityIds
matchedEntityIds,
{ refresh: false }
);
return {
status: 'success',
Expand Down
Loading