[Security Solutions] Create a new User Details Flyout (#154310)

issue: https://github.com/elastic/security-team/issues/6154

## Summary

<img width="400" alt="Screenshot 2023-04-11 at 12 50 53"
src="https://user-images.githubusercontent.com/1490444/231138956-efc25a93-9807-434f-be80-d6de2a504f48.png"><img
width="400" alt="Screenshot 2023-04-11 at 12 51 09"
src="https://user-images.githubusercontent.com/1490444/231138890-8e7ea468-7ac8-4c65-97bf-3f9f6c983d8f.png">

<img width="400" alt="Screenshot 2023-04-11 at 12 51 01"
src="https://user-images.githubusercontent.com/1490444/231138927-96b6b66c-f77b-4b63-b805-c410f5a15783.png"><img
width="400" alt="Screenshot 2023-04-11 at 12 47 21"
src="https://user-images.githubusercontent.com/1490444/231138978-de7c495b-56ce-4b7b-bd22-76c53656ef3e.png">

### Main changes
* Creates a new user details flyout displayed on the Alerts page and
timeline.
* Introduce a new experimental feature `newUserDetailsFlyout` (disabled
by default)
* Create `managedUserDetails` API which fetches data from the index
created by the Azure integration.

### Miscellaneous
* Delete unused `lastSeen` and `first_seen` types.
* Delete unused `jobKey`property from anomaly score components
* Rename `userDetails` API and hook to `observedUserDetails`.
* Add `filterQuery` property to `useFirstLastSeen `. 
* To use it inside the flyout, since the user flyout show data in the
time range.
* Create a simplified `TestProvidersComponent` for Storybook named
`StorybookProviders`
* It should allow us to render more complex components that require
access to the redux store, theme, and kibana context.
  * Add `experimentalFeatures` property to `queryFactory.buildDsl`.

### Out of scope:
* The user can Snooze or Dismiss this prompt.
* Displaying integration errors inside the flyout 
* User page

## Storybook 
Please check the "💚 Build Succeeded" message


## How to test it
* You need a kibana instance with user data and alerts
* Enable the experimental feature `newUserDetailsFlyout`
* Go to the alerts page or timeline
* Open the user flyout


## How to install the new Azure integration
_The integration is under development, so you have to follow a series of
steps:_

1. Install docker desktop for Mac (only for macOS)
2. Install elastic-package  https://github.com/elastic/elastic-package
3. Add elastic-package to $PATH
4. Download the integration source code from GitHub branch
https://github.com/taylor-swanson/integrations/tree/entityanalytics_azure
5. Start the local K8 cluster `elastic-package stack up -vd --version
8.8.0-SNAPSHOT`
6. Enter the integration folder `cd packages/entityanalytics_azure/`
7. Build the integration `elastic-package build`
8. Update the registry with the new integration build `elastic-package
stack up -vd --services package-registry`
9. Open kibana integrations
<img width="243" alt="Screenshot 2023-04-11 at 11 24 14"
src="https://user-images.githubusercontent.com/1490444/231116552-2f3a6858-16a7-4654-bbd3-4ce76c693a8a.png">

10. Find entity analytics Azure integration (you need to check the
'display beta integrations' box)

<img width="1176" alt="Screenshot 2023-04-11 at 11 24 29"
src="https://user-images.githubusercontent.com/1490444/231116927-a8ffcb0b-a175-4cfe-b8c3-4a8acade317c.png">

11. Configured the integration with Azure tenant id, application id, and
secret (ask @machadoum)
12. Configured the integration with login URL, Login scopes, and API URL
(ask @machadoum)


### Checklist

Delete any items that are not applicable to this PR.

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [x] Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
[x] This renders correctly on smaller devices using a responsive layout.
(You can test this [in your
browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))
- [x] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Pablo Machado 2023-04-18 17:01:53 +02:00 committed by GitHub
parent 47f0eb8803
commit 9ab0c45454
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
64 changed files with 2565 additions and 116 deletions

View file

@ -123,6 +123,12 @@ export const allowedExperimentalValues = Object.freeze({
*
**/
alertsPageFiltersEnabled: true,
/*
* Enables the new user details flyout displayed on the Alerts page and timeline.
*
**/
newUserDetailsFlyout: false,
});
type ExperimentalConfigKeys = Array<keyof ExperimentalFeatures>;

View file

@ -75,7 +75,10 @@ import type {
RiskScoreRequestOptions,
} from './risk_score';
import type { UsersQueries } from './users';
import type { UserDetailsRequestOptions, UserDetailsStrategyResponse } from './users/details';
import type {
ObservedUserDetailsRequestOptions,
ObservedUserDetailsStrategyResponse,
} from './users/observed_details';
import type {
TotalUsersKpiRequestOptions,
TotalUsersKpiStrategyResponse,
@ -96,6 +99,10 @@ import type {
FirstLastSeenRequestOptions,
FirstLastSeenStrategyResponse,
} from './first_last_seen';
import type {
ManagedUserDetailsRequestOptions,
ManagedUserDetailsStrategyResponse,
} from './users/managed_details';
export * from './cti';
export * from './hosts';
@ -144,8 +151,10 @@ export type StrategyResponseType<T extends FactoryQueryTypes> = T extends HostsQ
? HostsKpiHostsStrategyResponse
: T extends HostsKpiQueries.kpiUniqueIps
? HostsKpiUniqueIpsStrategyResponse
: T extends UsersQueries.details
? UserDetailsStrategyResponse
: T extends UsersQueries.observedDetails
? ObservedUserDetailsStrategyResponse
: T extends UsersQueries.managedDetails
? ManagedUserDetailsStrategyResponse
: T extends UsersQueries.kpiTotalUsers
? TotalUsersKpiStrategyResponse
: T extends UsersQueries.authentications
@ -210,8 +219,10 @@ export type StrategyRequestType<T extends FactoryQueryTypes> = T extends HostsQu
? HostsKpiUniqueIpsRequestOptions
: T extends UsersQueries.authentications
? UserAuthenticationsRequestOptions
: T extends UsersQueries.details
? UserDetailsRequestOptions
: T extends UsersQueries.observedDetails
? ObservedUserDetailsRequestOptions
: T extends UsersQueries.managedDetails
? ManagedUserDetailsRequestOptions
: T extends UsersQueries.kpiTotalUsers
? TotalUsersKpiRequestOptions
: T extends UsersQueries.users

View file

@ -18,8 +18,6 @@ export interface UserRiskScoreItem {
export interface UserItem {
user?: Maybe<UserEcs>;
host?: Maybe<HostEcs>;
lastSeen?: Maybe<string>;
firstSeen?: Maybe<string>;
}
export type SortableUsersFields = Exclude<UsersFields, typeof UsersFields.domain>;
@ -27,9 +25,9 @@ export type SortableUsersFields = Exclude<UsersFields, typeof UsersFields.domain
export type SortUsersField = SortField<SortableUsersFields>;
export enum UsersFields {
lastSeen = 'lastSeen',
name = 'name',
domain = 'domain',
lastSeen = 'lastSeen',
}
export interface UserAggEsItem {
@ -39,8 +37,6 @@ export interface UserAggEsItem {
host_os_name?: UserBuckets;
host_ip?: UserBuckets;
host_os_family?: UserBuckets;
first_seen?: { value_as_string: string };
last_seen?: { value_as_string: string };
}
export interface UserBuckets {
@ -68,3 +64,11 @@ interface UsersDomainHitsItem {
}>;
};
}
export const EVENT_KIND_ASSET_FILTER = { term: { 'event.kind': 'asset' } };
export const NOT_EVENT_KIND_ASSET_FILTER = {
bool: {
must_not: [EVENT_KIND_ASSET_FILTER],
},
};

View file

@ -10,15 +10,16 @@ import type { TotalUsersKpiStrategyResponse } from './kpi/total_users';
export * from './all';
export * from './common';
export * from './kpi';
export * from './details';
export * from './observed_details';
export * from './authentications';
export enum UsersQueries {
details = 'userDetails',
observedDetails = 'observedUserDetails',
managedDetails = 'managedUserDetails',
kpiTotalUsers = 'usersKpiTotalUsers',
users = 'allUsers',
authentications = 'authentications',
kpiAuthentications = 'usersKpiAuthentications',
}
export type UserskKpiStrategyResponse = Omit<TotalUsersKpiStrategyResponse, 'rawResponse'>;
export type UsersKpiStrategyResponse = Omit<TotalUsersKpiStrategyResponse, 'rawResponse'>;

View file

@ -0,0 +1,33 @@
/*
* 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 type { IEsSearchRequest, IEsSearchResponse } from '@kbn/data-plugin/common';
import type { EcsBase, EcsEvent, EcsHost, EcsUser, EcsAgent } from '@kbn/ecs';
import type { Inspect, Maybe } from '../../../common';
import type { RequestBasicOptions } from '../..';
export interface ManagedUserDetailsStrategyResponse extends IEsSearchResponse {
userDetails?: AzureManagedUser;
inspect?: Maybe<Inspect>;
}
export interface ManagedUserDetailsRequestOptions
extends Pick<RequestBasicOptions, 'defaultIndex' | 'factoryQueryType'>,
IEsSearchRequest {
userName: string;
}
export interface AzureManagedUser extends Pick<EcsBase, '@timestamp'> {
agent: EcsAgent;
host: EcsHost;
event: EcsEvent;
user: EcsUser & {
last_name?: string;
first_name?: string;
phone?: string[];
};
}

View file

@ -11,12 +11,12 @@ import type { Inspect, Maybe, TimerangeInput } from '../../../common';
import type { UserItem } from '../common';
import type { RequestBasicOptions } from '../..';
export interface UserDetailsStrategyResponse extends IEsSearchResponse {
export interface ObservedUserDetailsStrategyResponse extends IEsSearchResponse {
userDetails: UserItem;
inspect?: Maybe<Inspect>;
}
export interface UserDetailsRequestOptions extends Partial<RequestBasicOptions> {
export interface ObservedUserDetailsRequestOptions extends Partial<RequestBasicOptions> {
userName: string;
skip?: boolean;
timerange: TimerangeInput;

View file

@ -10,7 +10,7 @@ import { useInstalledSecurityJobNameById } from '../hooks/use_installed_security
import type { InfluencerInput, Anomalies, CriteriaFields } from '../types';
import { useAnomaliesTableData } from './use_anomalies_table_data';
interface ChildrenArgs {
export interface AnomalyTableProviderChildrenProps {
isLoadingAnomaliesData: boolean;
anomaliesData: Anomalies | null;
jobNameById: Record<string, string | undefined>;
@ -21,7 +21,7 @@ interface Props {
startDate: string;
endDate: string;
criteriaFields?: CriteriaFields[];
children: (args: ChildrenArgs) => React.ReactNode;
children: (args: AnomalyTableProviderChildrenProps) => React.ReactNode;
skip: boolean;
}

View file

@ -3,6 +3,7 @@
exports[`anomaly_scores renders correctly against snapshot 1`] = `
<Fragment>
<EuiFlexItem
data-test-subj="anomaly-score"
grow={false}
>
<Score

View file

@ -3,6 +3,7 @@
exports[`anomaly_scores renders correctly against snapshot 1`] = `
<Fragment>
<EuiFlexGroup
data-test-subj="anomaly-scores"
gutterSize="none"
responsive={false}
>
@ -10,7 +11,6 @@ exports[`anomaly_scores renders correctly against snapshot 1`] = `
endDate="3000-01-01T00:00:00.000Z"
index={0}
interval="day"
jobKey="job-1-16.193669439507826-process.name-du"
jobName="job-1"
key="job-1-16.193669439507826-process.name-du"
narrowDateRange={[MockFunction]}
@ -86,7 +86,6 @@ exports[`anomaly_scores renders correctly against snapshot 1`] = `
endDate="3000-01-01T00:00:00.000Z"
index={1}
interval="day"
jobKey="job-2-16.193669439507826-process.name-ls"
jobName="job-2"
key="job-2-16.193669439507826-process.name-ls"
narrowDateRange={[MockFunction]}

View file

@ -34,7 +34,6 @@ describe('anomaly_scores', () => {
test('renders correctly against snapshot', () => {
const wrapper = shallow(
<AnomalyScoreComponent
jobKey="job-key-1"
startDate={startDate}
endDate={endDate}
score={anomalies.anomalies[0]}
@ -50,7 +49,6 @@ describe('anomaly_scores', () => {
const wrapper = mount(
<TestProviders>
<AnomalyScoreComponent
jobKey="job-key-1"
startDate={startDate}
endDate={endDate}
score={anomalies.anomalies[0]}
@ -67,7 +65,6 @@ describe('anomaly_scores', () => {
const wrapper = mount(
<TestProviders>
<AnomalyScoreComponent
jobKey="job-key-1"
startDate={startDate}
endDate={endDate}
score={anomalies.anomalies[0]}

View file

@ -16,7 +16,6 @@ interface Args {
startDate: string;
endDate: string;
narrowDateRange: NarrowDateRange;
jobKey: string;
index?: number;
score: Anomaly;
interval: string;
@ -31,7 +30,6 @@ const Icon = styled(EuiIcon)`
Icon.displayName = 'Icon';
export const AnomalyScoreComponent = ({
jobKey,
startDate,
endDate,
index = 0,
@ -43,7 +41,7 @@ export const AnomalyScoreComponent = ({
const [isOpen, setIsOpen] = useState(false);
return (
<>
<EuiFlexItem grow={false}>
<EuiFlexItem grow={false} data-test-subj="anomaly-score">
<Score index={index} score={score} />
</EuiFlexItem>
<EuiFlexItem grow={false}>

View file

@ -41,13 +41,12 @@ export const AnomalyScoresComponent = ({
} else {
return (
<>
<EuiFlexGroup gutterSize="none" responsive={false}>
<EuiFlexGroup gutterSize="none" responsive={false} data-test-subj="anomaly-scores">
{getTopSeverityJobs(anomalies.anomalies, limit).map((score, index) => {
const jobKey = createJobKey(score);
return (
<AnomalyScore
key={jobKey}
jobKey={jobKey}
startDate={startDate}
endDate={endDate}
index={index}

View file

@ -132,4 +132,23 @@ describe('useFistLastSeen', () => {
const { result } = renderUseFirstLastSeen({ order: Direction.desc });
expect(result.current).toEqual([false, { errorMessage: `Error: ${msg}` }]);
});
it('should search with given filter query', () => {
const filterQuery = { terms: { test: ['test123'] } };
mockUseSearchStrategy.mockImplementation(() => ({
loading: false,
result: {},
search: mockSearch,
refetch: jest.fn(),
inspect: {},
}));
renderUseFirstLastSeen({ order: Direction.desc, filterQuery });
expect(mockSearch).toHaveBeenCalledWith(
expect.objectContaining({
filterQuery,
})
);
});
});

View file

@ -11,6 +11,7 @@ import * as i18n from './translations';
import { useSearchStrategy } from '../use_search_strategy';
import { FirstLastSeenQuery } from '../../../../common/search_strategy';
import type { Direction } from '../../../../common/search_strategy';
import type { ESQuery } from '../../../../common/typed_json';
export interface FirstLastSeenArgs {
errorMessage: string | null;
@ -22,6 +23,7 @@ export interface UseFirstLastSeen {
value: string;
order: Direction.asc | Direction.desc;
defaultIndex: string[];
filterQuery?: ESQuery | string;
}
export const useFirstLastSeen = ({
@ -29,6 +31,7 @@ export const useFirstLastSeen = ({
value,
order,
defaultIndex,
filterQuery,
}: UseFirstLastSeen): [boolean, FirstLastSeenArgs] => {
const { loading, result, search, error } = useSearchStrategy<typeof FirstLastSeenQuery>({
factoryQueryType: FirstLastSeenQuery,
@ -46,8 +49,9 @@ export const useFirstLastSeen = ({
field,
value,
order,
filterQuery,
});
}, [defaultIndex, field, value, order, search]);
}, [defaultIndex, field, value, order, search, filterQuery]);
const setFirstLastSeenResponse: FirstLastSeenArgs = useMemo(
() => ({

View file

@ -0,0 +1,68 @@
/*
* 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 { euiLightVars } from '@kbn/ui-theme';
import React from 'react';
import { Provider as ReduxStoreProvider } from 'react-redux';
import { BehaviorSubject, Subject } from 'rxjs';
import { ThemeProvider } from 'styled-components';
import type { CoreStart } from '@kbn/core/public';
import { createKibanaReactContext } from '@kbn/kibana-react-plugin/public';
import { I18nProvider } from '@kbn/i18n-react';
import { CellActionsProvider } from '@kbn/cell-actions';
import { createStore } from '../store';
import { mockGlobalState } from './global_state';
import { SUB_PLUGINS_REDUCER } from './utils';
import { createSecuritySolutionStorageMock } from './mock_local_storage';
import type { StartServices } from '../../types';
export const kibanaObservable = new BehaviorSubject({} as unknown as StartServices);
const { storage } = createSecuritySolutionStorageMock();
const uiSettings = {
get: (setting: string) => {
switch (setting) {
case 'dateFormat':
return 'MMM D, YYYY @ HH:mm:ss.SSS';
case 'dateFormat:scaled':
return [['', 'HH:mm:ss.SSS']];
}
},
get$: () => new Subject(),
};
const coreMock = {
application: {
getUrlForApp: () => {},
},
uiSettings,
} as unknown as CoreStart;
const KibanaReactContext = createKibanaReactContext(coreMock);
/**
* A utility for wrapping components in Storybook that provides access to the most common React contexts used by security components.
* It is a simplified version of TestProvidersComponent.
* To reuse TestProvidersComponent here, we need to remove all references to jest from mocks.
*/
export const StorybookProviders: React.FC = ({ children }) => {
const store = createStore(mockGlobalState, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
return (
<I18nProvider>
<KibanaReactContext.Provider>
<CellActionsProvider getTriggerCompatibleActions={() => Promise.resolve([])}>
<ReduxStoreProvider store={store}>
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
{children}
</ThemeProvider>
</ReduxStoreProvider>
</CellActionsProvider>
</KibanaReactContext.Provider>
</I18nProvider>
);
};

View file

@ -7,7 +7,7 @@
import type {
HostsKpiStrategyResponse,
NetworkKpiStrategyResponse,
UserskKpiStrategyResponse,
UsersKpiStrategyResponse,
} from '../../../../common/search_strategy';
import type { UpdateDateRange } from '../../../common/components/charts/common';
import type { StatItems, StatItemsProps } from './types';
@ -15,7 +15,7 @@ import { addValueToAreaChart, addValueToBarChart, addValueToFields } from './uti
export const useKpiMatrixStatus = (
mappings: Readonly<StatItems[]>,
data: HostsKpiStrategyResponse | NetworkKpiStrategyResponse | UserskKpiStrategyResponse,
data: HostsKpiStrategyResponse | NetworkKpiStrategyResponse | UsersKpiStrategyResponse,
id: string,
from: string,
to: string,

View file

@ -14,7 +14,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiTitle } from '@elastic/eui';
import type {
HostsKpiStrategyResponse,
NetworkKpiStrategyResponse,
UserskKpiStrategyResponse,
UsersKpiStrategyResponse,
} from '../../../../common/search_strategy';
import type { ChartSeriesData, ChartData } from '../../../common/components/charts/common';
@ -95,12 +95,12 @@ export const barchartConfigs = (config?: { onElementClick?: ElementClickListener
export const addValueToFields = (
fields: StatItem[],
data: HostsKpiStrategyResponse | NetworkKpiStrategyResponse | UserskKpiStrategyResponse
data: HostsKpiStrategyResponse | NetworkKpiStrategyResponse | UsersKpiStrategyResponse
): StatItem[] => fields.map((field) => ({ ...field, value: get(field.key, data) }));
export const addValueToAreaChart = (
fields: StatItem[],
data: HostsKpiStrategyResponse | NetworkKpiStrategyResponse | UserskKpiStrategyResponse
data: HostsKpiStrategyResponse | NetworkKpiStrategyResponse | UsersKpiStrategyResponse
): ChartSeriesData[] =>
fields
.filter((field) => get(`${field.key}Histogram`, data) != null)
@ -112,7 +112,7 @@ export const addValueToAreaChart = (
export const addValueToBarChart = (
fields: StatItem[],
data: HostsKpiStrategyResponse | NetworkKpiStrategyResponse | UserskKpiStrategyResponse
data: HostsKpiStrategyResponse | NetworkKpiStrategyResponse | UsersKpiStrategyResponse
): ChartSeriesData[] => {
if (fields.length === 0) return [];
return fields.reduce((acc: ChartSeriesData[], field: StatItem, idx: number) => {

View file

@ -18,7 +18,7 @@ import type {
import type { StatItemsProps, StatItems } from '../../../../components/stat_items';
import { StatItemsComponent, useKpiMatrixStatus } from '../../../../components/stat_items';
import type { UpdateDateRange } from '../../../../../common/components/charts/common';
import type { UserskKpiStrategyResponse } from '../../../../../../common/search_strategy/security_solution/users';
import type { UsersKpiStrategyResponse } from '../../../../../../common/search_strategy/security_solution/users';
const kpiWidgetHeight = 247;
@ -30,7 +30,7 @@ FlexGroup.displayName = 'FlexGroup';
interface KpiBaseComponentProps {
fieldsMapping: Readonly<StatItems[]>;
data: HostsKpiStrategyResponse | NetworkKpiStrategyResponse | UserskKpiStrategyResponse;
data: HostsKpiStrategyResponse | NetworkKpiStrategyResponse | UsersKpiStrategyResponse;
loading?: boolean;
id: string;
from: string;

View file

@ -6,7 +6,7 @@
*/
import { act, renderHook } from '@testing-library/react-hooks';
import { TestProviders } from '../../../../../common/mock';
import { useUserDetails } from '.';
import { useObservedUserDetails } from '.';
import { useSearchStrategy } from '../../../../../common/containers/use_search_strategy';
jest.mock('../../../../../common/containers/use_search_strategy', () => ({
@ -38,7 +38,7 @@ describe('useUserDetails', () => {
});
it('runs search', () => {
renderHook(() => useUserDetails(defaultProps), {
renderHook(() => useObservedUserDetails(defaultProps), {
wrapper: TestProviders,
});
@ -50,7 +50,7 @@ describe('useUserDetails', () => {
...defaultProps,
skip: true,
};
renderHook(() => useUserDetails(props), {
renderHook(() => useObservedUserDetails(props), {
wrapper: TestProviders,
});
@ -60,7 +60,7 @@ describe('useUserDetails', () => {
const props = {
...defaultProps,
};
const { rerender } = renderHook(() => useUserDetails(props), {
const { rerender } = renderHook(() => useObservedUserDetails(props), {
wrapper: TestProviders,
});
props.skip = true;

View file

@ -11,9 +11,11 @@ import * as i18n from './translations';
import type { InspectResponse } from '../../../../../types';
import { UsersQueries } from '../../../../../../common/search_strategy/security_solution/users';
import type { UserItem } from '../../../../../../common/search_strategy/security_solution/users/common';
import { NOT_EVENT_KIND_ASSET_FILTER } from '../../../../../../common/search_strategy/security_solution/users/common';
import { useSearchStrategy } from '../../../../../common/containers/use_search_strategy';
import { useIsExperimentalFeatureEnabled } from '../../../../../common/hooks/use_experimental_features';
export const ID = 'usersDetailsQuery';
export const OBSERVED_USER_QUERY_ID = 'observedUsersDetailsQuery';
export interface UserDetailsArgs {
id: string;
@ -33,22 +35,23 @@ interface UseUserDetails {
startDate: string;
}
export const useUserDetails = ({
export const useObservedUserDetails = ({
endDate,
userName,
indexNames,
id = ID,
id = OBSERVED_USER_QUERY_ID,
skip = false,
startDate,
}: UseUserDetails): [boolean, UserDetailsArgs] => {
const isNewUserDetailsFlyoutEnabled = useIsExperimentalFeatureEnabled('newUserDetailsFlyout');
const {
loading,
result: response,
search,
refetch,
inspect,
} = useSearchStrategy<UsersQueries.details>({
factoryQueryType: UsersQueries.details,
} = useSearchStrategy<UsersQueries.observedDetails>({
factoryQueryType: UsersQueries.observedDetails,
initialResult: {
userDetails: {},
},
@ -62,7 +65,6 @@ export const useUserDetails = ({
userDetails: response.userDetails,
id,
inspect,
isInspected: false,
refetch,
startDate,
}),
@ -72,15 +74,16 @@ export const useUserDetails = ({
const userDetailsRequest = useMemo(
() => ({
defaultIndex: indexNames,
factoryQueryType: UsersQueries.details,
factoryQueryType: UsersQueries.observedDetails,
userName,
timerange: {
interval: '12h',
from: startDate,
to: endDate,
},
filterQuery: isNewUserDetailsFlyoutEnabled ? NOT_EVENT_KIND_ASSET_FILTER : undefined,
}),
[endDate, indexNames, startDate, userName]
[endDate, indexNames, startDate, userName, isNewUserDetailsFlyoutEnabled]
);
useEffect(() => {

View file

@ -57,7 +57,7 @@ import { LastEventIndexKey } from '../../../../../common/search_strategy';
import { AnomalyTableProvider } from '../../../../common/components/ml/anomaly/anomaly_table_provider';
import { UserOverview } from '../../../../overview/components/user_overview';
import { useUserDetails } from '../../containers/users/details';
import { useObservedUserDetails } from '../../containers/users/observed_details';
import { useQueryInspector } from '../../../../common/components/page/manage_query';
import { scoreIntervalToDateTime } from '../../../../common/components/ml/score/score_interval_to_datetime';
import { getCriteriaFromUsersType } from '../../../../common/components/ml/criteria/get_criteria_from_users_type';
@ -134,7 +134,7 @@ const UsersDetailsComponent: React.FC<UsersDetailsProps> = ({
dispatch(setUsersDetailsTablesActivePageToZero());
}, [dispatch, detailName]);
const [loading, { inspect, userDetails, refetch }] = useUserDetails({
const [loading, { inspect, userDetails, refetch }] = useObservedUserDetails({
id: QUERY_ID,
endDate: to,
startDate: from,

View file

@ -9,12 +9,13 @@ import { render } from '@testing-library/react';
import { TestProviders } from '../../../common/mock';
import { UserEntityOverview } from './user_entity_overview';
import { useRiskScore } from '../../../explore/containers/risk_score';
import { useUserDetails } from '../../../explore/users/containers/users/details';
import {
ENTITIES_USER_OVERVIEW_IP_TEST_ID,
ENTITIES_USER_OVERVIEW_RISK_LEVEL_TEST_ID,
TECHNICAL_PREVIEW_ICON_TEST_ID,
} from './test_ids';
import { useObservedUserDetails } from '../../../explore/users/containers/users/observed_details';
const userName = 'user';
const ip = '10.200.000.000';
@ -38,8 +39,8 @@ jest.mock('../../../common/containers/sourcerer', () => {
};
});
const mockUseUserDetails = useUserDetails as jest.Mock;
jest.mock('../../../explore/users/containers/users/details');
const mockUseUserDetails = useObservedUserDetails as jest.Mock;
jest.mock('../../../explore/users/containers/users/observed_details');
const mockUseRiskScore = useRiskScore as jest.Mock;
jest.mock('../../../explore/containers/risk_score');

View file

@ -24,7 +24,6 @@ import { getEmptyTagValue } from '../../../common/components/empty_value';
import { useSourcererDataView } from '../../../common/containers/sourcerer';
import { useGlobalTime } from '../../../common/containers/use_global_time';
import { useRiskScore } from '../../../explore/containers/risk_score';
import { useUserDetails } from '../../../explore/users/containers/users/details';
import * as i18n from '../../../overview/components/user_overview/translations';
import { TECHNICAL_PREVIEW_TITLE, TECHNICAL_PREVIEW_MESSAGE } from './translations';
import {
@ -33,6 +32,7 @@ import {
ENTITIES_USER_OVERVIEW_IP_TEST_ID,
ENTITIES_USER_OVERVIEW_RISK_LEVEL_TEST_ID,
} from './test_ids';
import { useObservedUserDetails } from '../../../explore/users/containers/users/observed_details';
const StyledEuiBetaBadge = styled(EuiBetaBadge)`
margin-left: ${({ theme }) => theme.eui.euiSizeXS};
@ -66,7 +66,7 @@ export const UserEntityOverview: React.FC<UserEntityOverviewProps> = ({ userName
() => (userName ? buildUserNamesFilter([userName]) : undefined),
[userName]
);
const [_, { userDetails: data }] = useUserDetails({
const [_, { userDetails: data }] = useObservedUserDetails({
endDate: to,
userName,
indexNames: selectedPatterns,

View file

@ -23,6 +23,7 @@ import { EventDetailsPanel } from './event_details';
import { HostDetailsPanel } from './host_details';
import { NetworkDetailsPanel } from './network_details';
import { UserDetailsPanel } from './user_details';
import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features';
interface DetailsPanelProps {
browserFields: BrowserFields;
@ -52,6 +53,7 @@ export const DetailsPanel = React.memo(
isReadOnly,
}: DetailsPanelProps) => {
const dispatch = useDispatch();
const isNewUserDetailsFlyoutEnable = useIsExperimentalFeatureEnabled('newUserDetailsFlyout');
const getScope = useMemo(() => {
if (isTimelineScope(scopeId)) {
return timelineSelectors.getTimelineByIdSelector();
@ -124,6 +126,9 @@ export const DetailsPanel = React.memo(
if (currentTabDetail?.panelView === 'userDetail' && currentTabDetail?.params?.userName) {
flyoutUniqueKey = currentTabDetail.params.userName;
if (isNewUserDetailsFlyoutEnable) {
panelSize = 'm';
}
visiblePanel = (
<UserDetailsPanel
contextID={contextID}
@ -131,6 +136,7 @@ export const DetailsPanel = React.memo(
handleOnClose={closePanel}
isDraggable={isDraggable}
isFlyoutView={isFlyoutView}
isNewUserDetailsFlyoutEnable={isNewUserDetailsFlyoutEnable}
/>
);
}

View file

@ -0,0 +1,105 @@
/*
* 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 { RiskSeverity } from '../../../../../../common/search_strategy';
import { mockAnomalies } from '../../../../../common/components/ml/mock';
import type { ObservedUserData } from '../types';
const userRiskScore = {
'@timestamp': '123456',
user: {
name: 'test',
risk: {
rule_risks: [],
calculated_score_norm: 70,
multipliers: [],
calculated_level: RiskSeverity.high,
},
},
alertsCount: 0,
oldestAlertTimestamp: '123456',
};
export const mockRiskScoreState = {
data: [userRiskScore],
inspect: {
dsl: [],
response: [],
},
isInspected: false,
refetch: () => {},
totalCount: 0,
isModuleEnabled: true,
isLicenseValid: true,
isDeprecated: false,
loading: false,
};
const anomaly = mockAnomalies.anomalies[0];
export const managedUserDetails = {
'@timestamp': '',
agent: {},
host: {},
event: {},
user: {
id: '123456',
last_name: 'user',
first_name: 'test',
full_name: 'test user',
phone: ['123456', '654321'],
},
};
export const observedUserDetails = {
user: {
id: ['1234', '321'],
domain: ['test domain', 'another test domain'],
},
host: {
ip: ['10.0.0.1', '127.0.0.1'],
os: {
name: ['testOs'],
family: ['testFamily'],
},
},
};
export const mockManagedUser = {
details: managedUserDetails,
isLoading: false,
isIntegrationEnabled: true,
firstSeen: {
isLoading: false,
date: '2023-03-23T20:03:17.489Z',
},
lastSeen: {
isLoading: false,
date: '2023-03-23T20:03:17.489Z',
},
};
export const mockObservedUser: ObservedUserData = {
details: observedUserDetails,
isLoading: false,
firstSeen: {
isLoading: false,
date: '2023-02-23T20:03:17.489Z',
},
lastSeen: {
isLoading: false,
date: '2023-02-23T20:03:17.489Z',
},
anomalies: {
isLoading: false,
anomalies: {
anomalies: [anomaly],
interval: '',
},
jobNameById: { [anomaly.jobId]: 'job_name' },
},
};

View file

@ -0,0 +1,133 @@
/*
* 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 { css } from '@emotion/react';
import React, { useCallback } from 'react';
import { head } from 'lodash/fp';
import { euiLightVars } from '@kbn/ui-theme';
import type { EuiBasicTableColumn } from '@elastic/eui';
import { useDispatch } from 'react-redux';
import { DefaultFieldRenderer } from '../../field_renderers/field_renderers';
import type {
ManagedUsersTableColumns,
ManagedUserTable,
ObservedUsersTableColumns,
ObservedUserTable,
UserAnomalies,
} from './types';
import * as i18n from './translations';
import { defaultToEmptyTag } from '../../../../common/components/empty_value';
import { FormattedRelativePreferenceDate } from '../../../../common/components/formatted_date';
import { AnomalyScores } from '../../../../common/components/ml/score/anomaly_scores';
import { useGlobalTime } from '../../../../common/containers/use_global_time';
import { scoreIntervalToDateTime } from '../../../../common/components/ml/score/score_interval_to_datetime';
import { InputsModelId } from '../../../../common/store/inputs/constants';
import { setAbsoluteRangeDatePicker } from '../../../../common/store/inputs/actions';
const fieldColumn: EuiBasicTableColumn<ObservedUserTable | ManagedUserTable> = {
name: i18n.FIELD_COLUMN_TITLE,
field: 'label',
render: (label: string) => (
<span
css={css`
font-weight: ${euiLightVars.euiFontWeightMedium};
color: ${euiLightVars.euiTitleColor};
`}
>
{label}
</span>
),
};
export const getManagedUserTableColumns = (
contextID: string,
isDraggable: boolean
): ManagedUsersTableColumns => [
fieldColumn,
{
name: i18n.VALUES_COLUMN_TITLE,
field: 'value',
render: (value: ManagedUserTable['value'], { field }) => {
return field && value ? (
<DefaultFieldRenderer
rowItems={[value]}
attrName={field}
idPrefix={contextID ? `managedUser-${contextID}` : 'managedUser'}
isDraggable={isDraggable}
/>
) : (
defaultToEmptyTag(value)
);
},
},
];
function isAnomalies(
field: string | undefined,
values: UserAnomalies | unknown
): values is UserAnomalies {
return field === 'anomalies';
}
export const getObservedUserTableColumns = (
contextID: string,
isDraggable: boolean
): ObservedUsersTableColumns => [
fieldColumn,
{
name: i18n.VALUES_COLUMN_TITLE,
field: 'values',
render: (values: ObservedUserTable['values'], { field }) => {
if (isAnomalies(field, values) && values) {
return <AnomaliesField anomalies={values} />;
}
if (field === '@timestamp') {
return <FormattedRelativePreferenceDate value={head(values)} />;
}
return (
<DefaultFieldRenderer
rowItems={values}
attrName={field}
idPrefix={contextID ? `observedUser-${contextID}` : 'observedUser'}
isDraggable={isDraggable}
/>
);
},
},
];
const AnomaliesField = ({ anomalies }: { anomalies: UserAnomalies }) => {
const { to, from } = useGlobalTime();
const dispatch = useDispatch();
const narrowDateRange = useCallback(
(score, interval) => {
const fromTo = scoreIntervalToDateTime(score, interval);
dispatch(
setAbsoluteRangeDatePicker({
id: InputsModelId.global,
from: fromTo.from,
to: fromTo.to,
})
);
},
[dispatch]
);
return (
<AnomalyScores
anomalies={anomalies.anomalies}
startDate={from}
endDate={to}
isLoading={anomalies.isLoading}
narrowDateRange={narrowDateRange}
jobNameById={anomalies.jobNameById}
/>
);
};

View file

@ -0,0 +1,12 @@
/*
* 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.
*/
export const MANAGED_USER_INDEX = ['logs-entityanalytics_azure.users-*'];
export const MANAGED_USER_PACKAGE_NAME = 'entityanalytics_azure';
export const INSTALL_INTEGRATION_HREF = `/app/fleet/integrations/${MANAGED_USER_PACKAGE_NAME}/add-integration`;
export const ONE_WEEK_IN_HOURS = 24 * 7;
export const MANAGED_USER_QUERY_ID = 'managedUserDetailsQuery';

View file

@ -0,0 +1,61 @@
/*
* 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 { renderHook } from '@testing-library/react-hooks';
import type { InstalledIntegration } from '../../../../../common/detection_engine/fleet_integrations';
import { TestProviders } from '../../../../common/mock';
import { MANAGED_USER_PACKAGE_NAME } from './constants';
import { useManagedUser } from './hooks';
const makeInstalledIntegration = (
pkgName = 'testPkg',
isEnabled = false
): InstalledIntegration => ({
package_name: pkgName,
package_title: '',
package_version: '',
integration_name: '',
integration_title: '',
is_enabled: isEnabled,
});
const mockUseInstalledIntegrations = jest.fn().mockReturnValue({
data: [],
});
jest.mock(
'../../../../detections/components/rules/related_integrations/use_installed_integrations',
() => ({
useInstalledIntegrations: () => mockUseInstalledIntegrations(),
})
);
describe('useManagedUser', () => {
it('returns isIntegrationEnabled:true when it finds an enabled integration with the given name', () => {
mockUseInstalledIntegrations.mockReturnValue({
data: [makeInstalledIntegration(MANAGED_USER_PACKAGE_NAME, true)],
});
const { result } = renderHook(() => useManagedUser('test-userName'), {
wrapper: TestProviders,
});
expect(result.current.isIntegrationEnabled).toBeTruthy();
});
it('returns isIntegrationEnabled:false when it does not find an enabled integration with the given name', () => {
mockUseInstalledIntegrations.mockReturnValue({
data: [makeInstalledIntegration('fake-name', true)],
});
const { result } = renderHook(() => useManagedUser('test-userName'), {
wrapper: TestProviders,
});
expect(result.current.isIntegrationEnabled).toBeFalsy();
});
});

View file

@ -0,0 +1,236 @@
/*
* 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 { useMemo, useEffect } from 'react';
import * as i18n from './translations';
import type { ManagedUserTable, ObservedUserData, ObservedUserTable } from './types';
import type { AzureManagedUser } from '../../../../../common/search_strategy/security_solution/users/managed_details';
import {
Direction,
UsersQueries,
NOT_EVENT_KIND_ASSET_FILTER,
} from '../../../../../common/search_strategy';
import { useSourcererDataView } from '../../../../common/containers/sourcerer';
import { useGlobalTime } from '../../../../common/containers/use_global_time';
import { useSearchStrategy } from '../../../../common/containers/use_search_strategy';
import { useObservedUserDetails } from '../../../../explore/users/containers/users/observed_details';
import { useFirstLastSeen } from '../../../../common/containers/use_first_last_seen';
import { useInstalledIntegrations } from '../../../../detections/components/rules/related_integrations/use_installed_integrations';
import { MANAGED_USER_INDEX, MANAGED_USER_PACKAGE_NAME, MANAGED_USER_QUERY_ID } from './constants';
import { useQueryInspector } from '../../../../common/components/page/manage_query';
export const useObservedUserItems = (userData: ObservedUserData): ObservedUserTable[] =>
useMemo(
() =>
!userData.details
? []
: [
{ label: i18n.USER_ID, values: userData.details.user?.id, field: 'user.id' },
{ label: 'Domain', values: userData.details.user?.domain, field: 'user.domain' },
{
label: i18n.MAX_ANOMALY_SCORE_BY_JOB,
field: 'anomalies',
values: userData.anomalies,
},
{
label: i18n.FIRST_SEEN,
values: userData.firstSeen.date ? [userData.firstSeen.date] : undefined,
field: '@timestamp',
},
{
label: i18n.LAST_SEEN,
values: userData.lastSeen.date ? [userData.lastSeen.date] : undefined,
field: '@timestamp',
},
{
label: i18n.OPERATING_SYSTEM_TITLE,
values: userData.details.host?.os?.name,
field: 'host.os.name',
},
{
label: i18n.FAMILY,
values: userData.details.host?.os?.family,
field: 'host.os.family',
},
{ label: i18n.IP_ADDRESSES, values: userData.details.host?.ip, field: 'host.ip' },
],
[userData.details, userData.anomalies, userData.firstSeen, userData.lastSeen]
);
export const useManagedUserItems = (
managedUserDetails?: AzureManagedUser
): ManagedUserTable[] | null =>
useMemo(
() =>
!managedUserDetails
? null
: [
{
label: i18n.USER_ID,
value: managedUserDetails.user.id,
field: 'user.id',
},
{
label: i18n.FULL_NAME,
value: managedUserDetails.user.full_name,
field: 'user.full_name',
},
{
label: i18n.FIRST_NAME,
value: managedUserDetails.user.first_name,
},
{
label: i18n.LAST_NAME,
value: managedUserDetails.user.last_name,
},
{ label: i18n.PHONE, value: managedUserDetails.user.phone?.join(', ') },
],
[managedUserDetails]
);
export const useManagedUser = (userName: string) => {
const { to, from, isInitializing, deleteQuery, setQuery } = useGlobalTime();
const {
loading: loadingManagedUser,
result: { userDetails: managedUserDetails },
search,
refetch,
inspect,
} = useSearchStrategy<UsersQueries.managedDetails>({
factoryQueryType: UsersQueries.managedDetails,
initialResult: {},
errorMessage: i18n.FAIL_MANAGED_USER,
});
useEffect(() => {
if (!isInitializing) {
search({
defaultIndex: MANAGED_USER_INDEX,
factoryQueryType: UsersQueries.managedDetails,
userName,
});
}
}, [from, search, to, userName, isInitializing]);
const { data: installedIntegrations } = useInstalledIntegrations({
packages: [MANAGED_USER_PACKAGE_NAME],
});
useQueryInspector({
deleteQuery,
inspect,
refetch,
setQuery,
queryId: MANAGED_USER_QUERY_ID,
loading: loadingManagedUser,
});
const isIntegrationEnabled = useMemo(
() =>
!!installedIntegrations?.some(
({ package_name: packageName, is_enabled: isEnabled }) =>
packageName === MANAGED_USER_PACKAGE_NAME && isEnabled
),
[installedIntegrations]
);
const [loadingFirstSeen, { firstSeen }] = useFirstLastSeen({
field: 'user.name',
value: userName,
defaultIndex: MANAGED_USER_INDEX,
order: Direction.asc,
});
const [loadingLastSeen, { lastSeen }] = useFirstLastSeen({
field: 'user.name',
value: userName,
defaultIndex: MANAGED_USER_INDEX,
order: Direction.desc,
});
return useMemo(
() => ({
details: managedUserDetails,
isLoading: loadingManagedUser,
isIntegrationEnabled,
firstSeen: {
date: firstSeen,
isLoading: loadingFirstSeen,
},
lastSeen: { date: lastSeen, isLoading: loadingLastSeen },
}),
[
firstSeen,
isIntegrationEnabled,
lastSeen,
loadingFirstSeen,
loadingLastSeen,
loadingManagedUser,
managedUserDetails,
]
);
};
export const useObservedUser = (userName: string) => {
const { selectedPatterns } = useSourcererDataView();
const { to, from, isInitializing, deleteQuery, setQuery } = useGlobalTime();
const [loadingObservedUser, { userDetails: observedUserDetails, inspect, refetch, id: queryId }] =
useObservedUserDetails({
endDate: to,
startDate: from,
userName,
indexNames: selectedPatterns,
skip: isInitializing,
});
useQueryInspector({
deleteQuery,
inspect,
refetch,
setQuery,
queryId,
loading: loadingObservedUser,
});
const [loadingFirstSeen, { firstSeen }] = useFirstLastSeen({
field: 'user.name',
value: userName,
defaultIndex: selectedPatterns,
order: Direction.asc,
filterQuery: NOT_EVENT_KIND_ASSET_FILTER,
});
const [loadingLastSeen, { lastSeen }] = useFirstLastSeen({
field: 'user.name',
value: userName,
defaultIndex: selectedPatterns,
order: Direction.desc,
filterQuery: NOT_EVENT_KIND_ASSET_FILTER,
});
return useMemo(
() => ({
details: observedUserDetails,
isLoading: loadingObservedUser,
firstSeen: {
date: firstSeen,
isLoading: loadingFirstSeen,
},
lastSeen: { date: lastSeen, isLoading: loadingLastSeen },
}),
[
firstSeen,
lastSeen,
loadingFirstSeen,
loadingLastSeen,
loadingObservedUser,
observedUserDetails,
]
);
};

View file

@ -0,0 +1,81 @@
/*
* 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 { fireEvent, render } from '@testing-library/react';
import React from 'react';
import { TestProviders } from '../../../../common/mock';
import { ManagedUser } from './managed_user';
import { mockManagedUser } from './__mocks__';
describe('ManagedUser', () => {
const mockProps = {
managedUser: mockManagedUser,
contextID: '',
isDraggable: false,
};
it('renders', () => {
const { getByTestId } = render(
<TestProviders>
<ManagedUser {...mockProps} />
</TestProviders>
);
expect(getByTestId('managedUser-data')).toBeInTheDocument();
});
it('updates the accordion button title when visibility toggles', () => {
const { getByTestId } = render(
<TestProviders>
<ManagedUser {...mockProps} />
</TestProviders>
);
const accordionButton = getByTestId('managedUser-accordion-button');
expect(accordionButton).toHaveTextContent('Show Azure AD data');
fireEvent.click(accordionButton);
expect(accordionButton).toHaveTextContent('Hide Azure AD data');
});
it('renders the formatted date', () => {
const { getByTestId } = render(
<TestProviders>
<ManagedUser {...mockProps} />
</TestProviders>
);
expect(getByTestId('managedUser-data')).toHaveTextContent('Updated Mar 23, 2023');
});
it('renders enable integration callout when the integration is disabled', () => {
const { getByTestId } = render(
<TestProviders>
<ManagedUser
{...{
...mockProps,
managedUser: {
...mockManagedUser,
isIntegrationEnabled: false,
},
}}
/>
</TestProviders>
);
expect(getByTestId('managedUser-integration-disable-callout')).toBeInTheDocument();
});
it('renders phone number separated by comma', () => {
const { getByTestId } = render(
<TestProviders>
<ManagedUser {...mockProps} />
</TestProviders>
);
expect(getByTestId('managedUser-data')).toHaveTextContent('123456, 654321');
});
});

View file

@ -0,0 +1,157 @@
/*
* 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 {
EuiButton,
EuiSpacer,
EuiAccordion,
EuiTitle,
EuiPanel,
useEuiTheme,
EuiEmptyPrompt,
EuiCallOut,
} from '@elastic/eui';
import React, { useCallback, useMemo, useState } from 'react';
import { css } from '@emotion/react';
import { FormattedMessage } from '@kbn/i18n-react';
import * as i18n from './translations';
import { BasicTable } from '../../../../common/components/ml/tables/basic_table';
import { getManagedUserTableColumns } from './columns';
import { useManagedUserItems } from './hooks';
import { FormattedRelativePreferenceDate } from '../../../../common/components/formatted_date';
import type { ManagedUserData } from './types';
import { INSTALL_INTEGRATION_HREF, MANAGED_USER_QUERY_ID, ONE_WEEK_IN_HOURS } from './constants';
import { InspectButton, InspectButtonContainer } from '../../../../common/components/inspect';
export const ManagedUser = ({
managedUser,
contextID,
isDraggable,
}: {
managedUser: ManagedUserData;
contextID: string;
isDraggable: boolean;
}) => {
const { euiTheme } = useEuiTheme();
const managedItems = useManagedUserItems(managedUser.details);
const [isManagedDataToggleOpen, setManagedDataToggleOpen] = useState(false);
const onToggleManagedData = useCallback(() => {
setManagedDataToggleOpen((isOpen) => !isOpen);
}, [setManagedDataToggleOpen]);
const managedUserTableColumns = useMemo(
() => getManagedUserTableColumns(contextID, isDraggable),
[isDraggable, contextID]
);
if (!managedUser.isIntegrationEnabled) {
return (
<>
<EuiTitle size="s">
<h5>{i18n.MANAGED_DATA_TITLE}</h5>
</EuiTitle>
<EuiSpacer size="l" />
<EuiPanel data-test-subj="managedUser-integration-disable-callout">
<EuiEmptyPrompt
title={<h2>{i18n.NO_ACTIVE_INTEGRATION_TITLE}</h2>}
body={<p>{i18n.NO_ACTIVE_INTEGRATION_TEXT}</p>}
actions={
<EuiButton fill href={INSTALL_INTEGRATION_HREF}>
{i18n.ADD_EXTERNAL_INTEGRATION_BUTTON}
</EuiButton>
}
/>
</EuiPanel>
</>
);
}
return (
<>
<EuiTitle size="s">
<h5>{i18n.MANAGED_DATA_TITLE}</h5>
</EuiTitle>
<EuiSpacer size="l" />
<InspectButtonContainer>
<EuiAccordion
isLoading={managedUser.isLoading}
id={'managedUser-data'}
data-test-subj="managedUser-data"
forceState={isManagedDataToggleOpen ? 'open' : 'closed'}
buttonProps={{
'data-test-subj': 'managedUser-accordion-button',
css: css`
color: ${euiTheme.colors.primary};
`,
}}
buttonContent={
isManagedDataToggleOpen ? i18n.HIDE_AZURE_DATA_BUTTON : i18n.SHOW_AZURE_DATA_BUTTON
}
onToggle={onToggleManagedData}
extraAction={
<>
<span
css={css`
margin-right: ${euiTheme.size.s};
`}
>
<InspectButton
queryId={MANAGED_USER_QUERY_ID}
title={i18n.MANAGED_USER_INSPECT_TITLE}
/>
</span>
{managedUser.lastSeen.date && (
<FormattedMessage
id="xpack.securitySolution.timeline.userDetails.updatedTime"
defaultMessage="Updated {time}"
values={{
time: (
<FormattedRelativePreferenceDate
value={managedUser.lastSeen.date}
dateFormat="MMM D, YYYY"
relativeThresholdInHrs={ONE_WEEK_IN_HOURS}
/>
),
}}
/>
)}
</>
}
css={css`
.euiAccordion__optionalAction {
margin-left: auto;
}
`}
>
<EuiPanel color="subdued">
{managedItems || managedUser.isLoading ? (
<BasicTable
loading={managedUser.isLoading}
data-test-subj="managedUser-table"
columns={managedUserTableColumns}
items={managedItems ?? []}
/>
) : (
<>
<EuiCallOut
data-test-subj="managedUser-no-data"
title={i18n.NO_AZURE_DATA_TITLE}
color="warning"
iconType="help"
>
<p>{i18n.NO_AZURE_DATA_TEXT}</p>
</EuiCallOut>
</>
)}
</EuiPanel>
</EuiAccordion>
</InspectButtonContainer>
</>
);
};

View file

@ -0,0 +1,63 @@
/*
* 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 { fireEvent, render } from '@testing-library/react';
import React from 'react';
import { TestProviders } from '../../../../common/mock';
import { mockObservedUser } from './__mocks__';
import { ObservedUser } from './observed_user';
describe('ObservedUser', () => {
const mockProps = {
observedUser: mockObservedUser,
contextID: '',
isDraggable: false,
};
it('renders', () => {
const { getByTestId } = render(
<TestProviders>
<ObservedUser {...mockProps} />
</TestProviders>
);
expect(getByTestId('observedUser-data')).toBeInTheDocument();
});
it('updates the accordion button title when visibility toggles', () => {
const { getByTestId } = render(
<TestProviders>
<ObservedUser {...mockProps} />
</TestProviders>
);
const accordionButton = getByTestId('observedUser-accordion-button');
expect(accordionButton).toHaveTextContent('Show observed data');
fireEvent.click(accordionButton);
expect(accordionButton).toHaveTextContent('Hide observed data');
});
it('renders the formatted date', () => {
const { getByTestId } = render(
<TestProviders>
<ObservedUser {...mockProps} />
</TestProviders>
);
expect(getByTestId('observedUser-data')).toHaveTextContent('Updated Feb 23, 2023');
});
it('renders anomaly score', () => {
const { getByTestId } = render(
<TestProviders>
<ObservedUser {...mockProps} />
</TestProviders>
);
expect(getByTestId('anomaly-score')).toHaveTextContent('17');
});
});

View file

@ -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 { EuiAccordion, EuiSpacer, EuiTitle, useEuiTheme, EuiPanel } from '@elastic/eui';
import React, { useCallback, useMemo, useState } from 'react';
import { css } from '@emotion/react';
import { FormattedMessage } from '@kbn/i18n-react';
import * as i18n from './translations';
import type { ObservedUserData } from './types';
import { useObservedUserItems } from './hooks';
import { BasicTable } from '../../../../common/components/ml/tables/basic_table';
import { FormattedRelativePreferenceDate } from '../../../../common/components/formatted_date';
import { getObservedUserTableColumns } from './columns';
import { ONE_WEEK_IN_HOURS } from './constants';
import { InspectButton, InspectButtonContainer } from '../../../../common/components/inspect';
import { OBSERVED_USER_QUERY_ID } from '../../../../explore/users/containers/users/observed_details';
export const ObservedUser = ({
observedUser,
contextID,
isDraggable,
}: {
observedUser: ObservedUserData;
contextID: string;
isDraggable: boolean;
}) => {
const { euiTheme } = useEuiTheme();
const observedItems = useObservedUserItems(observedUser);
const [isObservedDataToggleOpen, setObservedDataToggleOpen] = useState(false);
const onToggleObservedData = useCallback(() => {
setObservedDataToggleOpen((isOpen) => !isOpen);
}, [setObservedDataToggleOpen]);
const observedUserTableColumns = useMemo(
() => getObservedUserTableColumns(contextID, isDraggable),
[contextID, isDraggable]
);
return (
<>
<EuiTitle size="s">
<h5>{i18n.OBSERVED_DATA_TITLE}</h5>
</EuiTitle>
<EuiSpacer size="l" />
<InspectButtonContainer>
<EuiAccordion
isLoading={observedUser.isLoading}
id="observedUser-data"
data-test-subj="observedUser-data"
forceState={isObservedDataToggleOpen ? 'open' : 'closed'}
buttonProps={{
'data-test-subj': 'observedUser-accordion-button',
css: css`
color: ${euiTheme.colors.primary};
`,
}}
buttonContent={
isObservedDataToggleOpen
? i18n.HIDE_OBSERVED_DATA_BUTTON
: i18n.SHOW_OBSERVED_DATA_BUTTON
}
onToggle={onToggleObservedData}
extraAction={
<>
<span
css={css`
margin-right: ${euiTheme.size.s};
`}
>
<InspectButton
queryId={OBSERVED_USER_QUERY_ID}
title={i18n.OBSERVED_USER_INSPECT_TITLE}
/>
</span>
{observedUser.lastSeen.date && (
<FormattedMessage
id="xpack.securitySolution.timeline.userDetails.observedUserUpdatedTime"
defaultMessage="Updated {time}"
values={{
time: (
<FormattedRelativePreferenceDate
value={observedUser.lastSeen.date}
dateFormat="MMM D, YYYY"
relativeThresholdInHrs={ONE_WEEK_IN_HOURS}
/>
),
}}
/>
)}
</>
}
css={css`
.euiAccordion__optionalAction {
margin-left: auto;
}
`}
>
<EuiPanel color="subdued">
<BasicTable
loading={
observedUser.isLoading ||
observedUser.firstSeen.isLoading ||
observedUser.lastSeen.isLoading ||
observedUser.anomalies.isLoading
}
data-test-subj="observedUser-table"
columns={observedUserTableColumns}
items={observedItems}
/>
</EuiPanel>
</EuiAccordion>
</InspectButtonContainer>
</>
);
};

View file

@ -0,0 +1,46 @@
/*
* 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 { render } from '@testing-library/react';
import React from 'react';
import { TestProviders } from '../../../../common/mock';
import { RiskScoreField } from './risk_score_field';
import { mockRiskScoreState } from './__mocks__';
import { getEmptyValue } from '../../../../common/components/empty_value';
describe('RiskScoreField', () => {
it('renders', () => {
const { getByTestId } = render(
<TestProviders>
<RiskScoreField riskScoreState={mockRiskScoreState} />
</TestProviders>
);
expect(getByTestId('user-details-risk-score')).toBeInTheDocument();
expect(getByTestId('user-details-risk-score')).toHaveTextContent('70');
});
it('does not render content when the license is invalid', () => {
const { queryByTestId } = render(
<TestProviders>
<RiskScoreField riskScoreState={{ ...mockRiskScoreState, isLicenseValid: false }} />
</TestProviders>
);
expect(queryByTestId('user-details-risk-score')).not.toBeInTheDocument();
});
it('renders empty tag when risk score is undefined', () => {
const { getByTestId } = render(
<TestProviders>
<RiskScoreField riskScoreState={{ ...mockRiskScoreState, data: [] }} />
</TestProviders>
);
expect(getByTestId('user-details-risk-score')).toHaveTextContent(getEmptyValue());
});
});

View file

@ -0,0 +1,68 @@
/*
* 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 { EuiFlexItem, EuiFlexGroup, useEuiFontSize, useEuiTheme } from '@elastic/eui';
import React from 'react';
import { css } from '@emotion/react';
import * as i18n from './translations';
import type { RiskScoreEntity } from '../../../../../common/search_strategy';
import { RiskSeverity } from '../../../../../common/search_strategy';
import { getEmptyTagValue } from '../../../../common/components/empty_value';
import { RiskScore } from '../../../../explore/components/risk_score/severity/common';
import type { RiskScoreState } from '../../../../explore/containers/risk_score';
export const RiskScoreField = ({
riskScoreState,
}: {
riskScoreState: RiskScoreState<RiskScoreEntity.user>;
}) => {
const { euiTheme } = useEuiTheme();
const { fontSize: xsFontSize } = useEuiFontSize('xs');
const { data: userRisk, isLicenseValid: isRiskLicenseValid } = riskScoreState;
const userRiskData = userRisk && userRisk.length > 0 ? userRisk[0] : undefined;
if (!isRiskLicenseValid) {
return null;
}
return (
<EuiFlexGroup
alignItems="center"
gutterSize="none"
responsive={false}
data-test-subj="user-details-risk-score"
>
<EuiFlexItem grow={false}>
<span
css={css`
font-weight: ${euiTheme.font.weight.bold};
font-size: ${xsFontSize};
margin-right: ${euiTheme.size.xs};
`}
>
{i18n.RISK_SCORE}
{': '}
</span>
</EuiFlexItem>
{userRiskData ? (
<>
<EuiFlexItem grow={false}>
{Math.round(userRiskData.user.risk.calculated_score_norm)}
</EuiFlexItem>
<EuiFlexItem grow={false}>
<RiskScore severity={RiskSeverity.high} hideBackgroundColor />
</EuiFlexItem>
</>
) : (
getEmptyTagValue()
)}
</EuiFlexGroup>
);
};

View file

@ -0,0 +1,222 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const OBSERVED_BADGE = i18n.translate(
'xpack.securitySolution.timeline.userDetails.observedBadge',
{
defaultMessage: 'OBSERVED',
}
);
export const MANAGED_BADGE = i18n.translate(
'xpack.securitySolution.timeline.userDetails.managedBadge',
{
defaultMessage: 'MANAGED',
}
);
export const USER = i18n.translate('xpack.securitySolution.timeline.userDetails.userLabel', {
defaultMessage: 'User',
});
export const FAIL_MANAGED_USER = i18n.translate(
'xpack.securitySolution.timeline.userDetails.failManagedUserDescription',
{
defaultMessage: 'Failed to run search on user managed data',
}
);
export const MANAGED_DATA_TITLE = i18n.translate(
'xpack.securitySolution.timeline.userDetails.managedDataTitle',
{
defaultMessage: 'Managed data',
}
);
export const OBSERVED_DATA_TITLE = i18n.translate(
'xpack.securitySolution.timeline.userDetails.observedDataTitle',
{
defaultMessage: 'Observed data',
}
);
export const HIDE_OBSERVED_DATA_BUTTON = i18n.translate(
'xpack.securitySolution.timeline.userDetails.hideObservedDataButton',
{
defaultMessage: 'Hide observed data',
}
);
export const SHOW_OBSERVED_DATA_BUTTON = i18n.translate(
'xpack.securitySolution.timeline.userDetails.showObservedDataButton',
{
defaultMessage: 'Show observed data',
}
);
export const HIDE_AZURE_DATA_BUTTON = i18n.translate(
'xpack.securitySolution.timeline.userDetails.hideManagedDataButton',
{
defaultMessage: 'Hide Azure AD data',
}
);
export const SHOW_AZURE_DATA_BUTTON = i18n.translate(
'xpack.securitySolution.timeline.userDetails.showManagedDataButton',
{
defaultMessage: 'Show Azure AD data',
}
);
export const RISK_SCORE = i18n.translate(
'xpack.securitySolution.timeline.userDetails.riskScoreLabel',
{
defaultMessage: 'Risk score',
}
);
export const VALUES_COLUMN_TITLE = i18n.translate(
'xpack.securitySolution.timeline.userDetails.valuesColumnTitle',
{
defaultMessage: 'Values',
}
);
export const FIELD_COLUMN_TITLE = i18n.translate(
'xpack.securitySolution.timeline.userDetails.fieldColumnTitle',
{
defaultMessage: 'Field',
}
);
export const USER_ID = i18n.translate('xpack.securitySolution.timeline.userDetails.userIdLabel', {
defaultMessage: 'User ID',
});
export const MAX_ANOMALY_SCORE_BY_JOB = i18n.translate(
'xpack.securitySolution.timeline.userDetails.maxAnomalyScoreByJobLabel',
{
defaultMessage: 'Max anomaly score by job',
}
);
export const FIRST_SEEN = i18n.translate(
'xpack.securitySolution.timeline.userDetails.firstSeenLabel',
{
defaultMessage: 'First seen',
}
);
export const LAST_SEEN = i18n.translate(
'xpack.securitySolution.timeline.userDetails.lastSeenLabel',
{
defaultMessage: 'Last seen',
}
);
export const OPERATING_SYSTEM_TITLE = i18n.translate(
'xpack.securitySolution.timeline.userDetails.hostOsNameLabel',
{
defaultMessage: 'Operating system',
}
);
export const FAMILY = i18n.translate('xpack.securitySolution.timeline.userDetails.familyLabel', {
defaultMessage: 'Family',
});
export const IP_ADDRESSES = i18n.translate(
'xpack.securitySolution.timeline.userDetails.ipAddressesLabel',
{
defaultMessage: 'IP addresses',
}
);
export const FULL_NAME = i18n.translate(
'xpack.securitySolution.timeline.userDetails.fullNameLabel',
{
defaultMessage: 'Full name',
}
);
export const FIRST_NAME = i18n.translate(
'xpack.securitySolution.timeline.userDetails.firstNameLabel',
{
defaultMessage: 'First name',
}
);
export const LAST_NAME = i18n.translate(
'xpack.securitySolution.timeline.userDetails.lastNameLabel',
{
defaultMessage: 'Last name',
}
);
export const PHONE = i18n.translate('xpack.securitySolution.timeline.userDetails.phoneLabel', {
defaultMessage: 'Phone',
});
export const NO_ACTIVE_INTEGRATION_TITLE = i18n.translate(
'xpack.securitySolution.timeline.userDetails.noActiveIntegrationTitle',
{
defaultMessage: 'You dont have any active integrations',
}
);
export const NO_ACTIVE_INTEGRATION_TEXT = i18n.translate(
'xpack.securitySolution.timeline.userDetails.noActiveIntegrationText',
{
defaultMessage:
'External integrations can provide additional metadata and help you manage users.',
}
);
export const ADD_EXTERNAL_INTEGRATION_BUTTON = i18n.translate(
'xpack.securitySolution.timeline.userDetails.addExternalIntegrationButton',
{
defaultMessage: 'Add external integrations',
}
);
export const NO_AZURE_DATA_TITLE = i18n.translate(
'xpack.securitySolution.timeline.userDetails.noAzureDataTitle',
{
defaultMessage: 'No metadata found for this user',
}
);
export const NO_AZURE_DATA_TEXT = i18n.translate(
'xpack.securitySolution.timeline.userDetails.noAzureDataText',
{
defaultMessage:
'If you expected to see metadata for this user, make sure you have configured your integrations properly.',
}
);
export const CLOSE_BUTTON = i18n.translate(
'xpack.securitySolution.timeline.userDetails.closeButton',
{
defaultMessage: 'close',
}
);
export const OBSERVED_USER_INSPECT_TITLE = i18n.translate(
'xpack.securitySolution.timeline.userDetails.observedUserInspectTitle',
{
defaultMessage: 'Observed user',
}
);
export const MANAGED_USER_INSPECT_TITLE = i18n.translate(
'xpack.securitySolution.timeline.userDetails.managedUserInspectTitle',
{
defaultMessage: 'Managed user',
}
);

View file

@ -0,0 +1,53 @@
/*
* 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 type { EuiBasicTableColumn } from '@elastic/eui';
import type { UserItem } from '../../../../../common/search_strategy';
import type { AzureManagedUser } from '../../../../../common/search_strategy/security_solution/users/managed_details';
import type { AnomalyTableProviderChildrenProps } from '../../../../common/components/ml/anomaly/anomaly_table_provider';
export interface ObservedUserTable {
label: string;
values: string[] | null | undefined | UserAnomalies;
field: string;
}
export interface ManagedUserTable {
label: string;
value: string | null | undefined;
field?: string;
}
export type ObservedUsersTableColumns = Array<EuiBasicTableColumn<ObservedUserTable>>;
export type ManagedUsersTableColumns = Array<EuiBasicTableColumn<ManagedUserTable>>;
export interface ObservedUserData {
isLoading: boolean;
details: UserItem;
firstSeen: FirstLastSeenData;
lastSeen: FirstLastSeenData;
anomalies: UserAnomalies;
}
export interface ManagedUserData {
isLoading: boolean;
details: AzureManagedUser | undefined;
isIntegrationEnabled: boolean;
firstSeen: FirstLastSeenData;
lastSeen: FirstLastSeenData;
}
export interface FirstLastSeenData {
date: string | null | undefined;
isLoading: boolean;
}
export interface UserAnomalies {
isLoading: AnomalyTableProviderChildrenProps['isLoadingAnomaliesData'];
anomalies: AnomalyTableProviderChildrenProps['anomaliesData'];
jobNameById: AnomalyTableProviderChildrenProps['jobNameById'];
}

View file

@ -0,0 +1,157 @@
/*
* 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 { storiesOf, addDecorator } from '@storybook/react';
import { EuiFlyout, EuiFlyoutBody } from '@elastic/eui';
import { UserDetailsContentComponent } from './user_details_content';
import { StorybookProviders } from '../../../../common/mock/storybook_providers';
import { mockManagedUser, mockObservedUser, mockRiskScoreState } from './__mocks__';
addDecorator((storyFn) => (
<StorybookProviders>
<EuiFlyout size="m" onClose={() => {}}>
<EuiFlyoutBody>{storyFn()}</EuiFlyoutBody>
</EuiFlyout>
</StorybookProviders>
));
storiesOf('UserDetailsContent', module)
.add('default', () => (
<UserDetailsContentComponent
userName="test"
managedUser={mockManagedUser}
observedUser={mockObservedUser}
riskScoreState={mockRiskScoreState}
contextID={'test-user-details'}
isDraggable={false}
/>
))
.add('integration disabled', () => (
<UserDetailsContentComponent
userName="test"
managedUser={{
details: undefined,
isLoading: false,
isIntegrationEnabled: false,
firstSeen: {
isLoading: false,
date: undefined,
},
lastSeen: {
isLoading: false,
date: undefined,
},
}}
observedUser={mockObservedUser}
riskScoreState={mockRiskScoreState}
contextID={'test-user-details'}
isDraggable={false}
/>
))
.add('no managed data', () => (
<UserDetailsContentComponent
userName="test"
managedUser={{
details: undefined,
isLoading: false,
isIntegrationEnabled: true,
firstSeen: {
isLoading: false,
date: undefined,
},
lastSeen: {
isLoading: false,
date: undefined,
},
}}
observedUser={mockObservedUser}
riskScoreState={mockRiskScoreState}
contextID={'test-user-details'}
isDraggable={false}
/>
))
.add('no observed data', () => (
<UserDetailsContentComponent
userName="test"
managedUser={mockManagedUser}
observedUser={{
details: {
user: {
id: [],
domain: [],
},
host: {
ip: [],
os: {
name: [],
family: [],
},
},
},
isLoading: false,
firstSeen: {
isLoading: false,
date: undefined,
},
lastSeen: {
isLoading: false,
date: undefined,
},
anomalies: { isLoading: false, anomalies: null, jobNameById: {} },
}}
riskScoreState={mockRiskScoreState}
contextID={'test-user-details'}
isDraggable={false}
/>
))
.add('loading', () => (
<UserDetailsContentComponent
userName="test"
managedUser={{
details: undefined,
isLoading: true,
isIntegrationEnabled: true,
firstSeen: {
isLoading: true,
date: undefined,
},
lastSeen: {
isLoading: true,
date: undefined,
},
}}
observedUser={{
details: {
user: {
id: [],
domain: [],
},
host: {
ip: [],
os: {
name: [],
family: [],
},
},
},
isLoading: true,
firstSeen: {
isLoading: true,
date: undefined,
},
lastSeen: {
isLoading: true,
date: undefined,
},
anomalies: { isLoading: true, anomalies: null, jobNameById: {} },
}}
riskScoreState={mockRiskScoreState}
contextID={'test-user-details'}
isDraggable={false}
/>
));

View file

@ -0,0 +1,130 @@
/*
* 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 { render } from '@testing-library/react';
import React from 'react';
import { TestProviders } from '../../../../common/mock';
import { mockManagedUser, mockObservedUser, mockRiskScoreState } from './__mocks__';
import { UserDetailsContentComponent } from './user_details_content';
const mockProps = {
userName: 'test',
managedUser: mockManagedUser,
observedUser: mockObservedUser,
riskScoreState: mockRiskScoreState,
contextID: 'test-user-details',
isDraggable: false,
};
describe('UserDetailsContentComponent', () => {
it('renders', () => {
const { getByTestId } = render(
<TestProviders>
<UserDetailsContentComponent {...mockProps} />
</TestProviders>
);
expect(getByTestId('user-details-content-header')).toBeInTheDocument();
});
it('renders observed user date when it is bigger than managed user date', () => {
const futureDay = '2989-03-07T20:00:00.000Z';
const { getByTestId } = render(
<TestProviders>
<UserDetailsContentComponent
{...{
...mockProps,
observedUser: {
...mockObservedUser,
lastSeen: {
isLoading: false,
date: futureDay,
},
},
}}
/>
</TestProviders>
);
expect(getByTestId('user-details-content-lastSeen').textContent).toContain('Mar 7, 2989');
});
it('renders managed user date when it is bigger than observed user date', () => {
const futureDay = '2989-03-07T20:00:00.000Z';
const { getByTestId } = render(
<TestProviders>
<UserDetailsContentComponent
{...{
...mockProps,
managedUser: {
...mockManagedUser,
lastSeen: {
isLoading: false,
date: futureDay,
},
},
}}
/>
</TestProviders>
);
expect(getByTestId('user-details-content-lastSeen').textContent).toContain('Mar 7, 2989');
});
it('renders observed and managed badges when lastSeen is defined', () => {
const { getByTestId } = render(
<TestProviders>
<UserDetailsContentComponent {...mockProps} />
</TestProviders>
);
expect(getByTestId('user-details-content-observed-badge')).toBeInTheDocument();
expect(getByTestId('user-details-content-managed-badge')).toBeInTheDocument();
});
it('does not render observed badge when lastSeen date is undefined', () => {
const { queryByTestId } = render(
<TestProviders>
<UserDetailsContentComponent
{...{
...mockProps,
observedUser: {
...mockObservedUser,
lastSeen: {
isLoading: false,
date: undefined,
},
},
}}
/>
</TestProviders>
);
expect(queryByTestId('user-details-content-observed-badge')).not.toBeInTheDocument();
});
it('does not render managed badge when lastSeen date is undefined', () => {
const { queryByTestId } = render(
<TestProviders>
<UserDetailsContentComponent
{...{
...mockProps,
managedUser: {
...mockManagedUser,
lastSeen: {
isLoading: false,
date: undefined,
},
},
}}
/>
</TestProviders>
);
expect(queryByTestId('user-details-content-managed-badge')).not.toBeInTheDocument();
});
});

View file

@ -0,0 +1,182 @@
/*
* 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 {
EuiSpacer,
EuiHorizontalRule,
EuiIcon,
EuiBadge,
EuiText,
EuiFlexItem,
EuiFlexGroup,
useEuiFontSize,
useEuiTheme,
euiTextBreakWord,
EuiProgress,
} from '@elastic/eui';
import React, { useMemo } from 'react';
import { css } from '@emotion/react';
import { max } from 'lodash';
import * as i18n from './translations';
import { RiskScoreEntity } from '../../../../../common/search_strategy';
import { UserDetailsLink } from '../../../../common/components/links';
import { useGlobalTime } from '../../../../common/containers/use_global_time';
import type { RiskScoreState } from '../../../../explore/containers/risk_score';
import { useRiskScore } from '../../../../explore/containers/risk_score';
import { useManagedUser, useObservedUser } from './hooks';
import { AnomalyTableProvider } from '../../../../common/components/ml/anomaly/anomaly_table_provider';
import { getCriteriaFromUsersType } from '../../../../common/components/ml/criteria/get_criteria_from_users_type';
import { UsersType } from '../../../../explore/users/store/model';
import { PreferenceFormattedDate } from '../../../../common/components/formatted_date';
import type { ManagedUserData, ObservedUserData } from './types';
import { RiskScoreField } from './risk_score_field';
import { ObservedUser } from './observed_user';
import { ManagedUser } from './managed_user';
export const QUERY_ID = 'usersDetailsQuery';
interface UserDetailsContentComponentProps {
userName: string;
observedUser: ObservedUserData;
managedUser: ManagedUserData;
riskScoreState: RiskScoreState<RiskScoreEntity.user>;
contextID: string;
isDraggable: boolean;
}
/**
* This is a visual component. It doesn't access any external Context or API.
* It designed for unit testing the UI and previewing changes on storybook.
*/
export const UserDetailsContentComponent = ({
userName,
observedUser,
managedUser,
riskScoreState,
contextID,
isDraggable,
}: UserDetailsContentComponentProps) => {
const { euiTheme } = useEuiTheme();
const { fontSize: xlFontSize } = useEuiFontSize('xl');
const lastSeenDate = useMemo(
() =>
max([observedUser.lastSeen, managedUser.lastSeen].map((el) => el.date && new Date(el.date))),
[managedUser.lastSeen, observedUser.lastSeen]
);
return (
<>
<EuiFlexGroup
gutterSize="m"
alignItems="center"
responsive={false}
data-test-subj="user-details-content-header"
>
<EuiFlexItem grow={false}>
<EuiIcon type="user" size="m" />
</EuiFlexItem>
<EuiFlexItem grow={false}>{i18n.USER}</EuiFlexItem>
<EuiFlexItem grow={false}>
{observedUser.lastSeen.date && (
<EuiBadge data-test-subj="user-details-content-observed-badge" color="hollow">
{i18n.OBSERVED_BADGE}
</EuiBadge>
)}
</EuiFlexItem>
<EuiFlexItem grow={false}>
{managedUser.lastSeen.date && (
<EuiBadge data-test-subj="user-details-content-managed-badge" color="hollow">
{i18n.MANAGED_BADGE}
</EuiBadge>
)}
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
{observedUser.lastSeen.isLoading || managedUser.lastSeen.isLoading ? (
<EuiProgress size="xs" color="accent" />
) : (
<EuiHorizontalRule margin="none" />
)}
<EuiSpacer size="m" />
<UserDetailsLink userName={userName}>
<span
css={css`
font-size: ${xlFontSize};
font-weight: ${euiTheme.font.weight.bold};
${euiTextBreakWord()}
`}
>
{userName}
</span>
</UserDetailsLink>
<EuiSpacer size="m" />
<EuiText size="xs" data-test-subj={'user-details-content-lastSeen'}>
{i18n.LAST_SEEN}
{': '}
{lastSeenDate && <PreferenceFormattedDate value={lastSeenDate} />}
</EuiText>
<EuiHorizontalRule margin="xs" />
<RiskScoreField riskScoreState={riskScoreState} />
<EuiHorizontalRule margin="xs" />
<EuiSpacer size="xxl" />
<ObservedUser observedUser={observedUser} contextID={contextID} isDraggable={isDraggable} />
<EuiSpacer />
<ManagedUser managedUser={managedUser} contextID={contextID} isDraggable={isDraggable} />
</>
);
};
export const UserDetailsContent = ({
userName,
contextID,
isDraggable = false,
}: {
userName: string;
contextID: string;
isDraggable?: boolean;
}) => {
const { to, from, isInitializing } = useGlobalTime();
const riskScoreState = useRiskScore({
riskEntity: RiskScoreEntity.user,
});
const observedUser = useObservedUser(userName);
const managedUser = useManagedUser(userName);
return (
<AnomalyTableProvider
criteriaFields={getCriteriaFromUsersType(UsersType.details, userName)}
startDate={from}
endDate={to}
skip={isInitializing}
>
{({ isLoadingAnomaliesData, anomaliesData, jobNameById }) => (
<UserDetailsContentComponent
userName={userName}
managedUser={managedUser}
observedUser={{
...observedUser,
anomalies: {
isLoading: isLoadingAnomaliesData,
anomalies: anomaliesData,
jobNameById,
},
}}
riskScoreState={riskScoreState}
contextID={contextID}
isDraggable={isDraggable}
/>
)}
</AnomalyTableProvider>
);
};

View file

@ -13,7 +13,7 @@ import { useDispatch } from 'react-redux';
import { InputsModelId } from '../../../../common/store/inputs/constants';
import { UserDetailsLink } from '../../../../common/components/links';
import { UserOverview } from '../../../../overview/components/user_overview';
import { useUserDetails } from '../../../../explore/users/containers/users/details';
import { useObservedUserDetails } from '../../../../explore/users/containers/users/observed_details';
import { useGlobalTime } from '../../../../common/containers/use_global_time';
import { useSourcererDataView } from '../../../../common/containers/sourcerer';
import { setAbsoluteRangeDatePicker } from '../../../../common/store/inputs/actions';
@ -61,7 +61,7 @@ export const ExpandableUserDetails = ({
const { selectedPatterns } = useSourcererDataView();
const dispatch = useDispatch();
const [loading, { userDetails }] = useUserDetails({
const [loading, { userDetails }] = useObservedUserDetails({
endDate: to,
startDate: from,
userName,

View file

@ -6,9 +6,13 @@
*/
import React from 'react';
import { EuiFlyoutBody, EuiSpacer, EuiButtonIcon } from '@elastic/eui';
import { css } from '@emotion/react';
import { UserDetailsFlyout } from './user_details_flyout';
import { UserDetailsSidePanel } from './user_details_side_panel';
import type { UserDetailsProps } from './types';
import { UserDetailsContent } from '../new_user_detail/user_details_content';
import * as i18n from './translations';
const UserDetailsPanelComponent = ({
contextID,
@ -16,7 +20,30 @@ const UserDetailsPanelComponent = ({
handleOnClose,
isFlyoutView,
isDraggable,
isNewUserDetailsFlyoutEnable,
}: UserDetailsProps) => {
if (isNewUserDetailsFlyoutEnable) {
return isFlyoutView ? (
<EuiFlyoutBody>
<UserDetailsContent userName={userName} contextID={contextID} isDraggable={isDraggable} />
</EuiFlyoutBody>
) : (
<div className="eui-yScroll">
<EuiSpacer size="m" />
<EuiButtonIcon
iconType="cross"
aria-label={i18n.CLOSE_BUTTON}
onClick={handleOnClose}
css={css`
float: right;
`}
/>
<UserDetailsContent userName={userName} contextID={contextID} isDraggable={isDraggable} />
</div>
);
}
return isFlyoutView ? (
<UserDetailsFlyout userName={userName} contextID={contextID} />
) : (

View file

@ -0,0 +1,15 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const CLOSE_BUTTON = i18n.translate(
'xpack.securitySolution.timeline.userDetails.closeButton',
{
defaultMessage: 'close',
}
);

View file

@ -11,4 +11,5 @@ export interface UserDetailsProps {
handleOnClose: () => void;
isFlyoutView?: boolean;
isDraggable?: boolean;
isNewUserDetailsFlyoutEnable?: boolean;
}

View file

@ -6,14 +6,16 @@
*/
import type { FirstLastSeenRequestOptions } from '../../../../../common/search_strategy/security_solution/first_last_seen';
import { createQueryFilterClauses } from '../../../../utils/build_query';
export const buildFirstOrLastSeenQuery = ({
field,
value,
defaultIndex,
order,
filterQuery,
}: FirstLastSeenRequestOptions) => {
const filter = [{ term: { [field]: value } }];
const filter = [...createQueryFilterClauses(filterQuery), { term: { [field]: value } }];
const dslQuery = {
allow_no_indices: true,

View file

@ -42,7 +42,6 @@ export const allUsers: SecuritySolutionFactory<UsersQueries.users> = {
deps?: {
esClient: IScopedClusterClient;
spaceId?: string;
// endpointContext: EndpointAppContext;
}
): Promise<UsersStrategyResponse> => {
const { activePage, cursorStart, fakePossibleCount, querySize } = options.pagination;

View file

@ -11,12 +11,14 @@ import { UsersQueries } from '../../../../../common/search_strategy/security_sol
import type { SecuritySolutionFactory } from '../types';
import { allUsers } from './all';
import { authentications } from './authentications';
import { userDetails } from './details';
import { managedUserDetails } from './managed_details';
import { usersKpiAuthentications } from './kpi/authentications';
import { totalUsersKpi } from './kpi/total_users';
import { observedUserDetails } from './observed_details';
export const usersFactory: Record<UsersQueries, SecuritySolutionFactory<FactoryQueryTypes>> = {
[UsersQueries.details]: userDetails,
[UsersQueries.observedDetails]: observedUserDetails,
[UsersQueries.managedDetails]: managedUserDetails,
[UsersQueries.kpiTotalUsers]: totalUsersKpi,
[UsersQueries.users]: allUsers,
[UsersQueries.authentications]: authentications,

View file

@ -0,0 +1,173 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`userDetails search strategy parse should parse data correctly 1`] = `
Object {
"inspect": Object {
"dsl": Array [
"{
\\"allow_no_indices\\": true,
\\"index\\": [
\\"logs-*\\"
],
\\"ignore_unavailable\\": true,
\\"track_total_hits\\": false,
\\"body\\": {
\\"query\\": {
\\"bool\\": {
\\"filter\\": [
{
\\"term\\": {
\\"user.name\\": \\"test-user-name\\"
}
},
{
\\"term\\": {
\\"event.kind\\": \\"asset\\"
}
}
]
}
},
\\"size\\": 1
},
\\"sort\\": [
{
\\"@timestamp\\": \\"desc\\"
}
]
}",
],
},
"isPartial": false,
"isRunning": false,
"loaded": 21,
"rawResponse": Object {
"_shards": Object {
"failed": 0,
"skipped": 1,
"successful": 2,
"total": 2,
},
"hits": Object {
"hits": Array [
Object {
"_id": "9AxbIocB-WLv2258YZtS",
"_index": ".test",
"_score": null,
"_source": Object {
"@timestamp": "2023-02-23T20:03:17.489Z",
"agent": Object {
"ephemeral_id": "914fd1fa-aa37-4ab4-b36d-972ab9b19cde",
"id": "9528bb69-1511-4631-a5af-1d7e93c02009",
"name": "docker-fleet-agent",
"type": "filebeat",
"version": "8.8.0",
},
"event": Object {
"action": "user-discovered",
"agent_id_status": "verified",
"dataset": "entityanalytics_azure.users",
"ingested": "2023-02-23T20:03:18Z",
"kind": "asset",
"provider": "Azure AD",
"type": Array [
"user",
"info",
],
},
"host": Object {
"architecture": "x86_64",
"hostname": "docker-fleet-agent",
"id": "cff3d165179d4aef9596ddbb263e3adb",
"ip": Array [
"172.26.0.7",
],
"mac": Array [
"02-42-AC-1A-00-07",
],
"name": "docker-fleet-agent",
"os": Object {
"family": "debian",
"kernel": "5.10.47-linuxkit",
"name": "Ubuntu",
"platform": "ubuntu",
"type": "linux",
"version": "20.04.5 LTS (Focal Fossa)",
},
},
"user": Object {
"email": "tes.user@elastic.co",
"first_name": "Taylor",
"full_name": "Test user",
"id": "39fac578-91fb-47f6-8f7a-fab05ce70d8b",
"last_name": "Test last name",
"phone": Array [
"1235559999",
],
},
},
"sort": Array [
1677182597489,
],
},
],
"max_score": null,
},
"timed_out": false,
"took": 124,
},
"total": 21,
"userDetails": Object {
"@timestamp": "2023-02-23T20:03:17.489Z",
"agent": Object {
"ephemeral_id": "914fd1fa-aa37-4ab4-b36d-972ab9b19cde",
"id": "9528bb69-1511-4631-a5af-1d7e93c02009",
"name": "docker-fleet-agent",
"type": "filebeat",
"version": "8.8.0",
},
"event": Object {
"action": "user-discovered",
"agent_id_status": "verified",
"dataset": "entityanalytics_azure.users",
"ingested": "2023-02-23T20:03:18Z",
"kind": "asset",
"provider": "Azure AD",
"type": Array [
"user",
"info",
],
},
"host": Object {
"architecture": "x86_64",
"hostname": "docker-fleet-agent",
"id": "cff3d165179d4aef9596ddbb263e3adb",
"ip": Array [
"172.26.0.7",
],
"mac": Array [
"02-42-AC-1A-00-07",
],
"name": "docker-fleet-agent",
"os": Object {
"family": "debian",
"kernel": "5.10.47-linuxkit",
"name": "Ubuntu",
"platform": "ubuntu",
"type": "linux",
"version": "20.04.5 LTS (Focal Fossa)",
},
},
"user": Object {
"email": "tes.user@elastic.co",
"first_name": "Taylor",
"full_name": "Test user",
"id": "39fac578-91fb-47f6-8f7a-fab05ce70d8b",
"last_name": "Test last name",
"phone": Array [
"1235559999",
],
},
},
}
`;

View file

@ -0,0 +1,36 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`buildManagedUserDetailsQuery build query from options correctly 1`] = `
Object {
"allow_no_indices": true,
"body": Object {
"query": Object {
"bool": Object {
"filter": Array [
Object {
"term": Object {
"user.name": "test-user-name",
},
},
Object {
"term": Object {
"event.kind": "asset",
},
},
],
},
},
"size": 1,
},
"ignore_unavailable": true,
"index": Array [
"logs-*",
],
"sort": Array [
Object {
"@timestamp": "desc",
},
],
"track_total_hits": false,
}
`;

View file

@ -0,0 +1,112 @@
/*
* 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 * as buildQuery from './query.managed_user_details.dsl';
import { managedUserDetails } from '.';
import type {
AzureManagedUser,
ManagedUserDetailsRequestOptions,
} from '../../../../../../common/search_strategy/security_solution/users/managed_details';
import type { IEsSearchResponse } from '@kbn/data-plugin/public';
export const mockOptions: ManagedUserDetailsRequestOptions = {
defaultIndex: ['logs-*'],
userName: 'test-user-name',
};
export const mockSearchStrategyResponse: IEsSearchResponse<AzureManagedUser> = {
isPartial: false,
isRunning: false,
rawResponse: {
took: 124,
timed_out: false,
_shards: {
total: 2,
successful: 2,
skipped: 1,
failed: 0,
},
hits: {
max_score: null,
hits: [
{
_index: '.test',
_id: '9AxbIocB-WLv2258YZtS',
_score: null,
_source: {
agent: {
name: 'docker-fleet-agent',
id: '9528bb69-1511-4631-a5af-1d7e93c02009',
type: 'filebeat',
ephemeral_id: '914fd1fa-aa37-4ab4-b36d-972ab9b19cde',
version: '8.8.0',
},
'@timestamp': '2023-02-23T20:03:17.489Z',
host: {
hostname: 'docker-fleet-agent',
os: {
kernel: '5.10.47-linuxkit',
name: 'Ubuntu',
type: 'linux',
family: 'debian',
version: '20.04.5 LTS (Focal Fossa)',
platform: 'ubuntu',
},
ip: ['172.26.0.7'],
name: 'docker-fleet-agent',
id: 'cff3d165179d4aef9596ddbb263e3adb',
mac: ['02-42-AC-1A-00-07'],
architecture: 'x86_64',
},
event: {
agent_id_status: 'verified',
ingested: '2023-02-23T20:03:18Z',
provider: 'Azure AD',
kind: 'asset',
action: 'user-discovered',
type: ['user', 'info'],
dataset: 'entityanalytics_azure.users',
},
user: {
full_name: 'Test user',
phone: ['1235559999'],
last_name: 'Test last name',
id: '39fac578-91fb-47f6-8f7a-fab05ce70d8b',
first_name: 'Taylor',
email: 'tes.user@elastic.co',
},
},
sort: [1677182597489],
},
],
},
},
total: 21,
loaded: 21,
};
describe('userDetails search strategy', () => {
const buildManagedUserDetailsQuery = jest.spyOn(buildQuery, 'buildManagedUserDetailsQuery');
afterEach(() => {
buildManagedUserDetailsQuery.mockClear();
});
describe('buildDsl', () => {
test('should build dsl query', () => {
managedUserDetails.buildDsl(mockOptions);
expect(buildManagedUserDetailsQuery).toHaveBeenCalledWith(mockOptions);
});
});
describe('parse', () => {
test('should parse data correctly', async () => {
const result = await managedUserDetails.parse(mockOptions, mockSearchStrategyResponse);
expect(result).toMatchSnapshot();
});
});
});

View file

@ -0,0 +1,40 @@
/*
* 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 type { IEsSearchResponse } from '@kbn/data-plugin/common';
import { inspectStringifyObject } from '../../../../../utils/build_query';
import type { SecuritySolutionFactory } from '../../types';
import { buildManagedUserDetailsQuery } from './query.managed_user_details.dsl';
import type { UsersQueries } from '../../../../../../common/search_strategy/security_solution/users';
import type {
AzureManagedUser,
ManagedUserDetailsRequestOptions,
ManagedUserDetailsStrategyResponse,
} from '../../../../../../common/search_strategy/security_solution/users/managed_details';
export const managedUserDetails: SecuritySolutionFactory<UsersQueries.managedDetails> = {
buildDsl: (options: ManagedUserDetailsRequestOptions) => buildManagedUserDetailsQuery(options),
parse: async (
options: ManagedUserDetailsRequestOptions,
response: IEsSearchResponse<AzureManagedUser>
): Promise<ManagedUserDetailsStrategyResponse> => {
const inspect = {
dsl: [inspectStringifyObject(buildManagedUserDetailsQuery(options))],
};
const hits = response.rawResponse.hits.hits;
const userDetails = hits.length > 0 ? hits[0]._source : undefined;
return {
...response,
inspect,
userDetails,
};
},
};

View file

@ -0,0 +1,20 @@
/*
* 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 type { ManagedUserDetailsRequestOptions } from '../../../../../../common/search_strategy/security_solution/users/managed_details';
import { buildManagedUserDetailsQuery } from './query.managed_user_details.dsl';
export const mockOptions: ManagedUserDetailsRequestOptions = {
defaultIndex: ['logs-*'],
userName: 'test-user-name',
};
describe('buildManagedUserDetailsQuery', () => {
test('build query from options correctly', () => {
expect(buildManagedUserDetailsQuery(mockOptions)).toMatchSnapshot();
});
});

View file

@ -0,0 +1,31 @@
/*
* 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 type { ISearchRequestParams } from '@kbn/data-plugin/common';
import { EVENT_KIND_ASSET_FILTER } from '../../../../../../common/search_strategy';
import type { ManagedUserDetailsRequestOptions } from '../../../../../../common/search_strategy/security_solution/users/managed_details';
export const buildManagedUserDetailsQuery = ({
userName,
defaultIndex,
}: ManagedUserDetailsRequestOptions): ISearchRequestParams => {
const filter = [{ term: { 'user.name': userName } }, EVENT_KIND_ASSET_FILTER];
const dslQuery = {
allow_no_indices: true,
index: defaultIndex,
ignore_unavailable: true,
track_total_hits: false,
body: {
query: { bool: { filter } },
size: 1,
},
sort: [{ '@timestamp': 'desc' }],
};
return dslQuery;
};

View file

@ -8,11 +8,11 @@
import type { IEsSearchResponse } from '@kbn/data-plugin/common';
import { UsersQueries } from '../../../../../../../common/search_strategy/security_solution/users';
import type { UserDetailsRequestOptions } from '../../../../../../../common/search_strategy/security_solution/users/details';
import type { ObservedUserDetailsRequestOptions } from '../../../../../../../common/search_strategy/security_solution/users/observed_details';
export const mockOptions: UserDetailsRequestOptions = {
export const mockOptions: ObservedUserDetailsRequestOptions = {
defaultIndex: ['test_indices*'],
factoryQueryType: UsersQueries.details,
factoryQueryType: UsersQueries.observedDetails,
filterQuery:
'{"bool":{"must":[],"filter":[{"match_all":{}},{"match_phrase":{"user.name":{"query":"test_user"}}}],"should":[],"must_not":[]}}',
timerange: {
@ -22,7 +22,7 @@ export const mockOptions: UserDetailsRequestOptions = {
},
params: {},
userName: 'bastion00.siem.estc.dev',
} as UserDetailsRequestOptions;
} as ObservedUserDetailsRequestOptions;
export const mockSearchStrategyResponse: IEsSearchResponse<unknown> = {
rawResponse: {
@ -125,14 +125,6 @@ export const mockSearchStrategyResponse: IEsSearchResponse<unknown> = {
},
],
},
first_seen: {
value: 1644837532000,
value_as_string: '2022-02-14T11:18:52.000Z',
},
last_seen: {
value: 1644837532000,
value_as_string: '2022-02-14T11:18:52.000Z',
},
user_domain: {
doc_count_error_upper_bound: 0,
sum_other_doc_count: 0,

View file

@ -116,6 +116,25 @@ Object {
\\"query\\": {
\\"bool\\": {
\\"filter\\": [
{
\\"bool\\": {
\\"must\\": [],
\\"filter\\": [
{
\\"match_all\\": {}
},
{
\\"match_phrase\\": {
\\"user.name\\": {
\\"query\\": \\"test_user\\"
}
}
}
],
\\"should\\": [],
\\"must_not\\": []
}
},
{
\\"term\\": {
\\"user.name\\": \\"bastion00.siem.estc.dev\\"
@ -149,10 +168,6 @@ Object {
"total": 2,
},
"aggregations": Object {
"first_seen": Object {
"value": 1644837532000,
"value_as_string": "2022-02-14T11:18:52.000Z",
},
"host_ip": Object {
"buckets": Array [
Object {
@ -267,10 +282,6 @@ Object {
"doc_count_error_upper_bound": 0,
"sum_other_doc_count": 0,
},
"last_seen": Object {
"value": 1644837532000,
"value_as_string": "2022-02-14T11:18:52.000Z",
},
"user_domain": Object {
"buckets": Array [
Object {
@ -323,7 +334,6 @@ Object {
},
"total": 2,
"userDetails": Object {
"firstSeen": "2022-02-14T11:18:52.000Z",
"host": Object {
"ip": Array [
"11.245.5.152",
@ -346,7 +356,6 @@ Object {
],
},
},
"lastSeen": "2022-02-14T11:18:52.000Z",
"user": Object {
"domain": Array [
"NT AUTHORITY",

View file

@ -108,6 +108,25 @@ Object {
"query": Object {
"bool": Object {
"filter": Array [
Object {
"bool": Object {
"filter": Array [
Object {
"match_all": Object {},
},
Object {
"match_phrase": Object {
"user.name": Object {
"query": "test_user",
},
},
},
],
"must": Array [],
"must_not": Array [],
"should": Array [],
},
},
Object {
"term": Object {
"user.name": "bastion00.siem.estc.dev",

View file

@ -24,13 +24,9 @@ describe('helpers', () => {
},
],
},
first_seen: { value_as_string: '123456789' },
last_seen: { value_as_string: '987654321' },
};
expect(formatUserItem(aggregations)).toEqual({
firstSeen: '123456789',
lastSeen: '987654321',
user: { id: [userId] },
});
});

View file

@ -25,11 +25,6 @@ export const USER_FIELDS = [
export const fieldNameToAggField = (fieldName: string) => fieldName.replace(/\./g, '_');
export const formatUserItem = (aggregations: UserAggEsItem): UserItem => {
const firstLastSeen = {
firstSeen: get('first_seen.value_as_string', aggregations),
lastSeen: get('last_seen.value_as_string', aggregations),
};
return USER_FIELDS.reduce<UserItem>((flattenedFields, fieldName) => {
const aggField = fieldNameToAggField(fieldName);
@ -40,5 +35,5 @@ export const formatUserItem = (aggregations: UserAggEsItem): UserItem => {
return set(fieldName, fieldValue, flattenedFields);
}
return flattenedFields;
}, firstLastSeen);
}, {});
};

View file

@ -5,27 +5,27 @@
* 2.0.
*/
import * as buildQuery from './query.user_details.dsl';
import { userDetails } from '.';
import * as buildQuery from './query.observed_user_details.dsl';
import { observedUserDetails } from '.';
import { mockOptions, mockSearchStrategyResponse } from './__mocks__';
describe('userDetails search strategy', () => {
const buildHostDetailsQuery = jest.spyOn(buildQuery, 'buildUserDetailsQuery');
const buildUserDetailsQuery = jest.spyOn(buildQuery, 'buildObservedUserDetailsQuery');
afterEach(() => {
buildHostDetailsQuery.mockClear();
buildUserDetailsQuery.mockClear();
});
describe('buildDsl', () => {
test('should build dsl query', () => {
userDetails.buildDsl(mockOptions);
expect(buildHostDetailsQuery).toHaveBeenCalledWith(mockOptions);
observedUserDetails.buildDsl(mockOptions);
expect(buildUserDetailsQuery).toHaveBeenCalledWith(mockOptions);
});
});
describe('parse', () => {
test('should parse data correctly', async () => {
const result = await userDetails.parse(mockOptions, mockSearchStrategyResponse);
const result = await observedUserDetails.parse(mockOptions, mockSearchStrategyResponse);
expect(result).toMatchSnapshot();
});
});

View file

@ -9,25 +9,25 @@ import type { IEsSearchResponse } from '@kbn/data-plugin/common';
import { inspectStringifyObject } from '../../../../../utils/build_query';
import type { SecuritySolutionFactory } from '../../types';
import { buildUserDetailsQuery } from './query.user_details.dsl';
import { buildObservedUserDetailsQuery } from './query.observed_user_details.dsl';
import type { UsersQueries } from '../../../../../../common/search_strategy/security_solution/users';
import type {
UserDetailsRequestOptions,
UserDetailsStrategyResponse,
} from '../../../../../../common/search_strategy/security_solution/users/details';
ObservedUserDetailsRequestOptions,
ObservedUserDetailsStrategyResponse,
} from '../../../../../../common/search_strategy/security_solution/users/observed_details';
import { formatUserItem } from './helpers';
export const userDetails: SecuritySolutionFactory<UsersQueries.details> = {
buildDsl: (options: UserDetailsRequestOptions) => buildUserDetailsQuery(options),
export const observedUserDetails: SecuritySolutionFactory<UsersQueries.observedDetails> = {
buildDsl: (options: ObservedUserDetailsRequestOptions) => buildObservedUserDetailsQuery(options),
parse: async (
options: UserDetailsRequestOptions,
options: ObservedUserDetailsRequestOptions,
response: IEsSearchResponse<unknown>
): Promise<UserDetailsStrategyResponse> => {
): Promise<ObservedUserDetailsStrategyResponse> => {
const aggregations = response.rawResponse.aggregations;
const inspect = {
dsl: [inspectStringifyObject(buildUserDetailsQuery(options))],
dsl: [inspectStringifyObject(buildObservedUserDetailsQuery(options))],
};
if (aggregations == null) {

View file

@ -5,11 +5,11 @@
* 2.0.
*/
import { buildUserDetailsQuery } from './query.user_details.dsl';
import { buildObservedUserDetailsQuery } from './query.observed_user_details.dsl';
import { mockOptions } from './__mocks__';
describe('buildUserDetailsQuery', () => {
test('build query from options correctly', () => {
expect(buildUserDetailsQuery(mockOptions)).toMatchSnapshot();
expect(buildObservedUserDetailsQuery(mockOptions)).toMatchSnapshot();
});
});

View file

@ -5,17 +5,21 @@
* 2.0.
*/
import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import type { ISearchRequestParams } from '@kbn/data-plugin/common';
import type { UserDetailsRequestOptions } from '../../../../../../common/search_strategy/security_solution/users/details';
import type { ObservedUserDetailsRequestOptions } from '../../../../../../common/search_strategy/security_solution/users/observed_details';
import { createQueryFilterClauses } from '../../../../../utils/build_query';
import { buildFieldsTermAggregation } from '../../hosts/details/helpers';
import { USER_FIELDS } from './helpers';
export const buildUserDetailsQuery = ({
export const buildObservedUserDetailsQuery = ({
userName,
defaultIndex,
timerange: { from, to },
}: UserDetailsRequestOptions): ISearchRequestParams => {
const filter = [
filterQuery,
}: ObservedUserDetailsRequestOptions): ISearchRequestParams => {
const filter: QueryDslQueryContainer[] = [
...createQueryFilterClauses(filterQuery),
{ term: { 'user.name': userName } },
{
range: {

View file

@ -152,6 +152,7 @@
"@kbn/core-analytics-server",
"@kbn/analytics-client",
"@kbn/security-solution-side-nav",
"@kbn/core-lifecycle-browser"
"@kbn/core-lifecycle-browser",
"@kbn/ecs",
]
}