mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 17:59:23 -04:00
[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:
parent
3704642366
commit
c1b7448e54
42 changed files with 1166 additions and 79 deletions
|
@ -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
|
||||
|
|
|
@ -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[];
|
||||
}
|
|
@ -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> } };
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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'>;
|
||||
|
|
|
@ -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`);
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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"]';
|
||||
|
||||
|
|
|
@ -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"]';
|
|
@ -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>
|
||||
|
|
|
@ -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>;
|
||||
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
|
|
@ -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(
|
||||
() => [
|
||||
|
|
|
@ -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]: {
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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`,
|
||||
}),
|
||||
},
|
||||
];
|
|
@ -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}}`,
|
||||
});
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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(() => {
|
||||
|
|
|
@ -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})`;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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';
|
|
@ -6,3 +6,4 @@
|
|||
*/
|
||||
|
||||
export * from './all_users_query_tab_body';
|
||||
export * from './authentications_query_tab_body';
|
||||
|
|
|
@ -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',
|
||||
}
|
||||
);
|
|
@ -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',
|
||||
{
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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<{
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
};
|
|
@ -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,
|
||||
}
|
||||
`;
|
|
@ -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,
|
||||
}
|
||||
`;
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
}
|
||||
};
|
|
@ -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,
|
||||
};
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue