diff --git a/x-pack/solutions/security/plugins/security_solution/public/explore/components/authentication/authentications_host_table.tsx b/x-pack/solutions/security/plugins/security_solution/public/explore/components/authentication/authentications_host_table.tsx index 4f8aa2f96e87b..2493c9f00b5bc 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/explore/components/authentication/authentications_host_table.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/explore/components/authentication/authentications_host_table.tsx @@ -9,6 +9,8 @@ import React, { useCallback, useEffect, useState } from 'react'; import { getOr } from 'lodash/fp'; import { useDispatch } from 'react-redux'; +import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; +import { UserPanelKey, HostPanelKey } from '../../../flyout/entity_details/shared/constants'; import type { SiemTables } from '../paginated_table'; import { PaginatedTable } from '../paginated_table'; @@ -41,6 +43,37 @@ const AuthenticationsHostTableComponent: React.FC = ( deleteQuery, }) => { const dispatch = useDispatch(); + const { openRightPanel } = useExpandableFlyoutApi(); + + const openUserFlyout = useCallback( + (userName: string) => { + openRightPanel({ + id: UserPanelKey, + params: { + userName, + contextID: 'authentications', + scopeId: 'authentications', + isPreviewMode: false, + }, + }); + }, + [openRightPanel] + ); + + const openHostFlyout = useCallback( + (hostName: string) => { + openRightPanel({ + id: HostPanelKey, + params: { + hostName, + contextID: 'authentications', + scopeId: 'authentications', + isPreviewMode: false, + }, + }); + }, + [openRightPanel] + ); const { toggleStatus } = useQueryToggle(TABLE_QUERY_ID); const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); useEffect(() => { @@ -68,8 +101,8 @@ const AuthenticationsHostTableComponent: React.FC = ( const columns = type === hostsModel.HostsType.details - ? getHostDetailsAuthenticationColumns() - : getHostsPageAuthenticationColumns(); + ? getHostDetailsAuthenticationColumns(openUserFlyout) + : getHostsPageAuthenticationColumns(openUserFlyout, openHostFlyout); const updateLimitPagination = useCallback( (newLimit) => diff --git a/x-pack/solutions/security/plugins/security_solution/public/explore/components/authentication/authentications_user_table.tsx b/x-pack/solutions/security/plugins/security_solution/public/explore/components/authentication/authentications_user_table.tsx index 28855562cf101..6e4051d11f326 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/explore/components/authentication/authentications_user_table.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/explore/components/authentication/authentications_user_table.tsx @@ -9,6 +9,8 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { getOr } from 'lodash/fp'; import { useDispatch } from 'react-redux'; +import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; +import { UserPanelKey, HostPanelKey } from '../../../flyout/entity_details/shared/constants'; import { AuthStackByField } from '../../../../common/search_strategy/security_solution/users/authentications'; import type { SiemTables } from '../paginated_table'; import { PaginatedTable } from '../paginated_table'; @@ -40,6 +42,37 @@ const AuthenticationsUserTableComponent: React.FC userName, }) => { const dispatch = useDispatch(); + const { openRightPanel } = useExpandableFlyoutApi(); + + const openUserFlyout = useCallback( + (name: string) => { + openRightPanel({ + id: UserPanelKey, + params: { + userName: name, + contextID: 'authentications', + scopeId: 'authentications', + isPreviewMode: false, + }, + }); + }, + [openRightPanel] + ); + + const openHostFlyout = useCallback( + (hostName: string) => { + openRightPanel({ + id: HostPanelKey, + params: { + hostName, + contextID: 'authentications', + scopeId: 'authentications', + isPreviewMode: false, + }, + }); + }, + [openRightPanel] + ); const { toggleStatus } = useQueryToggle(TABLE_QUERY_ID); const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); useEffect(() => { @@ -64,8 +97,11 @@ const AuthenticationsUserTableComponent: React.FC }); const columns = useMemo( - () => (userName ? getUserDetailsAuthenticationColumns() : getUsersPageAuthenticationColumns()), - [userName] + () => + userName + ? getUserDetailsAuthenticationColumns(openHostFlyout) + : getUsersPageAuthenticationColumns(openUserFlyout, openHostFlyout), + [userName, openUserFlyout, openHostFlyout] ); const updateLimitPagination = useCallback( diff --git a/x-pack/solutions/security/plugins/security_solution/public/explore/components/authentication/helpers.tsx b/x-pack/solutions/security/plugins/security_solution/public/explore/components/authentication/helpers.tsx index f9dcc97a2976a..35bf989070282 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/explore/components/authentication/helpers.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/explore/components/authentication/helpers.tsx @@ -26,8 +26,10 @@ import type { } from '../../../common/components/matrix_histogram/types'; import { getAuthenticationLensAttributes } from '../../../common/components/visualization_actions/lens_attributes/common/authentication'; -export const getHostDetailsAuthenticationColumns = (): AuthTableColumns => [ - USER_COLUMN, +export const getHostDetailsAuthenticationColumns = ( + openUserFlyout: (userName: string) => void +): AuthTableColumns => [ + getUserColumn(openUserFlyout), SUCCESS_COLUMN, FAILURES_COLUMN, LAST_SUCCESSFUL_TIME_COLUMN, @@ -36,23 +38,30 @@ export const getHostDetailsAuthenticationColumns = (): AuthTableColumns => [ LAST_FAILED_SOURCE_COLUMN, ]; -export const getHostsPageAuthenticationColumns = (): AuthTableColumns => [ - USER_COLUMN, +export const getHostsPageAuthenticationColumns = ( + openUserFlyout: (userName: string) => void, + openHostFlyout: (hostName: string) => void +): AuthTableColumns => [ + getUserColumn(openUserFlyout), SUCCESS_COLUMN, FAILURES_COLUMN, LAST_SUCCESSFUL_TIME_COLUMN, LAST_SUCCESSFUL_SOURCE_COLUMN, - LAST_SUCCESSFUL_DESTINATION_COLUMN, + getLastSuccessfulDestinationColumn(openHostFlyout), LAST_FAILED_TIME_COLUMN, LAST_FAILED_SOURCE_COLUMN, - LAST_FAILED_DESTINATION_COLUMN, + getLastFailedDestinationColumn(openHostFlyout), ]; -export const getUsersPageAuthenticationColumns = (): AuthTableColumns => - getHostsPageAuthenticationColumns(); +export const getUsersPageAuthenticationColumns = ( + openUserFlyout: (userName: string) => void, + openHostFlyout: (hostName: string) => void +): AuthTableColumns => getHostsPageAuthenticationColumns(openUserFlyout, openHostFlyout); -export const getUserDetailsAuthenticationColumns = (): AuthTableColumns => [ - HOST_COLUMN, +export const getUserDetailsAuthenticationColumns = ( + openHostFlyout: (hostName: string) => void +): AuthTableColumns => [ + getHostColumn(openHostFlyout), SUCCESS_COLUMN, FAILURES_COLUMN, LAST_SUCCESSFUL_TIME_COLUMN, @@ -102,7 +111,9 @@ const LAST_SUCCESSFUL_SOURCE_COLUMN: Columns , }), }; -const LAST_SUCCESSFUL_DESTINATION_COLUMN: Columns = { +const getLastSuccessfulDestinationColumn = ( + openHostFlyout: (hostName: string) => void +): Columns => ({ name: i18n.LAST_SUCCESSFUL_DESTINATION, truncateText: false, mobileOptions: { show: true }, @@ -111,9 +122,17 @@ const LAST_SUCCESSFUL_DESTINATION_COLUMN: Columns , + render: (item) => ( + { + e.preventDefault(); + openHostFlyout(item); + }} + /> + ), }), -}; +}); const LAST_FAILED_TIME_COLUMN: Columns = { name: i18n.LAST_FAILED_TIME, truncateText: false, @@ -137,7 +156,9 @@ const LAST_FAILED_SOURCE_COLUMN: Columns , }), }; -const LAST_FAILED_DESTINATION_COLUMN: Columns = { +const getLastFailedDestinationColumn = ( + openHostFlyout: (hostName: string) => void +): Columns => ({ name: i18n.LAST_FAILED_DESTINATION, truncateText: false, mobileOptions: { show: true }, @@ -146,11 +167,21 @@ const LAST_FAILED_DESTINATION_COLUMN: Columns , + render: (item) => ( + { + e.preventDefault(); + openHostFlyout(item); + }} + /> + ), }), -}; +}); -const USER_COLUMN: Columns = { +const getUserColumn = ( + openUserFlyout: (userName: string) => void +): Columns => ({ name: i18n.USER, truncateText: false, mobileOptions: { show: true }, @@ -159,11 +190,21 @@ const USER_COLUMN: Columns = { values: node.stackedValue, fieldName: 'user.name', idPrefix: `authentications-table-${node._id}-userName`, - render: (item) => , + render: (item) => ( + { + e.preventDefault(); + openUserFlyout(item); + }} + /> + ), }), -}; +}); -const HOST_COLUMN: Columns = { +const getHostColumn = ( + openHostFlyout: (hostName: string) => void +): Columns => ({ name: i18n.HOST, truncateText: false, mobileOptions: { show: true }, @@ -172,9 +213,17 @@ const HOST_COLUMN: Columns = { values: node.stackedValue, fieldName: 'host.name', idPrefix: `authentications-table-${node._id}-hostName`, - render: (item) => , + render: (item) => ( + { + e.preventDefault(); + openHostFlyout(item); + }} + /> + ), }), -}; +}); const SUCCESS_COLUMN: Columns = { name: i18n.SUCCESSES, diff --git a/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/components/hosts_table/columns.tsx b/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/components/hosts_table/columns.tsx index 1356ba793419d..9f47345809dff 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/components/hosts_table/columns.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/components/hosts_table/columns.tsx @@ -7,6 +7,7 @@ import { EuiIcon, EuiLink, EuiText, EuiToolTip } from '@elastic/eui'; import React from 'react'; +import type { SyntheticEvent } from 'react'; import { SECURITY_CELL_ACTIONS_DEFAULT } from '@kbn/ui-actions-plugin/common/trigger_ids'; import type { CriticalityLevelWithUnassigned } from '../../../../../common/entity_analytics/asset_criticality/types'; import { AssetCriticalityBadge } from '../../../../entity_analytics/components/asset_criticality'; @@ -24,7 +25,8 @@ import { ENTITY_RISK_LEVEL } from '../../../../entity_analytics/components/risk_ export const getHostsColumns = ( showRiskColumn: boolean, - dispatchSeverityUpdate: (s: RiskSeverity) => void + dispatchSeverityUpdate: (s: RiskSeverity) => void, + openHostFlyout: (hostName: string, entityId: string) => void ): HostsTableColumns => { const columns: HostsTableColumns = [ { @@ -34,24 +36,29 @@ export const getHostsColumns = ( mobileOptions: { show: true }, sortable: true, render: (hostName, hostEdge) => { - if (hostName != null && hostName.length > 0) { - const name = hostName[0]; - return ( - - - - ); - } - return getEmptyTagValue(); + if (hostName == null || hostName.length === 0) return getEmptyTagValue(); + const name = hostName[0]; + const entityId = hostEdge.node.entityId ?? undefined; + const onClick = entityId + ? (e: SyntheticEvent) => { + e.preventDefault(); + openHostFlyout(name, entityId); + } + : undefined; + return ( + + + + ); }, width: '35%', }, diff --git a/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/components/hosts_table/index.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/components/hosts_table/index.test.tsx index 4f3f43529cdb2..57e5a11312a27 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/components/hosts_table/index.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/components/hosts_table/index.test.tsx @@ -13,6 +13,7 @@ import { hostsModel } from '../../store'; import { HostsTableType } from '../../store/model'; import { HostsTable } from '.'; import { mockData } from './mock'; +import { HostPanelKey } from '../../../../flyout/entity_details/shared/constants'; jest.mock('../../../../common/lib/kibana'); @@ -44,6 +45,11 @@ jest.mock('../../../../helper_hooks', () => ({ useHasSecurityCapability: () => mockUseHasSecurityCapability(), })); +const mockOpenRightPanel = jest.fn(); +jest.mock('@kbn/expandable-flyout', () => ({ + useExpandableFlyoutApi: jest.fn(() => ({ openRightPanel: mockOpenRightPanel })), +})); + const mockUseUiSetting = jest.fn().mockReturnValue([false]); jest.mock('@kbn/kibana-react-plugin/public', () => { @@ -58,6 +64,10 @@ describe('Hosts Table', () => { const loadPage = jest.fn(); const store = createMockStore(); + beforeEach(() => { + mockOpenRightPanel.mockClear(); + }); + describe('rendering', () => { test('it renders the default Hosts table', () => { render( @@ -177,6 +187,74 @@ describe('Hosts Table', () => { expect(screen.queryByTestId('tableHeaderCell_node.criticality_5')).toBeInTheDocument(); }); + test('opens the host flyout when clicking a host name that has an entityId', () => { + const hostName = 'test-host'; + const entityId = 'test-entity-id'; + + render( + + + + ); + + fireEvent.click(screen.getByTestId('host-details-button')); + + expect(mockOpenRightPanel).toHaveBeenCalledWith({ + id: HostPanelKey, + params: { + hostName, + entityId, + contextID: 'allHosts', + scopeId: 'allHosts', + isPreviewMode: false, + }, + }); + }); + + test('does not open the flyout when clicking a host name without an entityId', () => { + render( + + + + ); + + fireEvent.click(screen.getByTestId('host-details-button')); + + expect(mockOpenRightPanel).not.toHaveBeenCalled(); + }); + describe('Sorting on Table', () => { test('Initial value of the store', async () => { const { container } = render( diff --git a/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/components/hosts_table/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/components/hosts_table/index.tsx index 4afcc319d3572..987684be7be07 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/components/hosts_table/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/components/hosts_table/index.tsx @@ -9,6 +9,7 @@ import React, { useMemo, useCallback } from 'react'; import { useDispatch } from 'react-redux'; import type { HostEcs, OsEcs } from '@kbn/securitysolution-ecs'; +import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; import type { CriticalityLevelWithUnassigned } from '../../../../../common/entity_analytics/asset_criticality/types'; import { HostsFields } from '../../../../../common/api/search_strategy/hosts/model/sort'; import type { @@ -34,6 +35,7 @@ import { HostsTableType } from '../../store/model'; import { useNavigateTo } from '../../../../common/lib/kibana/hooks'; import { useMlCapabilities } from '../../../../common/components/ml/hooks/use_ml_capabilities'; import { useHasSecurityCapability } from '../../../../helper_hooks'; +import { HostPanelKey } from '../../../../flyout/entity_details/shared/constants'; const tableType = hostsModel.HostsTableType.hosts; @@ -88,6 +90,7 @@ const HostsTableComponent: React.FC = ({ }) => { const dispatch = useDispatch(); const { navigateTo } = useNavigateTo(); + const { openRightPanel } = useExpandableFlyoutApi(); const getHostsSelector = useMemo(() => hostsSelectors.hostsSelector(), []); const { activePage, direction, limit, sortField } = useDeepEqualSelector((state) => getHostsSelector(state, type) @@ -140,6 +143,22 @@ const HostsTableComponent: React.FC = ({ const hasEntityAnalyticsCapability = useHasSecurityCapability('entity-analytics'); const isPlatinumOrTrialLicense = useMlCapabilities().isPlatinumOrTrialLicense; + const openHostFlyout = useCallback( + (hostName: string, entityId: string) => { + openRightPanel({ + id: HostPanelKey, + params: { + hostName, + entityId, + contextID: tableType, + scopeId: tableType, + isPreviewMode: false, + }, + }); + }, + [openRightPanel] + ); + const dispatchSeverityUpdate = useCallback( (s: RiskSeverity) => { dispatch( @@ -160,9 +179,10 @@ const HostsTableComponent: React.FC = ({ () => getHostsColumns( isPlatinumOrTrialLicense && hasEntityAnalyticsCapability, - dispatchSeverityUpdate + dispatchSeverityUpdate, + openHostFlyout ), - [dispatchSeverityUpdate, isPlatinumOrTrialLicense, hasEntityAnalyticsCapability] + [dispatchSeverityUpdate, isPlatinumOrTrialLicense, hasEntityAnalyticsCapability, openHostFlyout] ); const sorting = useMemo(() => getSorting(sortField, direction), [sortField, direction]); diff --git a/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/components/uncommon_process_table/columns.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/components/uncommon_process_table/columns.test.tsx index d7085b1dbc347..da038e6869de7 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/components/uncommon_process_table/columns.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/components/uncommon_process_table/columns.test.tsx @@ -63,32 +63,34 @@ describe('Uncommon Process Columns', () => { }); describe('#getUncommonColumnsCurated', () => { + const openHostFlyout = jest.fn(); + test('on hosts page, we expect to get all columns', () => { - expect(getUncommonColumnsCurated(HostsType.page).length).toEqual(6); + expect(getUncommonColumnsCurated(HostsType.page, openHostFlyout).length).toEqual(6); }); test('on host details page, we expect to remove two columns', () => { - const columns = getUncommonColumnsCurated(HostsType.details); + const columns = getUncommonColumnsCurated(HostsType.details, openHostFlyout); expect(columns.length).toEqual(4); }); test('on host page, we should have hosts', () => { - const columns = getUncommonColumnsCurated(HostsType.page); + const columns = getUncommonColumnsCurated(HostsType.page, openHostFlyout); expect(columns.some((col) => col.name === i18n.HOSTS)).toEqual(true); }); test('on host page, we should have number of hosts', () => { - const columns = getUncommonColumnsCurated(HostsType.page); + const columns = getUncommonColumnsCurated(HostsType.page, openHostFlyout); expect(columns.some((col) => col.name === i18n.NUMBER_OF_HOSTS)).toEqual(true); }); test('on host details page, we should not have hosts', () => { - const columns = getUncommonColumnsCurated(HostsType.details); + const columns = getUncommonColumnsCurated(HostsType.details, openHostFlyout); expect(columns.some((col) => col.name === i18n.HOSTS)).toEqual(false); }); test('on host details page, we should not have number of hosts', () => { - const columns = getUncommonColumnsCurated(HostsType.details); + const columns = getUncommonColumnsCurated(HostsType.details, openHostFlyout); expect(columns.some((col) => col.name === i18n.NUMBER_OF_HOSTS)).toEqual(false); }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/components/uncommon_process_table/columns.tsx b/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/components/uncommon_process_table/columns.tsx index 9ddb57f316922..3005ac39f1f21 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/components/uncommon_process_table/columns.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/components/uncommon_process_table/columns.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React from 'react'; +import React, { type SyntheticEvent } from 'react'; import type { HostEcs } from '@kbn/securitysolution-ecs'; import type { Columns } from '../../../components/paginated_table'; import { HostDetailsLink } from '../../../../common/components/links'; @@ -24,7 +24,9 @@ export const getHostNames = (hosts: HostEcs[]): string[] => { .map((host) => (host.name != null && host.name[0] != null ? host.name[0] : '')); }; -export const getUncommonColumns = (): UncommonProcessTableColumns => [ +export const getUncommonColumns = ( + openHostFlyout: (hostName: string) => void +): UncommonProcessTableColumns => [ { name: i18n.NAME, truncateText: false, @@ -63,7 +65,15 @@ export const getUncommonColumns = (): UncommonProcessTableColumns => [ values: getHostNames(node.hosts), fieldName: 'host.name', idPrefix: `uncommon-process-table-${node._id}-processHost`, - render: (item) => , + render: (item) => ( + { + e.preventDefault(); + openHostFlyout(item); + }} + /> + ), }), }, { @@ -92,8 +102,11 @@ export const getUncommonColumns = (): UncommonProcessTableColumns => [ }, ]; -export const getUncommonColumnsCurated = (pageType: HostsType): UncommonProcessTableColumns => { - const columns: UncommonProcessTableColumns = getUncommonColumns(); +export const getUncommonColumnsCurated = ( + pageType: HostsType, + openHostFlyout: (hostName: string) => void +): UncommonProcessTableColumns => { + const columns: UncommonProcessTableColumns = getUncommonColumns(openHostFlyout); if (pageType === HostsType.details) { const columnsToRemove = new Set([i18n.HOSTS, i18n.NUMBER_OF_HOSTS]); diff --git a/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/components/uncommon_process_table/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/components/uncommon_process_table/index.tsx index 25c10bd5c46fe..930255a213971 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/components/uncommon_process_table/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/components/uncommon_process_table/index.tsx @@ -7,6 +7,7 @@ import React, { useCallback, useMemo } from 'react'; import { useDispatch } from 'react-redux'; +import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; import type { HostsUncommonProcessesEdges } from '../../../../../common/search_strategy'; import { hostsActions, hostsModel, hostsSelectors } from '../../store'; @@ -15,6 +16,7 @@ import { PaginatedTable } from '../../../components/paginated_table'; import * as i18n from './translations'; import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; import { getUncommonColumnsCurated } from './columns'; +import { HostPanelKey } from '../../../../flyout/entity_details/shared/constants'; const tableType = hostsModel.HostsTableType.uncommonProcesses; interface UncommonProcessTableProps { @@ -63,6 +65,23 @@ const UncommonProcessTableComponent = React.memo( getUncommonProcessesSelector(state, type) ); + const { openRightPanel } = useExpandableFlyoutApi(); + + const openHostFlyout = useCallback( + (hostName: string) => { + openRightPanel({ + id: HostPanelKey, + params: { + hostName, + contextID: tableType, + scopeId: tableType, + isPreviewMode: false, + }, + }); + }, + [openRightPanel] + ); + const updateLimitPagination = useCallback( (newLimit: number) => dispatch( @@ -87,7 +106,10 @@ const UncommonProcessTableComponent = React.memo( [type, dispatch] ); - const columns = useMemo(() => getUncommonColumnsCurated(type), [type]); + const columns = useMemo( + () => getUncommonColumnsCurated(type, openHostFlyout), + [type, openHostFlyout] + ); return ( ({ useMlCapabilities: () => mockUseMlCapabilities(), })); +const mockOpenRightPanel = jest.fn(); + +jest.mock('@kbn/expandable-flyout', () => ({ + useExpandableFlyoutApi: jest.fn(() => ({ openRightPanel: mockOpenRightPanel })), +})); + describe('Users Table Component', () => { const loadPage = jest.fn(); + beforeEach(() => { + mockOpenRightPanel.mockClear(); + }); + describe('rendering', () => { test('it renders the users table', () => { const userName = 'testUser'; @@ -78,7 +89,7 @@ describe('Users Table Component', () => { expect(getByTestId('table-allUsers-loading-false')).toHaveTextContent('(Empty string)'); }); - test('it renders "Host Risk classfication" column when "isPlatinumOrTrialLicense" is truthy', () => { + test('it renders "Host Risk classification" column when "isPlatinumOrTrialLicense" is truthy', () => { mockUseMlCapabilities.mockReturnValue({ isPlatinumOrTrialLicense: true }); const { getAllByRole, getByText } = render( @@ -145,5 +156,82 @@ describe('Users Table Component', () => { expect(getAllByRole('columnheader').length).toBe(4); expect(queryByText('Critical')).not.toBeInTheDocument(); }); + + test('opens the user flyout when clicking a user name that has an entityId', () => { + const userName = 'testUser'; + const entityId = 'test-entity-id'; + + const { getByTestId } = render( + + {}} + /> + + ); + + fireEvent.click(getByTestId('users-link-anchor')); + + expect(mockOpenRightPanel).toHaveBeenCalledWith({ + id: UserPanelKey, + params: { + userName, + entityId, + contextID: 'allUsers', + scopeId: 'allUsers', + isPreviewMode: false, + }, + }); + }); + + test('does not open the flyout when clicking a user name without an entityId', () => { + const { getByTestId } = render( + + {}} + /> + + ); + + fireEvent.click(getByTestId('users-link-anchor')); + + expect(mockOpenRightPanel).not.toHaveBeenCalled(); + }); }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/explore/users/components/all_users/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/explore/users/components/all_users/index.tsx index ea2ddc5e55d2e..b23c9dd063b39 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/explore/users/components/all_users/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/explore/users/components/all_users/index.tsx @@ -6,9 +6,11 @@ */ import React, { useCallback, useMemo } from 'react'; +import type { SyntheticEvent } from 'react'; import { useDispatch } from 'react-redux'; import { EuiLink, EuiText } from '@elastic/eui'; +import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; import { SECURITY_CELL_ACTIONS_DEFAULT } from '@kbn/ui-actions-plugin/common/trigger_ids'; import { AssetCriticalityBadge } from '../../../../entity_analytics/components/asset_criticality'; import type { CriticalityLevelWithUnassigned } from '../../../../../common/entity_analytics/asset_criticality/types'; @@ -42,6 +44,7 @@ import { VIEW_USERS_BY_SEVERITY } from '../../../../entity_analytics/components/ import { SecurityPageName } from '../../../../app/types'; import { UsersTableType } from '../../store/model'; import { useNavigateTo } from '../../../../common/lib/kibana'; +import { UserPanelKey } from '../../../../flyout/entity_details/shared/constants'; const tableType = usersModel.UsersTableType.allUsers; @@ -79,7 +82,8 @@ const rowItems: ItemsPerRow[] = [ const getUsersColumns = ( showRiskColumn: boolean, - dispatchSeverityUpdate: (s: RiskSeverity) => void + dispatchSeverityUpdate: (s: RiskSeverity) => void, + openUserFlyout: (userName: string, entityId: string) => void ): UsersTableColumns => { const columns: UsersTableColumns = [ { @@ -88,8 +92,16 @@ const getUsersColumns = ( truncateText: false, sortable: true, mobileOptions: { show: true }, - render: (name, user: User) => - name != null && name.length > 0 ? ( + render: (name, user: User) => { + if (name == null || name.length === 0) return getOrEmptyTagFromValue(name); + const { entityId } = user; + const onClick = entityId + ? (e: SyntheticEvent) => { + e.preventDefault(); + openUserFlyout(name, entityId); + } + : undefined; + return ( - ) : ( - getOrEmptyTagFromValue(name) - ), + ); + }, }, { field: 'lastSeen', @@ -197,6 +209,23 @@ const UsersTableComponent: React.FC = ({ const { activePage, limit } = useDeepEqualSelector((state) => getUsersSelector(state)); const isPlatinumOrTrialLicense = useMlCapabilities().isPlatinumOrTrialLicense; const { navigateTo } = useNavigateTo(); + const { openRightPanel } = useExpandableFlyoutApi(); + + const openUserFlyout = useCallback( + (userName: string, entityId: string) => { + openRightPanel({ + id: UserPanelKey, + params: { + userName, + entityId, + contextID: tableType, + scopeId: tableType, + isPreviewMode: false, + }, + }); + }, + [openRightPanel] + ); const updateLimitPagination = useCallback( (newLimit) => { @@ -257,8 +286,8 @@ const UsersTableComponent: React.FC = ({ ); const columns = useMemo( - () => getUsersColumns(isPlatinumOrTrialLicense, dispatchSeverityUpdate), - [isPlatinumOrTrialLicense, dispatchSeverityUpdate] + () => getUsersColumns(isPlatinumOrTrialLicense, dispatchSeverityUpdate, openUserFlyout), + [isPlatinumOrTrialLicense, dispatchSeverityUpdate, openUserFlyout] ); return ( diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/host_right/header.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/host_right/header.test.tsx index db5600608658e..47b38468cba6d 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/host_right/header.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/host_right/header.test.tsx @@ -117,4 +117,24 @@ describe('HostPanelHeader', () => { expect(queryByText('Risk: High')).not.toBeInTheDocument(); }); + + it('renders the host name as a link to the details page when isEntityInStore is false', () => { + const { getByTestId } = render( + + + + ); + + expect(getByTestId('flyoutTitleLinkIcon')).toBeInTheDocument(); + }); + + it('renders the host name without a link when isEntityInStore is true', () => { + const { queryByTestId } = render( + + + + ); + + expect(queryByTestId('flyoutTitleLinkIcon')).not.toBeInTheDocument(); + }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/host_right/header.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/host_right/header.tsx index ab328d8b23557..8c8384515eb07 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/host_right/header.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/host_right/header.tsx @@ -79,23 +79,27 @@ export const HostPanelHeader = ({ alignItems="flexStart" > - 0 - ? identityFields - : undefined - )} - target={'_blank'} - external={false} - css={linkTitleCSS} - override={urlParamOverride} - > - - + {isEntityInStore ? ( + + ) : ( + 0 + ? identityFields + : undefined + )} + target={'_blank'} + external={false} + css={linkTitleCSS} + override={urlParamOverride} + > + + + )} diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/user_right/header.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/user_right/header.test.tsx index ba07f5228ad66..8d247460d3484 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/user_right/header.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/user_right/header.test.tsx @@ -164,4 +164,24 @@ describe('UserPanelHeader', () => { expect(queryByText('Risk: High')).not.toBeInTheDocument(); }); + + it('renders the user name as a link to the details page when isEntityInStore is false', () => { + const { getByTestId } = render( + + + + ); + + expect(getByTestId('flyoutTitleLinkIcon')).toBeInTheDocument(); + }); + + it('renders the user name without a link when isEntityInStore is true', () => { + const { queryByTestId } = render( + + + + ); + + expect(queryByTestId('flyoutTitleLinkIcon')).not.toBeInTheDocument(); + }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/user_right/header.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/user_right/header.tsx index 60727a2867071..4098fa7570473 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/user_right/header.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/user_right/header.tsx @@ -96,24 +96,28 @@ export const UserPanelHeader = ({ alignItems="flexStart" > - 0 - ? identityFields - : undefined - )} - target={'_blank'} - external={false} - css={linkTitleCSS} - override={urlParamOverride} - > - - + {isEntityInStore ? ( + + ) : ( + 0 + ? identityFields + : undefined + )} + target={'_blank'} + external={false} + css={linkTitleCSS} + override={urlParamOverride} + > + + + )}