Skip to content

Commit 06f92ef

Browse files
authored
Pin individual entities in graph (#251299)
## Summary Part of the fixes needed to close #239954 PRs: 1. #251299 <-- this PR 2. #250105 API allows for pinning events/alerts by document ID but we don't have a proper way to represent that in the UI so we're skipping for now ### Screenshots <details><summary>Pinning no entities from the group</summary> <img width="1660" height="842" alt="Screenshot 2026-02-02 at 19 18 56" src="https://github.com/user-attachments/assets/407c4b99-f0a2-441f-8731-e5b5a1101734" /> </details> <details><summary>Pinning "admin2@example.com" entity</summary> <img width="1665" height="952" alt="Screenshot 2026-02-02 at 19 22 15" src="https://github.com/user-attachments/assets/b45f8bbf-cd11-4db9-ae9a-37813b4919fa" /> </details> <details><summary>Pinning "admin2@example.com" and "admin-user2@example.com" entities</summary> <img width="1659" height="1023" alt="Screenshot 2026-02-02 at 19 23 11" src="https://github.com/user-attachments/assets/f701d0ea-debb-40d5-ade3-1c0d356e2d62" /> </details> <details><summary>Pinning all entities in actor node</summary> <img width="1661" height="1479" alt="Screenshot 2026-02-02 at 19 28 10" src="https://github.com/user-attachments/assets/20938268-0161-4767-a9d5-904bb6cde446" /> </details> <details><summary>pinnedIds field sent in the request payload</summary> <img width="927" height="311" alt="Screenshot 2026-02-02 at 19 35 56" src="https://github.com/user-attachments/assets/c9052483-5dff-4e1d-a1cb-b979c3bbf5b5" /> </details> ### How to test 1. Deploy a local env using the following command: `yarn es snapshot --license trial -E xpack.security.authc.api_key.enabled=true` 2. Run kibana using `yarn start --no-base-path` 3. Go to `Advanced settings` and make sure these toggles are on: - `securitySolution:enableGraphVisualization` - `securitySolution:enableAssetInventory` 4. Run these commands: ```bash node scripts/es_archiver load x-pack/solutions/security/test/cloud_security_posture_functional/es_archives/logs_gcp_audit --es-url http://elastic:changeme@localhost:9200 --kibana-url http://elastic:changeme@localhost:5601 node scripts/es_archiver load x-pack/solutions/security/test/cloud_security_posture_functional/es_archives/security_alerts_modified_mappings --es-url http://elastic:changeme@localhost:9200 --kibana-url http://elastic:changeme@localhost:5601 node scripts/es_archiver load x-pack/solutions/security/test/cloud_security_posture_functional/es_archives/entity_store --es-url http://elastic:changeme@localhost:9200 --kibana-url http://elastic:changeme@localhost:5601 node scripts/es_archiver load x-pack/solutions/security/test/cloud_security_posture_functional/es_archives/entity_store_v2 --es-url http://elastic:changeme@localhost:9200 --kibana-url http://elastic:changeme@localhost:5601 ``` 5. Go to Security -> Explore -> Network/Users/Hosts 6. Open an event's flyout, then expand Graph Visualization. Apply filters to see events from September, 1st 2017 till Now. Add an `event.action` filter set to "google.iam.admin.v1.CreateUser", then keep adding `user.entity.id` or `event.id` filters set to the IDs in the entity node using the `OR` operator 7. Individual entities/events/alerts should be pinned correctly in the graph, outside the entity/label groups ### 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) - [x] 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
1 parent d2bc189 commit 06f92ef

File tree

15 files changed

+882
-13
lines changed

15 files changed

+882
-13
lines changed

x-pack/solutions/security/packages/kbn-cloud-security-posture/common/schema/graph/v1.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import { schema } from '@kbn/config-schema';
99

1010
export const INDEX_PATTERN_REGEX = /^[^A-Z^\\/?"<>|\s#,]+$/;
1111

12+
const PINNED_IDS_MAX_SIZE = 1024;
13+
1214
/**
1315
* Entity ID for relationship queries.
1416
* isOrigin indicates whether this entity is the center/origin of the graph
@@ -23,6 +25,7 @@ export const graphRequestSchema = schema.object({
2325
nodesLimit: schema.maybe(schema.number()),
2426
showUnknownTarget: schema.maybe(schema.boolean()),
2527
query: schema.object({
28+
pinnedIds: schema.maybe(schema.arrayOf(schema.string(), { maxSize: PINNED_IDS_MAX_SIZE })),
2629
// Origin event IDs - optional, may be empty when opening from entity flyout
2730
originEventIds: schema.maybe(
2831
schema.arrayOf(schema.object({ id: schema.string(), isAlert: schema.boolean() }))

x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph_investigation/graph_investigation.tsx

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ import { Panel } from '@xyflow/react';
1717
import { getEsQueryConfig } from '@kbn/data-service';
1818
import { EuiFlexGroup, EuiFlexItem, EuiProgress } from '@elastic/eui';
1919
import useSessionStorage from 'react-use/lib/useSessionStorage';
20+
import {
21+
GRAPH_ACTOR_ENTITY_FIELDS,
22+
GRAPH_TARGET_ENTITY_FIELDS,
23+
} from '@kbn/cloud-security-posture-common/constants';
2024
import { Graph, isEntityNode, type NodeProps } from '../../..';
2125
import { Callout } from '../callout/callout';
2226
import { type UseFetchGraphDataParams, useFetchGraphData } from '../../hooks/use_fetch_graph_data';
@@ -30,7 +34,11 @@ import { analyzeDocuments } from '../node/label_node/analyze_documents';
3034
import { EVENT_ID, GRAPH_NODES_LIMIT, TOGGLE_SEARCH_BAR_STORAGE_KEY } from '../../common/constants';
3135
import { Actions } from '../controls/actions';
3236
import { AnimatedSearchBarContainer, useBorder } from './styles';
33-
import { CONTROLLED_BY_GRAPH_INVESTIGATION_FILTER, addFilter } from './search_filters';
37+
import {
38+
CONTROLLED_BY_GRAPH_INVESTIGATION_FILTER,
39+
addFilter,
40+
getFilterValues,
41+
} from './search_filters';
3442
import { useEntityNodeExpandPopover } from '../popovers/node_expand/use_entity_node_expand_popover';
3543
import { useLabelNodeExpandPopover } from '../popovers/node_expand/use_label_node_expand_popover';
3644
import type { NodeViewModel } from '../types';
@@ -284,6 +292,13 @@ export const GraphInvestigation = memo<GraphInvestigationProps>(
284292
return lastValidEsQuery.current;
285293
}, [dataView, kquery, notifications, searchFilters, uiSettings]);
286294

295+
const pinnedIds = useMemo(() => {
296+
return getFilterValues(searchFilters, [
297+
...GRAPH_ACTOR_ENTITY_FIELDS,
298+
...GRAPH_TARGET_ENTITY_FIELDS,
299+
]).map(String);
300+
}, [searchFilters]);
301+
287302
const { data, refresh, isFetching, isError, error } = useFetchGraphData({
288303
req: {
289304
query: {
@@ -292,6 +307,7 @@ export const GraphInvestigation = memo<GraphInvestigationProps>(
292307
esQuery,
293308
start: timeRange.from,
294309
end: timeRange.to,
310+
pinnedIds,
295311
},
296312
nodesLimit: GRAPH_NODES_LIMIT,
297313
},

x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph_investigation/search_filters.test.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
addFilter,
2121
containsFilter,
2222
removeFilter,
23+
getFilterValues,
2324
} from './search_filters';
2425

2526
const dataViewId = 'test-data-view';
@@ -303,4 +304,72 @@ describe('search_filters', () => {
303304
expect(newFilters[1].meta.params).toHaveLength(2);
304305
});
305306
});
307+
308+
describe('getFilterValues', () => {
309+
it('should return empty array for empty filters', () => {
310+
const filters: Filter[] = [];
311+
312+
expect(getFilterValues(filters, key)).toEqual([]);
313+
});
314+
315+
it('should return value from phrase filter with matching key', () => {
316+
const filters: Filter[] = [buildFilterMock(key, value)];
317+
318+
expect(getFilterValues(filters, key)).toEqual([value]);
319+
});
320+
321+
it('should return empty array when key does not match', () => {
322+
const filters: Filter[] = [buildFilterMock('other-key', value)];
323+
324+
expect(getFilterValues(filters, key)).toEqual([]);
325+
});
326+
327+
it('should return values from combined filter', () => {
328+
const filters: Filter[] = [
329+
buildCombinedFilterMock([buildFilterMock(key, 'value1'), buildFilterMock(key, 'value2')]),
330+
];
331+
332+
expect(getFilterValues(filters, key)).toEqual(['value1', 'value2']);
333+
});
334+
335+
it('should skip disabled filters', () => {
336+
const disabledFilter = buildFilterMock(key, value);
337+
disabledFilter.meta.disabled = true;
338+
const filters: Filter[] = [disabledFilter, buildFilterMock(key, 'enabled-value')];
339+
340+
expect(getFilterValues(filters, key)).toEqual(['enabled-value']);
341+
});
342+
343+
it('should handle multiple keys', () => {
344+
const filters: Filter[] = [
345+
buildFilterMock('key1', 'value1'),
346+
buildFilterMock('key2', 'value2'),
347+
buildFilterMock('key3', 'value3'),
348+
];
349+
350+
expect(getFilterValues(filters, ['key1', 'key2'])).toEqual(['value1', 'value2']);
351+
});
352+
353+
it('should return values from mixed phrase and combined filters', () => {
354+
const filters: Filter[] = [
355+
buildFilterMock(key, 'phrase-value'),
356+
buildCombinedFilterMock([
357+
buildFilterMock(key, 'combined-value1'),
358+
buildFilterMock('other-key', 'other-value'),
359+
]),
360+
];
361+
362+
expect(getFilterValues(filters, key)).toEqual(['phrase-value', 'combined-value1']);
363+
});
364+
365+
it('should handle readonly string array for keys', () => {
366+
const readonlyKeys = ['key1', 'key2'] as const;
367+
const filters: Filter[] = [
368+
buildFilterMock('key1', 'value1'),
369+
buildFilterMock('key2', 'value2'),
370+
];
371+
372+
expect(getFilterValues(filters, readonlyKeys)).toEqual(['value1', 'value2']);
373+
});
374+
});
306375
});

x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph_investigation/search_filters.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import type { Filter, PhraseFilter } from '@kbn/es-query';
1616
import type {
1717
CombinedFilter,
1818
PhraseFilterMetaParams,
19+
PhraseFilterValue,
1920
} from '@kbn/es-query/src/filters/build_filters';
2021

2122
export const CONTROLLED_BY_GRAPH_INVESTIGATION_FILTER = 'graph-investigation';
@@ -167,3 +168,45 @@ export const removeFilter = (filters: Filter[], key: string, value: string) => {
167168

168169
return filters;
169170
};
171+
172+
/**
173+
* Helper function to extract filter value(s) from a single filter.
174+
* Handles both simple phrase filters and combined filters recursively.
175+
*/
176+
const getFilterValue = (
177+
filter: Filter,
178+
keys: string[]
179+
): PhraseFilterValue[] | PhraseFilterValue | null => {
180+
if (isCombinedFilter(filter)) {
181+
return filter.meta.params
182+
.map((param) => getFilterValue(param, keys))
183+
.filter((value): value is PhraseFilterValue | PhraseFilterValue[] => value !== null)
184+
.flat();
185+
}
186+
187+
return filter.meta.key && keys.includes(filter.meta.key)
188+
? (filter.meta.params as PhraseFilterMetaParams)?.query
189+
: null;
190+
};
191+
192+
/**
193+
* Extracts all values from filters that match the specified keys.
194+
* Handles both simple phrase filters and combined filters.
195+
* Skips disabled filters.
196+
*
197+
* @param filters - The list of filters to extract values from.
198+
* @param key - The key or array of keys to match against filter keys.
199+
* @returns An array of all values from matching filters.
200+
*/
201+
export const getFilterValues = (
202+
filters: Filter[],
203+
key: string | readonly string[]
204+
): PhraseFilterValue[] => {
205+
const keys = Array.isArray(key) ? key : [key];
206+
207+
return filters
208+
.filter((filter) => !filter.meta.disabled)
209+
.map((filter) => getFilterValue(filter, keys as string[]))
210+
.filter((value): value is PhraseFilterValue | PhraseFilterValue[] => value !== null)
211+
.flat();
212+
};

x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/hooks/use_fetch_graph_data.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,13 +85,13 @@ export const useFetchGraphData = ({
8585
options,
8686
}: UseFetchGraphDataParams): UseFetchGraphDataResult => {
8787
const queryClient = useQueryClient();
88-
const { esQuery, originEventIds, start, end } = req.query;
88+
const { esQuery, originEventIds, start, end, pinnedIds } = req.query;
8989
const {
9090
services: { http },
9191
} = useKibana();
9292
const QUERY_KEY = useMemo(
93-
() => ['useFetchGraphData', originEventIds, start, end, esQuery],
94-
[end, esQuery, originEventIds, start]
93+
() => ['useFetchGraphData', originEventIds, start, end, esQuery, pinnedIds],
94+
[end, esQuery, originEventIds, start, pinnedIds]
9595
);
9696

9797
const { isLoading, isError, data, isFetching, error } = useQuery<GraphResponse>(

x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/fetch_events_graph.ts

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ interface BuildEsqlQueryParams {
4040
isEnrichPolicyExists: boolean;
4141
spaceId: string;
4242
alertsMappingsIncluded: boolean;
43+
pinnedIds?: string[];
4344
}
4445

4546
/**
@@ -56,6 +57,7 @@ export const fetchEvents = async ({
5657
indexPatterns,
5758
spaceId,
5859
esQuery,
60+
pinnedIds,
5961
}: {
6062
esClient: IScopedClusterClient;
6163
logger: Logger;
@@ -66,6 +68,7 @@ export const fetchEvents = async ({
6668
indexPatterns: string[];
6769
spaceId: string;
6870
esQuery?: EsQuery;
71+
pinnedIds?: string[];
6972
}): Promise<EsqlToRecords<EventEdge>> => {
7073
const originAlertIds = originEventIds.filter((originEventId) => originEventId.isAlert);
7174

@@ -98,6 +101,7 @@ export const fetchEvents = async ({
98101
isEnrichPolicyExists,
99102
spaceId,
100103
alertsMappingsIncluded,
104+
pinnedIds,
101105
});
102106

103107
logger.trace(`Executing query [${query}]`);
@@ -114,6 +118,7 @@ export const fetchEvents = async ({
114118
...originEventIds
115119
.filter((originEventId) => originEventId.isAlert)
116120
.map((originEventId, idx) => ({ [`og_alrt_id${idx}`]: originEventId.id })),
121+
...(pinnedIds ?? []).map((id, idx) => ({ [`pinned_id${idx}`]: id })),
117122
],
118123
})
119124
.toRecords<EventEdge>();
@@ -196,6 +201,25 @@ const checkEnrichPolicyExists = async (
196201
}
197202
};
198203

204+
/**
205+
* Generates ESQL statement for evaluating pinned IDs.
206+
* This checks if the document _id, actorEntityId, or targetEntityId matches any of the pinned IDs.
207+
*/
208+
const buildPinnedEsql = (pinnedIds?: string[]): string => {
209+
if (!pinnedIds || pinnedIds.length === 0) {
210+
return '| EVAL pinned = TO_STRING(null)';
211+
}
212+
213+
const pinnedParamsStr = pinnedIds.map((_id, idx) => `?pinned_id${idx}`).join(', ');
214+
215+
return `| EVAL pinned = CASE(
216+
_id IN (${pinnedParamsStr}), _id,
217+
actorEntityId IN (${pinnedParamsStr}), actorEntityId,
218+
targetEntityId IN (${pinnedParamsStr}), targetEntityId,
219+
null
220+
)`;
221+
};
222+
199223
/**
200224
* Generates ESQL statements for building entity fields with enrichment data.
201225
* This is used when entity store enrichment is available (via LOOKUP JOIN or ENRICH).
@@ -253,6 +277,7 @@ const buildEsqlQuery = ({
253277
isEnrichPolicyExists,
254278
spaceId,
255279
alertsMappingsIncluded,
280+
pinnedIds,
256281
}: BuildEsqlQueryParams): string => {
257282
const SECURITY_ALERTS_PARTIAL_IDENTIFIER = '.alerts-security.alerts-';
258283
const enrichPolicyName = getEnrichPolicyId(spaceId);
@@ -293,6 +318,7 @@ const buildEsqlQuery = ({
293318
${targetEntityIdEvals}
294319
| MV_EXPAND actorEntityId
295320
| MV_EXPAND targetEntityId
321+
${buildPinnedEsql(pinnedIds)}
296322
| EVAL actorEntityFieldHint = CASE(
297323
${actorFieldHintCases},
298324
""
@@ -425,9 +451,12 @@ ${buildEnrichedEntityFieldsEsql()}
425451
targetEntityType,
426452
targetEntitySubType,
427453
isOrigin,
428-
isOriginAlert
454+
isOriginAlert,
455+
pinned
456+
| EVAL pinnedSort = CASE(pinned IS NULL, 1, 0)
457+
| SORT action DESC, pinnedSort ASC, isOrigin
429458
| LIMIT 1000
430-
| SORT action DESC, isOrigin`;
459+
| DROP pinnedSort`;
431460

432461
return query;
433462
};

x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/fetch_graph.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ describe('fetchGraph', () => {
9595
indexPatterns: baseParams.indexPatterns,
9696
spaceId: baseParams.spaceId,
9797
esQuery: undefined,
98+
pinnedIds: undefined,
9899
});
99100
});
100101

@@ -211,4 +212,30 @@ describe('fetchGraph', () => {
211212
'Failed to fetch entity relationships: Connection refused'
212213
);
213214
});
215+
216+
describe('Pinned IDs', () => {
217+
it('should pass pinnedIds to fetchEvents when provided', async () => {
218+
const pinnedIds = ['entity-1', 'entity-2'];
219+
220+
await fetchGraph({ ...baseParams, pinnedIds });
221+
222+
expect(mockedFetchEvents).toHaveBeenCalledWith(
223+
expect.objectContaining({ pinnedIds: ['entity-1', 'entity-2'] })
224+
);
225+
});
226+
227+
it('should pass pinnedIds as undefined to fetchEvents when not provided', async () => {
228+
await fetchGraph(baseParams);
229+
230+
expect(mockedFetchEvents).toHaveBeenCalledWith(
231+
expect.objectContaining({ pinnedIds: undefined })
232+
);
233+
});
234+
235+
it('should pass empty pinnedIds array to fetchEvents when provided as empty', async () => {
236+
await fetchGraph({ ...baseParams, pinnedIds: [] });
237+
238+
expect(mockedFetchEvents).toHaveBeenCalledWith(expect.objectContaining({ pinnedIds: [] }));
239+
});
240+
});
214241
});

x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/fetch_graph.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export interface FetchGraphParams {
2222
spaceId: string;
2323
esQuery?: EsQuery;
2424
entityIds?: EntityId[];
25+
pinnedIds?: string[];
2526
}
2627

2728
export interface FetchGraphResult {
@@ -47,6 +48,7 @@ export const fetchGraph = async ({
4748
spaceId,
4849
esQuery,
4950
entityIds,
51+
pinnedIds,
5052
}: FetchGraphParams): Promise<FetchGraphResult> => {
5153
// Only fetch events when originEventIds or esQuery are provided
5254
const hasOriginEventIds = originEventIds.length > 0;
@@ -68,6 +70,7 @@ export const fetchGraph = async ({
6870
indexPatterns,
6971
spaceId,
7072
esQuery,
73+
pinnedIds,
7174
}).catch((error) => {
7275
logger.error(`Failed to fetch events: ${error.message}`);
7376
throw error;

x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/route.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,8 @@ export const defineGraphRoute = (router: CspRouter) =>
4343
async (context: CspRequestHandlerContext, request, response) => {
4444
const cspContext = await context.csp;
4545
const { nodesLimit, showUnknownTarget = false } = request.body;
46-
const { originEventIds, start, end, indexPatterns, esQuery, entityIds } = request.body
47-
.query as GraphRequest['query'];
46+
const { originEventIds, start, end, indexPatterns, esQuery, entityIds, pinnedIds } = request
47+
.body.query as GraphRequest['query'];
4848
const spaceId = await cspContext.spacesService?.getSpaceId(request);
4949
const isGraphEnabled = await (
5050
await context.core
@@ -70,6 +70,7 @@ export const defineGraphRoute = (router: CspRouter) =>
7070
end,
7171
esQuery,
7272
entityIds,
73+
pinnedIds,
7374
},
7475
showUnknownTarget,
7576
nodesLimit,

0 commit comments

Comments
 (0)