mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[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:
parent
47f0eb8803
commit
9ab0c45454
64 changed files with 2565 additions and 116 deletions
|
@ -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>;
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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],
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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'>;
|
||||
|
|
|
@ -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[];
|
||||
};
|
||||
}
|
|
@ -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;
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
exports[`anomaly_scores renders correctly against snapshot 1`] = `
|
||||
<Fragment>
|
||||
<EuiFlexItem
|
||||
data-test-subj="anomaly-score"
|
||||
grow={false}
|
||||
>
|
||||
<Score
|
||||
|
|
|
@ -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]}
|
||||
|
|
|
@ -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]}
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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(
|
||||
() => ({
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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,
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
|
@ -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(() => {
|
|
@ -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,
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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' },
|
||||
},
|
||||
};
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -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';
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
]
|
||||
);
|
||||
};
|
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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());
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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 don’t 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',
|
||||
}
|
||||
);
|
|
@ -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'];
|
||||
}
|
|
@ -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}
|
||||
/>
|
||||
));
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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,
|
||||
|
|
|
@ -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} />
|
||||
) : (
|
||||
|
|
|
@ -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',
|
||||
}
|
||||
);
|
|
@ -11,4 +11,5 @@ export interface UserDetailsProps {
|
|||
handleOnClose: () => void;
|
||||
isFlyoutView?: boolean;
|
||||
isDraggable?: boolean;
|
||||
isNewUserDetailsFlyoutEnable?: boolean;
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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",
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
`;
|
|
@ -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,
|
||||
}
|
||||
`;
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
};
|
||||
},
|
||||
};
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
};
|
|
@ -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,
|
|
@ -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",
|
|
@ -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",
|
|
@ -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] },
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
}, {});
|
||||
};
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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) {
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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: {
|
|
@ -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",
|
||||
]
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue