[SecuritySolution] Bootstrap Privileged User Monitoring page (#217350)

## Summary

Bootstrap Privileged User Monitoring page. This page is hidden behind
`privilegeMonitoringEnabled` flag.

![Screenshot 2025-04-16 at 13 28
56](https://github.com/user-attachments/assets/f1c79cfb-a7b3-4dfb-a1b3-6259f00e6a19)



### Included
* Add the Privileged User Monitoring page content according to design
* Link integrations to the integrations page
* Find index modal
* New API to search for compatible indices
* It also renames the navigation title to only have the first letter
capitalised.

### Not Included
* The navigation is already implemented by
https://github.com/elastic/kibana/pull/217180
* The video introduction
* The final API call in the "choose index" is out of scope for this
issue.
* The CSV upload functionality is entirely out of scope for this ticket.
* The "Sample Dashboard"
* The link to docs

### How to test it?
* Enable `privilegeMonitoringEnabled` flag.
* Start kibana.
* Use the menu to navigate to the Priv User monitoring page

### Checklist

Check the PR satisfies following conditions. 

Reviewers should verify this PR satisfies this list as well.

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md)
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [x] The PR description includes the appropriate Release Notes section,
and the correct `release_note:*` label is applied per the
[guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Pablo Machado 2025-04-22 15:00:14 +02:00 committed by GitHub
parent cc18709d01
commit b623001080
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
47 changed files with 1272 additions and 72 deletions

View file

@ -84,7 +84,7 @@ export enum SecurityPageName {
entityAnalyticsManagement = 'entity_analytics-management',
entityAnalyticsAssetClassification = 'entity_analytics-asset-classification',
entityAnalyticsLanding = 'entity_analytics-landing',
privilegedUserMonitoring = 'entity_analytics-privileged_user_monitoring',
entityAnalyticsPrivilegedUserMonitoring = 'entity_analytics-privileged_user_monitoring',
entityAnalyticsEntityStoreManagement = 'entity_analytics-entity_store_management',
coverageOverview = 'coverage-overview',
notes = 'notes',

View file

@ -141,6 +141,7 @@ export const useGetPackageInfoByKeyQuery = (
queryOptions: {
// If enabled is false, the query will not be fetched
enabled?: boolean;
suspense?: boolean;
refetchOnMount?: boolean | 'always';
} = {
enabled: true,
@ -164,6 +165,7 @@ export const useGetPackageInfoByKeyQuery = (
},
}),
{
suspense: queryOptions.suspense,
enabled: queryOptions.enabled,
refetchOnMount: queryOptions.refetchOnMount,
retry: (_, error) => !isRegistryConnectionError(error),

View file

@ -91,7 +91,13 @@ export const AvailablePackagesHook = () => {
);
};
export const LazyPackageCard = lazy(() =>
import('./applications/integrations/sections/epm/components/package_card').then((module) => ({
default: module.PackageCard,
}))
);
export { useGetDataStreams } from './hooks/use_request/data_stream';
export { useGetPackagesQuery } from './hooks/use_request/epm';
export { useGetPackagesQuery, useGetPackageInfoByKeyQuery } from './hooks/use_request/epm';
export { useGetSettingsQuery } from './hooks/use_request/settings';
export { useLink } from './hooks/use_link';

View file

@ -148,14 +148,14 @@ export const i18nStrings = {
},
},
entityRiskScore: i18n.translate('securitySolutionPackages.navLinks.entityRiskScore', {
defaultMessage: 'Entity Risk Score',
defaultMessage: 'Entity risk score',
}),
entityStore: i18n.translate('securitySolutionPackages.navLinks.entityStore', {
defaultMessage: 'Entity Store',
defaultMessage: 'Entity store',
}),
entityAnalytics: {
landing: i18n.translate('securitySolutionPackages.navLinks.entityAnalytics', {
defaultMessage: 'Entity Analytics',
defaultMessage: 'Entity analytics',
}),
},
devTools: i18n.translate('securitySolutionPackages.navLinks.devTools', {

View file

@ -25,7 +25,7 @@ export const ENTITY_ANALYTICS_LICENSE_DESC = i18n.translate(
export const ENTITY_ANALYTICS_TITLE = i18n.translate(
'securitySolutionPackages.entityAnalytics.navigation',
{
defaultMessage: 'Entity Analytics',
defaultMessage: 'Entity analytics',
}
);

View file

@ -8,4 +8,6 @@
export * from './asset_criticality';
export * from './risk_engine';
export * from './entity_store';
export * from './monitoring';
export type { EntityAnalyticsPrivileges } from './common';

View file

@ -0,0 +1,8 @@
/*
* 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 * from './search_indices.gen';

View file

@ -0,0 +1,30 @@
/*
* 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.
*/
/*
* NOTICE: Do not edit this file manually.
* This file is automatically generated by the OpenAPI Generator, @kbn/openapi-generator.
*
* info:
* title: Search indices for Privileges Monitoring import
* version: 2023-10-31
*/
import { z } from '@kbn/zod';
export type SearchPrivilegesIndicesRequestQuery = z.infer<
typeof SearchPrivilegesIndicesRequestQuery
>;
export const SearchPrivilegesIndicesRequestQuery = z.object({
searchQuery: z.string().optional(),
});
export type SearchPrivilegesIndicesRequestQueryInput = z.input<
typeof SearchPrivilegesIndicesRequestQuery
>;
export type SearchPrivilegesIndicesResponse = z.infer<typeof SearchPrivilegesIndicesResponse>;
export const SearchPrivilegesIndicesResponse = z.array(z.string());

View file

@ -0,0 +1,28 @@
openapi: 3.0.0
info:
title: Search indices for Privileges Monitoring import
version: '2023-10-31'
paths:
/api/entity_analytics/monitoring/privileges/indices:
get:
x-labels: [ess, serverless]
x-internal: true
x-codegen-enabled: true
operationId: SearchPrivilegesIndices
summary: Search Indices for Privileges Monitoring import
parameters:
- name: searchQuery
in: query
schema:
type: string
description: The search query to filter the indices
responses:
'200':
description: Successful response
content:
application/json:
schema:
type: array
items:
type: string

View file

@ -258,6 +258,10 @@ import type {
GetEntityStoreStatusRequestQueryInput,
GetEntityStoreStatusResponse,
} from './entity_analytics/entity_store/status.gen';
import type {
SearchPrivilegesIndicesRequestQueryInput,
SearchPrivilegesIndicesResponse,
} from './entity_analytics/monitoring/search_indices.gen';
import type { InitMonitoringEngineResponse } from './entity_analytics/privilege_monitoring/engine/init.gen';
import type { PrivMonHealthResponse } from './entity_analytics/privilege_monitoring/health.gen';
import type { CleanUpRiskEngineResponse } from './entity_analytics/risk_engine/engine_cleanup_route.gen';
@ -2138,6 +2142,20 @@ The difference between the `id` and `rule_id` is that the `id` is a unique rule
})
.catch(catchAxiosErrorFormatAndThrow);
}
async searchPrivilegesIndices(props: SearchPrivilegesIndicesProps) {
this.log.info(`${new Date().toISOString()} Calling API SearchPrivilegesIndices`);
return this.kbnClient
.request<SearchPrivilegesIndicesResponse>({
path: '/api/entity_analytics/monitoring/privileges/indices',
headers: {
[ELASTIC_HTTP_VERSION_HEADER]: '2023-10-31',
},
method: 'GET',
query: props.query,
})
.catch(catchAxiosErrorFormatAndThrow);
}
/**
* Assign users to detection alerts, and unassign them from alerts.
> info
@ -2612,6 +2630,9 @@ export interface RunScriptActionProps {
export interface SearchAlertsProps {
body: SearchAlertsRequestBodyInput;
}
export interface SearchPrivilegesIndicesProps {
query: SearchPrivilegesIndicesRequestQueryInput;
}
export interface SetAlertAssigneesProps {
body: SetAlertAssigneesRequestBodyInput;
}

View file

@ -21,12 +21,12 @@ export const DATA_QUALITY = i18n.translate(
export const ENTITY_ANALYTICS_RISK_SCORE = i18n.translate(
'xpack.securitySolution.navigation.entityRiskScore',
{
defaultMessage: 'Entity Risk Score',
defaultMessage: 'Entity risk score',
}
);
export const ENTITY_STORE = i18n.translate('xpack.securitySolution.navigation.entityStore', {
defaultMessage: 'Entity Store',
defaultMessage: 'Entity store',
});
export const NOTES = i18n.translate('xpack.securitySolution.navigation.notes', {
@ -50,14 +50,14 @@ export const DETECTION_RESPONSE = i18n.translate(
export const ENTITY_ANALYTICS = i18n.translate(
'xpack.securitySolution.navigation.entityAnalytics',
{
defaultMessage: 'Entity Analytics',
defaultMessage: 'Entity analytics',
}
);
export const ENTITY_ANALYTICS_PRIVILEGED_USER_MONITORING = i18n.translate(
'xpack.securitySolution.navigation.privilegedUserMonitoring',
{
defaultMessage: 'Privileged User Monitoring',
defaultMessage: 'Privileged user monitoring',
}
);

View file

@ -0,0 +1,30 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useMemo } from 'react';
import type { GetAppUrl } from '@kbn/security-solution-navigation/src/navigation';
import { useNavigation } from '@kbn/security-solution-navigation/src/navigation';
import { APP_UI_ID } from '../../../../common';
export const useIntegrationLinkState = (path: string) => {
const { getAppUrl } = useNavigation();
return useMemo(() => getIntegrationLinkState(path, getAppUrl), [getAppUrl, path]);
};
export const getIntegrationLinkState = (path: string, getAppUrl: GetAppUrl) => {
const url = getAppUrl({
appId: APP_UI_ID,
path,
});
return {
onCancelNavigateTo: [APP_UI_ID, { path }],
onCancelUrl: url,
onSaveNavigateTo: [APP_UI_ID, { path }],
};
};

View file

@ -0,0 +1,43 @@
/*
* 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 { addPathParamToUrl, RETURN_APP_ID, RETURN_PATH } from '.';
import { APP_UI_ID } from '../../../../common';
describe('addPathParamToUrl', () => {
const encodedOnboardingLink = encodeURIComponent('/onboarding');
it('should append query parameters to a URL without existing query parameters', () => {
const url = 'https://example.com';
const onboardingLink = '/onboarding';
const result = addPathParamToUrl(url, onboardingLink);
expect(result).toBe(
`https://example.com?${RETURN_APP_ID}=${APP_UI_ID}&${RETURN_PATH}=${encodedOnboardingLink}`
);
});
it('should append query parameters to a URL with existing query parameters', () => {
const url = 'https://example.com?foo=bar';
const onboardingLink = '/onboarding';
const result = addPathParamToUrl(url, onboardingLink);
expect(result).toBe(
`https://example.com?foo=bar&${RETURN_APP_ID}=${APP_UI_ID}&${RETURN_PATH}=${encodedOnboardingLink}`
);
});
it('should encode the onboarding link correctly', () => {
const url = 'https://example.com';
const onboardingLink = '/onboarding?step=1&next=2';
const customEncodedOnboardingLink = encodeURIComponent(onboardingLink);
const result = addPathParamToUrl(url, onboardingLink);
expect(result).toBe(
`https://example.com?${RETURN_APP_ID}=${APP_UI_ID}&${RETURN_PATH}=${customEncodedOnboardingLink}`
);
});
});

View file

@ -0,0 +1,28 @@
/*
* 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 { stringifyUrl } from 'query-string';
import { APP_UI_ID } from '../../../../common';
export const RETURN_APP_ID = 'returnAppId';
export const RETURN_PATH = 'returnPath';
export const addPathParamToUrl = (url: string, onboardingLink: string): string => {
return stringifyUrl(
{
url,
query: {
[RETURN_APP_ID]: APP_UI_ID,
[RETURN_PATH]: onboardingLink,
},
},
{
encode: true,
sort: false,
}
);
};

View file

@ -27,6 +27,7 @@ import type {
import type {
AssetCriticalityRecord,
EntityAnalyticsPrivileges,
SearchPrivilegesIndicesResponse,
} from '../../../common/api/entity_analytics';
import {
RISK_ENGINE_STATUS_URL,
@ -183,6 +184,25 @@ export const useEntityAnalyticsRoutes = () => {
method: 'GET',
});
/**
* Search indices for privilege monitoring import
*/
const searchPrivMonIndices = async (params: {
query: string | undefined;
signal?: AbortSignal;
}) =>
http.fetch<SearchPrivilegesIndicesResponse>(
'/api/entity_analytics/monitoring/privileges/indices',
{
version: API_VERSIONS.public.v1,
method: 'GET',
query: {
searchQuery: params.query,
},
signal: params.signal,
}
);
/**
* Create asset criticality
*/
@ -294,6 +314,7 @@ export const useEntityAnalyticsRoutes = () => {
fetchRiskEnginePrivileges,
fetchAssetCriticalityPrivileges,
fetchEntityStorePrivileges,
searchPrivMonIndices,
createAssetCriticality,
deleteAssetCriticality,
fetchAssetCriticality,

View file

@ -0,0 +1,90 @@
/*
* 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, screen, fireEvent } from '@testing-library/react';
import { AddDataSourcePanel } from './add_data_source';
import { TestProviders } from '../../../common/mock';
const mockUseNavigation = jest.fn().mockReturnValue({
navigateTo: jest.fn(),
});
jest.mock('../../../common/lib/kibana', () => {
const original = jest.requireActual('../../../common/lib/kibana');
return {
...original,
useNavigation: () => mockUseNavigation(),
};
});
jest.mock('../../../common/hooks/integrations/use_integration_link_state', () => ({
useIntegrationLinkState: jest.fn(() => ({})),
}));
jest.mock('./hooks/use_integrations', () => ({
useEntityAnalyticsIntegrations: jest.fn(() => [
{
name: 'Okta',
version: '1.0.0',
title: 'Okta Integration',
description: 'Okta integration description',
icon: 'oktaIcon',
},
{
name: 'Entra ID',
version: '1.0.0',
title: 'Entra ID Integration',
description: 'Entra ID integration description',
icon: 'entraIdIcon',
},
{
name: 'Active Directory',
version: '1.0.0',
title: 'Active Directory Integration',
description: 'Active Directory integration description',
icon: 'adIcon',
},
]),
}));
describe('AddDataSourcePanel', () => {
it('renders the panel title and description', () => {
render(<AddDataSourcePanel />, { wrapper: TestProviders });
expect(screen.getByText('Add data source of your privileged users')).toBeInTheDocument();
expect(
screen.getByText(
'To get started, define your privileged users by adding an integration with your organizations user identities, select an index with the relevant data, or import your list of privileged users from a CSV file.'
)
).toBeInTheDocument();
});
it('renders integration cards and handles navigation on click', async () => {
const mockNavigateTo = jest.fn();
mockUseNavigation.mockReturnValue({
navigateTo: mockNavigateTo,
});
render(<AddDataSourcePanel />, { wrapper: TestProviders });
const integrationCards = await screen.findAllByTestId('entity_analytics-integration-card');
expect(integrationCards.length).toBe(3);
const firstButton = integrationCards[0]?.querySelector('button')!;
fireEvent.click(firstButton);
expect(mockNavigateTo).toHaveBeenCalled();
});
it('renders the file import card', () => {
render(<AddDataSourcePanel />, { wrapper: TestProviders });
const fileCard = screen.getByRole('button', { name: /file/i });
expect(fileCard).toBeInTheDocument();
});
});

View file

@ -0,0 +1,133 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { Suspense, useState } from 'react';
import {
EuiCard,
EuiFlexGrid,
EuiFlexGroup,
EuiFlexItem,
EuiHorizontalRule,
EuiIcon,
EuiPanel,
EuiSkeletonRectangle,
EuiSpacer,
EuiText,
EuiTitle,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { IndexSelectorModal } from './select_index_modal';
import { IntegrationCards } from './integrations_cards';
export const AddDataSourcePanel = () => {
const [isIndexModalOpen, setIsIndexModalOpen] = useState(false);
return (
<EuiPanel paddingSize="xl" hasShadow={false} hasBorder={false}>
<EuiTitle>
<h3>
<FormattedMessage
id="xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.addDataSource.title"
defaultMessage="Add data source of your privileged users"
/>
</h3>
</EuiTitle>
<EuiSpacer size="m" />
<EuiText size="m">
<p>
<FormattedMessage
id="xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.addDataSource.description"
defaultMessage="To get started, define your privileged users by adding an integration with your organizations user identities, select an index with the relevant data, or import your list of privileged users from a CSV file."
/>
</p>
</EuiText>
<EuiSpacer size="xl" />
<Suspense
fallback={
<EuiFlexGrid gutterSize="l" columns={3}>
{Array.from({ length: 3 }).map((_, index) => (
<EuiFlexItem grow={1} key={index}>
<EuiSkeletonRectangle height="127px" width="100%" />
</EuiFlexItem>
))}
</EuiFlexGrid>
}
>
<IntegrationCards />
</Suspense>
<EuiSpacer size="m" />
<EuiFlexGroup alignItems="center" justifyContent="spaceAround" responsive={false}>
<EuiFlexItem grow={true}>
<EuiHorizontalRule size="full" margin="none" />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<FormattedMessage
id="xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.addDataSource.or"
defaultMessage="OR"
/>
</EuiFlexItem>
<EuiFlexItem grow={true}>
<EuiHorizontalRule size="full" margin="none" />
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
<EuiFlexGroup direction="row" justifyContent="spaceBetween">
<EuiFlexItem grow={1}>
<EuiCard
hasBorder
layout="horizontal"
icon={<EuiIcon size="l" type="indexOpen" />}
titleSize="xs"
title={
<FormattedMessage
id="xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.addDataSource.index.title"
defaultMessage="Index"
/>
}
description={
<FormattedMessage
id="xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.addDataSource.index.description"
defaultMessage="Select an index that contains relevant user activity data"
/>
}
onClick={() => {
setIsIndexModalOpen(true);
}}
/>
<IndexSelectorModal
isOpen={isIndexModalOpen}
onClose={() => {
setIsIndexModalOpen(false);
}}
/>
</EuiFlexItem>
<EuiFlexItem grow={1}>
<EuiCard
hasBorder
layout="horizontal"
icon={<EuiIcon size="l" type="importAction" />}
titleSize="xs"
title={
<FormattedMessage
id="xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.addDataSource.file.title"
defaultMessage="File"
/>
}
description={
<FormattedMessage
id="xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.addDataSource.file.description"
defaultMessage="Import a list of privileged users from a CSV, TXT, or TSV file"
/>
}
onClick={() => {}}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
);
};

View file

@ -0,0 +1,22 @@
/*
* 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 { useQuery } from '@tanstack/react-query';
import { useEntityAnalyticsRoutes } from '../../../api/api';
export const useFetchPrivilegedUserIndices = (query: string | undefined) => {
const { searchPrivMonIndices } = useEntityAnalyticsRoutes();
return useQuery(
['POST', 'SEARCH_PRIVILEGED_USER_MONITORING_INDICES', query],
({ signal }) => searchPrivMonIndices({ signal, query }),
{
keepPreviousData: true,
cacheTime: 0, // Do not cache the data because it is used by an autocomplete query
refetchOnWindowFocus: false,
}
);
};

View file

@ -0,0 +1,46 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useGetPackageInfoByKeyQuery } from '@kbn/fleet-plugin/public';
import type { GetInfoResponse } from '@kbn/fleet-plugin/common';
export const useEntityAnalyticsIntegrations = () => {
const { data: okta } = useGetPackageInfoByKeyQuery(
'entityanalytics_okta',
undefined, // When package version is undefined it gets the latest version
undefined, // No options required
{
suspense: true, // Make query suspend, it needs tu be wrapped by <Suspense />
}
);
const { data: entra } = useGetPackageInfoByKeyQuery(
'entityanalytics_entra_id',
undefined, // When package version is undefined it gets the latest version
undefined, // No options required
{
suspense: true,
}
);
const { data: ad } = useGetPackageInfoByKeyQuery(
'entityanalytics_ad',
undefined, // When package version is undefined it gets the latest version
{
prerelease: true, // This is a technical preview package, delete this line when it is GA
},
{
suspense: true,
}
);
const integrations = [okta, entra, ad]
.filter<GetInfoResponse>(
(integration): integration is GetInfoResponse => integration !== undefined
)
.map(({ item }) => item);
return integrations;
};

View file

@ -0,0 +1,63 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useCallback } from 'react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { LazyPackageCard } from '@kbn/fleet-plugin/public';
import { useIntegrationLinkState } from '../../../common/hooks/integrations/use_integration_link_state';
import { addPathParamToUrl } from '../../../common/utils/integrations';
import { ENTITY_ANALYTICS_PRIVILEGED_USER_MONITORING_PATH } from '../../../../common/constants';
import { useNavigation } from '../../../common/lib/kibana';
import { INTEGRATION_APP_ID } from '../../../onboarding/components/onboarding_body/cards/integrations/constants';
import { useEntityAnalyticsIntegrations } from './hooks/use_integrations';
/**
* This component has to be wrapped by react Suspense.
* It suspends while loading the lazy package card and while fetching the integrations.
*/
export const IntegrationCards = () => {
const state = useIntegrationLinkState(ENTITY_ANALYTICS_PRIVILEGED_USER_MONITORING_PATH);
const { navigateTo } = useNavigation();
const integrations = useEntityAnalyticsIntegrations();
const navigateToIntegration = useCallback(
(id: string, version: string) => {
navigateTo({
appId: INTEGRATION_APP_ID,
path: addPathParamToUrl(
`/detail/${id}-${version}/overview`,
ENTITY_ANALYTICS_PRIVILEGED_USER_MONITORING_PATH
),
state,
});
},
[navigateTo, state]
);
return (
<EuiFlexGroup direction="row" justifyContent="spaceBetween">
{integrations.map(({ name, title, icons, description, version }) => (
<EuiFlexItem grow={1} key={name} data-test-subj="entity_analytics-integration-card">
<LazyPackageCard
description={description ?? ''}
icons={icons ?? []}
id={name}
name={name}
title={title}
version={version}
onCardClick={() => {
navigateToIntegration(name, version);
}}
// Required values that don't make sense for this scenario
categories={[]}
integration={''}
url={''}
/>
</EuiFlexItem>
))}
</EuiFlexGroup>
);
};

View file

@ -0,0 +1,118 @@
/*
* 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 {
EuiFlexGroup,
EuiFlexItem,
EuiIcon,
EuiLink,
EuiPanel,
EuiText,
EuiTitle,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n';
import { css } from '@emotion/react';
import { ONBOARDING_VIDEO_SOURCE } from '../../../common/constants';
import { AddDataSourcePanel } from './add_data_source';
const VIDEO_TITLE = i18n.translate(
'xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.videoTitle',
{
defaultMessage: 'Onboarding Video',
}
);
export const PrivilegedUserMonitoringOnboardingPanel = () => {
return (
<EuiPanel paddingSize="none">
<EuiPanel paddingSize="xl" color="subdued" hasShadow={false} hasBorder={false}>
<EuiFlexGroup
direction="row"
justifyContent="spaceBetween"
gutterSize="xl"
alignItems="center"
>
<EuiFlexItem grow={1} paddingSize="xl">
<EuiPanel paddingSize="s" hasShadow={false} hasBorder={false} color="subdued">
<EuiFlexGroup justifyContent="spaceBetween" direction="column">
<EuiFlexItem grow={false}>
<EuiTitle>
<h2>
<FormattedMessage
id="xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.pageTitle"
defaultMessage="Privileged user monitoring"
/>
</h2>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText size="m">
<FormattedMessage
id="xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.pageDescription"
defaultMessage="The Privileged user monitoring provides visibility into privileged user
activity, helping security teams analyze account usage, track access events, and
spot potential risks. By continuously monitoring high-risk accounts, the
dashboard enables early detection of potential threats, such as unauthorized
access or lateral movement, before they escalate."
/>
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText color="subdued" size="s">
<EuiFlexGroup
alignItems="center"
justifyContent="flexStart"
gutterSize="s"
responsive={false}
>
<EuiIcon type="documentation" size="m" />
<FormattedMessage
id="xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.learnMore.label"
defaultMessage="Want to learn more?"
/>
<EuiLink
css={{ color: '#FF007F' }} // TODO DELETE THIS WHEN THE HREF LINK IS READY
external={true}
data-test-subj="learnMoreLink"
href="??????" // TODO Add Link to docs
target="_blank"
>
<FormattedMessage
id="xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.docLink.description"
defaultMessage="Check our documentation"
/>
</EuiLink>
</EuiFlexGroup>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
</EuiFlexItem>
<EuiFlexItem grow={1}>
<iframe
css={css`
height: auto;
width: 100%;
aspect-ratio: 16 / 9;
max-width: 480px;
`}
className="eui-alignMiddle"
style={{ border: '8px solid #FF007F' }} // TODO DELETE THIS LINE WHEN THE VIDEO IS READY
referrerPolicy="no-referrer"
sandbox="allow-scripts allow-same-origin"
src={ONBOARDING_VIDEO_SOURCE}
title={VIDEO_TITLE}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
<AddDataSourcePanel />
</EuiPanel>
);
};

View file

@ -0,0 +1,101 @@
/*
* 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 { EuiPanel, EuiSpacer, EuiTextColor, EuiTitle, useEuiTheme } from '@elastic/eui';
import { css } from '@emotion/react';
import { FormattedMessage } from '@kbn/i18n-react';
export const PrivilegedUserMonitoringSampleDashboardsPanel = () => {
const { euiTheme } = useEuiTheme();
return (
<EuiPanel
color="warning"
hasBorder={false}
paddingSize="none"
className="test"
css={css`
border: ${euiTheme.border.width.thick} solid ${euiTheme.colors.warning};
`}
>
<DashboardsSectionHeader />
<EuiPanel hasShadow={false} hasBorder={false}>
<EuiSpacer size="xxl" />
<EuiSpacer size="xxl" />
<EuiSpacer size="xxl" />
<EuiSpacer size="xxl" />
<EuiSpacer size="xxl" />
<EuiSpacer size="xxl" />
<EuiSpacer size="xxl" />
<EuiSpacer size="xxl" />
<EuiSpacer size="xxl" />
<EuiSpacer size="xxl" />
<EuiSpacer size="xxl" />
<EuiSpacer size="xxl" />
<EuiSpacer size="xxl" />
<EuiSpacer size="xxl" />
<EuiSpacer size="xxl" />
<EuiSpacer size="xxl" />
<EuiSpacer size="xxl" />
<EuiSpacer size="xxl" />
<EuiSpacer size="xxl" />
<EuiSpacer size="xxl" />
<EuiSpacer size="xxl" />
<EuiSpacer size="xxl" />
<EuiSpacer size="xxl" />
<EuiSpacer size="xxl" />
<EuiSpacer size="xxl" />
<EuiSpacer size="xxl" />
<EuiSpacer size="xxl" />
<EuiSpacer size="xxl" />
<EuiSpacer size="xxl" />
<EuiSpacer size="xxl" />
<EuiSpacer size="xxl" />
<EuiSpacer size="xxl" />
<EuiSpacer size="xxl" />
<EuiSpacer size="xxl" />
<EuiSpacer size="xxl" />
<EuiSpacer size="xxl" />
<EuiSpacer size="xxl" />
<EuiSpacer size="xxl" />
<EuiSpacer size="xxl" />
<EuiSpacer size="xxl" />
<EuiSpacer size="xxl" />
<EuiSpacer size="xxl" />
</EuiPanel>
<EuiSpacer size="s" />
</EuiPanel>
);
};
const DashboardsSectionHeader = () => {
const { euiTheme } = useEuiTheme();
return (
<EuiPanel
color="warning"
hasBorder={false}
hasShadow={false}
paddingSize="s"
css={css`
position: sticky;
top: 0;
z-index: ${euiTheme.levels.header};
top: var(--kbnAppHeadersOffset, var(--euiFixedHeadersOffset, 0));
`}
>
<EuiTitle size="xxs" className="eui-textCenter" textTransform="uppercase">
<h3>
<EuiTextColor color="warning">
<FormattedMessage
id="xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.sampleDashboard.title"
defaultMessage="Sample Dashboard"
/>
</EuiTextColor>
</h3>
</EuiTitle>
</EuiPanel>
);
};

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 React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { IndexSelectorModal } from './select_index_modal';
import { TestProviders } from '../../../common/mock';
jest.mock('../../../common/hooks/use_app_toasts', () => ({
useAppToasts: () => ({
addError: jest.fn(),
}),
}));
const mockUseFetchPrivilegedUserIndices = jest.fn().mockReturnValue({
data: ['index1', 'index2'],
isFetching: false,
error: null,
});
jest.mock('./hooks/use_fetch_privileged_user_indices', () => ({
useFetchPrivilegedUserIndices: () => mockUseFetchPrivilegedUserIndices(),
}));
describe('IndexSelectorModal', () => {
const onCloseMock = jest.fn();
afterEach(() => {
jest.clearAllMocks();
});
it('renders the modal when isOpen is true', () => {
render(<IndexSelectorModal isOpen={true} onClose={onCloseMock} />, {
wrapper: TestProviders,
});
expect(screen.getByText('Select index')).toBeInTheDocument();
});
it('does not render the modal when isOpen is false', () => {
render(<IndexSelectorModal isOpen={false} onClose={onCloseMock} />, {
wrapper: TestProviders,
});
expect(screen.queryByText('Select index')).not.toBeInTheDocument();
});
it('calls onClose when the cancel button is clicked', () => {
render(<IndexSelectorModal isOpen={true} onClose={onCloseMock} />, {
wrapper: TestProviders,
});
fireEvent.click(screen.getByText('Cancel'));
expect(onCloseMock).toHaveBeenCalled();
});
it('displays the indices in the combo box', () => {
render(<IndexSelectorModal isOpen={true} onClose={onCloseMock} />, {
wrapper: TestProviders,
});
fireEvent.click(screen.getByPlaceholderText('Select index'));
expect(screen.getByText('index1')).toBeInTheDocument();
expect(screen.getByText('index2')).toBeInTheDocument();
});
it('shows an error callout when there is an error', () => {
mockUseFetchPrivilegedUserIndices.mockReturnValue({
data: undefined,
isFetching: false,
error: new Error('Test error'),
});
render(<IndexSelectorModal isOpen={true} onClose={onCloseMock} />, {
wrapper: TestProviders,
});
expect(screen.getByText('Error loading indices. Please try again later.')).toBeInTheDocument();
});
it('displays a loading state when fetching indices', () => {
mockUseFetchPrivilegedUserIndices.mockReturnValue({
data: null,
isFetching: true,
error: null,
});
render(<IndexSelectorModal isOpen={true} onClose={onCloseMock} />, {
wrapper: TestProviders,
});
expect(screen.queryByLabelText('Select index')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,154 @@
/*
* 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, { useEffect, useMemo, useState } from 'react';
import type { EuiComboBoxOptionOption } from '@elastic/eui';
import {
EuiButtonEmpty,
EuiFlexItem,
EuiFlexGroup,
EuiButton,
EuiModalFooter,
EuiSpacer,
EuiComboBox,
EuiModalHeaderTitle,
EuiModalHeader,
EuiModalBody,
EuiModal,
EuiFormRow,
EuiCallOut,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n';
import { useDebounceFn } from '@kbn/react-hooks';
import { useAppToasts } from '../../../common/hooks/use_app_toasts';
import { useFetchPrivilegedUserIndices } from './hooks/use_fetch_privileged_user_indices';
const SELECT_INDEX_LABEL = i18n.translate(
'xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.selectIndex.comboboxPlaceholder',
{
defaultMessage: 'Select index',
}
);
const LOADING_ERROR_MESSAGE = i18n.translate(
'xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.selectIndex.error',
{
defaultMessage: 'Error loading indices. Please try again later.',
}
);
const DEBOUNCE_OPTIONS = { wait: 300 };
export const IndexSelectorModal = ({
isOpen,
onClose,
}: {
isOpen: boolean;
onClose: () => void;
}) => {
const { addError } = useAppToasts();
const [searchQuery, setSearchQuery] = useState<string | undefined>(undefined);
const { data: indices, isFetching, error } = useFetchPrivilegedUserIndices(searchQuery);
const [selectedOptions, setSelected] = useState<Array<EuiComboBoxOptionOption<string>>>([]);
const debouncedSetSearchQuery = useDebounceFn(setSearchQuery, DEBOUNCE_OPTIONS);
const options = useMemo(
() =>
indices?.map((index) => ({
label: index,
})) ?? [],
[indices]
);
useEffect(() => {
if (error != null) {
addError(error, { title: LOADING_ERROR_MESSAGE });
}
}, [addError, error]);
if (!isOpen) {
return null;
}
return (
<EuiModal onClose={onClose} maxWidth="624px">
<EuiModalHeader>
<EuiModalHeaderTitle>
<FormattedMessage
id="xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.selectIndex.title"
defaultMessage="Select index"
/>
</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
<FormattedMessage
id="xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.selectIndex.description"
defaultMessage="Add your privileged users by selecting one or more indices as data source. All user names in the indices, specified in user.name field, will be defined as privileged users."
/>
<EuiSpacer size="l" />
{error ? (
<>
<EuiCallOut color="danger">{LOADING_ERROR_MESSAGE}</EuiCallOut>
<EuiSpacer size="m" />
</>
) : null}
<EuiFormRow
label={
<FormattedMessage
id="xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.selectIndex.comboboxLabel"
defaultMessage="Index"
/>
}
fullWidth
>
<EuiComboBox
isLoading={isFetching && !error}
fullWidth
aria-label={SELECT_INDEX_LABEL}
placeholder={SELECT_INDEX_LABEL}
options={options}
selectedOptions={selectedOptions}
onChange={setSelected}
isClearable={true}
onSearchChange={(query) => {
debouncedSetSearchQuery.run(query);
}}
async
optionMatcher={(_) => false} // prevent the combo box from searching on the client side
/>
</EuiFormRow>
</EuiModalBody>
<EuiModalFooter>
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<EuiButtonEmpty iconType="plusInCircle" onClick={() => {}}>
<FormattedMessage
id="xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.selectIndex.createIndexButtonLabel"
defaultMessage="Create index"
/>
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={true}>
<EuiFlexGroup justifyContent="flexEnd">
<EuiButtonEmpty onClick={onClose}>
<FormattedMessage
id="xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.selectIndex.cancelButtonLabel"
defaultMessage="Cancel"
/>
</EuiButtonEmpty>
<EuiButton onClick={() => {}} fill>
<FormattedMessage
id="xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.selectIndex.addUserButtonLabel"
defaultMessage="Add privileged users"
/>
</EuiButton>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</EuiModalFooter>
</EuiModal>
);
};

View file

@ -17,19 +17,13 @@ import { ENTITY_ANALYTICS, ENTITY_ANALYTICS_PRIVILEGED_USER_MONITORING } from '.
import privilegedUserMonitoringPageImg from '../common/images/privileged_user_monitoring_page.png';
const privMonLinks: LinkItem = {
id: SecurityPageName.privilegedUserMonitoring,
id: SecurityPageName.entityAnalyticsPrivilegedUserMonitoring,
title: ENTITY_ANALYTICS_PRIVILEGED_USER_MONITORING,
landingImage: privilegedUserMonitoringPageImg,
description: i18n.translate(
'xpack.securitySolution.appLinks.privilegedUserMonitoring.Description',
{
defaultMessage: '???????????????????', // TODO
}
),
path: ENTITY_ANALYTICS_PRIVILEGED_USER_MONITORING_PATH,
globalSearchKeywords: [
i18n.translate('xpack.securitySolution.appLinks.privilegedUserMonitoring', {
defaultMessage: 'Privileged User Monitoring',
defaultMessage: 'Privileged user monitoring',
}),
],
experimentalKey: 'privilegeMonitoringEnabled',
@ -46,7 +40,7 @@ export const entityAnalyticsLinks: LinkItem = {
globalNavPosition: 10,
globalSearchKeywords: [
i18n.translate('xpack.securitySolution.appLinks.entityAnalytics.landing', {
defaultMessage: 'Entity Analytics',
defaultMessage: 'Entity analytics',
}),
],
links: [privMonLinks],

View file

@ -16,7 +16,7 @@ import { trackLandingLinkClick } from '../../common/lib/telemetry/trackers';
import { useGlobalQueryString } from '../../common/utils/global_query_string';
const PAGE_TITLE = i18n.translate('xpack.securitySolution.entityAnalytics.landing.pageTitle', {
defaultMessage: 'Entity Analytics',
defaultMessage: 'Entity analytics',
});
export const EntityAnalyticsLandingPage = () => {

View file

@ -5,24 +5,20 @@
* 2.0.
*/
import React from 'react';
import { i18n } from '@kbn/i18n';
import { EuiSpacer } from '@elastic/eui';
import { SecurityPageName } from '../../app/types';
import { HeaderPage } from '../../common/components/header_page';
import { SecuritySolutionPageWrapper } from '../../common/components/page_wrapper';
import { SpyRoute } from '../../common/utils/route/spy_routes';
const PAGE_TITLE = i18n.translate(
'xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.pageTitle',
{
defaultMessage: 'Privileged User Monitoring',
}
);
import { PrivilegedUserMonitoringSampleDashboardsPanel } from '../components/privileged_user_monitoring_onboarding/sample_dashboards_panel';
import { PrivilegedUserMonitoringOnboardingPanel } from '../components/privileged_user_monitoring_onboarding/onboarding_panel';
export const EntityAnalyticsPrivilegedUserMonitoringPage = () => {
return (
<SecuritySolutionPageWrapper>
<HeaderPage title={PAGE_TITLE} />
<SpyRoute pageName={SecurityPageName.privilegedUserMonitoring} />
<PrivilegedUserMonitoringOnboardingPanel />
<EuiSpacer size="l" />
<PrivilegedUserMonitoringSampleDashboardsPanel />
<SpyRoute pageName={SecurityPageName.entityAnalyticsPrivilegedUserMonitoring} />
</SecuritySolutionPageWrapper>
);
};

View file

@ -82,7 +82,7 @@ describe('EntityStoreManagementPage', () => {
render(<EntityStoreManagementPage />, { wrapper: TestProviders });
expect(screen.getByTestId('entityStoreManagementPage')).toBeInTheDocument();
expect(screen.getByText('Entity Store')).toBeInTheDocument();
expect(screen.getByText('Entity store')).toBeInTheDocument();
});
it('disables the switch when status is installing', () => {

View file

@ -67,7 +67,7 @@ const isEntityStoreInstalled = (status?: StoreStatus) => status && status !== 'n
const entityStoreLabel = i18n.translate(
'xpack.securitySolution.entityAnalytics.entityStoreManagementPage.title',
{
defaultMessage: 'Entity Store',
defaultMessage: 'Entity store',
}
);

View file

@ -167,7 +167,7 @@ export const routes = [
path: ENTITY_ANALYTICS_PRIVILEGED_USER_MONITORING_PATH,
component: withSecurityRoutePageWrapper(
EntityAnalyticsPrivilegedUserMonitoringContainer,
SecurityPageName.privilegedUserMonitoring,
SecurityPageName.entityAnalyticsPrivilegedUserMonitoring,
{
redirectOnMissing: true,
}

View file

@ -57,7 +57,7 @@ import { IconAssetCriticality } from '../common/icons/asset_criticality';
const categories = [
{
label: i18n.translate('xpack.securitySolution.appLinks.category.entityAnalytics', {
defaultMessage: 'Entity Analytics',
defaultMessage: 'Entity analytics',
}),
linkIds: [
SecurityPageName.entityAnalyticsManagement,

View file

@ -18,8 +18,6 @@ export const FLEET_APP_ID = `fleet`;
export const INTEGRATION_APP_ID = `integrations`;
export const LOADING_SKELETON_TEXT_LINES = 10; // 10 lines of text
export const MAX_CARD_HEIGHT_IN_PX = 127; // px
export const RETURN_APP_ID = 'returnAppId';
export const RETURN_PATH = 'returnPath';
export const SCROLL_ELEMENT_ID = 'integrations-scroll-container';
export const SEARCH_FILTER_CATEGORIES: CategoryFacet[] = [];
export const WITH_SEARCH_BOX_HEIGHT = '568px';

View file

@ -7,34 +7,20 @@
import { useMemo } from 'react';
import type { IntegrationCardItem } from '@kbn/fleet-plugin/public';
import { SECURITY_UI_APP_ID } from '@kbn/security-solution-navigation';
import { getIntegrationLinkState } from '../../../../../common/hooks/integrations/use_integration_link_state';
import { addPathParamToUrl } from '../../../../../common/utils/integrations';
import { useNavigation } from '../../../../../common/lib/kibana';
import {
APP_INTEGRATIONS_PATH,
APP_UI_ID,
ONBOARDING_PATH,
} from '../../../../../../common/constants';
import { APP_INTEGRATIONS_PATH, ONBOARDING_PATH } from '../../../../../../common/constants';
import {
CARD_DESCRIPTION_LINE_CLAMP,
CARD_TITLE_LINE_CLAMP,
INTEGRATION_APP_ID,
MAX_CARD_HEIGHT_IN_PX,
RETURN_APP_ID,
RETURN_PATH,
TELEMETRY_INTEGRATION_CARD,
} from './constants';
import type { GetAppUrl, NavigateTo } from '../../../../../common/lib/kibana';
import { trackOnboardingLinkClick } from '../../../lib/telemetry';
const addPathParamToUrl = (url: string, onboardingLink: string) => {
const encoded = encodeURIComponent(onboardingLink);
const paramsString = `${RETURN_PATH}=${encoded}&${RETURN_APP_ID}=${APP_UI_ID}`;
if (url.indexOf('?') >= 0) {
return `${url}&${paramsString}`;
}
return `${url}?${paramsString}`;
};
const extractFeaturedCards = (filteredCards: IntegrationCardItem[], featuredCardIds: string[]) => {
return filteredCards.reduce<IntegrationCardItem[]>((acc, card) => {
if (featuredCardIds.includes(card.id)) {
@ -70,7 +56,7 @@ const getFilteredCards = ({
};
};
const addSecuritySpecificProps = ({
export const addSecuritySpecificProps = ({
navigateTo,
getAppUrl,
card,
@ -82,11 +68,7 @@ const addSecuritySpecificProps = ({
}): IntegrationCardItem => {
const onboardingLink = getAppUrl({ appId: SECURITY_UI_APP_ID, path: ONBOARDING_PATH });
const integrationRootUrl = getAppUrl({ appId: INTEGRATION_APP_ID });
const state = {
onCancelNavigateTo: [APP_UI_ID, { path: ONBOARDING_PATH }],
onCancelUrl: onboardingLink,
onSaveNavigateTo: [APP_UI_ID, { path: ONBOARDING_PATH }],
};
const state = getIntegrationLinkState(ONBOARDING_PATH, getAppUrl);
const url =
card.url.indexOf(APP_INTEGRATIONS_PATH) >= 0 && onboardingLink
? addPathParamToUrl(card.url, ONBOARDING_PATH)

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { EXCLUDE_ELASTIC_CLOUD_INDICES, INCLUDE_INDEX_PATTERN } from '../../../../common/constants';
export const SCOPE = ['securitySolution'];
export const TYPE = 'entity_analytics:monitoring:privileges:engine';
export const VERSION = '1.0.0';
@ -21,3 +22,12 @@ export const PRIVILEGE_MONITORING_ENGINE_STATUS = {
// Base constants
export const PRIVMON_BASE_PREFIX = 'privmon' as const;
export const PRIVILEGE_MONITORING_INTERNAL_INDICES_PATTERN = `.${PRIVMON_BASE_PREFIX}*` as const;
// Indices that are exclude from the search
export const PRE_EXCLUDE_INDICES: string[] = [
...INCLUDE_INDEX_PATTERN.map((index) => `-${index}`),
...EXCLUDE_ELASTIC_CLOUD_INDICES,
];
// Indices that are excludes from the search result (This patterns can't be excluded from the search)
export const POST_EXCLUDE_INDICES = ['.']; // internal indices

View file

@ -27,7 +27,11 @@ import { startPrivilegeMonitoringTask } from './tasks/privilege_monitoring_task'
import { createOrUpdateIndex } from '../utils/create_or_update_index';
import { generateUserIndexMappings, getPrivilegedMonitorUsersIndex } from './indices';
import { PrivilegeMonitoringEngineDescriptorClient } from './saved_object/privilege_monitoring';
import { PRIVILEGE_MONITORING_ENGINE_STATUS } from './constants';
import {
POST_EXCLUDE_INDICES,
PRE_EXCLUDE_INDICES,
PRIVILEGE_MONITORING_ENGINE_STATUS,
} from './constants';
import { AUDIT_CATEGORY, AUDIT_OUTCOME, AUDIT_TYPE } from '../audit';
import { PrivilegeMonitoringEngineActions } from './auditing/actions';
import {
@ -135,6 +139,28 @@ export class PrivilegeMonitoringDataClient {
});
}
public async searchPrivilegesIndices(query: string | undefined) {
const { indices } = await this.esClient.fieldCaps({
index: [query ? `*${query}*` : '*', ...PRE_EXCLUDE_INDICES],
types: ['keyword'],
fields: ['user.name'], // search for indices with field 'user.name' of type 'keyword'
include_unmapped: false,
ignore_unavailable: true,
allow_no_indices: true,
expand_wildcards: 'open',
include_empty_fields: false,
filters: '-parent',
});
if (!Array.isArray(indices) || indices.length === 0) {
return [];
}
return indices.filter(
(name) => !POST_EXCLUDE_INDICES.some((pattern) => name.startsWith(pattern))
);
}
public getIndex() {
return getPrivilegedMonitorUsersIndex(this.opts.namespace);
}

View file

@ -7,8 +7,8 @@
import type { EntityAnalyticsRoutesDeps } from '../../types';
import { healthCheckPrivilegeMonitoringRoute } from './health';
import { initPrivilegeMonitoringEngineRoute } from './init';
import { searchPrivilegeMonitoringIndicesRoute } from './search_indices';
export const registerPrivilegeMonitoringRoutes = ({
router,
@ -18,4 +18,5 @@ export const registerPrivilegeMonitoringRoutes = ({
}: EntityAnalyticsRoutesDeps) => {
initPrivilegeMonitoringEngineRoute(router, logger, config);
healthCheckPrivilegeMonitoringRoute(router, logger, config);
searchPrivilegeMonitoringIndicesRoute(router, logger, config);
};

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 type { IKibanaResponse, Logger } from '@kbn/core/server';
import { buildSiemResponse } from '@kbn/lists-plugin/server/routes/utils';
import { transformError } from '@kbn/securitysolution-es-utils';
import { take } from 'lodash/fp';
import { buildRouteValidationWithZod } from '@kbn/zod-helpers';
import { API_VERSIONS, APP_ID } from '../../../../../common/constants';
import type { EntityAnalyticsRoutesDeps } from '../../types';
import { SearchPrivilegesIndicesRequestQuery } from '../../../../../common/api/entity_analytics/monitoring';
// Return a subset of all indices that contain the user.name field
const LIMIT = 20;
export const searchPrivilegeMonitoringIndicesRoute = (
router: EntityAnalyticsRoutesDeps['router'],
logger: Logger,
config: EntityAnalyticsRoutesDeps['config']
) => {
router.versioned
.get({
access: 'public',
path: '/api/entity_analytics/monitoring/privileges/indices',
security: {
authz: {
requiredPrivileges: ['securitySolution', `${APP_ID}-entity-analytics`],
},
},
})
.addVersion(
{
version: API_VERSIONS.public.v1,
validate: {
request: {
query: buildRouteValidationWithZod(SearchPrivilegesIndicesRequestQuery),
},
},
},
async (context, request, response): Promise<IKibanaResponse<{}>> => {
const secSol = await context.securitySolution;
const siemResponse = buildSiemResponse(response);
const query = request.query.searchQuery;
try {
const indices = await secSol
.getPrivilegeMonitoringDataClient()
.searchPrivilegesIndices(query);
return response.ok({
body: take(LIMIT, indices),
});
} catch (e) {
const error = transformError(e);
logger.error(`Error searching privilege monitoring indices: ${error.message}`);
return siemResponse.error({
statusCode: error.statusCode,
body: error.message,
});
}
}
);
};

View file

@ -11,6 +11,7 @@ import { registerRiskEngineRoutes } from './risk_engine/routes';
import type { EntityAnalyticsRoutesDeps } from './types';
import { registerEntityStoreRoutes } from './entity_store/routes';
import { registerPrivilegeMonitoringRoutes } from './privilege_monitoring/routes/register_privilege_monitoring_routes';
export const registerEntityAnalyticsRoutes = (routeDeps: EntityAnalyticsRoutesDeps) => {
registerAssetCriticalityRoutes(routeDeps);
registerRiskScoreRoutes(routeDeps);
@ -18,6 +19,7 @@ export const registerEntityAnalyticsRoutes = (routeDeps: EntityAnalyticsRoutesDe
if (!routeDeps.config.experimentalFeatures.entityStoreDisabled) {
registerEntityStoreRoutes(routeDeps);
}
if (routeDeps.config.experimentalFeatures.privilegeMonitoringEnabled) {
registerPrivilegeMonitoringRoutes(routeDeps);
}

View file

@ -386,7 +386,7 @@ Object {
"link": "securitySolutionUI:entity_analytics-landing",
"renderAs": "panelOpener",
"spaceBefore": null,
"title": "Entity Analytics",
"title": "Entity analytics",
},
Object {
"breadcrumbStatus": "hidden",
@ -479,13 +479,13 @@ Object {
"id": "entity_analytics-management",
"link": "securitySolutionUI:entity_analytics-management",
"sideNavStatus": "hidden",
"title": "Entity Risk Score",
"title": "Entity risk score",
},
Object {
"id": "entity_analytics-entity_store_management",
"link": "securitySolutionUI:entity_analytics-entity_store_management",
"sideNavStatus": "hidden",
"title": "Entity Store",
"title": "Entity store",
},
],
},

View file

@ -446,7 +446,7 @@ const createNavigationTree$ = (services: Services): Rx.Observable<NavigationTree
children: [
{
id: 'entity_analytics-privileged_user_monitoring',
link: securityLink(SecurityPageName.privilegedUserMonitoring),
link: securityLink(SecurityPageName.entityAnalyticsPrivilegedUserMonitoring),
renderAs: 'item',
},
],

View file

@ -372,7 +372,7 @@ Object {
"link": "securitySolutionUI:entity_analytics-landing",
"renderAs": "panelOpener",
"spaceBefore": null,
"title": "Entity Analytics",
"title": "Entity analytics",
},
Object {
"children": Array [
@ -458,13 +458,13 @@ Object {
"id": "entity_analytics-management",
"link": "securitySolutionUI:entity_analytics-management",
"sideNavStatus": "hidden",
"title": "Entity Risk Score",
"title": "Entity risk score",
},
Object {
"id": "entity_analytics-entity_store_management",
"link": "securitySolutionUI:entity_analytics-entity_store_management",
"sideNavStatus": "hidden",
"title": "Entity Store",
"title": "Entity store",
},
],
"defaultIsCollapsed": false,

View file

@ -424,7 +424,7 @@ export const createSecurityNavigationTree$ = (
children: [
{
id: 'entity_analytics-privileged_user_monitoring',
link: securityLink(SecurityPageName.privilegedUserMonitoring),
link: securityLink(SecurityPageName.entityAnalyticsPrivilegedUserMonitoring),
renderAs: 'item',
},
],

View file

@ -137,6 +137,7 @@ import {
} from '@kbn/security-solution-plugin/common/api/detection_engine/rule_preview/rule_preview.gen';
import { RunScriptActionRequestBodyInput } from '@kbn/security-solution-plugin/common/api/endpoint/actions/response_actions/run_script/run_script.gen';
import { SearchAlertsRequestBodyInput } from '@kbn/security-solution-plugin/common/api/detection_engine/signals/query_signals/query_signals_route.gen';
import { SearchPrivilegesIndicesRequestQueryInput } from '@kbn/security-solution-plugin/common/api/entity_analytics/monitoring/search_indices.gen';
import { SetAlertAssigneesRequestBodyInput } from '@kbn/security-solution-plugin/common/api/detection_engine/alert_assignees/set_alert_assignees_route.gen';
import { SetAlertsStatusRequestBodyInput } from '@kbn/security-solution-plugin/common/api/detection_engine/signals/set_signal_status/set_signals_status_route.gen';
import { SetAlertTagsRequestBodyInput } from '@kbn/security-solution-plugin/common/api/detection_engine/alert_tags/set_alert_tags/set_alert_tags.gen';
@ -1517,6 +1518,14 @@ The difference between the `id` and `rule_id` is that the `id` is a unique rule
.set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana')
.send(props.body as object);
},
searchPrivilegesIndices(props: SearchPrivilegesIndicesProps, kibanaSpace: string = 'default') {
return supertest
.get(routeWithNamespace('/api/entity_analytics/monitoring/privileges/indices', kibanaSpace))
.set('kbn-xsrf', 'true')
.set(ELASTIC_HTTP_VERSION_HEADER, '2023-10-31')
.set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana')
.query(props.query);
},
/**
* Assign users to detection alerts, and unassign them from alerts.
> info
@ -1951,6 +1960,9 @@ export interface RunScriptActionProps {
export interface SearchAlertsProps {
body: SearchAlertsRequestBodyInput;
}
export interface SearchPrivilegesIndicesProps {
query: SearchPrivilegesIndicesRequestQueryInput;
}
export interface SetAlertAssigneesProps {
body: SetAlertAssigneesRequestBodyInput;
}

View file

@ -10,5 +10,6 @@ import { FtrProviderContext } from '../../../../ftr_provider_context';
export default function ({ loadTestFile }: FtrProviderContext) {
describe('Entity Analytics - Privilege Monitoring', function () {
loadTestFile(require.resolve('./engine'));
loadTestFile(require.resolve('./search_indices'));
});
}

View file

@ -0,0 +1,64 @@
/*
* 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 expect from '@kbn/expect';
import { FtrProviderContext } from '../../../../ftr_provider_context';
export default ({ getService }: FtrProviderContext) => {
const api = getService('securitySolutionApi');
const es = getService('es');
const log = getService('log');
const logWhenNot200 = (res: any) => {
if (res.status !== 200) {
log.error(`Search index failed`);
log.error(JSON.stringify(res.body));
}
};
const indexName = 'user-index-test';
describe('@ess @serverless @skipInServerlessMKI EntityAnalytics Monitoring SearchIndices', () => {
before(async () => {
await es.indices.create({ index: indexName });
});
after(async () => {
await es.indices.delete({ index: indexName, ignore_unavailable: true });
});
describe('search_indices API', () => {
it('should return an empty array if no indices match the search query', async () => {
const res = await api.searchPrivilegesIndices({ query: { searchQuery: 'test_1235678' } });
logWhenNot200(res);
expect(res.status).eql(200);
expect(res.body).eql([]);
});
it('should return all indices when no searchQuery is given', async () => {
const res = await api.searchPrivilegesIndices({ query: { searchQuery: undefined } });
logWhenNot200(res);
expect(res.status).eql(200);
expect(res.body).to.contain(indexName);
});
it('should return index when searchQuery matches', async () => {
const res = await api.searchPrivilegesIndices({ query: { searchQuery: indexName } });
logWhenNot200(res);
expect(res.status).eql(200);
expect(res.body.length).eql(1);
expect(res.body).to.contain(indexName);
});
});
});
};

View file

@ -29,7 +29,7 @@ describe(
});
it('renders page as expected', () => {
cy.get(PAGE_TITLE).should('include.text', 'Entity Store');
cy.get(PAGE_TITLE).should('include.text', 'Entity store');
});
it('uploads a file', () => {

View file

@ -46,7 +46,7 @@ describe(
});
it('renders page as expected', () => {
cy.get(PAGE_TITLE).should('have.text', 'Entity Risk Score');
cy.get(PAGE_TITLE).should('have.text', 'Entity risk score');
});
describe('Risk preview', () => {