[Entity Analytics][Privmon] Manage data sources page (#225053)

## Summary

This PR adds a management page to the current privmon dashboard to
facilitate adding data sources *after* the initial onboarding flow

---------

Co-authored-by: jaredburgettelastic <jared.burgett@elastic.co>
Co-authored-by: Pablo Machado <pablo.nevesmachado@elastic.co>
This commit is contained in:
Tiago Vila Verde 2025-06-24 17:39:38 +02:00 committed by GitHub
parent 0c377fafa8
commit e7d6e441de
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 325 additions and 8 deletions

View file

@ -0,0 +1,112 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import {
EuiButton,
EuiLoadingSpinner,
EuiCallOut,
EuiFlexGroup,
EuiIcon,
EuiText,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { useBoolean } from '@kbn/react-hooks';
import { useGetLatestCSVPrivilegedUserUploadQuery } from './hooks/manage_data_sources_query_hooks';
import { UploadPrivilegedUsersModal } from '../privileged_user_monitoring_onboarding/components/file_uploader';
import type { AddDataSourceResult } from '.';
import { PreferenceFormattedDate } from '../../../common/components/formatted_date';
export const CsvUploadManageDataSource = ({
setAddDataSourceResult,
namespace,
}: {
setAddDataSourceResult: (result: AddDataSourceResult) => void;
namespace: string;
}) => {
const [isImportFileModalVisible, { on: showImportFileModal, off: closeImportFileModal }] =
useBoolean(false);
const { latestTimestamp, isLoading, isError, refetch } =
useGetLatestCSVPrivilegedUserUploadQuery(namespace);
return (
<>
<EuiFlexGroup alignItems={'flexStart'} direction={'column'}>
<EuiFlexGroup gutterSize={'s'} alignItems={'center'}>
<EuiIcon size={'l'} type={'importAction'} />
<EuiText>
<h1>
<FormattedMessage
id="xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.manageDataSources.file"
defaultMessage="File"
/>
</h1>
</EuiText>
</EuiFlexGroup>
<EuiText>
<p>
<FormattedMessage
id="xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.manageDataSources.file.text"
defaultMessage="CSV file exported from your user management tool. Only one file can be added as a data source, and privileged users previously uploaded through CSV will be overwritten."
/>
</p>
{isError && (
<EuiCallOut
title={
<FormattedMessage
id="xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.manageDataSources.file.retrievalError"
defaultMessage="There was an error retrieving previous CSV uploads."
/>
}
color={'danger'}
/>
)}
{isLoading && <EuiLoadingSpinner size="l" />}
{!isLoading && !isError && !latestTimestamp && (
<h4>
<FormattedMessage
id="xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.manageDataSources.file.noFilesAdded"
defaultMessage="No files added"
/>
</h4>
)}
{latestTimestamp && (
<h4>
<FormattedMessage
id="xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.manageDataSources.file.lastUpdatedTimestamp"
defaultMessage="Last uploaded: "
/>
<PreferenceFormattedDate value={new Date(latestTimestamp)} />
</h4>
)}
</EuiText>
<EuiButton
disabled={isError || isLoading}
onClick={showImportFileModal}
fullWidth={false}
iconType={'plusInCircle'}
>
<FormattedMessage
id="xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.manageDataSources.indices.text"
defaultMessage="Import file"
/>
</EuiButton>
</EuiFlexGroup>
{isImportFileModalVisible && (
<UploadPrivilegedUsersModal
onClose={closeImportFileModal}
onImport={async (userCount: number) => {
closeImportFileModal();
setAddDataSourceResult({ successful: true, userCount });
await refetch();
}}
/>
)}
</>
);
};

View file

@ -0,0 +1,41 @@
/*
* 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 { getESQLResults } from '@kbn/esql-utils';
import { getPrivilegedMonitorUsersIndex } from '../../../../../common/entity_analytics/privilege_monitoring/constants';
import { esqlResponseToRecords } from '../../../../common/utils/esql';
import { useKibana } from '../../../../common/lib/kibana';
const getLatestCSVPrivilegedUserUploadQuery = (namespace: string) => {
return `FROM ${getPrivilegedMonitorUsersIndex(namespace)}
| WHERE labels.sources == "csv"
| STATS latest_timestamp = MAX(@timestamp)`;
};
export const useGetLatestCSVPrivilegedUserUploadQuery = (namespace: string) => {
const search = useKibana().services.data.search.search;
const { isLoading, data, isError, refetch } = useQuery([], async ({ signal }) => {
return esqlResponseToRecords<{ latest_timestamp: string }>(
(
await getESQLResults({
esqlQuery: getLatestCSVPrivilegedUserUploadQuery(namespace),
search,
signal,
})
)?.response
);
});
const latestTimestamp = data ? data.find(Boolean)?.latest_timestamp : undefined;
return {
latestTimestamp,
isLoading,
isError,
refetch,
};
};

View file

@ -0,0 +1,140 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import {
EuiButton,
EuiCallOut,
EuiButtonEmpty,
EuiFlexGroup,
EuiIcon,
EuiSpacer,
EuiText,
EuiLoadingSpinner,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import React, { useState } from 'react';
import { useBoolean } from '@kbn/react-hooks';
import { CsvUploadManageDataSource } from './csv_upload_manage_data_source';
import { HeaderPage } from '../../../common/components/header_page';
import { useSpaceId } from '../../../common/hooks/use_space_id';
import { IndexSelectorModal } from '../privileged_user_monitoring_onboarding/components/select_index_modal';
import { useFetchPrivilegedUserIndices } from '../privileged_user_monitoring_onboarding/hooks/use_fetch_privileged_user_indices';
export interface AddDataSourceResult {
successful: boolean;
userCount: number;
}
export const PrivilegedUserMonitoringManageDataSources = ({
onBackToDashboardClicked,
onDone,
}: {
onBackToDashboardClicked: () => void;
onDone: (userCount: number) => void;
}) => {
const spaceId = useSpaceId();
const [addDataSourceResult, setAddDataSourceResult] = useState<AddDataSourceResult | undefined>();
const [isIndexModalOpen, { on: showIndexModal, off: hideIndexModal }] = useBoolean(false);
const { data: indices = [], isFetching } = useFetchPrivilegedUserIndices(undefined);
return (
<>
<EuiButtonEmpty
flush={'left'}
iconType={'arrowLeft'}
iconSide={'left'}
onClick={onBackToDashboardClicked}
>
<FormattedMessage
id="xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.manageDataSources.back"
defaultMessage={'Back to privileged user monitoring'}
/>
</EuiButtonEmpty>
<HeaderPage
border={true}
title={
<FormattedMessage
id="xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.manageDataSources.title"
defaultMessage={'Manage data sources'}
/>
}
/>
{addDataSourceResult?.successful && (
<>
<EuiCallOut
title={i18n.translate(
'xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.manageDataSources.successMessage',
{
defaultMessage:
'New data source of privileged users successfully set up: {userCount} users added',
values: { userCount: addDataSourceResult.userCount },
}
)}
color="success"
iconType={'check'}
/>
<EuiSpacer size={'l'} />
</>
)}
<EuiFlexGroup alignItems={'flexStart'} direction={'column'}>
<EuiFlexGroup gutterSize={'s'} alignItems={'center'}>
<EuiIcon size={'l'} type={'indexOpen'} />
<EuiText>
<h1>
<FormattedMessage
id="xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.manageDataSources.indices"
defaultMessage="Indices"
/>
</h1>
</EuiText>
</EuiFlexGroup>
<EuiText>
<p>
<FormattedMessage
id="xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.manageDataSources.indices.infoText"
defaultMessage="One or more indices containing the user.name field. All user names in the indices, specified in the user.name field, will be defined as privileged users."
/>
</p>
<h4>
{isFetching && <EuiLoadingSpinner size="m" data-test-subj="loading-indices-spinner" />}
{indices.length === 0 && (
<FormattedMessage
id="xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.manageDataSources.indices.noIndicesAdded"
defaultMessage="No indices added"
/>
)}
{indices.length > 0 && (
<FormattedMessage
id="xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.manageDataSources.indices.numIndicesAdded"
defaultMessage="{indexCount, plural, one {# index} other {# indices}} added"
values={{ indexCount: indices.length }}
/>
)}
</h4>
</EuiText>
<EuiButton fullWidth={false} iconType={'plusInCircle'} onClick={showIndexModal}>
<FormattedMessage
id="xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.manageDataSources.indices.btnText"
defaultMessage="Select index"
/>
</EuiButton>
</EuiFlexGroup>
<EuiSpacer size={'xxl'} />
{spaceId && (
<CsvUploadManageDataSource
setAddDataSourceResult={setAddDataSourceResult}
namespace={spaceId}
/>
)}
{isIndexModalOpen && <IndexSelectorModal onClose={hideIndexModal} onImport={onDone} />}
</>
);
};

View file

@ -93,7 +93,7 @@ export const AddDataSourcePanel = ({ onComplete }: AddDataSourcePanelProps) => {
description={
<FormattedMessage
id="xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.addDataSource.file.description"
defaultMessage="Import a list of privileged users from a CSV, TXT, or TSV file"
defaultMessage="Import a list of privileged users from a CSV file"
/>
}
onClick={showImportFileModal}

View file

@ -29,21 +29,21 @@ import { useFetchPrivilegedUserIndices } from '../hooks/use_fetch_privileged_use
import { useEntityAnalyticsRoutes } from '../../../api/api';
import { CreateIndexModal } from './create_index_modal';
const SELECT_INDEX_LABEL = i18n.translate(
export const SELECT_INDEX_LABEL = i18n.translate(
'xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.selectIndex.comboboxPlaceholder',
{
defaultMessage: 'Select index',
}
);
const LOADING_ERROR_MESSAGE = i18n.translate(
export 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 DEBOUNCE_OPTIONS = { wait: 300 };
export const IndexSelectorModal = ({
onClose,

View file

@ -36,6 +36,7 @@ import { useSourcererDataView } from '../../sourcerer/containers';
import { HeaderPage } from '../../common/components/header_page';
import { useEntityAnalyticsRoutes } from '../api/api';
import { usePrivilegedMonitoringEngineStatus } from '../api/hooks/use_privileged_monitoring_engine_status';
import { PrivilegedUserMonitoringManageDataSources } from '../components/privileged_user_monitoring_manage_data_sources';
type PageState =
| { type: 'fetchingEngineStatus' }
@ -45,7 +46,9 @@ type PageState =
initResponse?: InitMonitoringEngineResponse | PrivMonHealthResponse;
userCount: number;
}
| { type: 'dashboard'; onboardingCallout?: OnboardingCallout; error: string | undefined };
| { type: 'dashboard'; onboardingCallout?: OnboardingCallout; error: string | undefined }
| { type: 'initializingEngine'; initResponse?: InitMonitoringEngineResponse; userCount: number }
| { type: 'manageDataSources' };
type Action =
| { type: 'INITIALIZING_ENGINE'; userCount: number; initResponse?: InitMonitoringEngineResponse }
@ -57,7 +60,8 @@ type Action =
}
| {
type: 'SHOW_ONBOARDING';
};
}
| { type: 'SHOW_MANAGE_DATA_SOURCES' };
const initialState: PageState = { type: 'fetchingEngineStatus' };
function reducer(state: PageState, action: Action): PageState {
@ -85,6 +89,8 @@ function reducer(state: PageState, action: Action): PageState {
};
}
return state;
case 'SHOW_MANAGE_DATA_SOURCES':
return { type: 'manageDataSources' };
default:
return state;
}
@ -117,7 +123,13 @@ export const EntityAnalyticsPrivilegedUserMonitoringPage = () => {
[initPrivilegedMonitoringEngine]
);
const onManageUserClicked = useCallback(() => {}, []);
const onManageUserClicked = useCallback(() => {
dispatch({ type: 'SHOW_MANAGE_DATA_SOURCES' });
}, []);
const onBackToDashboardClicked = useCallback(() => {
dispatch({ type: 'SHOW_DASHBOARD' });
}, []);
useEffect(() => {
if (engineStatus.isLoading) {
@ -262,7 +274,7 @@ export const EntityAnalyticsPrivilegedUserMonitoringPage = () => {
<EuiButtonEmpty onClick={onManageUserClicked} iconType="gear" color="primary">
<FormattedMessage
id="xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.dashboards.manageUsersButton"
defaultMessage="Manage users"
defaultMessage="Manage data sources"
/>
</EuiButtonEmpty>,
]}
@ -276,6 +288,13 @@ export const EntityAnalyticsPrivilegedUserMonitoringPage = () => {
</>
)}
{state.type === 'manageDataSources' && (
<PrivilegedUserMonitoringManageDataSources
onBackToDashboardClicked={onBackToDashboardClicked}
onDone={initEngineCallBack}
/>
)}
<SpyRoute pageName={SecurityPageName.entityAnalyticsPrivilegedUserMonitoring} />
</SecuritySolutionPageWrapper>
</>

View file

@ -37,6 +37,9 @@ export const PRIVILEGED_MONITOR_USERS_INDEX_MAPPING: MappingProperties = {
'user.is_privileged': {
type: 'boolean',
},
'labels.sources': {
type: 'keyword',
},
};
export const generateUserIndexMappings = (): MappingTypeMapping => ({

View file

@ -18,11 +18,13 @@ export const bulkUpsertBatch =
index,
operations: users.flatMap((u) => {
const id = batch.existingUsers[u.username];
const timestamp = new Date().toISOString();
if (!id) {
return [
{ create: {} },
{
'@timestamp': timestamp,
user: { name: u.username, is_privileged: true },
labels: { sources: ['csv'] },
},