[Security Solutions] Create all users tab on user page (#128375)

* Create all users tab on user page

* Fix unrelated bug on user details flyout

* Fix unit test

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Pablo Machado 2022-03-28 12:41:46 +02:00 committed by GitHub
parent 3704642366
commit c1b7448e54
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
42 changed files with 1166 additions and 79 deletions

View file

@ -87,6 +87,7 @@ import {
TotalUsersKpiRequestOptions,
TotalUsersKpiStrategyResponse,
} from './users/kpi/total_users';
import { UsersRequestOptions, UsersStrategyResponse } from './users/all';
export * from './cti';
export * from './hosts';
@ -147,6 +148,8 @@ export type StrategyResponseType<T extends FactoryQueryTypes> = T extends HostsQ
? UserDetailsStrategyResponse
: T extends UsersQueries.kpiTotalUsers
? TotalUsersKpiStrategyResponse
: T extends UsersQueries.users
? UsersStrategyResponse
: T extends NetworkQueries.details
? NetworkDetailsStrategyResponse
: T extends NetworkQueries.dns
@ -207,6 +210,8 @@ export type StrategyRequestType<T extends FactoryQueryTypes> = T extends HostsQu
? UserDetailsRequestOptions
: T extends UsersQueries.kpiTotalUsers
? TotalUsersKpiRequestOptions
: T extends UsersQueries.users
? UsersRequestOptions
: T extends NetworkQueries.details
? NetworkDetailsRequestOptions
: T extends NetworkQueries.dns

View file

@ -0,0 +1,29 @@
/*
* 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 '../../../../../../../../src/plugins/data/common';
import { Inspect, Maybe, PageInfoPaginated } from '../../../common';
import { RequestOptionsPaginated } from '../..';
import { SortableUsersFields } from '../common';
export interface User {
name: string;
lastSeen: string;
domain: string;
}
export interface UsersStrategyResponse extends IEsSearchResponse {
users: User[];
totalCount: number;
pageInfo: PageInfoPaginated;
inspect?: Maybe<Inspect>;
}
export interface UsersRequestOptions extends RequestOptionsPaginated<SortableUsersFields> {
defaultIndex: string[];
}

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { Maybe, RiskSeverity } from '../../..';
import { Maybe, RiskSeverity, SortField } from '../../..';
import { HostEcs } from '../../../../ecs/host';
import { UserEcs } from '../../../../ecs/user';
@ -30,9 +30,14 @@ export interface UserItem {
firstSeen?: Maybe<string>;
}
export type SortableUsersFields = Exclude<UsersFields, typeof UsersFields.domain>;
export type SortUsersField = SortField<SortableUsersFields>;
export enum UsersFields {
lastSeen = 'lastSeen',
hostName = 'userName',
name = 'name',
domain = 'domain',
}
export interface UserAggEsItem {
@ -52,3 +57,17 @@ export interface UserBuckets {
doc_count: number;
}>;
}
export interface AllUsersAggEsItem {
key: string;
domain?: UsersDomainHitsItem;
lastSeen?: { value_as_string: string };
}
export interface UsersDomainHitsItem {
hits: {
hits: Array<{
_source: { user: { domain: Maybe<string> } };
}>;
};
}

View file

@ -9,15 +9,15 @@ import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import type { IEsSearchResponse } from '../../../../../../../../src/plugins/data/common';
import { Inspect, Maybe, TimerangeInput } from '../../../common';
import { UserItem, UsersFields } from '../common';
import { RequestOptionsPaginated } from '../..';
import { UserItem } from '../common';
import { RequestBasicOptions } from '../..';
export interface UserDetailsStrategyResponse extends IEsSearchResponse {
userDetails: UserItem;
inspect?: Maybe<Inspect>;
}
export interface UserDetailsRequestOptions extends Partial<RequestOptionsPaginated<UsersFields>> {
export interface UserDetailsRequestOptions extends Partial<RequestBasicOptions> {
userName: string;
skip?: boolean;
timerange: TimerangeInput;

View file

@ -10,6 +10,7 @@ import { TotalUsersKpiStrategyResponse } from './kpi/total_users';
export enum UsersQueries {
details = 'userDetails',
kpiTotalUsers = 'usersKpiTotalUsers',
users = 'allUsers',
}
export type UserskKpiStrategyResponse = Omit<TotalUsersKpiStrategyResponse, 'rawResponse'>;

View file

@ -20,7 +20,7 @@ describe('Users stats and tables', () => {
});
it(`renders all users`, () => {
const totalUsers = 35;
const totalUsers = 72;
const usersPerPage = 10;
cy.get(HEADER_SUBTITLE).should('have.text', `Showing: ${totalUsers} users`);

View file

@ -0,0 +1,38 @@
/*
* 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 {
AUTHENTICATIONS_TAB,
AUTHENTICATIONS_TABLE,
HEADER_SUBTITLE,
USER_NAME_CELL,
} from '../../screens/users/user_authentications';
import { cleanKibana } from '../../tasks/common';
import { loginAndWaitForPage } from '../../tasks/login';
import { USERS_URL } from '../../urls/navigation';
describe('Authentications stats and tables', () => {
before(() => {
cleanKibana();
loginAndWaitForPage(USERS_URL);
});
it(`renders all authentications`, () => {
const totalUsers = 35;
const usersPerPage = 10;
cy.get(AUTHENTICATIONS_TAB).click();
cy.get(AUTHENTICATIONS_TABLE)
.find(HEADER_SUBTITLE)
.should('have.text', `Showing: ${totalUsers} users`);
cy.get(USER_NAME_CELL).should('have.length', usersPerPage);
});
});

View file

@ -5,8 +5,10 @@
* 2.0.
*/
import { AUTHENTICATIONS_TABLE } from '../../screens/hosts/authentications';
import { INSPECT_MODAL } from '../../screens/inspect';
import { ALL_USERS_TABLE } from '../../screens/users/all_users';
import { AUTHENTICATIONS_TAB } from '../../screens/users/user_authentications';
import { cleanKibana } from '../../tasks/common';
import { clickInspectButton, closesModal } from '../../tasks/inspect';
@ -30,5 +32,11 @@ describe('Inspect', () => {
clickInspectButton(ALL_USERS_TABLE);
cy.get(INSPECT_MODAL).should('be.visible');
});
it(`inspects authentications table`, () => {
cy.get(AUTHENTICATIONS_TAB).click();
clickInspectButton(AUTHENTICATIONS_TABLE);
cy.get(INSPECT_MODAL).should('be.visible');
});
});
});

View file

@ -5,7 +5,7 @@
* 2.0.
*/
export const ALL_USERS_TABLE = '[data-test-subj="table-authentications-loading-false"]';
export const ALL_USERS_TABLE = '[data-test-subj="table-allUsers-loading-false"]';
export const HEADER_SUBTITLE = '[data-test-subj="header-panel-subtitle"]';

View file

@ -0,0 +1,11 @@
/*
* 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 AUTHENTICATIONS_TAB = '[data-test-subj="navigation-authentications"]';
export const HEADER_SUBTITLE = '[data-test-subj="header-panel-subtitle"]';
export const USER_NAME_CELL = '[data-test-subj="render-content-user.name"]';
export const AUTHENTICATIONS_TABLE = '[data-test-subj="table-authentications-loading-false"]';

View file

@ -78,7 +78,7 @@ const UserDetailsLinkComponent: React.FC<{
dataTestSubj="data-grid-user-details"
href={formatUrl(getUsersDetailsUrl(encodedUserName))}
onClick={onClick ?? goToUsersDetails}
title={title ?? encodedUserName}
title={title ?? userName}
>
{children ? children : userName}
</GenericLinkButton>

View file

@ -50,6 +50,7 @@ import * as i18n from './translations';
import { Panel } from '../panel';
import { InspectButtonContainer } from '../inspect';
import { useQueryToggle } from '../../containers/query_toggle';
import { UsersTableColumns } from '../../../users/components/all_users';
const DEFAULT_DATA_TEST_SUBJ = 'paginated-table';
@ -89,7 +90,8 @@ declare type BasicTableColumns =
| HostRiskScoreColumns
| TlsColumns
| UncommonProcessTableColumns
| UsersColumns;
| UsersColumns
| UsersTableColumns;
declare type SiemTables = BasicTableProps<BasicTableColumns>;

View file

@ -200,18 +200,18 @@ describe('useSearchStrategy', () => {
expect(start).toBeCalledWith(expect.objectContaining({ signal }));
});
it('skip = true will cancel any running request', () => {
it('abort = true will cancel any running request', () => {
const abortSpy = jest.fn();
const signal = new AbortController().signal;
jest.spyOn(window, 'AbortController').mockReturnValue({ abort: abortSpy, signal });
const factoryQueryType = 'fakeQueryType' as FactoryQueryTypes;
const localProps = {
...userSearchStrategyProps,
skip: false,
abort: false,
factoryQueryType,
};
const { rerender } = renderHook(() => useSearchStrategy<FactoryQueryTypes>(localProps));
localProps.skip = true;
localProps.abort = true;
act(() => rerender());
expect(abortSpy).toHaveBeenCalledTimes(1);
});

View file

@ -33,6 +33,7 @@ import { getInspectResponse } from '../../../helpers';
import { inputsModel } from '../../store';
import { useKibana } from '../../lib/kibana';
import { useAppToasts } from '../../hooks/use_app_toasts';
import { AbortError } from '../../../../../../../src/plugins/kibana_utils/common';
type UseSearchStrategyRequestArgs = RequestBasicOptions & {
data: DataPublicPluginStart;
@ -96,7 +97,7 @@ export const useSearchStrategy = <QueryType extends FactoryQueryTypes>({
factoryQueryType,
initialResult,
errorMessage,
skip = false,
abort = false,
}: {
factoryQueryType: QueryType;
/**
@ -107,7 +108,10 @@ export const useSearchStrategy = <QueryType extends FactoryQueryTypes>({
* Message displayed to the user on a Toast when an erro happens.
*/
errorMessage?: string;
skip?: boolean;
/**
* When the flag switches from `false` to `true`, it will abort any ongoing request.
*/
abort?: boolean;
}) => {
const abortCtrl = useRef(new AbortController());
const { getTransformChangesIfTheyExist } = useTransforms();
@ -122,7 +126,7 @@ export const useSearchStrategy = <QueryType extends FactoryQueryTypes>({
>(searchComplete);
useEffect(() => {
if (error != null) {
if (error != null && !(error instanceof AbortError)) {
addError(error, {
title: errorMessage ?? i18n.DEFAULT_ERROR_SEARCH_STRATEGY(factoryQueryType),
});
@ -157,10 +161,10 @@ export const useSearchStrategy = <QueryType extends FactoryQueryTypes>({
}, []);
useEffect(() => {
if (skip) {
if (abort) {
abortCtrl.current.abort();
}
}, [skip]);
}, [abort]);
const [formatedResult, inspect] = useMemo(
() => [

View file

@ -41,6 +41,7 @@ import {
mockRuntimeMappings,
} from '../containers/source/mock';
import { usersModel } from '../../users/store';
import { UsersFields } from '../../../common/search_strategy/security_solution/users/common';
export const mockSourcererState = {
...initialSourcererState,
@ -203,6 +204,11 @@ export const mockGlobalState: State = {
[usersModel.UsersTableType.allUsers]: {
activePage: 0,
limit: 10,
sort: { field: UsersFields.name, direction: Direction.asc },
},
[usersModel.UsersTableType.authentications]: {
activePage: 0,
limit: 10,
},
[usersModel.UsersTableType.anomalies]: null,
[usersModel.UsersTableType.risk]: {

View file

@ -0,0 +1,52 @@
/*
* 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 '../../../common/mock/match_media';
import { TestProviders } from '../../../common/mock';
import { UsersTable } from '.';
import { usersModel } from '../../store';
import { Direction } from '../../../../common/search_strategy';
import { UsersFields } from '../../../../common/search_strategy/security_solution/users/common';
import { render } from '@testing-library/react';
describe('Users Table Component', () => {
const loadPage = jest.fn();
describe('rendering', () => {
test('it renders the users table', () => {
const userName = 'testUser';
const { getByTestId, getAllByRole, getByText } = render(
<TestProviders>
<UsersTable
users={[
{ name: userName, lastSeen: '2019-04-08T18:35:45.064Z', domain: 'test domain' },
]}
fakeTotalCount={50}
id="users"
loading={false}
loadPage={loadPage}
showMorePagesIndicator={false}
totalCount={0}
type={usersModel.UsersType.page}
sort={{
field: UsersFields.name,
direction: Direction.asc,
}}
setQuerySkip={() => {}}
/>
</TestProviders>
);
expect(getByTestId('table-allUsers-loading-false')).toBeInTheDocument();
expect(getAllByRole('columnheader').length).toBe(3);
expect(getByText(userName)).toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,186 @@
/*
* 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, { useCallback, useMemo } from 'react';
import { useDispatch } from 'react-redux';
import { FormattedRelativePreferenceDate } from '../../../common/components/formatted_date';
import { UserDetailsLink } from '../../../common/components/links';
import {
Columns,
Criteria,
ItemsPerRow,
PaginatedTable,
} from '../../../common/components/paginated_table';
import { getRowItemDraggables } from '../../../common/components/tables/helpers';
import { useDeepEqualSelector } from '../../../common/hooks/use_selector';
import * as i18n from './translations';
import { usersActions, usersModel, usersSelectors } from '../../store';
import { User } from '../../../../common/search_strategy/security_solution/users/all';
import { SortUsersField } from '../../../../common/search_strategy/security_solution/users/common';
const tableType = usersModel.UsersTableType.allUsers;
interface UsersTableProps {
users: User[];
fakeTotalCount: number;
loading: boolean;
loadPage: (newActivePage: number) => void;
id: string;
showMorePagesIndicator: boolean;
totalCount: number;
type: usersModel.UsersType;
sort: SortUsersField;
setQuerySkip: (skip: boolean) => void;
}
export type UsersTableColumns = [
Columns<User['name']>,
Columns<User['lastSeen']>,
Columns<User['domain']>
];
const rowItems: ItemsPerRow[] = [
{
text: i18n.ROWS_5,
numberOfRow: 5,
},
{
text: i18n.ROWS_10,
numberOfRow: 10,
},
];
const UsersTableComponent: React.FC<UsersTableProps> = ({
users,
totalCount,
type,
id,
fakeTotalCount,
loading,
loadPage,
showMorePagesIndicator,
sort,
setQuerySkip,
}) => {
const dispatch = useDispatch();
const getUsersSelector = useMemo(() => usersSelectors.allUsersSelector(), []);
const { activePage, limit } = useDeepEqualSelector((state) => getUsersSelector(state));
const updateLimitPagination = useCallback(
(newLimit) => {
dispatch(
usersActions.updateTableLimit({
usersType: type,
limit: newLimit,
tableType,
})
);
},
[type, dispatch]
);
const updateActivePage = useCallback(
(newPage) => {
dispatch(
usersActions.updateTableActivePage({
activePage: newPage,
usersType: type,
tableType,
})
);
},
[type, dispatch]
);
const onSort = useCallback(
(criteria: Criteria) => {
if (criteria.sort != null) {
const newSort = criteria.sort;
if (newSort.direction !== sort.direction || newSort.field !== sort.field) {
dispatch(
usersActions.updateTableSorting({
sort: newSort as SortUsersField,
tableType,
})
);
}
}
},
[dispatch, sort]
);
const columns = useMemo(() => getUsersColumns(), []);
return (
<PaginatedTable
activePage={activePage}
columns={columns}
dataTestSubj={`table-${tableType}`}
headerCount={totalCount}
headerTitle={i18n.USERS}
headerUnit={i18n.UNIT(totalCount)}
id={id}
itemsPerRow={rowItems}
limit={limit}
loading={loading}
loadPage={loadPage}
pageOfItems={users}
showMorePagesIndicator={showMorePagesIndicator}
totalCount={fakeTotalCount}
updateLimitPagination={updateLimitPagination}
updateActivePage={updateActivePage}
sorting={sort}
onChange={onSort}
setQuerySkip={setQuerySkip}
/>
);
};
UsersTableComponent.displayName = 'UsersTableComponent';
export const UsersTable = React.memo(UsersTableComponent);
const getUsersColumns = (): UsersTableColumns => [
{
field: 'name',
name: i18n.USER_NAME,
truncateText: false,
sortable: true,
mobileOptions: { show: true },
render: (name) =>
getRowItemDraggables({
rowItems: [name],
attrName: 'user.name',
idPrefix: `users-table-${name}-name`,
render: (item) => <UserDetailsLink userName={item} />,
}),
},
{
field: 'lastSeen',
name: i18n.LAST_SEEN,
sortable: true,
truncateText: false,
mobileOptions: { show: true },
render: (lastSeen) => <FormattedRelativePreferenceDate value={lastSeen} />,
},
{
field: 'domain',
name: i18n.DOMAIN,
sortable: false,
truncateText: false,
mobileOptions: { show: true },
render: (domain) =>
getRowItemDraggables({
rowItems: [domain],
attrName: 'user.domain',
idPrefix: `users-table-${domain}-domain`,
}),
},
];

View file

@ -0,0 +1,40 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
export const USERS = i18n.translate('xpack.securitySolution.usersTable.title', {
defaultMessage: 'Users',
});
export const USER_NAME = i18n.translate('xpack.securitySolution.usersTable.userNameTitle', {
defaultMessage: 'User name',
});
export const LAST_SEEN = i18n.translate('xpack.securitySolution.usersTable.lastSeenTitle', {
defaultMessage: 'Last seen',
});
export const DOMAIN = i18n.translate('xpack.securitySolution.usersTable.domainTitle', {
defaultMessage: 'Domain',
});
export const ROWS_5 = i18n.translate('xpack.securitySolution.usersTable.rows', {
values: { numRows: 5 },
defaultMessage: '{numRows} {numRows, plural, =0 {rows} =1 {row} other {rows}}',
});
export const ROWS_10 = i18n.translate('xpack.securitySolution.usersTable.rows', {
values: { numRows: 10 },
defaultMessage: '{numRows} {numRows, plural, =0 {rows} =1 {row} other {rows}}',
});
export const UNIT = (totalCount: number) =>
i18n.translate('xpack.securitySolution.usersTable.unit', {
values: { totalCount },
defaultMessage: `{totalCount, plural, =1 {user} other {users}}`,
});

View file

@ -52,7 +52,7 @@ describe('Total Users KPI', () => {
<TotalUsersKpi {...defaultProps} />
</TestProviders>
);
expect(mockUseSearchStrategy.mock.calls[0][0].skip).toEqual(false);
expect(mockUseSearchStrategy.mock.calls[0][0].abort).toEqual(false);
expect(mockSearch).toHaveBeenCalled();
});
it('toggleStatus=false, skip', () => {
@ -62,7 +62,7 @@ describe('Total Users KPI', () => {
<TotalUsersKpi {...defaultProps} />
</TestProviders>
);
expect(mockUseSearchStrategy.mock.calls[0][0].skip).toEqual(true);
expect(mockUseSearchStrategy.mock.calls[0][0].abort).toEqual(true);
expect(mockSearch).not.toHaveBeenCalled();
});
});

View file

@ -61,7 +61,7 @@ const TotalUsersKpiComponent: React.FC<UsersKpiProps> = ({
setQuery,
skip,
}) => {
const { toggleStatus } = useQueryToggle(UsersQueries.kpiTotalUsers);
const { toggleStatus } = useQueryToggle(QUERY_ID);
const [querySkip, setQuerySkip] = useState(skip || !toggleStatus);
useEffect(() => {
setQuerySkip(skip || !toggleStatus);
@ -71,7 +71,7 @@ const TotalUsersKpiComponent: React.FC<UsersKpiProps> = ({
factoryQueryType: UsersQueries.kpiTotalUsers,
initialResult: { users: 0, usersHistogram: [] },
errorMessage: i18n.ERROR_USERS_KPI,
skip: querySkip,
abort: querySkip,
});
useEffect(() => {

View file

@ -10,6 +10,6 @@ import { UsersTableType } from '../store/model';
export const usersDetailsPagePath = `${USERS_PATH}/:detailName`;
export const usersTabPath = `${USERS_PATH}/:tabName(${UsersTableType.allUsers}|${UsersTableType.anomalies}|${UsersTableType.risk}|${UsersTableType.events}|${UsersTableType.alerts})`;
export const usersTabPath = `${USERS_PATH}/:tabName(${UsersTableType.allUsers}|${UsersTableType.authentications}|${UsersTableType.anomalies}|${UsersTableType.risk}|${UsersTableType.events}|${UsersTableType.alerts})`;
export const usersDetailsTabPath = `${usersDetailsPagePath}/:tabName(${UsersTableType.anomalies}|${UsersTableType.events}|${UsersTableType.alerts})`;

View file

@ -22,6 +22,7 @@ export const type = usersModel.UsersType.details;
const TabNameMappedToI18nKey: Record<UsersTableType, string> = {
[UsersTableType.allUsers]: i18n.NAVIGATION_ALL_USERS_TITLE,
[UsersTableType.authentications]: i18n.NAVIGATION_AUTHENTICATIONS_TITLE,
[UsersTableType.anomalies]: i18n.NAVIGATION_ANOMALIES_TITLE,
[UsersTableType.risk]: i18n.NAVIGATION_RISK_TITLE,
[UsersTableType.events]: i18n.NAVIGATION_EVENTS_TITLE,

View file

@ -26,6 +26,12 @@ export const navTabsUsers = (
href: getTabsOnUsersUrl(UsersTableType.allUsers),
disabled: false,
},
[UsersTableType.authentications]: {
id: UsersTableType.authentications,
name: i18n.NAVIGATION_AUTHENTICATIONS_TITLE,
href: getTabsOnUsersUrl(UsersTableType.authentications),
disabled: false,
},
[UsersTableType.anomalies]: {
id: UsersTableType.anomalies,
name: i18n.NAVIGATION_ANOMALIES_TITLE,

View file

@ -8,61 +8,69 @@
import React from 'react';
import { render } from '@testing-library/react';
import { TestProviders } from '../../../common/mock';
import { useAuthentications } from '../../../hosts/containers/authentications';
import { useQueryToggle } from '../../../common/containers/query_toggle';
import { AllUsersQueryTabBody } from './all_users_query_tab_body';
import { UsersType } from '../../store/model';
jest.mock('../../../hosts/containers/authentications');
jest.mock('../../../common/containers/query_toggle');
jest.mock('../../../common/lib/kibana');
const mockSearch = jest.fn();
jest.mock('../../../common/containers/use_search_strategy', () => {
const original = jest.requireActual('../../../common/containers/use_search_strategy');
return {
...original,
useSearchStrategy: () => ({
search: mockSearch,
loading: false,
inspect: {
dsl: [],
response: [],
},
result: {
users: [],
totalCount: 0,
pageInfo: { activePage: 1, fakeTotalCount: 100, showMorePagesIndicator: false },
},
refetch: jest.fn(),
}),
};
});
describe('All users query tab body', () => {
const mockUseAuthentications = useAuthentications as jest.Mock;
const mockUseQueryToggle = useQueryToggle as jest.Mock;
const defaultProps = {
skip: false,
indexNames: [],
setQuery: jest.fn(),
skip: false,
startDate: '2019-06-25T04:31:59.345Z',
endDate: '2019-06-25T06:31:59.345Z',
type: UsersType.page,
};
beforeEach(() => {
jest.clearAllMocks();
mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: jest.fn() });
mockUseAuthentications.mockReturnValue([
false,
{
authentications: [],
id: '123',
inspect: {
dsl: [],
response: [],
},
isInspected: false,
totalCount: 0,
pageInfo: { activePage: 1, fakeTotalCount: 100, showMorePagesIndicator: false },
loadPage: jest.fn(),
refetch: jest.fn(),
},
]);
});
it('toggleStatus=true, do not skip', () => {
it('calls search when toggleStatus=true', () => {
mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: jest.fn() });
render(
<TestProviders>
<AllUsersQueryTabBody {...defaultProps} />
</TestProviders>
);
expect(mockUseAuthentications.mock.calls[0][0].skip).toEqual(false);
expect(mockSearch).toHaveBeenCalled();
});
it('toggleStatus=false, skip', () => {
it("doesn't calls search when toggleStatus=false", () => {
mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: jest.fn() });
render(
<TestProviders>
<AllUsersQueryTabBody {...defaultProps} />
</TestProviders>
);
expect(mockUseAuthentications.mock.calls[0][0].skip).toEqual(true);
expect(mockSearch).not.toHaveBeenCalled();
});
});

View file

@ -5,16 +5,24 @@
* 2.0.
*/
import { getOr } from 'lodash/fp';
import React, { useEffect, useState } from 'react';
import { useAuthentications, ID } from '../../../hosts/containers/authentications';
import { getOr, noop } from 'lodash/fp';
import React, { useEffect, useMemo, useState } from 'react';
import { UsersComponentsQueryProps } from './types';
import { AuthenticationTable } from '../../../hosts/components/authentications_table';
import { manageQuery } from '../../../common/components/page/manage_query';
import { UsersTable } from '../../components/all_users';
import { useSearchStrategy } from '../../../common/containers/use_search_strategy';
import { UsersQueries } from '../../../../common/search_strategy/security_solution/users';
import * as i18n from './translations';
import { generateTablePaginationOptions } from '../../../common/components/paginated_table/helpers';
import { useDeepEqualSelector } from '../../../common/hooks/use_selector';
import { usersSelectors } from '../../store';
import { useQueryToggle } from '../../../common/containers/query_toggle';
const AuthenticationTableManage = manageQuery(AuthenticationTable);
const UsersTableManage = manageQuery(UsersTable);
const QUERY_ID = 'UsersTable';
export const AllUsersQueryTabBody = ({
endDate,
@ -27,46 +35,80 @@ export const AllUsersQueryTabBody = ({
docValueFields,
deleteQuery,
}: UsersComponentsQueryProps) => {
const { toggleStatus } = useQueryToggle(ID);
const { toggleStatus } = useQueryToggle(QUERY_ID);
const [querySkip, setQuerySkip] = useState(skip || !toggleStatus);
useEffect(() => {
setQuerySkip(skip || !toggleStatus);
}, [skip, toggleStatus]);
const [
const getUsersSelector = useMemo(() => usersSelectors.allUsersSelector(), []);
const { activePage, limit, sort } = useDeepEqualSelector((state) => getUsersSelector(state));
const {
loading,
{ authentications, totalCount, pageInfo, loadPage, id, inspect, isInspected, refetch },
] = useAuthentications({
docValueFields,
result: { users, pageInfo, totalCount },
search,
refetch,
inspect,
} = useSearchStrategy<UsersQueries.users>({
factoryQueryType: UsersQueries.users,
initialResult: {
users: [],
totalCount: 0,
pageInfo: {
activePage: 0,
fakeTotalCount: 0,
showMorePagesIndicator: false,
},
},
errorMessage: i18n.ERROR_FETCHING_USERS_DATA,
abort: querySkip,
});
useEffect(() => {
if (!querySkip) {
search({
filterQuery,
defaultIndex: indexNames,
docValueFields,
timerange: {
interval: '12h',
from: startDate,
to: endDate,
},
pagination: generateTablePaginationOptions(activePage, limit),
sort,
});
}
}, [
search,
startDate,
endDate,
filterQuery,
indexNames,
skip: querySkip,
startDate,
// TODO Move authentication table and hook store to 'public/common' folder when 'usersEnabled' FF is removed
// @ts-ignore
type,
deleteQuery,
});
querySkip,
docValueFields,
activePage,
limit,
sort,
]);
return (
<AuthenticationTableManage
data={authentications}
<UsersTableManage
users={users}
deleteQuery={deleteQuery}
fakeTotalCount={getOr(50, 'fakeTotalCount', pageInfo)}
id={id}
id={QUERY_ID}
inspect={inspect}
isInspect={isInspected}
loading={loading}
loadPage={loadPage}
loadPage={noop} // It isn't necessary because PaginatedTable updates redux store and we load the page when activePage updates on the store
refetch={refetch}
showMorePagesIndicator={getOr(false, 'showMorePagesIndicator', pageInfo)}
setQuery={setQuery}
setQuerySkip={setQuerySkip}
totalCount={totalCount}
docValueFields={docValueFields}
indexNames={indexNames}
// TODO Move authentication table and hook store to 'public/common' folder when 'usersEnabled' FF is removed
// @ts-ignore
type={type}
sort={sort}
setQuerySkip={setQuerySkip}
/>
);
};

View file

@ -0,0 +1,68 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { render } from '@testing-library/react';
import { TestProviders } from '../../../common/mock';
import { useAuthentications } from '../../../hosts/containers/authentications';
import { useQueryToggle } from '../../../common/containers/query_toggle';
import { AuthenticationsQueryTabBody } from './authentications_query_tab_body';
import { UsersType } from '../../store/model';
jest.mock('../../../hosts/containers/authentications');
jest.mock('../../../common/containers/query_toggle');
jest.mock('../../../common/lib/kibana');
describe('Authentications query tab body', () => {
const mockUseAuthentications = useAuthentications as jest.Mock;
const mockUseQueryToggle = useQueryToggle as jest.Mock;
const defaultProps = {
indexNames: [],
setQuery: jest.fn(),
skip: false,
startDate: '2019-06-25T04:31:59.345Z',
endDate: '2019-06-25T06:31:59.345Z',
type: UsersType.page,
};
beforeEach(() => {
jest.clearAllMocks();
mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: jest.fn() });
mockUseAuthentications.mockReturnValue([
false,
{
authentications: [],
id: '123',
inspect: {
dsl: [],
response: [],
},
isInspected: false,
totalCount: 0,
pageInfo: { activePage: 1, fakeTotalCount: 100, showMorePagesIndicator: false },
loadPage: jest.fn(),
refetch: jest.fn(),
},
]);
});
it('toggleStatus=true, do not skip', () => {
render(
<TestProviders>
<AuthenticationsQueryTabBody {...defaultProps} />
</TestProviders>
);
expect(mockUseAuthentications.mock.calls[0][0].skip).toEqual(false);
});
it('toggleStatus=false, skip', () => {
mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: jest.fn() });
render(
<TestProviders>
<AuthenticationsQueryTabBody {...defaultProps} />
</TestProviders>
);
expect(mockUseAuthentications.mock.calls[0][0].skip).toEqual(true);
});
});

View file

@ -0,0 +1,74 @@
/*
* 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 { getOr } from 'lodash/fp';
import React, { useEffect, useState } from 'react';
import { useAuthentications, ID } from '../../../hosts/containers/authentications';
import { UsersComponentsQueryProps } from './types';
import { AuthenticationTable } from '../../../hosts/components/authentications_table';
import { manageQuery } from '../../../common/components/page/manage_query';
import { useQueryToggle } from '../../../common/containers/query_toggle';
const AuthenticationTableManage = manageQuery(AuthenticationTable);
export const AuthenticationsQueryTabBody = ({
endDate,
filterQuery,
indexNames,
skip,
setQuery,
startDate,
type,
docValueFields,
deleteQuery,
}: UsersComponentsQueryProps) => {
const { toggleStatus } = useQueryToggle(ID);
const [querySkip, setQuerySkip] = useState(skip || !toggleStatus);
useEffect(() => {
setQuerySkip(skip || !toggleStatus);
}, [skip, toggleStatus]);
const [
loading,
{ authentications, totalCount, pageInfo, loadPage, id, inspect, isInspected, refetch },
] = useAuthentications({
docValueFields,
endDate,
filterQuery,
indexNames,
skip: querySkip,
startDate,
// TODO Move authentication table and hook store to 'public/common' folder when 'usersEnabled' FF is removed
// @ts-ignore
type,
deleteQuery,
});
return (
<AuthenticationTableManage
data={authentications}
deleteQuery={deleteQuery}
fakeTotalCount={getOr(50, 'fakeTotalCount', pageInfo)}
id={id}
inspect={inspect}
isInspect={isInspected}
loading={loading}
loadPage={loadPage}
refetch={refetch}
showMorePagesIndicator={getOr(false, 'showMorePagesIndicator', pageInfo)}
setQuery={setQuery}
setQuerySkip={setQuerySkip}
totalCount={totalCount}
docValueFields={docValueFields}
indexNames={indexNames}
// TODO Move authentication table and store to 'public/common' folder when 'usersEnabled' FF is removed
// @ts-ignore
type={type}
/>
);
};
AuthenticationsQueryTabBody.displayName = 'AllUsersQueryTabBody';

View file

@ -6,3 +6,4 @@
*/
export * from './all_users_query_tab_body';
export * from './authentications_query_tab_body';

View file

@ -0,0 +1,14 @@
/*
* 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 ERROR_FETCHING_USERS_DATA = i18n.translate(
'xpack.securitySolution.userTab.errorFetchingsData',
{
defaultMessage: 'Failed to query users data',
}
);

View file

@ -18,6 +18,13 @@ export const NAVIGATION_ALL_USERS_TITLE = i18n.translate(
}
);
export const NAVIGATION_AUTHENTICATIONS_TITLE = i18n.translate(
'xpack.securitySolution.users.navigation.authenticationsTitle',
{
defaultMessage: 'Authentications',
}
);
export const NAVIGATION_ANOMALIES_TITLE = i18n.translate(
'xpack.securitySolution.users.navigation.anomaliesTitle',
{

View file

@ -11,7 +11,7 @@ import { Route, Switch } from 'react-router-dom';
import { UsersTabsProps } from './types';
import { UsersTableType } from '../store/model';
import { USERS_PATH } from '../../../common/constants';
import { AllUsersQueryTabBody } from './navigation';
import { AllUsersQueryTabBody, AuthenticationsQueryTabBody } from './navigation';
import { AnomaliesQueryTabBody } from '../../common/containers/anomalies/anomalies_query_tab_body';
import { AnomaliesUserTable } from '../../common/components/ml/tables/anomalies_user_table';
import { Anomaly } from '../../common/components/ml/types';
@ -80,6 +80,9 @@ export const UsersTabs = memo<UsersTabsProps>(
<Route path={`${USERS_PATH}/:tabName(${UsersTableType.allUsers})`}>
<AllUsersQueryTabBody {...tabProps} />
</Route>
<Route path={`${USERS_PATH}/:tabName(${UsersTableType.authentications})`}>
<AuthenticationsQueryTabBody {...tabProps} />
</Route>
<Route path={`${USERS_PATH}/:tabName(${UsersTableType.anomalies})`}>
<AnomaliesQueryTabBody {...tabProps} AnomaliesTableComponent={AnomaliesUserTable} />
</Route>

View file

@ -8,6 +8,7 @@
import actionCreatorFactory from 'typescript-fsa';
import { usersModel } from '.';
import { RiskScoreSortField, RiskSeverity } from '../../../common/search_strategy';
import { SortUsersField } from '../../../common/search_strategy/security_solution/users/common';
const actionCreator = actionCreatorFactory('x-pack/security_solution/local/users');
@ -30,8 +31,8 @@ export const updateTableActivePage = actionCreator<{
}>('UPDATE_USERS_ACTIVE_PAGE');
export const updateTableSorting = actionCreator<{
sort: RiskScoreSortField;
tableType: usersModel.UsersTableType.risk;
tableType: usersModel.UsersTableType;
sort: RiskScoreSortField | SortUsersField;
}>('UPDATE_USERS_SORTING');
export const updateUserRiskScoreSeverityFilter = actionCreator<{

View file

@ -6,6 +6,7 @@
*/
import { RiskScoreSortField, RiskSeverity } from '../../../common/search_strategy';
import { SortUsersField } from '../../../common/search_strategy/security_solution/users/common';
export enum UsersType {
page = 'page',
@ -14,6 +15,7 @@ export enum UsersType {
export enum UsersTableType {
allUsers = 'allUsers',
authentications = 'authentications',
anomalies = 'anomalies',
risk = 'userRisk',
events = 'events',
@ -27,15 +29,18 @@ export interface BasicQueryPaginated {
limit: number;
}
export type AllUsersQuery = BasicQueryPaginated;
export interface AllUsersQuery extends BasicQueryPaginated {
sort: SortUsersField;
}
export interface UsersRiskScoreQuery extends BasicQueryPaginated {
sort: RiskScoreSortField; // TODO fix it when be is implemented
sort: RiskScoreSortField;
severitySelection: RiskSeverity[];
}
export interface UsersQueries {
[UsersTableType.allUsers]: AllUsersQuery;
[UsersTableType.authentications]: BasicQueryPaginated;
[UsersTableType.anomalies]: null | undefined;
[UsersTableType.risk]: UsersRiskScoreQuery;
[UsersTableType.events]: BasicQueryPaginated;

View file

@ -19,6 +19,7 @@ import { setUsersPageQueriesActivePageToZero } from './helpers';
import { UsersTableType, UsersModel } from './model';
import { Direction } from '../../../common/search_strategy/common';
import { RiskScoreFields } from '../../../common/search_strategy';
import { UsersFields } from '../../../common/search_strategy/security_solution/users/common';
export const initialUsersState: UsersModel = {
page: {
@ -26,6 +27,14 @@ export const initialUsersState: UsersModel = {
[UsersTableType.allUsers]: {
activePage: DEFAULT_TABLE_ACTIVE_PAGE,
limit: DEFAULT_TABLE_LIMIT,
sort: {
field: UsersFields.lastSeen,
direction: Direction.desc,
},
},
[UsersTableType.authentications]: {
activePage: DEFAULT_TABLE_ACTIVE_PAGE,
limit: DEFAULT_TABLE_LIMIT,
},
[UsersTableType.risk]: {
activePage: DEFAULT_TABLE_ACTIVE_PAGE,

View file

@ -0,0 +1,100 @@
/*
* 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 { IEsSearchResponse } from '../../../../../../../../../../src/plugins/data/common';
import { Direction } from '../../../../../../../common/search_strategy';
import { UsersQueries } from '../../../../../../../common/search_strategy/security_solution/users';
import { UsersRequestOptions } from '../../../../../../../common/search_strategy/security_solution/users/all';
import { UsersFields } from '../../../../../../../common/search_strategy/security_solution/users/common';
export const mockOptions: UsersRequestOptions = {
defaultIndex: ['test_indices*'],
docValueFields: [
{
field: '@timestamp',
format: 'date_time',
},
],
factoryQueryType: UsersQueries.users,
filterQuery:
'{"bool":{"must":[],"filter":[{"match_all":{}},{"match_phrase":{"user.name":{"query":"test_user"}}}],"should":[],"must_not":[]}}',
timerange: {
interval: '12h',
from: '2020-09-02T15:17:13.678Z',
to: '2020-09-03T15:17:13.678Z',
},
params: {},
pagination: {
activePage: 0,
cursorStart: 0,
fakePossibleCount: 50,
querySize: 10,
},
sort: { field: UsersFields.name, direction: Direction.asc },
};
export const mockSearchStrategyResponse: IEsSearchResponse<unknown> = {
rawResponse: {
took: 2,
timed_out: false,
_shards: {
total: 2,
successful: 2,
skipped: 1,
failed: 0,
},
hits: {
max_score: null,
hits: [],
},
aggregations: {
user_count: {
value: 1,
},
user_data: {
doc_count_error_upper_bound: 0,
sum_other_doc_count: 0,
buckets: [
{
key: 'vagrant',
doc_count: 780,
lastSeen: {
value: 1644837532000,
value_as_string: '2022-02-14T11:18:52.000Z',
},
domain: {
hits: {
total: {
value: 780,
relation: 'eq',
},
max_score: null,
hits: [
{
_index: 'endgame-00001',
_id: 'inT0934BjUd1_U2597Vf',
_score: null,
_source: {
user: {
domain: 'ENDPOINT-W-8-03',
},
},
sort: [1644837532000],
},
],
},
},
},
],
},
},
},
isPartial: false,
isRunning: false,
total: 2,
loaded: 2,
};

View file

@ -0,0 +1,21 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`allHosts search strategy parse should parse data correctly 1`] = `
Array [
Object {
"domain": "ENDPOINT-W-8-03",
"lastSeen": "2022-02-14T11:18:52.000Z",
"name": "vagrant",
},
]
`;
exports[`allHosts search strategy parse should parse data correctly 2`] = `1`;
exports[`allHosts search strategy parse should parse data correctly 3`] = `
Object {
"activePage": 0,
"fakeTotalCount": 1,
"showMorePagesIndicator": false,
}
`;

View file

@ -0,0 +1,95 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`buildUsersQuery build query from options correctly 1`] = `
Object {
"allow_no_indices": true,
"body": Object {
"aggregations": Object {
"user_count": Object {
"cardinality": Object {
"field": "user.name",
},
},
"user_data": Object {
"aggs": Object {
"domain": Object {
"top_hits": Object {
"_source": Object {
"includes": Array [
"user.domain",
],
},
"size": 1,
"sort": Array [
Object {
"@timestamp": Object {
"order": "desc",
},
},
],
},
},
"lastSeen": Object {
"max": Object {
"field": "@timestamp",
},
},
},
"terms": Object {
"field": "user.name",
"order": Object {
"_key": "asc",
},
"size": 10,
},
},
},
"docvalue_fields": Array [
Object {
"field": "@timestamp",
"format": "date_time",
},
],
"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 {
"range": Object {
"@timestamp": Object {
"format": "strict_date_optional_time",
"gte": "2020-09-02T15:17:13.678Z",
"lte": "2020-09-03T15:17:13.678Z",
},
},
},
],
},
},
"size": 0,
},
"ignore_unavailable": true,
"index": Array [
"test_indices*",
],
"track_total_hits": false,
}
`;

View file

@ -0,0 +1,51 @@
/*
* 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 { DEFAULT_MAX_TABLE_QUERY_SIZE } from '../../../../../../common/constants';
import * as buildQuery from './query.all_users.dsl';
import { allUsers } from '.';
import { mockOptions, mockSearchStrategyResponse } from './__mocks__';
import { UsersRequestOptions } from '../../../../../../common/search_strategy/security_solution/users/all';
describe('allHosts search strategy', () => {
const buildAllHostsQuery = jest.spyOn(buildQuery, 'buildUsersQuery');
afterEach(() => {
buildAllHostsQuery.mockClear();
});
describe('buildDsl', () => {
test('should build dsl query', () => {
allUsers.buildDsl(mockOptions);
expect(buildAllHostsQuery).toHaveBeenCalledWith(mockOptions);
});
test('should throw error if query size is greater equal than DEFAULT_MAX_TABLE_QUERY_SIZE ', () => {
const overSizeOptions = {
...mockOptions,
pagination: {
...mockOptions.pagination,
querySize: DEFAULT_MAX_TABLE_QUERY_SIZE,
},
} as UsersRequestOptions;
expect(() => {
allUsers.buildDsl(overSizeOptions);
}).toThrowError(`No query size above ${DEFAULT_MAX_TABLE_QUERY_SIZE}`);
});
});
describe('parse', () => {
test('should parse data correctly', async () => {
const result = await allUsers.parse(mockOptions, mockSearchStrategyResponse);
expect(result.users).toMatchSnapshot();
expect(result.totalCount).toMatchSnapshot();
expect(result.pageInfo).toMatchSnapshot();
});
});
});

View file

@ -0,0 +1,71 @@
/*
* 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 { getOr } from 'lodash/fp';
import type { IEsSearchResponse } from '../../../../../../../../../src/plugins/data/common';
import { DEFAULT_MAX_TABLE_QUERY_SIZE } from '../../../../../../common/constants';
import { inspectStringifyObject } from '../../../../../utils/build_query';
import { SecuritySolutionFactory } from '../../types';
import { buildUsersQuery } from './query.all_users.dsl';
import { UsersQueries } from '../../../../../../common/search_strategy/security_solution/users';
import {
UsersRequestOptions,
UsersStrategyResponse,
} from '../../../../../../common/search_strategy/security_solution/users/all';
import { AllUsersAggEsItem } from '../../../../../../common/search_strategy/security_solution/users/common';
export const allUsers: SecuritySolutionFactory<UsersQueries.users> = {
buildDsl: (options: UsersRequestOptions) => {
if (options.pagination && options.pagination.querySize >= DEFAULT_MAX_TABLE_QUERY_SIZE) {
throw new Error(`No query size above ${DEFAULT_MAX_TABLE_QUERY_SIZE}`);
}
return buildUsersQuery(options);
},
parse: async (
options: UsersRequestOptions,
response: IEsSearchResponse<unknown>
): Promise<UsersStrategyResponse> => {
const { activePage, cursorStart, fakePossibleCount, querySize } = options.pagination;
const inspect = {
dsl: [inspectStringifyObject(buildUsersQuery(options))],
};
const buckets: AllUsersAggEsItem[] = getOr(
[],
'aggregations.user_data.buckets',
response.rawResponse
);
const totalCount = getOr(0, 'aggregations.user_count.value', response.rawResponse);
const fakeTotalCount = fakePossibleCount <= totalCount ? fakePossibleCount : totalCount;
const users = buckets.map(
(bucket: AllUsersAggEsItem) => ({
name: bucket.key,
lastSeen: getOr(null, `lastSeen.value_as_string`, bucket),
domain: getOr(null, `domain.hits.hits[0]._source.user.domain`, bucket),
}),
{}
);
const showMorePagesIndicator = totalCount > fakeTotalCount;
return {
...response,
inspect,
totalCount,
users: users.splice(cursorStart, querySize - cursorStart),
pageInfo: {
activePage: activePage ?? 0,
fakeTotalCount,
showMorePagesIndicator,
},
};
},
};

View file

@ -0,0 +1,15 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { buildUsersQuery } from './query.all_users.dsl';
import { mockOptions } from './__mocks__/';
describe('buildUsersQuery', () => {
test('build query from options correctly', () => {
expect(buildUsersQuery(mockOptions)).toMatchSnapshot();
});
});

View file

@ -0,0 +1,92 @@
/*
* 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 { isEmpty } from 'lodash/fp';
import type { ISearchRequestParams } from '../../../../../../../../../src/plugins/data/common';
import { Direction } from '../../../../../../common/search_strategy';
import { createQueryFilterClauses } from '../../../../../utils/build_query';
import { UsersRequestOptions } from '../../../../../../common/search_strategy/security_solution/users/all';
import {
SortUsersField,
UsersFields,
} from '../../../../../../common/search_strategy/security_solution/users/common';
import { assertUnreachable } from '../../../../../../common/utility_types';
export const buildUsersQuery = ({
defaultIndex,
docValueFields,
filterQuery,
pagination: { querySize },
sort,
timerange: { from, to },
}: UsersRequestOptions): ISearchRequestParams => {
const filter = [
...createQueryFilterClauses(filterQuery),
{
range: {
'@timestamp': {
gte: from,
lte: to,
format: 'strict_date_optional_time',
},
},
},
];
const agg = { user_count: { cardinality: { field: 'user.name' } } };
const dslQuery = {
allow_no_indices: true,
index: defaultIndex,
ignore_unavailable: true,
track_total_hits: false,
body: {
...(!isEmpty(docValueFields) ? { docvalue_fields: docValueFields } : {}),
aggregations: {
...agg,
user_data: {
terms: { size: querySize, field: 'user.name', order: getQueryOrder(sort) },
aggs: {
lastSeen: { max: { field: '@timestamp' } },
domain: {
top_hits: {
size: 1,
sort: [
{
'@timestamp': {
order: 'desc' as const,
},
},
],
_source: {
includes: ['user.domain'],
},
},
},
},
},
},
query: { bool: { filter } },
size: 0,
},
};
return dslQuery;
};
type QueryOrder = { lastSeen: Direction } | { domain: Direction } | { _key: Direction };
const getQueryOrder = (sort: SortUsersField): QueryOrder => {
switch (sort.field) {
case UsersFields.lastSeen:
return { lastSeen: sort.direction };
case UsersFields.name:
return { _key: sort.direction };
default:
return assertUnreachable(sort.field);
}
};

View file

@ -9,10 +9,12 @@ import { FactoryQueryTypes } from '../../../../../common/search_strategy/securit
import { UsersQueries } from '../../../../../common/search_strategy/security_solution/users';
import { SecuritySolutionFactory } from '../types';
import { allUsers } from './all';
import { userDetails } from './details';
import { totalUsersKpi } from './kpi/total_users';
export const usersFactory: Record<UsersQueries, SecuritySolutionFactory<FactoryQueryTypes>> = {
[UsersQueries.details]: userDetails,
[UsersQueries.kpiTotalUsers]: totalUsersKpi,
[UsersQueries.users]: allUsers,
};