diff --git a/x-pack/solutions/security/packages/kbn-cloud-security-posture/common/schema/graph/v1.ts b/x-pack/solutions/security/packages/kbn-cloud-security-posture/common/schema/graph/v1.ts index b03eb2f423031..ebfb837a64a97 100644 --- a/x-pack/solutions/security/packages/kbn-cloud-security-posture/common/schema/graph/v1.ts +++ b/x-pack/solutions/security/packages/kbn-cloud-security-posture/common/schema/graph/v1.ts @@ -88,7 +88,12 @@ export const REACHED_NODES_LIMIT = 'REACHED_NODES_LIMIT'; export const graphResponseSchema = () => schema.object({ nodes: schema.arrayOf( - schema.oneOf([entityNodeDataSchema, groupNodeDataSchema, labelNodeDataSchema]) + schema.oneOf([ + entityNodeDataSchema, + groupNodeDataSchema, + labelNodeDataSchema, + relationshipNodeDataSchema, + ]) ), edges: schema.arrayOf(edgeDataSchema), messages: schema.maybe(schema.arrayOf(schema.oneOf([schema.literal(REACHED_NODES_LIMIT)]))), @@ -115,6 +120,7 @@ export const nodeShapeSchema = schema.oneOf([ schema.literal('diamond'), schema.literal('label'), schema.literal('group'), + schema.literal('relationship'), ]); export const nodeBaseDataSchema = schema.object({ @@ -164,6 +170,14 @@ export const labelNodeDataSchema = schema.allOf([ }), ]); +export const relationshipNodeDataSchema = schema.allOf([ + nodeBaseDataSchema, + schema.object({ + shape: schema.literal('relationship'), + parentId: schema.maybe(schema.string()), + }), +]); + export const edgeDataSchema = schema.object({ id: schema.string(), source: schema.string(), diff --git a/x-pack/solutions/security/packages/kbn-cloud-security-posture/common/types/graph/v1.ts b/x-pack/solutions/security/packages/kbn-cloud-security-posture/common/types/graph/v1.ts index 947fb40a3307c..63a578cccf07f 100644 --- a/x-pack/solutions/security/packages/kbn-cloud-security-posture/common/types/graph/v1.ts +++ b/x-pack/solutions/security/packages/kbn-cloud-security-posture/common/types/graph/v1.ts @@ -15,6 +15,7 @@ import type { graphResponseSchema, groupNodeDataSchema, labelNodeDataSchema, + relationshipNodeDataSchema, nodeColorSchema, nodeShapeSchema, nodeDocumentDataSchema, @@ -49,9 +50,15 @@ export type GroupNodeDataModel = TypeOf; export type LabelNodeDataModel = TypeOf; +export type RelationshipNodeDataModel = TypeOf; + export type EdgeDataModel = TypeOf; -export type NodeDataModel = EntityNodeDataModel | GroupNodeDataModel | LabelNodeDataModel; +export type NodeDataModel = + | EntityNodeDataModel + | GroupNodeDataModel + | LabelNodeDataModel + | RelationshipNodeDataModel; export type NodeDocumentDataModel = TypeOf; diff --git a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/edge/default_edge.tsx b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/edge/default_edge.tsx index fa06ba4c8cd34..71cf9f69ff160 100644 --- a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/edge/default_edge.tsx +++ b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/edge/default_edge.tsx @@ -13,6 +13,7 @@ import { getMarkerEnd } from './markers'; import { useEdgeColor } from './styles'; import { STACK_NODE_HORIZONTAL_PADDING } from '../constants'; import { GRAPH_EDGE_ID } from '../test_ids'; +import { isConnectorShape } from '../utils'; type EdgeColor = EdgeViewModel['color']; @@ -20,7 +21,7 @@ const dashedStyle = { strokeDasharray: '2 2', }; -const NODES_WITHOUT_MARKER = ['label', 'group']; +const NODES_WITHOUT_MARKER = ['label', 'group', 'relationship']; export const DefaultEdge = memo( ({ @@ -35,9 +36,9 @@ export const DefaultEdge = memo( data, }: EdgeProps) => { const color: EdgeColor = data?.color || 'primary'; - const entityToLabel = data?.sourceShape !== 'group' && data?.targetShape === 'label'; - const labelToEntity = data?.sourceShape === 'label' && data?.targetShape !== 'group'; - const isExtraAlignment = entityToLabel || labelToEntity; + const entityToConnector = data?.sourceShape !== 'group' && isConnectorShape(data?.targetShape); + const connectorToEntity = isConnectorShape(data?.sourceShape) && data?.targetShape !== 'group'; + const isExtraAlignment = entityToConnector || connectorToEntity; const sourceMargin = getShapeHandlePosition(data?.sourceShape); const targetMargin = getShapeHandlePosition(data?.targetShape); const markerEnd = @@ -56,7 +57,7 @@ export const DefaultEdge = memo( ? targetX + xOffset : targetX - xOffset + - (isExtraAlignment ? STACK_NODE_HORIZONTAL_PADDING / 2 : 0) * (labelToEntity ? 1 : -1); + (isExtraAlignment ? STACK_NODE_HORIZONTAL_PADDING / 2 : 0) * (connectorToEntity ? 1 : -1); const [edgePath] = getSmoothStepPath({ // sourceX and targetX are adjusted to account for the shape handle position diff --git a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/edge/utils.ts b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/edge/utils.ts index e56f7d31ba432..86cc496a687b5 100644 --- a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/edge/utils.ts +++ b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/edge/utils.ts @@ -20,6 +20,7 @@ export function getShapeHandlePosition(shape?: NodeShape) { case 'diamond': return 14; case 'label': + case 'relationship': return 3; case 'group': return 0; diff --git a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph/graph.test.tsx b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph/graph.test.tsx index 9fe85ad2545e3..28e32448b0257 100644 --- a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph/graph.test.tsx +++ b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph/graph.test.tsx @@ -720,4 +720,78 @@ describe('', () => { }); }); }); + + describe('interactive class', () => { + const testNodes: NodeViewModel[] = [ + { + id: 'entity1', + label: 'Entity Node', + color: 'primary', + shape: 'hexagon', + }, + { + id: 'label1', + label: 'Label Node', + color: 'primary', + shape: 'label', + }, + ]; + + it('should add non-interactive class to nodes when interactive is false', async () => { + const { container } = renderGraphPreview({ + nodes: testNodes, + edges: [], + interactive: false, + }); + + await waitFor(() => { + const nodes = container.querySelectorAll('.react-flow__node'); + expect(nodes.length).toBeGreaterThan(0); + + nodes.forEach((node) => { + expect(node).toHaveClass('non-interactive'); + }); + }); + }); + + it('should not add non-interactive class to nodes when interactive is true', async () => { + const { container } = renderGraphPreview({ + nodes: testNodes, + edges: [], + interactive: true, + }); + + await waitFor(() => { + const nodes = container.querySelectorAll('.react-flow__node'); + expect(nodes.length).toBeGreaterThan(0); + + nodes.forEach((node) => { + expect(node).not.toHaveClass('non-interactive'); + }); + }); + }); + + it('should add non-interactive class to relationship nodes when interactive is false', async () => { + const nodesWithRelationship: NodeViewModel[] = [ + ...testNodes, + { + id: 'rel1', + label: 'Owns', + shape: 'relationship', + }, + ]; + + const { container } = renderGraphPreview({ + nodes: nodesWithRelationship, + edges: [], + interactive: false, + }); + + await waitFor(() => { + const relationshipNode = container.querySelector('[data-id="rel1"]'); + expect(relationshipNode).not.toBeNull(); + expect(relationshipNode).toHaveClass('non-interactive'); + }); + }); + }); }); diff --git a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph/graph.tsx b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph/graph.tsx index ab276fb02d905..41c5b2253cc14 100644 --- a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph/graph.tsx +++ b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph/graph.tsx @@ -27,11 +27,13 @@ import { DiamondNode, LabelNode, EdgeGroupNode, + RelationshipNode, } from '../node'; import { layoutGraph } from './layout_graph'; import { DefaultEdge } from '../edge'; import { Minimap } from '../minimap/minimap'; import type { EdgeViewModel, NodeViewModel } from '../types'; +import { isConnectorShape } from '../utils'; import { ONLY_RENDER_VISIBLE_ELEMENTS, GRID_SIZE } from '../constants'; import '@xyflow/react/dist/style.css'; @@ -91,6 +93,7 @@ const nodeTypes = { diamond: DiamondNode, label: LabelNode, group: EdgeGroupNode, + relationship: RelationshipNode, }; const edgeTypes = { @@ -132,11 +135,34 @@ export const Graph = memo( const currNodesRef = useRef([]); const currEdgesRef = useRef([]); const isInitialRenderRef = useRef(true); - const [isGraphInteractive, _setIsGraphInteractive] = useState(interactive); + const [isGraphInteractive, setIsGraphInteractive] = useState(interactive); const [nodesState, setNodes, onNodesChange] = useNodesState>([]); const [edgesState, setEdges, onEdgesChange] = useEdgesState>([]); const [reactFlowKey, setReactFlowKey] = useState(0); + // Sync isGraphInteractive with interactive prop and re-process nodes when it changes + useEffect(() => { + setIsGraphInteractive(interactive); + + // Re-process graph with new interactive state if nodes exist + if (currNodesRef.current.length > 0) { + const { initialNodes, initialEdges } = processGraph( + currNodesRef.current, + currEdgesRef.current, + interactive + ); + const { nodes: layoutedNodes } = layoutGraph(initialNodes, initialEdges); + + // Force ReactFlow to remount to apply new className + setReactFlowKey((prev) => prev + 1); + + setTimeout(() => { + setNodes(layoutedNodes); + setEdges(initialEdges); + }, 0); + } + }, [interactive, setNodes, setEdges]); + // Filter the ids of those nodes that are origin events const originNodeIds = useMemo( () => nodes.filter((node) => node.isOrigin || node.isOriginAlert).map((node) => node.id), @@ -144,7 +170,7 @@ export const Graph = memo( ); useEffect(() => { - // On nodes or edges changes reset the graph and re-layout + // On nodes or edges changes, or interactive state changes, reset the graph and re-layout if ( !isArrayOfObjectsEqual(nodes, currNodesRef.current) || !isArrayOfObjectsEqual(edges, currEdgesRef.current) @@ -157,7 +183,6 @@ export const Graph = memo( const { initialNodes, initialEdges } = processGraph(nodes, edges, isGraphInteractive); const { nodes: layoutedNodes } = layoutGraph(initialNodes, initialEdges); - // Force ReactFlow to remount by changing the key first setReactFlowKey((prev) => prev + 1); @@ -322,6 +347,7 @@ const processGraph = ( type: nodeData.shape, data: { ...nodeData, interactive }, position: { x: 0, y: 0 }, // Default position, should be updated later + className: interactive ? undefined : 'non-interactive', }; if (node.type === 'group' && nodeData.shape === 'group') { @@ -329,7 +355,10 @@ const processGraph = ( node.targetPosition = Position.Left; node.resizing = false; node.focusable = false; - } else if (nodeData.shape === 'label' && nodeData.parentId) { + } else if ( + (nodeData.shape === 'label' || nodeData.shape === 'relationship') && + nodeData.parentId + ) { node.parentId = nodeData.parentId; node.extent = 'parent'; node.expandParent = false; @@ -342,18 +371,13 @@ const processGraph = ( const initialEdges: Array> = edgesModel .filter((edgeData) => nodesById[edgeData.source] && nodesById[edgeData.target]) .map((edgeData) => { - const isIn = - nodesById[edgeData.source].shape !== 'label' && - nodesById[edgeData.target].shape === 'group'; - const isInside = - nodesById[edgeData.source].shape === 'group' && - nodesById[edgeData.target].shape === 'label'; - const isOut = - nodesById[edgeData.source].shape === 'label' && - nodesById[edgeData.target].shape === 'group'; - const isOutside = - nodesById[edgeData.source].shape === 'group' && - nodesById[edgeData.target].shape !== 'label'; + const sourceShape = nodesById[edgeData.source].shape; + const targetShape = nodesById[edgeData.target].shape; + + const isIn = !isConnectorShape(sourceShape) && targetShape === 'group'; + const isInside = sourceShape === 'group' && isConnectorShape(targetShape); + const isOut = isConnectorShape(sourceShape) && targetShape === 'group'; + const isOutside = sourceShape === 'group' && !isConnectorShape(targetShape); return { id: edgeData.id, diff --git a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph/layout_graph.ts b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph/layout_graph.ts index 89a05b746737d..1bd877fed6c8d 100644 --- a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph/layout_graph.ts +++ b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph/layout_graph.ts @@ -9,7 +9,7 @@ import Dagre from '@dagrejs/dagre'; import type { Node, Edge } from '@xyflow/react'; import type { EdgeViewModel, NodeViewModel, Size } from '../types'; import { getStackNodeStyle } from '../node/styles'; -import { isEntityNode, isLabelNode, isStackNode, isStackedLabel } from '../utils'; +import { isEntityNode, isConnectorNode, isStackNode, isStackedLabel } from '../utils'; import { GRID_SIZE, STACK_NODE_VERTICAL_PADDING, @@ -55,7 +55,7 @@ export const layoutGraph = ( nodes.forEach((node) => { let size = { width: NODE_WIDTH, height: node.measured?.height ?? NODE_HEIGHT }; - if (isLabelNode(node.data)) { + if (isConnectorNode(node.data)) { size = { height: NODE_LABEL_TOTAL_HEIGHT, width: NODE_LABEL_WIDTH, @@ -100,7 +100,7 @@ export const layoutGraph = ( const layoutedNodes = nodes.map((node) => { // For stacked nodes, we want to keep the original position relative to the parent - if (isLabelNode(node.data) && node.data.parentId) { + if (isConnectorNode(node.data) && node.data.parentId) { return { ...node, position: nodesById[node.data.id].position, @@ -114,7 +114,7 @@ export const layoutGraph = ( const dagreNode = g.node(node.data.id); - if (isLabelNode(node.data)) { + if (isConnectorNode(node.data)) { const x = snapped(Math.round(dagreNode.x - (dagreNode.width ?? 0) / 2)); const y = Math.round(dagreNode.y - NODE_LABEL_HEIGHT / 2); @@ -166,7 +166,7 @@ const layoutStackedLabels = ( nodes: Array> ): { size: Size; children: Array> } => { const children = nodes.filter( - (child) => isLabelNode(child.data) && child.parentId === groupNode.id + (child) => isConnectorNode(child.data) && child.parentId === groupNode.id ); const stackSize = children.length; const stackWidth = NODE_LABEL_WIDTH + STACK_NODE_HORIZONTAL_PADDING * 2; diff --git a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph_layout.stories.test.tsx b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph_layout.stories.test.tsx index 729f9867e1221..8da472fb78baa 100644 --- a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph_layout.stories.test.tsx +++ b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph_layout.stories.test.tsx @@ -10,7 +10,8 @@ import { composeStories } from '@storybook/react'; import { render, waitFor } from '@testing-library/react'; import * as stories from './graph_layout.stories'; -const { GraphLargeStackedEdgeCases } = composeStories(stories); +const { GraphLargeStackedEdgeCases, EventsAndEntityRelationships, EventsAndRelationshipsStacked } = + composeStories(stories); const TRANSLATE_XY_REGEX = /translate\(\s*([+-]?\d+(\.\d+)?)(px|%)?\s*,\s*([+-]?\d+(\.\d+)?)(px|%)?\s*\)/; @@ -128,3 +129,268 @@ describe('GraphLargeStackedEdgeCases story', () => { } }); }); + +describe('EventsAndEntityRelationships story', () => { + it('should render all entity nodes correctly', async () => { + const { container, getByText } = render(); + + // Wait for nodes to be rendered + await waitFor(() => { + const nodeElements = container.querySelectorAll('.react-flow__node'); + expect(nodeElements.length).toBeGreaterThan(0); + }); + + // Verify entity nodes are rendered with correct labels + expect(getByText('john.doe@company.com')).toBeInTheDocument(); + expect(getByText('prod-ec2-instance-01')).toBeInTheDocument(); + expect(getByText('prod-ec2-instance-02')).toBeInTheDocument(); + expect(getByText('AdminRole')).toBeInTheDocument(); + }); + + it('should render event labels (shape: label) correctly', async () => { + const { container, getByText } = render(); + + // Wait for nodes to be rendered + await waitFor(() => { + const nodeElements = container.querySelectorAll('.react-flow__node'); + expect(nodeElements.length).toBeGreaterThan(0); + }); + + // Verify event labels are rendered + expect(getByText('ConsoleLogin')).toBeInTheDocument(); + expect(getByText('AssumeRole')).toBeInTheDocument(); + }); + + it('should render relationship labels (shape: relationship) correctly', async () => { + const { container, getAllByText } = render(); + + // Wait for nodes to be rendered + await waitFor(() => { + const nodeElements = container.querySelectorAll('.react-flow__node'); + expect(nodeElements.length).toBeGreaterThan(0); + }); + + // Verify relationship labels are rendered + // "Owns" appears twice (user owns 2 hosts) + const ownsLabels = getAllByText('Owns'); + expect(ownsLabels.length).toBe(2); + + // "Has Access" appears once + expect(getAllByText('Has Access').length).toBe(1); + }); + + it('should render both label and relationship nodes without overlap', async () => { + const { container, getAllByText } = render(); + + // Wait for nodes to be rendered + await waitFor(() => { + const nodeElements = container.querySelectorAll('.react-flow__node'); + expect(nodeElements.length).toBeGreaterThan(0); + }); + + // Get all connector nodes (label and relationship nodes) + const connectorLabels = ['ConsoleLogin', 'AssumeRole', 'Owns', 'Has Access']; + const connectorBoundingRects: Rect[] = []; + + for (const label of connectorLabels) { + const elements = getAllByText(label); + + for (const element of elements) { + // Find the parent node element + const nodeElement = element.closest('.react-flow__node'); + if (nodeElement) { + const rect = getLabelRect(nodeElement as HTMLElement); + if (rect) { + // Check that this node doesn't overlap with previously checked nodes + for (const prevRect of connectorBoundingRects) { + expect(rectIntersect(prevRect, rect)).toBeFalsy(); + } + connectorBoundingRects.push(rect); + } + } + } + } + + // Ensure we found some connector nodes + expect(connectorBoundingRects.length).toBeGreaterThan(0); + }); + + it('should render correct icons for entity nodes', async () => { + const { container } = render(); + + // Wait for nodes to be rendered + await waitFor(() => { + const nodeElements = container.querySelectorAll('.react-flow__node'); + expect(nodeElements.length).toBeGreaterThan(0); + }); + + // Get all nodes in the rendered component + const nodeElements = container.querySelectorAll('.react-flow__node'); + + // Verify entity nodes have correct icons + const expectedNodes = [ + { labelContains: 'john.doe', expectedIcon: 'user' }, + { labelContains: 'prod-ec2-instance-01', expectedIcon: 'compute' }, + { labelContains: 'prod-ec2-instance-02', expectedIcon: 'compute' }, + { labelContains: 'AdminRole', expectedIcon: 'key' }, + ]; + + for (const { labelContains, expectedIcon } of expectedNodes) { + const nodeElement = Array.from(nodeElements).find((el) => + el.textContent?.includes(labelContains) + ); + + expect(nodeElement).not.toBeNull(); + + if (nodeElement) { + const iconElement = nodeElement.querySelector(`[data-euiicon-type="${expectedIcon}"]`); + expect(iconElement).not.toBeNull(); + } + } + }); +}); + +describe('EventsAndRelationshipsStacked story', () => { + it('should render all entity nodes correctly', async () => { + const { container, getByText } = render(); + + // Wait for nodes to be rendered + await waitFor(() => { + const nodeElements = container.querySelectorAll('.react-flow__node'); + expect(nodeElements.length).toBeGreaterThan(0); + }); + + // Verify entity nodes are rendered with correct labels + expect(getByText('john.doe@company.com')).toBeInTheDocument(); + expect(getByText('prod-ec2-instance-01')).toBeInTheDocument(); + expect(getByText('user-1@company.com')).toBeInTheDocument(); + expect(getByText('user-2@company.com')).toBeInTheDocument(); + expect(getByText('hosts')).toBeInTheDocument(); + }); + + it('should render stacked label nodes (shape: label) correctly', async () => { + const { container, getByText } = render(); + + // Wait for nodes to be rendered + await waitFor(() => { + const nodeElements = container.querySelectorAll('.react-flow__node'); + expect(nodeElements.length).toBeGreaterThan(0); + }); + + // Verify stacked event labels are rendered + expect(getByText('ConsoleLogin')).toBeInTheDocument(); + expect(getByText('DescribeInstance')).toBeInTheDocument(); + }); + + it('should render stacked relationship nodes (shape: relationship) correctly', async () => { + const { container, getAllByText } = render(); + + // Wait for nodes to be rendered + await waitFor(() => { + const nodeElements = container.querySelectorAll('.react-flow__node'); + expect(nodeElements.length).toBeGreaterThan(0); + }); + + // Verify relationship labels are rendered + // "Owns" appears multiple times (user-john, user-1, user-2 all have Owns relationships) + const ownsLabels = getAllByText('Owns'); + expect(ownsLabels.length).toBeGreaterThanOrEqual(3); + + // "Has Access" appears multiple times (user-john, user-1, user-2) + const hasAccessLabels = getAllByText('Has Access'); + expect(hasAccessLabels.length).toBeGreaterThanOrEqual(3); + + // "Supervises" appears twice (user-john supervises user-1 and user-2) + const supervisesLabels = getAllByText('Supervises'); + expect(supervisesLabels.length).toBe(2); + }); + + it('should render stacked connectors without overlap', async () => { + const { container, getAllByText } = render(); + + // Wait for nodes to be rendered + await waitFor(() => { + const nodeElements = container.querySelectorAll('.react-flow__node'); + expect(nodeElements.length).toBeGreaterThan(0); + }); + + // Get stacked connector labels from the john.doe -> host-prod-1 connection + const stackedLabels = ['ConsoleLogin', 'DescribeInstance', 'Owns', 'Has Access']; + const connectorBoundingRects: Rect[] = []; + + for (const label of stackedLabels) { + const elements = getAllByText(label); + + for (const element of elements) { + // Find the parent node element + const nodeElement = element.closest('.react-flow__node'); + if (nodeElement) { + const rect = getLabelRect(nodeElement as HTMLElement); + if (rect) { + // Check that this node doesn't overlap with previously checked nodes + for (const prevRect of connectorBoundingRects) { + expect(rectIntersect(prevRect, rect)).toBeFalsy(); + } + connectorBoundingRects.push(rect); + } + } + } + } + + // Ensure we found some connector nodes + expect(connectorBoundingRects.length).toBeGreaterThan(0); + }); + + it('should render grouped hosts node with count indicator', async () => { + const { container, getByText } = render(); + + // Wait for nodes to be rendered + await waitFor(() => { + const nodeElements = container.querySelectorAll('.react-flow__node'); + expect(nodeElements.length).toBeGreaterThan(0); + }); + + // Verify the grouped hosts node is rendered + const hostsLabel = getByText('hosts'); + expect(hostsLabel).toBeInTheDocument(); + + // Find the parent node element and verify it has a count indicator + const nodeElement = hostsLabel.closest('.react-flow__node'); + expect(nodeElement).not.toBeNull(); + }); + + it('should render correct icons for entity nodes', async () => { + const { container } = render(); + + // Wait for nodes to be rendered + await waitFor(() => { + const nodeElements = container.querySelectorAll('.react-flow__node'); + expect(nodeElements.length).toBeGreaterThan(0); + }); + + // Get all nodes in the rendered component + const nodeElements = container.querySelectorAll('.react-flow__node'); + + // Verify entity nodes have correct icons + const expectedNodes = [ + { labelContains: 'john.doe', expectedIcon: 'user' }, + { labelContains: 'prod-ec2-instance-01', expectedIcon: 'compute' }, + { labelContains: 'user-1@company.com', expectedIcon: 'user' }, + { labelContains: 'user-2@company.com', expectedIcon: 'user' }, + { labelContains: 'hosts', expectedIcon: 'compute' }, + ]; + + for (const { labelContains, expectedIcon } of expectedNodes) { + const nodeElement = Array.from(nodeElements).find((el) => + el.textContent?.includes(labelContains) + ); + + expect(nodeElement).not.toBeNull(); + + if (nodeElement) { + const iconElement = nodeElement.querySelector(`[data-euiicon-type="${expectedIcon}"]`); + expect(iconElement).not.toBeNull(); + } + } + }); +}); diff --git a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph_layout.stories.tsx b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph_layout.stories.tsx index 6eac359c67ff1..b7eab1b173c28 100644 --- a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph_layout.stories.tsx +++ b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph_layout.stories.tsx @@ -9,6 +9,7 @@ import React from 'react'; import { ThemeProvider, css } from '@emotion/react'; import type { StoryObj, Meta } from '@storybook/react'; import type { Writable } from '@kbn/utility-types'; +import type { EdgeColor } from '@kbn/cloud-security-posture-common/types/graph/latest'; import { GlobalStylesStorybookDecorator } from '../../.storybook/decorators'; import type { EdgeViewModel, @@ -16,6 +17,7 @@ import type { NodeViewModel, EntityNodeViewModel, GroupNodeViewModel, + RelationshipNodeViewModel, } from '.'; import { Graph } from '.'; @@ -53,36 +55,47 @@ type Story = StoryObj; type EnhancedNodeViewModel = | EntityNodeViewModel | GroupNodeViewModel - | (LabelNodeViewModel & { source: string; target: string }); + | (LabelNodeViewModel & { source: string; target: string }) + | (RelationshipNodeViewModel & { source: string; target: string }); + +// Helper to get edge color for connector nodes (label or relationship) +const getConnectorEdgeColor = (node: LabelNodeViewModel | RelationshipNodeViewModel): EdgeColor => { + // Relationship nodes use 'subdued' color, label nodes use their color property + return node.shape === 'relationship' ? 'subdued' : node.color; +}; const extractEdges = ( graphData: EnhancedNodeViewModel[] ): { nodes: NodeViewModel[]; edges: EdgeViewModel[] } => { - // Process nodes, transform nodes of id in the format of a(source)-b(target) to edges from a to label and from label to b - // If there are multiple edges from a to b, create a parent node and group the labels under it. The parent node will be a group node. - // Connect from a to the group node and from the group node to all the labels. and from the labels to the group again and from the group to b. + // Process nodes, transform connector nodes (label/relationship) to edges + // If there are multiple connectors from a to b, create a parent node and group them under it. + // The parent node will be a group node. const nodesMetadata: { [key: string]: { edgesIn: number; edgesOut: number } } = {}; const edgesMetadata: { [key: string]: { source: string; target: string; edgesStacked: number; edges: string[] }; } = {}; - const labelsMetadata: { - [key: string]: { source: string; target: string; labelsNodes: LabelNodeViewModel[] }; + const connectorsMetadata: { + [key: string]: { + source: string; + target: string; + connectorNodes: Array; + }; } = {}; const nodes: { [key: string]: NodeViewModel } = {}; const edges: EdgeViewModel[] = []; graphData.forEach((node) => { - if (node.shape === 'label') { - const labelNode: LabelNodeViewModel = { ...node, id: `${node.id}label(${node.label})` }; + if (node.shape === 'label' || node.shape === 'relationship') { + const connectorNode = { ...node, id: `${node.id}connector(${node.label})` }; const { source, target } = node; - if (labelsMetadata[node.id]) { - labelsMetadata[node.id].labelsNodes.push(labelNode); + if (connectorsMetadata[node.id]) { + connectorsMetadata[node.id].connectorNodes.push(connectorNode); } else { - labelsMetadata[node.id] = { source, target, labelsNodes: [labelNode] }; + connectorsMetadata[node.id] = { source, target, connectorNodes: [connectorNode] }; } - nodes[labelNode.id] = labelNode; + nodes[connectorNode.id] = connectorNode; // Set metadata const edgeId = node.id; @@ -97,7 +110,7 @@ const extractEdges = ( source, target, edgesStacked: 1, - edges: [labelNode.id], + edges: [connectorNode.id], }; } } else { @@ -106,70 +119,78 @@ const extractEdges = ( } }); - Object.values(labelsMetadata).forEach((edge) => { - if (edge.labelsNodes.length > 1) { + Object.values(connectorsMetadata).forEach((connector) => { + if (connector.connectorNodes.length > 1) { const groupNode: NodeViewModel = { - id: `grp(a(${edge.source})-b(${edge.target}))`, + id: `grp(a(${connector.source})-b(${connector.target}))`, shape: 'group', }; + const firstConnectorColor = getConnectorEdgeColor(connector.connectorNodes[0]); + nodes[groupNode.id] = groupNode; edges.push({ - id: `a(${edge.source})-b(${groupNode.id})`, - source: edge.source, - sourceShape: nodes[edge.source].shape, + id: `a(${connector.source})-b(${groupNode.id})`, + source: connector.source, + sourceShape: nodes[connector.source].shape, target: groupNode.id, targetShape: groupNode.shape, - color: edge.labelsNodes[0].color, + color: firstConnectorColor, }); edges.push({ - id: `a(${groupNode.id})-b(${edge.target})`, + id: `a(${groupNode.id})-b(${connector.target})`, source: groupNode.id, sourceShape: groupNode.shape, - target: edge.target, - targetShape: nodes[edge.target].shape, - color: edge.labelsNodes[0].color, + target: connector.target, + targetShape: nodes[connector.target].shape, + color: firstConnectorColor, }); - edge.labelsNodes.forEach((labelNode: Writable) => { - labelNode.parentId = groupNode.id; + connector.connectorNodes.forEach( + (connectorNode: Writable) => { + connectorNode.parentId = groupNode.id; + const edgeColor = getConnectorEdgeColor(connectorNode); - edges.push({ - id: `a(${groupNode.id})-b(${labelNode.id})`, - source: groupNode.id, - sourceShape: groupNode.shape, - target: labelNode.id, - targetShape: labelNode.shape, - color: labelNode.color, - }); + edges.push({ + id: `a(${groupNode.id})-b(${connectorNode.id})`, + source: groupNode.id, + sourceShape: groupNode.shape, + target: connectorNode.id, + targetShape: connectorNode.shape, + color: edgeColor, + }); - edges.push({ - id: `a(${labelNode.id})-b(${groupNode.id})`, - source: labelNode.id, - sourceShape: labelNode.shape, - target: groupNode.id, - targetShape: groupNode.shape, - color: labelNode.color, - }); - }); + edges.push({ + id: `a(${connectorNode.id})-b(${groupNode.id})`, + source: connectorNode.id, + sourceShape: connectorNode.shape, + target: groupNode.id, + targetShape: groupNode.shape, + color: edgeColor, + }); + } + ); } else { + const connectorNode = connector.connectorNodes[0]; + const edgeColor = getConnectorEdgeColor(connectorNode); + edges.push({ - id: `a(${edge.source})-b(${edge.labelsNodes[0].id})`, - source: edge.source, - sourceShape: nodes[edge.source].shape, - target: edge.labelsNodes[0].id, - targetShape: edge.labelsNodes[0].shape, - color: edge.labelsNodes[0].color, + id: `a(${connector.source})-b(${connectorNode.id})`, + source: connector.source, + sourceShape: nodes[connector.source].shape, + target: connectorNode.id, + targetShape: connectorNode.shape, + color: edgeColor, }); edges.push({ - id: `a(${edge.labelsNodes[0].id})-b(${edge.target})`, - source: edge.labelsNodes[0].id, - sourceShape: edge.labelsNodes[0].shape, - target: edge.target, - targetShape: nodes[edge.target].shape, - color: edge.labelsNodes[0].color, + id: `a(${connectorNode.id})-b(${connector.target})`, + source: connectorNode.id, + sourceShape: connectorNode.shape, + target: connector.target, + targetShape: nodes[connector.target].shape, + color: edgeColor, }); } }); @@ -1071,3 +1092,216 @@ export const SingleAndGroupNodes: Story = { ]), }, }; + +/** + * Story: Events and Relationships Combined + * Demonstrates a realistic AWS/cloud scenario with both: + * - Event connections (label nodes): Actions performed by a user (ConsoleLogin, AssumeRole) + * - Relationship connections (relationship nodes): Static ownership/access relationships + * + * Scenario: A user logs into a host, assumes a role, and has ownership/access relationships to multiple hosts + */ +export const EventsAndEntityRelationships: Story = { + args: { + ...extractEdges([ + // Entity nodes + { + id: 'user-john', + label: 'john.doe@company.com', + color: 'primary', + shape: 'ellipse', + icon: 'user', + }, + { + id: 'host-prod-1', + label: 'prod-ec2-instance-01', + color: 'primary', + shape: 'pentagon', + icon: 'compute', + }, + { + id: 'host-prod-2', + label: 'prod-ec2-instance-02', + color: 'primary', + shape: 'pentagon', + icon: 'compute', + }, + { + id: 'iam-role', + label: 'AdminRole', + color: 'primary', + shape: 'hexagon', + icon: 'key', + }, + + // Event edges (activity-based) - Actions performed by the user + { + id: 'evt-console-login', + source: 'user-john', + target: 'host-prod-1', + label: 'ConsoleLogin', + color: 'primary', + shape: 'label', + uniqueEventsCount: 3, + }, + { + id: 'evt-assume-role', + source: 'user-john', + target: 'iam-role', + label: 'AssumeRole', + color: 'primary', + shape: 'label', + uniqueEventsCount: 1, + }, + + // Relationship edges (static/configuration-based) - Ownership and access permissions + { + id: 'rel-user-owns-host1', + source: 'user-john', + target: 'host-prod-1', + label: 'Owns', + shape: 'relationship', + }, + { + id: 'rel-user-owns-host2', + source: 'user-john', + target: 'host-prod-2', + label: 'Owns', + shape: 'relationship', + }, + { + id: 'rel-user-access-host1', + source: 'user-john', + target: 'host-prod-1', + label: 'Has Access', + shape: 'relationship', + }, + ]), + }, +}; + +export const EventsAndRelationshipsStacked: Story = { + args: { + ...extractEdges([ + // Entity nodes + { + id: 'user-john', + label: 'john.doe@company.com', + color: 'primary', + shape: 'ellipse', + icon: 'user', + }, + { + id: 'host-prod-1', + label: 'prod-ec2-instance-01', + color: 'primary', + shape: 'pentagon', + icon: 'compute', + }, + // Relationship edges (static/configuration-based) - Ownership and access permissions + // Nodes with the same id + same source/target will be stacked together + { + id: 'a(user-john)-b(host-prod-1)', + source: 'user-john', + target: 'host-prod-1', + label: 'Owns', + shape: 'relationship', + }, + { + id: 'a(user-john)-b(host-prod-1)', + source: 'user-john', + target: 'host-prod-1', + label: 'Has Access', + shape: 'relationship', + }, + { + id: 'a(user-john)-b(host-prod-1)', + source: 'user-john', + target: 'host-prod-1', + label: 'ConsoleLogin', + shape: 'label', + color: 'primary', + }, + { + id: 'a(user-john)-b(host-prod-1)', + source: 'user-john', + target: 'host-prod-1', + label: 'DescribeInstance', + shape: 'label', + color: 'primary', + }, + + // Additional entity nodes for multi-user ownership scenario + { + id: 'user-1', + label: 'user-1@company.com', + color: 'primary', + shape: 'ellipse', + icon: 'user', + }, + { + id: 'user-2', + label: 'user-2@company.com', + color: 'primary', + shape: 'ellipse', + icon: 'user', + }, + { + id: 'hosts-group', + label: 'hosts', + color: 'primary', + shape: 'pentagon', + icon: 'compute', + count: 3, + }, + + // Connect subgraphs: user-john supervises user-1 and user-2 + { + id: 'a(user-john)-b(user-1)', + source: 'user-john', + target: 'user-1', + label: 'Supervises', + shape: 'relationship', + }, + { + id: 'a(user-john)-b(user-2)', + source: 'user-john', + target: 'user-2', + label: 'Supervises', + shape: 'relationship', + }, + + // user-1 owns and has access to hosts (stacked) + { + id: 'a(user-1)-b(hosts-group)', + source: 'user-1', + target: 'hosts-group', + label: 'Owns', + shape: 'relationship', + }, + { + id: 'a(user-1)-b(hosts-group)', + source: 'user-1', + target: 'hosts-group', + label: 'Has Access', + shape: 'relationship', + }, + + // user-2 owns and has access to the same hosts (stacked) + { + id: 'a(user-2)-b(hosts-group)', + source: 'user-2', + target: 'hosts-group', + label: 'Owns', + shape: 'relationship', + }, + { + id: 'a(user-2)-b(hosts-group)', + source: 'user-2', + target: 'hosts-group', + label: 'Has Access', + shape: 'relationship', + }, + ]), + }, +}; diff --git a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/index.ts b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/index.ts index 83c8b188e2a6a..203d4d9b3901a 100644 --- a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/index.ts +++ b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/index.ts @@ -31,6 +31,7 @@ export type { GroupNodeViewModel, LabelNodeViewModel, EntityNodeViewModel, + RelationshipNodeViewModel, NodeProps, } from './types'; export { diff --git a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/minimap/minimap.test.tsx b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/minimap/minimap.test.tsx index 99d8d6e6ba26a..2cde600afcb31 100644 --- a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/minimap/minimap.test.tsx +++ b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/minimap/minimap.test.tsx @@ -14,11 +14,13 @@ import { GRAPH_ID, GRAPH_ENTITY_NODE_ID, GRAPH_LABEL_NODE_ID, + GRAPH_RELATIONSHIP_NODE_ID, GRAPH_STACK_NODE_ID, GRAPH_EDGE_ID, GRAPH_MINIMAP_ID, GRAPH_MINIMAP_ENTITY_NODE_ID, GRAPH_MINIMAP_LABEL_NODE_ID, + GRAPH_MINIMAP_RELATIONSHIP_NODE_ID, GRAPH_MINIMAP_UNKNOWN_NODE_ID, } from '../test_ids'; import { NODE_HEIGHT, NODE_WIDTH, NODE_LABEL_HEIGHT, NODE_LABEL_WIDTH } from '../node/styles'; @@ -205,40 +207,50 @@ describe('Minimap integrated with Graph', () => { await waitFor(() => { // Check Graph has expected nodes - // 3 entity nodes: A, B, C + // 5 entity nodes: A, B, C, D, E // 3 label nodes: IndividualLabel, StackedLabel1, StackedLabel2 + // 2 relationship nodes: Owns, Communicates_with // 1 stack node: Stack(StackedLabel1, StackedLabel2) - // 8 edges: + // 12 edges: // A->IndividualLabel, IndividualLabel->B // B->Stack // Stack->StackedLabel1, StackedLabel1->Stack // Stack->StackedLabel2, StackedLabel2->Stack // Stack->C + // A->Owns, Owns->D + // A->Communicates_with, Communicates_with->E const graphEntityNodes = screen.getAllByTestId(GRAPH_ENTITY_NODE_ID); - expect(graphEntityNodes).toHaveLength(3); + expect(graphEntityNodes).toHaveLength(5); const graphLabelNodes = screen.getAllByTestId(GRAPH_LABEL_NODE_ID); expect(graphLabelNodes).toHaveLength(3); + const graphRelationshipNodes = screen.getAllByTestId(GRAPH_RELATIONSHIP_NODE_ID); + expect(graphRelationshipNodes).toHaveLength(2); const graphStackNodes = screen.getAllByTestId(GRAPH_STACK_NODE_ID); expect(graphStackNodes).toHaveLength(1); const graphEdgeNodes = screen.getAllByTestId(GRAPH_EDGE_ID); - expect(graphEdgeNodes).toHaveLength(8); + expect(graphEdgeNodes).toHaveLength(12); - // Check Minimap contains the same number of entity and label nodes as Graph + // Check Minimap contains the same number of entity, label, and relationship nodes as Graph // Check it does not render stack nodes or edges - // Total DOM elements: 3 entity nodes + 3 label nodes + 1 + 1 <path> const minimapEntityNodes = screen.getAllByTestId(GRAPH_MINIMAP_ENTITY_NODE_ID); - expect(minimapEntityNodes).toHaveLength(graphEntityNodes.length); // 3 + expect(minimapEntityNodes).toHaveLength(graphEntityNodes.length); // 5 const minimapLabelNodes = screen.getAllByTestId(GRAPH_MINIMAP_LABEL_NODE_ID); expect(minimapLabelNodes).toHaveLength(graphLabelNodes.length); // 3 + const minimapRelationshipNodes = screen.getAllByTestId(GRAPH_MINIMAP_RELATIONSHIP_NODE_ID); + expect(minimapRelationshipNodes).toHaveLength(graphRelationshipNodes.length); // 2 + const minimap = screen.getByTestId(GRAPH_MINIMAP_ID); - expect(minimapEntityNodes.length + minimapLabelNodes.length).toBe(6); + const totalMinimapNodes = + minimapEntityNodes.length + minimapLabelNodes.length + minimapRelationshipNodes.length; + expect(totalMinimapNodes).toBe(10); // 5 entity + 3 label + 2 relationship expect(minimap?.querySelector('svg')?.querySelectorAll('title')).toHaveLength(1); expect(minimap?.querySelector('svg')?.querySelectorAll('path')).toHaveLength(1); const minimapChildren = minimap.querySelector('svg')?.children!; - expect(minimapChildren).toHaveLength(graphEntityNodes.length + graphLabelNodes.length + 2); + // Total children: entity + label + relationship nodes + 1 <title> + 1 <path> + expect(minimapChildren).toHaveLength(totalMinimapNodes + 2); }); }); @@ -272,6 +284,7 @@ describe('Minimap integrated with Graph', () => { await waitFor(() => { const minimapEntityNodes = screen.getAllByTestId(GRAPH_MINIMAP_ENTITY_NODE_ID); const minimapLabelNodes = screen.getAllByTestId(GRAPH_MINIMAP_LABEL_NODE_ID); + const minimapRelationshipNodes = screen.getAllByTestId(GRAPH_MINIMAP_RELATIONSHIP_NODE_ID); // Verify Minimap entity nodes have the correct dimensions (but scaled down) expect( @@ -290,6 +303,35 @@ describe('Minimap integrated with Graph', () => { node.getAttribute('height') === NODE_LABEL_HEIGHT.toString() ) ).toBe(true); + + // Verify Minimap relationship nodes have the same dimensions as label nodes + expect( + minimapRelationshipNodes.every( + (node) => + node.getAttribute('width') === NODE_LABEL_WIDTH.toString() && + node.getAttribute('height') === NODE_LABEL_HEIGHT.toString() + ) + ).toBe(true); + }); + }); + + it('should render relationship nodes with correct color in minimap', async () => { + renderGraph({ + ...graphSample, + interactive: true, + showMinimap: true, + }); + + await waitFor(() => { + const minimapRelationshipNodes = screen.getAllByTestId(GRAPH_MINIMAP_RELATIONSHIP_NODE_ID); + expect(minimapRelationshipNodes).toHaveLength(2); + + // Verify relationship nodes have the dark background color (backgroundFilledText) + minimapRelationshipNodes.forEach((node) => { + expect(node).toHaveAttribute('fill'); + const fillColor = node.getAttribute('fill'); + expect(fillColor).toBe('#5A6D8C'); + }); }); }); }); diff --git a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/minimap/minimap.tsx b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/minimap/minimap.tsx index 01208e0662a7f..5bff8445369af 100644 --- a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/minimap/minimap.tsx +++ b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/minimap/minimap.tsx @@ -12,10 +12,11 @@ import { GRAPH_MINIMAP_ID, GRAPH_MINIMAP_ENTITY_NODE_ID, GRAPH_MINIMAP_LABEL_NODE_ID, + GRAPH_MINIMAP_RELATIONSHIP_NODE_ID, GRAPH_MINIMAP_UNKNOWN_NODE_ID, } from '../test_ids'; import type { NodeViewModel } from '../types'; -import { isEntityNode, isLabelNode, isStackNode } from '../utils'; +import { isEntityNode, isLabelNode, isRelationshipNode, isStackNode } from '../utils'; import { NODE_HEIGHT, NODE_WIDTH, NODE_LABEL_HEIGHT, NODE_LABEL_WIDTH } from '../node/styles'; interface MiniMapNodeRenderedProps extends MiniMapNodeProps { @@ -78,6 +79,21 @@ const MiniMapNode = ({ ); } + // For relationship nodes, render with the same dark background color as in the graph + if (isRelationshipNode(data)) { + return ( + <rect + data-id={data.id} + data-test-subj={GRAPH_MINIMAP_RELATIONSHIP_NODE_ID} + x={x} + y={y} + height={NODE_LABEL_HEIGHT} + width={NODE_LABEL_WIDTH} + fill={euiTheme.colors.backgroundFilledText} + /> + ); + } + // Fallback for unknown types return ( <rect diff --git a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/mock/graph_sample.ts b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/mock/graph_sample.ts index bc2ee92a8c9fd..1b2f69ce60c3f 100644 --- a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/mock/graph_sample.ts +++ b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/mock/graph_sample.ts @@ -75,6 +75,20 @@ export const graphSample = { shape: 'hexagon', icon: 'user', }, + { + id: 'D', + label: 'D', + color: 'primary', + shape: 'rectangle', + icon: 'storage', + }, + { + id: 'E', + label: 'E', + color: 'primary', + shape: 'rectangle', + icon: 'storage', + }, { id: 'a(A)-b(B)label(IndividualLabel)', label: 'IndividualLabel', @@ -105,6 +119,16 @@ export const graphSample = { shape: 'label', parentId: 'grp(a(B)-b(C))', }, + { + id: 'a(A)-b(D)rel(Owns)', + label: 'Owns', + shape: 'relationship', + }, + { + id: 'a(A)-b(E)rel(Communicates_with)', + label: 'Communicates with', + shape: 'relationship', + }, ] as NodeViewModel[], edges: [ { @@ -171,5 +195,37 @@ export const graphSample = { targetShape: 'group', color: 'primary', }, + { + id: 'a(A)-b(a(A)-b(D)rel(Owns))', + source: 'A', + sourceShape: 'hexagon', + target: 'a(A)-b(D)rel(Owns)', + targetShape: 'relationship', + color: 'subdued', + }, + { + id: 'a(a(A)-b(D)rel(Owns))-b(D)', + source: 'a(A)-b(D)rel(Owns)', + sourceShape: 'relationship', + target: 'D', + targetShape: 'rectangle', + color: 'subdued', + }, + { + id: 'a(A)-b(a(A)-b(E)rel(Communicates_with))', + source: 'A', + sourceShape: 'hexagon', + target: 'a(A)-b(E)rel(Communicates_with)', + targetShape: 'relationship', + color: 'subdued', + }, + { + id: 'a(a(A)-b(E)rel(Communicates_with))-b(E)', + source: 'a(A)-b(E)rel(Communicates_with)', + sourceShape: 'relationship', + target: 'E', + targetShape: 'rectangle', + color: 'subdued', + }, ] as EdgeViewModel[], }; diff --git a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/node/index.ts b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/node/index.ts index aa9d57d061edf..48ef73f617797 100644 --- a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/node/index.ts +++ b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/node/index.ts @@ -12,3 +12,4 @@ export { PentagonNode } from './pentagon_node'; export { RectangleNode } from './rectangle_node'; export { LabelNode } from './label_node/label_node'; export { EdgeGroupNode } from './edge_group_node'; +export { RelationshipNode } from './relationship_node/relationship_node'; diff --git a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/node/relationship_node/relationship_node.stories.tsx b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/node/relationship_node/relationship_node.stories.tsx new file mode 100644 index 0000000000000..47f0937139948 --- /dev/null +++ b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/node/relationship_node/relationship_node.stories.tsx @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { ThemeProvider } from '@emotion/react'; +import { ReactFlow, Background } from '@xyflow/react'; +import type { Meta, StoryFn, StoryObj } from '@storybook/react'; +import { RelationshipNode as RelationshipNodeComponent } from './relationship_node'; +import type { RelationshipNodeViewModel } from '../../types'; +import { GlobalStylesStorybookDecorator } from '../../../../.storybook/decorators'; +import { GlobalGraphStyles } from '../../graph/styles'; + +import '@xyflow/react/dist/style.css'; + +const meta: Meta<RelationshipNodeViewModel> = { + title: 'Components/Graph Components/Relationship Node', + args: { + id: 'relationship-1', + label: 'Owns', + shape: 'relationship', + interactive: false, + }, + argTypes: { + label: { + control: { type: 'text' }, + }, + interactive: { + control: { + type: 'boolean', + }, + }, + }, + decorators: [GlobalStylesStorybookDecorator], +}; + +export default meta; + +const nodeTypes = { + relationship: RelationshipNodeComponent, +}; + +const Template: StoryFn<RelationshipNodeViewModel> = (args: RelationshipNodeViewModel) => ( + <ThemeProvider theme={{ darkMode: false }}> + <ReactFlow + fitView + attributionPosition={undefined} + nodeTypes={nodeTypes} + nodes={[ + { + id: args.id, + type: args.shape, + data: args, + position: { x: 0, y: 0 }, + }, + ]} + > + <Background /> + </ReactFlow> + <GlobalGraphStyles /> + </ThemeProvider> +); + +export const RelationshipNode: StoryObj<RelationshipNodeViewModel> = { + render: Template, +}; diff --git a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/node/relationship_node/relationship_node.test.tsx b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/node/relationship_node/relationship_node.test.tsx new file mode 100644 index 0000000000000..2500923418272 --- /dev/null +++ b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/node/relationship_node/relationship_node.test.tsx @@ -0,0 +1,224 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ReactFlow, Position } from '@xyflow/react'; +import type { EuiThemeComputed } from '@elastic/eui'; +import type { NodeProps } from '../../types'; +import { getRelationshipColors, getLabelColors } from '../styles'; +import { + GRAPH_RELATIONSHIP_NODE_ID, + GRAPH_RELATIONSHIP_NODE_SHAPE_ID, + GRAPH_RELATIONSHIP_NODE_HANDLE_ID, + GRAPH_RELATIONSHIP_NODE_HOVER_OUTLINE_ID, + GRAPH_RELATIONSHIP_NODE_TOOLTIP_ID, + GRAPH_RELATIONSHIP_NODE_LABEL_TEXT_ID, +} from '../../test_ids'; +import { RelationshipNode } from './relationship_node'; + +describe('RelationshipNode', () => { + const baseProps: NodeProps = { + id: 'test-relationship-node', + data: { + id: 'test-relationship-node', + label: 'Owns', + shape: 'relationship', + interactive: true, + }, + type: 'relationship', + selected: false, + dragging: false, + dragHandle: '', + targetPosition: Position.Left, + sourcePosition: Position.Right, + positionAbsoluteX: 0, + positionAbsoluteY: 0, + width: 100, + height: 100, + zIndex: 1, + isConnectable: false, + selectable: true, + deletable: true, + draggable: true, + }; + + test('renders basic relationship node', () => { + render( + <ReactFlow> + <RelationshipNode {...baseProps} /> + </ReactFlow> + ); + + expect(screen.getByText('Owns')).toBeInTheDocument(); + expect(screen.getAllByTestId(GRAPH_RELATIONSHIP_NODE_HANDLE_ID)).toHaveLength(2); + expect(screen.getByTestId(GRAPH_RELATIONSHIP_NODE_SHAPE_ID)).toBeInTheDocument(); + }); + + test('renders with node id when label is not provided', () => { + const props = { + ...baseProps, + data: { + ...baseProps.data, + label: undefined, + }, + }; + + render( + <ReactFlow> + <RelationshipNode {...props} /> + </ReactFlow> + ); + + expect(screen.getByText('test-relationship-node')).toBeInTheDocument(); + }); + + test('renders hover outline if interactive', async () => { + render( + <ReactFlow> + <RelationshipNode {...baseProps} /> + </ReactFlow> + ); + + await userEvent.hover(screen.getByTestId(GRAPH_RELATIONSHIP_NODE_ID)); + + await waitFor(() => { + expect(screen.queryByTestId(GRAPH_RELATIONSHIP_NODE_HOVER_OUTLINE_ID)).toBeInTheDocument(); + }); + }); + + test('does not render hover outline if not interactive', async () => { + const props = { + ...baseProps, + data: { + ...baseProps.data, + interactive: false, + }, + }; + + render( + <ReactFlow> + <RelationshipNode {...props} /> + </ReactFlow> + ); + + await userEvent.hover(screen.getByTestId(GRAPH_RELATIONSHIP_NODE_ID)); + + await waitFor(() => { + expect( + screen.queryByTestId(GRAPH_RELATIONSHIP_NODE_HOVER_OUTLINE_ID) + ).not.toBeInTheDocument(); + }); + }); + + describe('Tooltip', () => { + test('shows tooltip when text is truncated', async () => { + const props = { + ...baseProps, + data: { + ...baseProps.data, + label: 'This relationship label is too long so it will be truncated for sure', + }, + }; + + render( + <ReactFlow> + <RelationshipNode {...props} /> + </ReactFlow> + ); + + await userEvent.hover(screen.getByTestId(GRAPH_RELATIONSHIP_NODE_LABEL_TEXT_ID)); + + await waitFor(() => { + expect(screen.queryByTestId(GRAPH_RELATIONSHIP_NODE_TOOLTIP_ID)).toBeInTheDocument(); + }); + }); + + test('tooltip shows full text content', async () => { + const longText = + 'This is a very long relationship label that exceeds twenty-seven characters'; + const props = { + ...baseProps, + data: { + ...baseProps.data, + label: longText, + }, + }; + + render( + <ReactFlow> + <RelationshipNode {...props} /> + </ReactFlow> + ); + + await userEvent.hover(screen.getByTestId(GRAPH_RELATIONSHIP_NODE_LABEL_TEXT_ID)); + + await waitFor(() => { + expect(screen.queryByTestId(GRAPH_RELATIONSHIP_NODE_TOOLTIP_ID)).toBeInTheDocument(); + expect(screen.queryByTestId(GRAPH_RELATIONSHIP_NODE_TOOLTIP_ID)).toHaveTextContent( + longText + ); + }); + }); + + test('does not show tooltip for short text', async () => { + render( + <ReactFlow> + <RelationshipNode {...baseProps} /> + </ReactFlow> + ); + + await userEvent.hover(screen.getByTestId(GRAPH_RELATIONSHIP_NODE_LABEL_TEXT_ID)); + + await waitFor(() => { + expect(screen.queryByTestId(GRAPH_RELATIONSHIP_NODE_TOOLTIP_ID)).not.toBeInTheDocument(); + }); + }); + }); + + describe('Shape colors', () => { + const mockEuiTheme = { + colors: { + danger: '#FF0000', + backgroundBasePrimary: '#0000FF', + borderStrongPrimary: '#0000DD', + textInverse: '#FFFFFF', + textPrimary: '#000000', + backgroundFilledText: '#333333', + borderBaseProminent: '#CCCCCC', + }, + }; + + it('should return relationship colors with dark background and light text', () => { + const colors = getRelationshipColors(mockEuiTheme as EuiThemeComputed); + expect(colors).toEqual({ + backgroundColor: mockEuiTheme.colors.backgroundFilledText, + borderColor: mockEuiTheme.colors.borderBaseProminent, + textColor: mockEuiTheme.colors.textInverse, + }); + }); + + it('should return label colors for primary color', () => { + const colors = getLabelColors('primary', mockEuiTheme as EuiThemeComputed); + expect(colors).toEqual({ + backgroundColor: mockEuiTheme.colors.backgroundBasePrimary, + borderColor: mockEuiTheme.colors.borderStrongPrimary, + textColor: mockEuiTheme.colors.textPrimary, + }); + }); + + it('should return danger colors for label nodes with danger color', () => { + const colors = getLabelColors('danger', mockEuiTheme as EuiThemeComputed); + expect(colors).toEqual({ + backgroundColor: mockEuiTheme.colors.danger, + borderColor: mockEuiTheme.colors.danger, + textColor: mockEuiTheme.colors.textInverse, + }); + }); + }); +}); diff --git a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/node/relationship_node/relationship_node.tsx b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/node/relationship_node/relationship_node.tsx new file mode 100644 index 0000000000000..62ee35941d237 --- /dev/null +++ b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/node/relationship_node/relationship_node.tsx @@ -0,0 +1,119 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, useMemo } from 'react'; +import { Handle, Position } from '@xyflow/react'; +import { css } from '@emotion/react'; +import { EuiText, EuiTextTruncate, EuiToolTip, useEuiShadow, useEuiTheme } from '@elastic/eui'; +import { + LabelNodeContainer, + LabelShape, + HandleStyleOverride, + LabelShapeOnHover, + getRelationshipColors, +} from '../styles'; +import type { RelationshipNodeViewModel, NodeProps } from '../../types'; +import { + GRAPH_RELATIONSHIP_NODE_ID, + GRAPH_RELATIONSHIP_NODE_SHAPE_ID, + GRAPH_RELATIONSHIP_NODE_HANDLE_ID, + GRAPH_RELATIONSHIP_NODE_HOVER_OUTLINE_ID, + GRAPH_RELATIONSHIP_NODE_TOOLTIP_ID, + GRAPH_RELATIONSHIP_NODE_LABEL_TEXT_ID, +} from '../../test_ids'; + +const MAX_LABEL_LENGTH = 27; + +export const RelationshipNode = memo<NodeProps>((props: NodeProps) => { + const { id, label, interactive } = props.data as RelationshipNodeViewModel; + + const { euiTheme } = useEuiTheme(); + const shadow = useEuiShadow('m', { property: 'filter' }); + + const text = label ? label : id; + + const { backgroundColor, borderColor, textColor } = useMemo( + () => getRelationshipColors(euiTheme), + [euiTheme] + ); + + return ( + <LabelNodeContainer data-test-subj={GRAPH_RELATIONSHIP_NODE_ID}> + {interactive && ( + <LabelShapeOnHover + data-test-subj={GRAPH_RELATIONSHIP_NODE_HOVER_OUTLINE_ID} + color="primary" + /> + )} + <LabelShape + data-test-subj={GRAPH_RELATIONSHIP_NODE_SHAPE_ID} + backgroundColor={backgroundColor} + borderColor={borderColor} + textAlign="center" + shadow={shadow} + > + <div + css={css` + display: flex; + align-items: center; + justify-content: center; + width: 100%; + `} + > + <EuiText + color={textColor} + css={css` + flex: 1; + min-width: 0; + text-overflow: ellipsis; + font-weight: ${euiTheme.font.weight.semiBold}; + font-size: ${euiTheme.font.scale.xs * 10.5}px; + `} + > + {text.length > MAX_LABEL_LENGTH ? ( + <EuiToolTip + content={text} + display="block" + data-test-subj={GRAPH_RELATIONSHIP_NODE_TOOLTIP_ID} + > + <EuiTextTruncate + data-test-subj={GRAPH_RELATIONSHIP_NODE_LABEL_TEXT_ID} + truncation="middle" + text={text} + /> + </EuiToolTip> + ) : ( + <EuiTextTruncate + data-test-subj={GRAPH_RELATIONSHIP_NODE_LABEL_TEXT_ID} + truncation="middle" + text={text} + /> + )} + </EuiText> + </div> + </LabelShape> + <Handle + data-test-subj={GRAPH_RELATIONSHIP_NODE_HANDLE_ID} + type="target" + isConnectable={false} + position={Position.Left} + id="in" + style={HandleStyleOverride} + /> + <Handle + data-test-subj={GRAPH_RELATIONSHIP_NODE_HANDLE_ID} + type="source" + isConnectable={false} + position={Position.Right} + id="out" + style={HandleStyleOverride} + /> + </LabelNodeContainer> + ); +}); + +RelationshipNode.displayName = 'RelationshipNode'; diff --git a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/node/styles.tsx b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/node/styles.tsx index a92caf4002695..eee3cdeff6d99 100644 --- a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/node/styles.tsx +++ b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/node/styles.tsx @@ -113,13 +113,13 @@ export const LabelShape = styled(EuiText, { min-width: 100%; ${({ shadow }) => ` - /* Apply shadow when node is selected */ - .react-flow__node.selected & { + /* Apply shadow when node is selected (only for interactive nodes) */ + .react-flow__node:not(.non-interactive).selected & { ${shadow}; } - /* Apply shadow when node is pressed but still not selected */ - .react-flow__node:active:not(.selected) & { + /* Apply shadow when node is pressed but still not selected (only for interactive nodes) */ + .react-flow__node:not(.non-interactive):active:not(.selected) & { ${shadow}; } @@ -157,12 +157,13 @@ export const LabelShapeOnHover = styled.div` width: calc(100% + 12px); height: calc(100% + 12px); - ${LabelNodeContainer}:hover & { + /* Only show hover effects for interactive nodes */ + .react-flow__node:not(.non-interactive) ${LabelNodeContainer}:hover & { opacity: 1; /* Show on hover */ } - .react-flow__node:focus:focus-visible & { - opacity: 1; /* Show on hover */ + .react-flow__node:not(.non-interactive):focus:focus-visible & { + opacity: 1; /* Show on focus */ } `; @@ -175,7 +176,7 @@ export const NodeContainer = styled.div` `; /** - * Gets the background, border and text colors for the label based on document analysis + * Gets the background, border and text colors for label nodes based on color prop */ export const getLabelColors = ( color: LabelNodeViewModel['color'], @@ -196,6 +197,20 @@ export const getLabelColors = ( }; }; +/** + * Gets the background, border and text colors for relationship nodes + * Relationship nodes have fixed colors (dark background with light text) + */ +export const getRelationshipColors = ( + euiTheme: EuiThemeComputed +): { backgroundColor: string; borderColor: string; textColor: string } => { + return { + backgroundColor: euiTheme.colors.backgroundFilledText, + borderColor: euiTheme.colors.borderBaseProminent, + textColor: euiTheme.colors.textInverse, + }; +}; + export const NodeShapeContainer = styled.div` position: relative; width: ${NODE_WIDTH}px; @@ -214,13 +229,13 @@ export const NodeShapeSvg = styled.svg<{ shadow?: string; yPosDelta?: number }>` }} ${({ shadow }) => ` - /* Apply shadow when node is selected */ - .react-flow__node.selected & { + /* Apply shadow when node is selected (only for interactive nodes) */ + .react-flow__node:not(.non-interactive).selected & { ${shadow}; } - /* Apply shadow when node is pressed but still not selected */ - .react-flow__node:active:not(.selected) & { + /* Apply shadow when node is pressed but still not selected (only for interactive nodes) */ + .react-flow__node:not(.non-interactive):active:not(.selected) & { ${shadow}; } @@ -279,7 +294,9 @@ export const NodeExpandButtonContainer = styled.div<NodeExpandButtonContainerPro opacity: 1; } - ${NodeShapeContainer}:hover &, ${LabelNodeContainer}:hover & { + /* Only show hover effects for interactive nodes */ + .react-flow__node:not(.non-interactive) ${NodeShapeContainer}:hover &, + .react-flow__node:not(.non-interactive) ${LabelNodeContainer}:hover & { opacity: 1; /* Show on hover */ } @@ -287,7 +304,7 @@ export const NodeExpandButtonContainer = styled.div<NodeExpandButtonContainerPro opacity: 1; /* Show when button is active */ } - .react-flow__node:focus:focus-visible & { + .react-flow__node:not(.non-interactive):focus:focus-visible & { opacity: 1; /* Show on node focus */ } `; @@ -296,17 +313,23 @@ export const NodeShapeOnHoverSvg = styled(NodeShapeSvg)` opacity: 0; /* Hidden by default */ transition: opacity 0.2s ease; /* Smooth transition */ - ${NodeShapeContainer}:hover &, ${LabelNodeContainer}:hover & { + /* Only show hover effects for interactive nodes */ + .react-flow__node:not(.non-interactive) ${NodeShapeContainer}:hover &, + .react-flow__node:not(.non-interactive) ${LabelNodeContainer}:hover & { opacity: 1; /* Show on hover */ } - ${NodeShapeContainer}:has(${NodeExpandButtonContainer}.toggled) &, - ${LabelNodeContainer}:has(${NodeExpandButtonContainer}.toggled) & { - opacity: 1; /* Show on hover */ + .react-flow__node:not(.non-interactive) + ${NodeShapeContainer}:has(${NodeExpandButtonContainer}.toggled) + &, + .react-flow__node:not(.non-interactive) + ${LabelNodeContainer}:has(${NodeExpandButtonContainer}.toggled) + & { + opacity: 1; /* Show when expand button is toggled */ } - .react-flow__node:focus:focus-visible & { - opacity: 1; /* Show on hover */ + .react-flow__node:not(.non-interactive):focus:focus-visible & { + opacity: 1; /* Show on focus */ } `; diff --git a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/popovers/primitives/graph_popover.stories.tsx b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/popovers/primitives/graph_popover.stories.tsx index 7c4951efaf7cb..42ef7e791a250 100644 --- a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/popovers/primitives/graph_popover.stories.tsx +++ b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/popovers/primitives/graph_popover.stories.tsx @@ -20,6 +20,7 @@ import { GlobalStylesStorybookDecorator } from '../../../../.storybook/decorator import { GraphPopover } from './graph_popover'; import { useGraphPopoverState } from './use_graph_popover_state'; import { PopoverListItem } from './popover_list_item'; +import { isLabelNode } from '../../utils'; export default { title: 'Components/Graph Components/Graph Popovers', @@ -76,6 +77,8 @@ const useExpandButtonPopover = () => { closePopover(); }, [closePopover]); + const isLabel = selectedNode.current?.data && isLabelNode(selectedNode.current.data); + // eslint-disable-next-line react/display-name const PopoverComponent = memo(() => ( <GraphPopover @@ -86,19 +89,42 @@ const useExpandButtonPopover = () => { closePopover={closePopoverHandler} > <EuiListGroup color="primary" gutterSize="none" bordered={false} flush={true}> - <PopoverListItem - iconType="visTagCloud" - label="Explore related entities" - onClick={() => {}} - /> - <PopoverListItem iconType="users" label="Show actions by this entity" onClick={() => {}} /> - <PopoverListItem - iconType="storage" - label="Show actions on this entity" - onClick={() => {}} - /> - <EuiHorizontalRule margin="xs" /> - <PopoverListItem iconType="expand" label="View entity details" onClick={() => {}} /> + {isLabel ? ( + <> + <PopoverListItem + iconType="visTagCloud" + label="Show related entities" + onClick={() => {}} + /> + <EuiHorizontalRule margin="xs" /> + <PopoverListItem iconType="expand" label="Show entity details" onClick={() => {}} /> + </> + ) : ( + <> + <PopoverListItem + iconType="graphApp" + label="Show entity relationships" + onClick={() => {}} + /> + <PopoverListItem + iconType="indexRuntime" + label="Show this entity's actions" + onClick={() => {}} + /> + <PopoverListItem + iconType="indexFlush" + label="Show actions done to this entity" + onClick={() => {}} + /> + <PopoverListItem + iconType="logstashQueue" + label="Show related events" + onClick={() => {}} + /> + <EuiHorizontalRule margin="xs" /> + <PopoverListItem iconType="expand" label="Show entity details" onClick={() => {}} /> + </> + )} </EuiListGroup> </GraphPopover> )); diff --git a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/test_ids.ts b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/test_ids.ts index 62c1ab3e3ebda..cac20fab8fc01 100644 --- a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/test_ids.ts +++ b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/test_ids.ts @@ -41,6 +41,7 @@ export const GRAPH_CONTROLS_FIT_VIEW_ID = `${GRAPH_INVESTIGATION_TEST_ID}FitView export const GRAPH_ID = `${GRAPH_INVESTIGATION_TEST_ID}Graph` as const; export const GRAPH_ENTITY_NODE_ID = `${GRAPH_INVESTIGATION_TEST_ID}EntityNode` as const; export const GRAPH_LABEL_NODE_ID = `${GRAPH_INVESTIGATION_TEST_ID}LabelNode` as const; +export const GRAPH_RELATIONSHIP_NODE_ID = `${GRAPH_INVESTIGATION_TEST_ID}RelationshipNode` as const; export const GRAPH_STACK_NODE_ID = `${GRAPH_INVESTIGATION_TEST_ID}StackNode` as const; export const GRAPH_EDGE_ID = `${GRAPH_INVESTIGATION_TEST_ID}Edge` as const; @@ -51,6 +52,8 @@ export const GRAPH_MINIMAP_ENTITY_NODE_ID = `${GRAPH_INVESTIGATION_TEST_ID}MinimapEntityNode` as const; export const GRAPH_MINIMAP_LABEL_NODE_ID = `${GRAPH_INVESTIGATION_TEST_ID}MinimapLabelNode` as const; +export const GRAPH_MINIMAP_RELATIONSHIP_NODE_ID = + `${GRAPH_INVESTIGATION_TEST_ID}MinimapRelationshipNode` as const; export const GRAPH_MINIMAP_UNKNOWN_NODE_ID = `${GRAPH_INVESTIGATION_TEST_ID}MinimapUnknownNode` as const; @@ -104,3 +107,15 @@ export const GRAPH_POPOVER_PREVIEW_PANEL = export const GRAPH_CALLOUT_TEST_ID = `${GRAPH_INVESTIGATION_TEST_ID}Callout` as const; export const GRAPH_CALLOUT_LINK_TEST_ID = `${GRAPH_INVESTIGATION_TEST_ID}CalloutLink` as const; + +// Relationship node test IDs +export const GRAPH_RELATIONSHIP_NODE_SHAPE_ID = + `${GRAPH_INVESTIGATION_TEST_ID}RelationshipNodeShape` as const; +export const GRAPH_RELATIONSHIP_NODE_HANDLE_ID = + `${GRAPH_INVESTIGATION_TEST_ID}RelationshipNodeHandle` as const; +export const GRAPH_RELATIONSHIP_NODE_HOVER_OUTLINE_ID = + `${GRAPH_INVESTIGATION_TEST_ID}RelationshipNodeHoverOutline` as const; +export const GRAPH_RELATIONSHIP_NODE_TOOLTIP_ID = + `${GRAPH_INVESTIGATION_TEST_ID}RelationshipNodeTooltip` as const; +export const GRAPH_RELATIONSHIP_NODE_LABEL_TEXT_ID = + `${GRAPH_INVESTIGATION_TEST_ID}RelationshipNodeLabelText` as const; diff --git a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/types.ts b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/types.ts index c73453d6b7b64..270c7cbbbe083 100644 --- a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/types.ts +++ b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/types.ts @@ -10,6 +10,7 @@ import type { EntityNodeDataModel, GroupNodeDataModel, LabelNodeDataModel, + RelationshipNodeDataModel, EdgeDataModel, NodeShape, NodeColor, @@ -68,7 +69,16 @@ export interface LabelNodeViewModel eventClickHandler?: EventClickCallback; } -export type NodeViewModel = EntityNodeViewModel | GroupNodeViewModel | LabelNodeViewModel; +export interface RelationshipNodeViewModel + extends Record<string, unknown>, + RelationshipNodeDataModel, + BaseNodeDataViewModel {} + +export type NodeViewModel = + | EntityNodeViewModel + | GroupNodeViewModel + | LabelNodeViewModel + | RelationshipNodeViewModel; export type NodeProps = xyNodeProps<Node<NodeViewModel>>; diff --git a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/utils.ts b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/utils.ts index d03ee7d1461f4..96dfaed96c27d 100644 --- a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/utils.ts +++ b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/utils.ts @@ -21,6 +21,7 @@ import type { EntityNodeViewModel, LabelNodeViewModel, GroupNodeViewModel, + RelationshipNodeViewModel, EdgeViewModel, } from './types'; @@ -34,11 +35,29 @@ export const isEntityNode = (node: NodeViewModel): node is EntityNodeViewModel = export const isLabelNode = (node: NodeViewModel): node is LabelNodeViewModel => node.shape === 'label'; +export const isRelationshipNode = (node: NodeViewModel): node is RelationshipNodeViewModel => + node.shape === 'relationship'; + +/** + * Returns true if the shape is a connector shape (label or relationship). + * Connector shapes act as connectors between entity nodes. + */ +export const isConnectorShape = (shape?: string): boolean => + shape === 'label' || shape === 'relationship'; + +/** + * Returns true for nodes that act as connectors between entity nodes (label or relationship nodes). + * These nodes share similar layout and sizing behavior. + */ +export const isConnectorNode = ( + node: NodeViewModel +): node is LabelNodeViewModel | RelationshipNodeViewModel => isConnectorShape(node.shape); + export const isStackNode = (node: NodeViewModel): node is GroupNodeViewModel => node.shape === 'group'; export const isStackedLabel = (node: NodeViewModel): boolean => - !(node.shape === 'label' && Boolean(node.parentId)); + !((node.shape === 'label' || node.shape === 'relationship') && Boolean(node.parentId)); /** * Type guard: Returns true if node.documentsData is a non-empty array. @@ -200,7 +219,10 @@ export const buildGraphFromViewModels = ( node.targetPosition = Position.Left; node.resizing = false; node.focusable = false; - } else if (nodeData.shape === 'label' && nodeData.parentId) { + } else if ( + (nodeData.shape === 'label' || nodeData.shape === 'relationship') && + nodeData.parentId + ) { node.parentId = nodeData.parentId; node.extent = 'parent'; node.expandParent = false; @@ -213,18 +235,13 @@ export const buildGraphFromViewModels = ( const edges: Array<Edge<EdgeViewModel>> = edgesModel .filter((edgeData) => nodesById[edgeData.source] && nodesById[edgeData.target]) .map((edgeData) => { - const isIn = - nodesById[edgeData.source].shape !== 'label' && - nodesById[edgeData.target].shape === 'group'; - const isInside = - nodesById[edgeData.source].shape === 'group' && - nodesById[edgeData.target].shape === 'label'; - const isOut = - nodesById[edgeData.source].shape === 'label' && - nodesById[edgeData.target].shape === 'group'; - const isOutside = - nodesById[edgeData.source].shape === 'group' && - nodesById[edgeData.target].shape !== 'label'; + const sourceShape = nodesById[edgeData.source].shape; + const targetShape = nodesById[edgeData.target].shape; + + const isIn = !isConnectorShape(sourceShape) && targetShape === 'group'; + const isInside = sourceShape === 'group' && isConnectorShape(targetShape); + const isOut = isConnectorShape(sourceShape) && targetShape === 'group'; + const isOutside = sourceShape === 'group' && !isConnectorShape(targetShape); return { id: edgeData.id, diff --git a/x-pack/solutions/security/test/cloud_security_posture_api/routes/graph.ts b/x-pack/solutions/security/test/cloud_security_posture_api/routes/graph.ts index 51a1e2090d90e..de02e72303534 100644 --- a/x-pack/solutions/security/test/cloud_security_posture_api/routes/graph.ts +++ b/x-pack/solutions/security/test/cloud_security_posture_api/routes/graph.ts @@ -538,7 +538,7 @@ export default function (providerContext: FtrProviderContext) { expect(response.body).not.to.have.property('messages'); response.body.nodes.forEach((node: NodeDataModel) => { - if (node.shape !== 'group') { + if (node.shape !== 'group' && node.shape !== 'relationship') { expect(node).to.have.property('color'); expect(node.color).equal( 'primary',