diff --git a/x-pack/solutions/security/plugins/security_solution/common/api/entity_analytics/privilege_monitoring/monitoring_entity_source/monitoring_entity_source.gen.ts b/x-pack/solutions/security/plugins/security_solution/common/api/entity_analytics/privilege_monitoring/monitoring_entity_source/monitoring_entity_source.gen.ts index 14f90de1cc715..6025c2d4930fe 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/api/entity_analytics/privilege_monitoring/monitoring_entity_source/monitoring_entity_source.gen.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/api/entity_analytics/privilege_monitoring/monitoring_entity_source/monitoring_entity_source.gen.ts @@ -15,9 +15,10 @@ */ import { z } from '@kbn/zod'; +import { BooleanFromString } from '@kbn/zod-helpers'; -export type MonitoringEntitySourceDescriptor = z.infer; -export const MonitoringEntitySourceDescriptor = z.object({ +export type CreateMonitoringEntitySource = z.infer; +export const CreateMonitoringEntitySource = z.object({ type: z.string(), name: z.string(), managed: z.boolean().optional(), @@ -33,14 +34,42 @@ export const MonitoringEntitySourceDescriptor = z.object({ }) ) .optional(), - filter: z.object({}).optional(), + filter: z + .object({ + kuery: z.union([z.string(), z.object({})]).optional(), + }) + .optional(), }); -export type MonitoringEntitySourceResponse = z.infer; -export const MonitoringEntitySourceResponse = z.object({ - id: z.string().optional(), - name: z.string().optional(), +export type UpdatedMonitoringEntitySource = z.infer; +export const UpdatedMonitoringEntitySource = z.object({ type: z.string().optional(), + name: z.string().optional(), + managed: z.boolean().optional(), + indexPattern: z.string().optional(), + enabled: z.boolean().optional(), + error: z.string().optional(), + integrationName: z.string().optional(), + matchers: z + .array( + z.object({ + fields: z.array(z.string()), + values: z.array(z.string()), + }) + ) + .optional(), + filter: z + .object({ + kuery: z.union([z.string(), z.object({})]).optional(), + }) + .optional(), +}); + +export type MonitoringEntitySource = z.infer; +export const MonitoringEntitySource = z.object({ + id: z.string(), + name: z.string(), + type: z.string(), indexPattern: z.string().optional(), integrationName: z.string().optional(), enabled: z.boolean().optional(), @@ -52,4 +81,54 @@ export const MonitoringEntitySourceResponse = z.object({ }) ) .optional(), + filter: z + .object({ + kuery: z.union([z.string(), z.object({})]).optional(), + }) + .optional(), +}); + +export type CreateEntitySourceRequestBody = z.infer; +export const CreateEntitySourceRequestBody = CreateMonitoringEntitySource; +export type CreateEntitySourceRequestBodyInput = z.input; + +export type CreateEntitySourceResponse = z.infer; +export const CreateEntitySourceResponse = UpdatedMonitoringEntitySource; + +export type DeleteEntitySourceRequestParams = z.infer; +export const DeleteEntitySourceRequestParams = z.object({ + id: z.string(), +}); +export type DeleteEntitySourceRequestParamsInput = z.input; + +export type GetEntitySourceRequestParams = z.infer; +export const GetEntitySourceRequestParams = z.object({ + id: z.string(), }); +export type GetEntitySourceRequestParamsInput = z.input; + +export type GetEntitySourceResponse = z.infer; +export const GetEntitySourceResponse = MonitoringEntitySource; +export type ListEntitySourcesRequestQuery = z.infer; +export const ListEntitySourcesRequestQuery = z.object({ + type: z.string().optional(), + managed: BooleanFromString.optional(), + name: z.string().optional(), +}); +export type ListEntitySourcesRequestQueryInput = z.input; + +export type ListEntitySourcesResponse = z.infer; +export const ListEntitySourcesResponse = z.array(MonitoringEntitySource); + +export type UpdateEntitySourceRequestParams = z.infer; +export const UpdateEntitySourceRequestParams = z.object({ + id: z.string(), +}); +export type UpdateEntitySourceRequestParamsInput = z.input; + +export type UpdateEntitySourceRequestBody = z.infer; +export const UpdateEntitySourceRequestBody = MonitoringEntitySource; +export type UpdateEntitySourceRequestBodyInput = z.input; + +export type UpdateEntitySourceResponse = z.infer; +export const UpdateEntitySourceResponse = UpdatedMonitoringEntitySource; diff --git a/x-pack/solutions/security/plugins/security_solution/common/api/entity_analytics/privilege_monitoring/monitoring_entity_source/monitoring_entity_source.schema.yaml b/x-pack/solutions/security/plugins/security_solution/common/api/entity_analytics/privilege_monitoring/monitoring_entity_source/monitoring_entity_source.schema.yaml index edcc1080517b8..9f0ba11da1dbe 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/api/entity_analytics/privilege_monitoring/monitoring_entity_source/monitoring_entity_source.schema.yaml +++ b/x-pack/solutions/security/plugins/security_solution/common/api/entity_analytics/privilege_monitoring/monitoring_entity_source/monitoring_entity_source.schema.yaml @@ -7,25 +7,27 @@ info: paths: /api/entity_analytics/monitoring/entity_source: post: - operationId: createEntitySource + operationId: CreateEntitySource + x-codegen-enabled: true summary: Create a new entity source configuration requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/MonitoringEntitySourceDescriptor" + $ref: "#/components/schemas/CreateMonitoringEntitySource" responses: "200": description: Entity source created successfully content: application/json: schema: - $ref: "#/components/schemas/MonitoringEntitySourceResponse" + $ref: "#/components/schemas/UpdatedMonitoringEntitySource" /api/entity_analytics/monitoring/entity_source/{id}: get: - operationId: getEntitySource + operationId: GetEntitySource + x-codegen-enabled: true summary: Get an entity source configuration by ID parameters: - name: id @@ -39,10 +41,11 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/MonitoringEntitySourceResponse" + $ref: "#/components/schemas/MonitoringEntitySource" put: - operationId: updateEntitySource + operationId: UpdateEntitySource + x-codegen-enabled: true summary: Update an entity source configuration parameters: - name: id @@ -55,13 +58,18 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/MonitoringEntitySourceDescriptor" + $ref: "#/components/schemas/MonitoringEntitySource" responses: "200": description: Entity source updated successfully + content: + application/json: + schema: + $ref: "#/components/schemas/UpdatedMonitoringEntitySource" delete: - operationId: deleteEntitySource + operationId: DeleteEntitySource + x-codegen-enabled: true summary: Delete an entity source configuration parameters: - name: id @@ -75,8 +83,23 @@ paths: /api/entity_analytics/monitoring/entity_source/list: get: - operationId: listEntitySources + operationId: ListEntitySources + x-codegen-enabled: true summary: List all entity source configurations + parameters: + - name: type + in: query + schema: + type: string + - name: managed + in: query + schema: + type: boolean + - name: name + in: query + schema: + type: string + responses: "200": description: List of entity sources retrieved @@ -85,10 +108,10 @@ paths: schema: type: array items: - $ref: "#/components/schemas/MonitoringEntitySourceDescriptor" + $ref: "#/components/schemas/MonitoringEntitySource" components: schemas: - MonitoringEntitySourceDescriptor: + CreateMonitoringEntitySource: type: object required: [type, name] properties: @@ -124,9 +147,56 @@ components: type: string filter: type: object + properties: + kuery: + oneOf: + - type: string + - type: object - MonitoringEntitySourceResponse: + UpdatedMonitoringEntitySource: type: object + properties: + type: + type: string + name: + type: string + managed: + type: boolean + indexPattern: + type: string + enabled: + type: boolean + error: + type: string + integrationName: + type: string + matchers: + type: array + items: + type: object + required: + - fields + - values + properties: + fields: + type: array + items: + type: string + values: + type: array + items: + type: string + filter: + type: object + properties: + kuery: + oneOf: + - type: string + - type: object + + MonitoringEntitySource: + type: object + required: [type, name, id, managed] properties: id: type: string @@ -155,4 +225,11 @@ components: values: type: array items: - type: string \ No newline at end of file + type: string + filter: + type: object + properties: + kuery: + oneOf: + - type: string + - type: object diff --git a/x-pack/solutions/security/plugins/security_solution/common/api/quickstart_client.gen.ts b/x-pack/solutions/security/plugins/security_solution/common/api/quickstart_client.gen.ts index d9bdb770867a6..668b2665416e6 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/api/quickstart_client.gen.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/api/quickstart_client.gen.ts @@ -269,6 +269,18 @@ import type { } 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 { + CreateEntitySourceRequestBodyInput, + CreateEntitySourceResponse, + DeleteEntitySourceRequestParamsInput, + GetEntitySourceRequestParamsInput, + GetEntitySourceResponse, + ListEntitySourcesRequestQueryInput, + ListEntitySourcesResponse, + UpdateEntitySourceRequestParamsInput, + UpdateEntitySourceRequestBodyInput, + UpdateEntitySourceResponse, +} from './entity_analytics/privilege_monitoring/monitoring_entity_source/monitoring_entity_source.gen'; import type { InstallPrivilegedAccessDetectionPackageResponse } from './entity_analytics/privilege_monitoring/privileged_access_detection/install.gen'; import type { GetPrivilegedAccessDetectionPackageStatusResponse } from './entity_analytics/privilege_monitoring/privileged_access_detection/status.gen'; import type { @@ -627,6 +639,19 @@ If a record already exists for the specified entity, that record is overwritten }) .catch(catchAxiosErrorFormatAndThrow); } + async createEntitySource(props: CreateEntitySourceProps) { + this.log.info(`${new Date().toISOString()} Calling API CreateEntitySource`); + return this.kbnClient + .request({ + path: '/api/entity_analytics/monitoring/entity_source', + headers: { + [ELASTIC_HTTP_VERSION_HEADER]: '1', + }, + method: 'POST', + body: props.body, + }) + .catch(catchAxiosErrorFormatAndThrow); + } async createPrivilegesImportIndex(props: CreatePrivilegesImportIndexProps) { this.log.info(`${new Date().toISOString()} Calling API CreatePrivilegesImportIndex`); return this.kbnClient @@ -830,6 +855,18 @@ For detailed information on Kibana actions and alerting, and additional API call }) .catch(catchAxiosErrorFormatAndThrow); } + async deleteEntitySource(props: DeleteEntitySourceProps) { + this.log.info(`${new Date().toISOString()} Calling API DeleteEntitySource`); + return this.kbnClient + .request({ + path: replaceParams('/api/entity_analytics/monitoring/entity_source/{id}', props.params), + headers: { + [ELASTIC_HTTP_VERSION_HEADER]: '1', + }, + method: 'DELETE', + }) + .catch(catchAxiosErrorFormatAndThrow); + } /** * Delete a note from a Timeline using the note ID. */ @@ -1403,6 +1440,18 @@ finalize it. }) .catch(catchAxiosErrorFormatAndThrow); } + async getEntitySource(props: GetEntitySourceProps) { + this.log.info(`${new Date().toISOString()} Calling API GetEntitySource`); + return this.kbnClient + .request({ + path: replaceParams('/api/entity_analytics/monitoring/entity_source/{id}', props.params), + headers: { + [ELASTIC_HTTP_VERSION_HEADER]: '1', + }, + method: 'GET', + }) + .catch(catchAxiosErrorFormatAndThrow); + } async getEntityStoreStatus(props: GetEntityStoreStatusProps) { this.log.info(`${new Date().toISOString()} Calling API GetEntityStoreStatus`); return this.kbnClient @@ -1956,6 +2005,20 @@ providing you with the most current and effective threat detection capabilities. }) .catch(catchAxiosErrorFormatAndThrow); } + async listEntitySources(props: ListEntitySourcesProps) { + this.log.info(`${new Date().toISOString()} Calling API ListEntitySources`); + return this.kbnClient + .request({ + path: '/api/entity_analytics/monitoring/entity_source/list', + headers: { + [ELASTIC_HTTP_VERSION_HEADER]: '1', + }, + method: 'GET', + + query: props.query, + }) + .catch(catchAxiosErrorFormatAndThrow); + } async listPrivMonUsers(props: ListPrivMonUsersProps) { this.log.info(`${new Date().toISOString()} Calling API ListPrivMonUsers`); return this.kbnClient @@ -2496,6 +2559,19 @@ The difference between the `id` and `rule_id` is that the `id` is a unique rule }) .catch(catchAxiosErrorFormatAndThrow); } + async updateEntitySource(props: UpdateEntitySourceProps) { + this.log.info(`${new Date().toISOString()} Calling API UpdateEntitySource`); + return this.kbnClient + .request({ + path: replaceParams('/api/entity_analytics/monitoring/entity_source/{id}', props.params), + headers: { + [ELASTIC_HTTP_VERSION_HEADER]: '1', + }, + method: 'PUT', + body: props.body, + }) + .catch(catchAxiosErrorFormatAndThrow); + } async updatePrivMonUser(props: UpdatePrivMonUserProps) { this.log.info(`${new Date().toISOString()} Calling API UpdatePrivMonUser`); return this.kbnClient @@ -2632,6 +2708,9 @@ export interface CreateAlertsMigrationProps { export interface CreateAssetCriticalityRecordProps { body: CreateAssetCriticalityRecordRequestBodyInput; } +export interface CreateEntitySourceProps { + body: CreateEntitySourceRequestBodyInput; +} export interface CreatePrivilegesImportIndexProps { body: CreatePrivilegesImportIndexRequestBodyInput; } @@ -2662,6 +2741,9 @@ export interface DeleteEntityEngineProps { query: DeleteEntityEngineRequestQueryInput; params: DeleteEntityEngineRequestParamsInput; } +export interface DeleteEntitySourceProps { + params: DeleteEntitySourceRequestParamsInput; +} export interface DeleteNoteProps { body: DeleteNoteRequestBodyInput; } @@ -2755,6 +2837,9 @@ export interface GetEndpointSuggestionsProps { export interface GetEntityEngineProps { params: GetEntityEngineRequestParamsInput; } +export interface GetEntitySourceProps { + params: GetEntitySourceRequestParamsInput; +} export interface GetEntityStoreStatusProps { query: GetEntityStoreStatusRequestQueryInput; } @@ -2834,6 +2919,9 @@ export interface InternalUploadAssetCriticalityRecordsProps { export interface ListEntitiesProps { query: ListEntitiesRequestQueryInput; } +export interface ListEntitySourcesProps { + query: ListEntitySourcesRequestQueryInput; +} export interface ListPrivMonUsersProps { query: ListPrivMonUsersRequestQueryInput; } @@ -2912,6 +3000,10 @@ export interface SuggestUserProfilesProps { export interface TriggerRiskScoreCalculationProps { body: TriggerRiskScoreCalculationRequestBodyInput; } +export interface UpdateEntitySourceProps { + params: UpdateEntitySourceRequestParamsInput; + body: UpdateEntitySourceRequestBodyInput; +} export interface UpdatePrivMonUserProps { params: UpdatePrivMonUserRequestParamsInput; body: UpdatePrivMonUserRequestBodyInput; diff --git a/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/api/api.ts b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/api/api.ts index 6e879cef734a9..9d3e9ed2727fa 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/api/api.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/api/api.ts @@ -6,6 +6,11 @@ */ import { useMemo } from 'react'; +import type { + CreateEntitySourceResponse, + ListEntitySourcesResponse, + UpdateEntitySourceResponse, +} from '../../../common/api/entity_analytics/privilege_monitoring/monitoring_entity_source/monitoring_entity_source.gen'; import type { CreatePrivilegesImportIndexResponse } from '../../../common/api/entity_analytics/monitoring/create_index.gen'; import type { PrivMonHealthResponse } from '../../../common/api/entity_analytics/privilege_monitoring/health.gen'; import type { InitMonitoringEngineResponse } from '../../../common/api/entity_analytics/privilege_monitoring/engine/init.gen'; @@ -65,6 +70,13 @@ import { type ListEntitiesRequestQuery } from '../../../common/api/entity_analyt export interface DeleteAssetCriticalityResponse { deleted: true; } + +/** + * This hardcoded name was temporarily introduced for 9.1.0. + * It is used to identify the only entity source that can be edited by the UI. + */ +const ENTITY_SOURCE_NAME = 'User Monitored Indices'; + export const useEntityAnalyticsRoutes = () => { const http = useKibana().services.http; @@ -237,19 +249,31 @@ export const useEntityAnalyticsRoutes = () => { * Register a data source for privilege monitoring engine */ const registerPrivMonMonitoredIndices = async (indexPattern: string | undefined) => - http.fetch( - '/api/entity_analytics/monitoring/entity_source', - { - version: API_VERSIONS.public.v1, - method: 'POST', + http.fetch('/api/entity_analytics/monitoring/entity_source', { + version: API_VERSIONS.public.v1, + method: 'POST', - body: JSON.stringify({ - type: 'index', - name: 'User Monitored Indices', - indexPattern, - }), - } - ); + body: JSON.stringify({ + type: 'index', + name: ENTITY_SOURCE_NAME, + indexPattern, + }), + }); + + /** + * Update a data source for privilege monitoring engine + */ + const updatePrivMonMonitoredIndices = async (id: string, indexPattern: string | undefined) => + http.fetch('/api/entity_analytics/monitoring/entity_source', { + version: API_VERSIONS.public.v1, + method: 'PUT', + body: JSON.stringify({ + id, + type: 'index', + name: ENTITY_SOURCE_NAME, + indexPattern, + }), + }); /** * Create asset criticality @@ -345,6 +369,21 @@ export const useEntityAnalyticsRoutes = () => { ); }; + /** + * List all data source for privilege monitoring engine + */ + const listPrivMonMonitoredIndices = async ({ signal }: { signal?: AbortSignal }) => + http.fetch('/api/entity_analytics/monitoring/entity_source/list', { + version: API_VERSIONS.public.v1, + method: 'GET', + signal, + query: { + type: 'index', + managed: false, + name: ENTITY_SOURCE_NAME, + }, + }); + const uploadPrivilegedUserMonitoringFile = async ( fileContent: string, fileName: string @@ -424,12 +463,14 @@ export const useEntityAnalyticsRoutes = () => { uploadPrivilegedUserMonitoringFile, initPrivilegedMonitoringEngine, registerPrivMonMonitoredIndices, + updatePrivMonMonitoredIndices, fetchPrivilegeMonitoringEngineStatus, fetchRiskEngineSettings, calculateEntityRiskScore, cleanUpRiskEngine, fetchEntitiesList, updateSavedObjectConfiguration, + listPrivMonMonitoredIndices, }; }, [http]); }; diff --git a/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/privileged_user_monitoring_manage_data_sources/csv_upload_manage_data_source.tsx b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/privileged_user_monitoring_manage_data_sources/csv_upload_manage_data_source.tsx index 35b1082eb66c9..21336f2a401af 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/privileged_user_monitoring_manage_data_sources/csv_upload_manage_data_source.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/privileged_user_monitoring_manage_data_sources/csv_upload_manage_data_source.tsx @@ -36,9 +36,9 @@ export const CsvUploadManageDataSource = ({ return ( <> - - - + + +

} - color={'danger'} + color="danger" /> )} {isLoading && } @@ -89,7 +89,7 @@ export const CsvUploadManageDataSource = ({ disabled={isError || isLoading} onClick={showImportFileModal} fullWidth={false} - iconType={'plusInCircle'} + iconType="plusInCircle" > void; - onDone: (userCount: number) => void; }) => { const spaceId = useSpaceId(); const [addDataSourceResult, setAddDataSourceResult] = useState(); - const [isIndexModalOpen, { on: showIndexModal, off: hideIndexModal }] = useBoolean(false); - - const { data: indices = [], isFetching } = useFetchPrivilegedUserIndices(undefined); return ( <> } /> {addDataSourceResult?.successful && ( <> 0 + ? 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 }, + } + ) + : i18n.translate( + 'xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.manageDataSources.successMessageWithoutUserCount', + { + defaultMessage: 'New data source of privileged users successfully set up', + } + ) + } color="success" - iconType={'check'} + iconType="check" /> - + )} - - - - -

- -

-
-
- -

- -

-

- {isFetching && } - {indices.length === 0 && ( - - )} - {indices.length > 0 && ( - - )} -

-
- - - -
- + + {spaceId && ( )} - {isIndexModalOpen && } ); }; diff --git a/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/privileged_user_monitoring_manage_data_sources/index_import_manage_data_source.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/privileged_user_monitoring_manage_data_sources/index_import_manage_data_source.test.tsx new file mode 100644 index 0000000000000..f4663fdbc0c3a --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/privileged_user_monitoring_manage_data_sources/index_import_manage_data_source.test.tsx @@ -0,0 +1,109 @@ +/* + * 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, waitFor } from '@testing-library/react'; +import { IndexImportManageDataSource } from './index_import_manage_data_source'; +import { TestProviders } from '../../../common/mock'; + +const mockUseFetchMonitoredIndices = jest.fn().mockImplementation(() => ({ + data: [], + isFetching: false, + refetch: jest.fn(), +})); + +jest.mock('../privileged_user_monitoring_onboarding/hooks/use_fetch_monitored_indices', () => ({ + useFetchMonitoredIndices: () => mockUseFetchMonitoredIndices(), +})); + +jest.mock('../../api/api', () => ({ + useEntityAnalyticsRoutes: () => ({ + updatePrivMonMonitoredIndices: jest.fn(), + registerPrivMonMonitoredIndices: jest.fn(), + }), +})); + +describe('IndexImportManageDataSource', () => { + const setAddDataSourceResult = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders indices header and info text', () => { + render(, { + wrapper: TestProviders, + }); + + expect( + screen.getByText(/One or more indices containing the user\.name field/i) + ).toBeInTheDocument(); + }); + + it('shows "No indices added" when there are no indices', () => { + render(, { + wrapper: TestProviders, + }); + expect(screen.getByText(/No indices added/i)).toBeInTheDocument(); + }); + + it('shows loading spinner when isFetching is true', () => { + mockUseFetchMonitoredIndices.mockImplementation(() => ({ + data: [], + isFetching: true, + refetch: jest.fn(), + })); + + render(, { + wrapper: TestProviders, + }); + expect(screen.getByTestId('loading-indices-spinner')).toBeInTheDocument(); + }); + + it('shows number of indices when indices exist', () => { + mockUseFetchMonitoredIndices.mockImplementation(() => ({ + data: [{ indexPattern: 'foo,bar,baz' }], + isFetching: false, + refetch: jest.fn(), + })); + + render(, { + wrapper: TestProviders, + }); + expect(screen.getByText(/3 indices added/i)).toBeInTheDocument(); + }); + + it('opens and closes the index selector modal', () => { + render(, { + wrapper: TestProviders, + }); + fireEvent.click(screen.getByText(/Select index/i)); + expect(screen.getByTestId('index-selector-modal')).toBeInTheDocument(); + fireEvent.click(screen.getByText('Cancel')); + expect(screen.queryByTestId('index-selector-modal')).not.toBeInTheDocument(); + }); + + it('calls setAddDataSourceResult and refetch on import', async () => { + const refetch = jest.fn(); + + mockUseFetchMonitoredIndices.mockImplementation(() => ({ + data: [{ indexPattern: 'foo,bar,baz' }], + isFetching: false, + refetch, + })); + + render(, { + wrapper: TestProviders, + }); + fireEvent.click(screen.getByText(/Select index/i)); + fireEvent.click(screen.getByText('Add privileged users')); + await waitFor(() => { + expect(setAddDataSourceResult).toHaveBeenCalledWith({ successful: true, userCount: 0 }); + expect(refetch).toHaveBeenCalled(); + }); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/privileged_user_monitoring_manage_data_sources/index_import_manage_data_source.tsx b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/privileged_user_monitoring_manage_data_sources/index_import_manage_data_source.tsx new file mode 100644 index 0000000000000..06c76da77c988 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/privileged_user_monitoring_manage_data_sources/index_import_manage_data_source.tsx @@ -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 { EuiButton, EuiLoadingSpinner, EuiFlexGroup, EuiIcon, EuiText } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { useBoolean } from '@kbn/react-hooks'; +import { IndexSelectorModal } from '../privileged_user_monitoring_onboarding/components/select_index_modal'; +import { useFetchMonitoredIndices } from '../privileged_user_monitoring_onboarding/hooks/use_fetch_monitored_indices'; +import type { AddDataSourceResult } from '.'; + +export const IndexImportManageDataSource = ({ + setAddDataSourceResult, +}: { + setAddDataSourceResult: (result: AddDataSourceResult) => void; +}) => { + const [isIndexModalOpen, { on: showIndexModal, off: hideIndexModal }] = useBoolean(false); + const { data: datasources = [], isFetching, refetch } = useFetchMonitoredIndices(); + const monitoredDataSource = datasources[0]; + const monitoredIndices = monitoredDataSource?.indexPattern + ? monitoredDataSource.indexPattern.split(',') + : []; + + const onImport = async () => { + hideIndexModal(); + setAddDataSourceResult({ successful: true, userCount: 0 }); + await refetch(); + }; + + return ( + <> + + + + +

+ +

+
+
+ +

+ +

+ +

+ {isFetching && } + {monitoredIndices.length === 0 && ( + + )} + {monitoredIndices.length > 0 && ( + + )} +

+
+ + + +
+ + {isIndexModalOpen && ( + + )} + + ); +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/privileged_user_monitoring_onboarding/components/select_index_modal.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/privileged_user_monitoring_onboarding/components/select_index_modal.test.tsx index 8b11dfabdc8d7..cd9162a6a95cc 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/privileged_user_monitoring_onboarding/components/select_index_modal.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/privileged_user_monitoring_onboarding/components/select_index_modal.test.tsx @@ -6,10 +6,19 @@ */ import React from 'react'; -import { render, screen, fireEvent } from '@testing-library/react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { IndexSelectorModal } from './select_index_modal'; import { TestProviders } from '../../../../common/mock'; +const mockUpdatePrivMonMonitoredIndices = jest.fn().mockImplementation(() => Promise.resolve({})); +const mockRegisterPrivMonMonitoredIndices = jest.fn().mockImplementation(() => Promise.resolve({})); +jest.mock('../../../api/api', () => ({ + useEntityAnalyticsRoutes: () => ({ + updatePrivMonMonitoredIndices: () => mockUpdatePrivMonMonitoredIndices(), + registerPrivMonMonitoredIndices: () => mockRegisterPrivMonMonitoredIndices(), + }), +})); + jest.mock('../../../../common/hooks/use_app_toasts', () => ({ useAppToasts: () => ({ addError: jest.fn(), @@ -90,4 +99,38 @@ describe('IndexSelectorModal', () => { expect(screen.queryByLabelText('Select index')).toBeInTheDocument(); }); + + it('pre-selects indices when editDataSource is provided', () => { + render( + , + { wrapper: TestProviders } + ); + + // The selected options should be visible in the combo box + expect(screen.getByText('index1')).toBeInTheDocument(); + expect(screen.getByText('index2')).toBeInTheDocument(); + }); + + it('calls updatePrivMonMonitoredIndices and onImport when editing and clicking add', async () => { + render( + , + { wrapper: TestProviders } + ); + + // Add button should be enabled since index1 is preselected + fireEvent.click(screen.getByText('Add privileged users')); + + waitFor(() => { + expect(mockUpdatePrivMonMonitoredIndices).toHaveBeenCalledWith('edit-id', 'index1'); + expect(onImportMock).toHaveBeenCalledWith(0); + }); + }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/privileged_user_monitoring_onboarding/components/select_index_modal.tsx b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/privileged_user_monitoring_onboarding/components/select_index_modal.tsx index 3f0ad53ff0e0b..589d33da2bc46 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/privileged_user_monitoring_onboarding/components/select_index_modal.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/privileged_user_monitoring_onboarding/components/select_index_modal.tsx @@ -48,18 +48,27 @@ export const DEBOUNCE_OPTIONS = { wait: 300 }; export const IndexSelectorModal = ({ onClose, onImport, + editDataSource, }: { onClose: () => void; onImport: (userCount: number) => void; + editDataSource?: { + id: string; + indexPattern?: string; + }; }) => { + const [selectedOptions, setSelected] = useState>>( + editDataSource?.indexPattern?.split(',').map((index) => ({ label: index })) ?? [] + ); + const [isCreateIndexModalOpen, { on: showCreateIndexModal, off: hideCreateIndexModal }] = useBoolean(false); const { addError } = useAppToasts(); const [searchQuery, setSearchQuery] = useState(undefined); const { data: indices, isFetching, error, refetch } = useFetchPrivilegedUserIndices(searchQuery); - const [selectedOptions, setSelected] = useState>>([]); const debouncedSetSearchQuery = useDebounceFn(setSearchQuery, DEBOUNCE_OPTIONS); - const { registerPrivMonMonitoredIndices } = useEntityAnalyticsRoutes(); + const { registerPrivMonMonitoredIndices, updatePrivMonMonitoredIndices } = + useEntityAnalyticsRoutes(); const options = useMemo( () => indices?.map((index) => ({ @@ -76,11 +85,24 @@ export const IndexSelectorModal = ({ const addPrivilegedUsers = useCallback(async () => { if (selectedOptions.length > 0) { - await registerPrivMonMonitoredIndices(selectedOptions.map(({ label }) => label).join(',')); + if (editDataSource?.id) { + await updatePrivMonMonitoredIndices( + editDataSource.id, + selectedOptions.map(({ label }) => label).join(',') + ); + } else { + await registerPrivMonMonitoredIndices(selectedOptions.map(({ label }) => label).join(',')); + } onImport(0); // The API does not return the user count because it is not available at this point. } - }, [onImport, registerPrivMonMonitoredIndices, selectedOptions]); + }, [ + editDataSource?.id, + onImport, + registerPrivMonMonitoredIndices, + selectedOptions, + updatePrivMonMonitoredIndices, + ]); const onCreateIndex = useCallback( (indexName: string) => { @@ -88,13 +110,13 @@ export const IndexSelectorModal = ({ setSelected(selectedOptions.concat({ label: indexName })); refetch(); }, - [hideCreateIndexModal, refetch, selectedOptions] + [hideCreateIndexModal, refetch, selectedOptions, setSelected] ); return isCreateIndexModalOpen ? ( ) : ( - + { + const { listPrivMonMonitoredIndices } = useEntityAnalyticsRoutes(); + return useQuery( + ['POST', 'LIST_PRIVILEGED_USER_MONITORED_INDICES'], + ({ signal }) => listPrivMonMonitoredIndices({ signal }), + { + keepPreviousData: true, + refetchOnWindowFocus: false, + } + ); +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/pages/entity_analytics_privileged_user_monitoring_page.tsx b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/pages/entity_analytics_privileged_user_monitoring_page.tsx index b008c71d902ed..7d1dc659e3519 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/pages/entity_analytics_privileged_user_monitoring_page.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/pages/entity_analytics_privileged_user_monitoring_page.tsx @@ -323,7 +323,6 @@ export const EntityAnalyticsPrivilegedUserMonitoringPage = () => { {state.type === 'manageDataSources' && ( )} diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/privilege_monitoring/monitoring_entity_source_data_client.test.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/privilege_monitoring/monitoring_entity_source_data_client.test.ts index db7243443584a..a45b3ac370c78 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/privilege_monitoring/monitoring_entity_source_data_client.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/privilege_monitoring/monitoring_entity_source_data_client.test.ts @@ -12,7 +12,11 @@ import { loggingSystemMock, } from '@kbn/core/server/mocks'; import { monitoringEntitySourceTypeName } from './saved_objects'; -import type { SavedObject, SavedObjectsFindResponse } from '@kbn/core/server'; +import type { + SavedObject, + SavedObjectsClientContract, + SavedObjectsFindResponse, +} from '@kbn/core/server'; describe('MonitoringEntitySourceDataClient', () => { const mockSavedObjectClient = savedObjectsClientMock.create(); @@ -33,6 +37,7 @@ describe('MonitoringEntitySourceDataClient', () => { type: 'test-type', name: 'Test Source', indexPattern: 'test-index-pattern', + managed: false, matchers: [ { fields: ['user.role'], @@ -56,10 +61,12 @@ describe('MonitoringEntitySourceDataClient', () => { (err as Error & { output?: { statusCode: number } }).output = { statusCode: 404 }; throw err; }); - defaultOpts.soClient.find.mockResolvedValue({ - total: 0, - saved_objects: [], - } as unknown as SavedObjectsFindResponse); + defaultOpts.soClient.asScopedToNamespace.mockReturnValue({ + find: jest.fn().mockResolvedValue({ + total: 0, + saved_objects: [], + }), + } as unknown as SavedObjectsClientContract); defaultOpts.soClient.create.mockResolvedValue({ id: 'temp-id', // TODO: update to use dynamic ID @@ -69,41 +76,28 @@ describe('MonitoringEntitySourceDataClient', () => { }); const result = await dataClient.init(testDescriptor); + const id = `entity-analytics-monitoring-entity-source-${namespace}-${testDescriptor.type}-${testDescriptor.indexPattern}`; expect(defaultOpts.soClient.create).toHaveBeenCalledWith( monitoringEntitySourceTypeName, testDescriptor, { - id: `entity-analytics-monitoring-entity-source-${namespace}-${testDescriptor.type}-${testDescriptor.indexPattern}`, + id, } ); - expect(result).toEqual(testDescriptor); + expect(result).toEqual({ ...testDescriptor, managed: false, id }); }); - }); - describe('get', () => { - it('should get Monitoring Entity Source Sync Config Successfully', async () => { - const getResponse = { - type: monitoringEntitySourceTypeName, - attributes: testDescriptor, - references: [], - }; - defaultOpts.soClient.get.mockResolvedValue(getResponse as unknown as SavedObject); - const result = await dataClient.get(); - expect(defaultOpts.soClient.get).toHaveBeenCalledWith( - monitoringEntitySourceTypeName, - `temp-id` // TODO: https://github.com/elastic/security-team/issues/12851 - ); - expect(result).toEqual(getResponse.attributes); - }); - }); - - describe('update', () => { - it('should update Monitoring Entity Source Sync Config Successfully', async () => { + it('should update Monitoring Entity Source Sync Config Successfully when calling init when the SO already exists', async () => { const existingDescriptor = { total: 1, - saved_objects: [{ attributes: testDescriptor }], + saved_objects: [ + { + attributes: testDescriptor, + id: 'entity-analytics-monitoring-entity-source-test-namespace-test-type-test-index-pattern', + }, + ], } as unknown as SavedObjectsFindResponse; const testSourceObject = { @@ -117,14 +111,15 @@ describe('MonitoringEntitySourceDataClient', () => { ], name: 'Test Source', type: 'test-type', + managed: false, }; - defaultOpts.soClient.find.mockResolvedValue( - existingDescriptor as unknown as SavedObjectsFindResponse - ); + defaultOpts.soClient.asScopedToNamespace.mockReturnValue({ + find: jest.fn().mockResolvedValue(existingDescriptor), + } as unknown as SavedObjectsClientContract); defaultOpts.soClient.update.mockResolvedValue({ - id: `temp-id`, // TODO: https://github.com/elastic/security-team/issues/12851 + id: 'entity-analytics-monitoring-entity-source-test-namespace-test-type-test-index-pattern', type: monitoringEntitySourceTypeName, attributes: { ...testDescriptor, name: 'Updated Source' }, references: [], @@ -142,6 +137,77 @@ describe('MonitoringEntitySourceDataClient', () => { expect(result).toEqual(updatedDescriptor); }); + + it('should not create Monitoring Entity Source Sync Config when a SO already exist with the same name', async () => { + const existingSavedObject = { + id: 'unique-id', + attributes: testDescriptor, + } as unknown as SavedObject; + + defaultOpts.soClient.asScopedToNamespace.mockReturnValue({ + find: jest.fn().mockResolvedValue({ + total: 1, + saved_objects: [existingSavedObject], + }), + } as unknown as SavedObjectsClientContract); + + await expect(dataClient.init(testDescriptor)).rejects.toThrow( + `A monitoring entity source with the name "${testDescriptor.name}" already exists.` + ); + }); + }); + + describe('get', () => { + it('should get Monitoring Entity Source Sync Config Successfully', async () => { + const getResponse = { + type: monitoringEntitySourceTypeName, + attributes: testDescriptor, + references: [], + }; + defaultOpts.soClient.get.mockResolvedValue(getResponse as unknown as SavedObject); + const result = await dataClient.get(); + expect(defaultOpts.soClient.get).toHaveBeenCalledWith( + monitoringEntitySourceTypeName, + `temp-id` // TODO: https://github.com/elastic/security-team/issues/12851 + ); + expect(result).toEqual(getResponse.attributes); + }); + }); + + describe('update', () => { + it('should update Monitoring Entity Source Sync Config Successfully', async () => { + const id = 'temp-id'; // TODO: https://github.com/elastic/security-team/issues/12851 + const updateDescriptor = { + ...testDescriptor, + managed: false, + name: 'Updated Source', + id, // it preserves the id when updating + }; + + defaultOpts.soClient.asScopedToNamespace.mockReturnValue({ + find: jest.fn().mockResolvedValue({ + total: 0, + saved_objects: [], + }), + } as unknown as SavedObjectsClientContract); + + defaultOpts.soClient.update.mockResolvedValue({ + id, + type: monitoringEntitySourceTypeName, + attributes: updateDescriptor, + references: [], + }); + + const result = await dataClient.update(updateDescriptor); + expect(defaultOpts.soClient.update).toHaveBeenCalledWith( + monitoringEntitySourceTypeName, + id, + updateDescriptor, + + { refresh: 'wait_for' } + ); + expect(result).toEqual(updateDescriptor); + }); }); describe('delete', () => { diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/privilege_monitoring/monitoring_entity_source_data_client.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/privilege_monitoring/monitoring_entity_source_data_client.ts index 75d80e4f93368..e01408d898907 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/privilege_monitoring/monitoring_entity_source_data_client.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/privilege_monitoring/monitoring_entity_source_data_client.ts @@ -7,8 +7,9 @@ import type { IScopedClusterClient, Logger, SavedObjectsClientContract } from '@kbn/core/server'; import type { - MonitoringEntitySourceDescriptor, - MonitoringEntitySourceResponse, + CreateMonitoringEntitySource, + ListEntitySourcesRequestQuery, + MonitoringEntitySource, } from '../../../../common/api/entity_analytics/privilege_monitoring/monitoring_entity_source/monitoring_entity_source.gen'; import { MonitoringEntitySourceDescriptorClient } from './saved_objects'; @@ -28,9 +29,7 @@ export class MonitoringEntitySourceDataClient { }); } - public async init( - input: MonitoringEntitySourceDescriptor - ): Promise { + public async init(input: CreateMonitoringEntitySource) { const descriptor = await this.monitoringEntitySourceClient.create({ ...input, }); @@ -38,12 +37,12 @@ export class MonitoringEntitySourceDataClient { return descriptor; } - public async get(): Promise { + public async get(): Promise { this.log('debug', 'Getting Monitoring Entity Source Sync saved object'); return this.monitoringEntitySourceClient.get(); } - public async update(update: Partial) { + public async update(update: Partial & { id: string }) { this.log('debug', 'Updating Monitoring Entity Source Sync saved object'); const sanitizedUpdate = { @@ -62,9 +61,9 @@ export class MonitoringEntitySourceDataClient { return this.monitoringEntitySourceClient.delete(); } - public async list(): Promise { + public async list(query: ListEntitySourcesRequestQuery): Promise { this.log('debug', 'Finding all Monitoring Entity Source Sync saved objects'); - return this.monitoringEntitySourceClient.findAll(); + return this.monitoringEntitySourceClient.findAll(query); } private log(level: Exclude, msg: string) { diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/privilege_monitoring/privilege_monitoring_data_client.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/privilege_monitoring/privilege_monitoring_data_client.ts index d282ba078ee20..2fe64535208f4 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/privilege_monitoring/privilege_monitoring_data_client.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/privilege_monitoring/privilege_monitoring_data_client.ts @@ -69,7 +69,6 @@ import { privilegedUserParserTransform } from './users/privileged_user_parse_tra import type { Accumulator } from './users/bulk/utils'; import { accumulateUpsertResults } from './users/bulk/utils'; import type { PrivMonBulkUser, PrivMonUserSource } from './types'; -import type { MonitoringEntitySourceDescriptor } from './saved_objects'; import { PrivilegeMonitoringEngineDescriptorClient, MonitoringEntitySourceDescriptorClient, @@ -435,8 +434,7 @@ export class PrivilegeMonitoringDataClient { */ public async plainIndexSync() { // get all monitoring index source saved objects of type 'index' - const indexSources: MonitoringEntitySourceDescriptor[] = - await this.monitoringIndexSourceClient.findByIndex(); + const indexSources = await this.monitoringIndexSourceClient.findByIndex(); if (indexSources.length === 0) { this.log('debug', 'No monitoring index sources found. Skipping sync.'); return; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/privilege_monitoring/routes/monitoring_entity_source/list.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/privilege_monitoring/routes/monitoring_entity_source/list.ts index 46c4dd3b85383..85f7895efa0ce 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/privilege_monitoring/routes/monitoring_entity_source/list.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/privilege_monitoring/routes/monitoring_entity_source/list.ts @@ -8,9 +8,13 @@ import { buildSiemResponse } from '@kbn/lists-plugin/server/routes/utils'; import { transformError } from '@kbn/securitysolution-es-utils'; import type { IKibanaResponse, Logger } from '@kbn/core/server'; -import type { MonitoringEntitySourceResponse } from '../../../../../../common/api/entity_analytics/privilege_monitoring/monitoring_entity_source/monitoring_entity_source.gen'; +import { buildRouteValidationWithZod } from '@kbn/zod-helpers'; import { API_VERSIONS, APP_ID } from '../../../../../../common/constants'; import type { EntityAnalyticsRoutesDeps } from '../../../types'; +import { + ListEntitySourcesRequestQuery, + type ListEntitySourcesResponse, +} from '../../../../../../common/api/entity_analytics/privilege_monitoring/monitoring_entity_source/monitoring_entity_source.gen'; export const listMonitoringEntitySourceRoute = ( router: EntityAnalyticsRoutesDeps['router'], @@ -30,19 +34,19 @@ export const listMonitoringEntitySourceRoute = ( .addVersion( { version: API_VERSIONS.public.v1, - validate: {}, + validate: { + request: { + query: buildRouteValidationWithZod(ListEntitySourcesRequestQuery), + }, + }, }, - async ( - context, - request, - response - ): Promise> => { + async (context, request, response): Promise> => { const siemResponse = buildSiemResponse(response); try { const secSol = await context.securitySolution; const client = secSol.getMonitoringEntitySourceDataClient(); - const body = await client.list(); + const body = await client.list(request.query); return response.ok({ body }); } catch (e) { diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/privilege_monitoring/routes/monitoring_entity_source/monitoring_entity_source.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/privilege_monitoring/routes/monitoring_entity_source/monitoring_entity_source.ts index c6f5f252b997d..c6362bb8f178f 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/privilege_monitoring/routes/monitoring_entity_source/monitoring_entity_source.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/privilege_monitoring/routes/monitoring_entity_source/monitoring_entity_source.ts @@ -10,11 +10,17 @@ import { buildSiemResponse } from '@kbn/lists-plugin/server/routes/utils'; import { transformError } from '@kbn/securitysolution-es-utils'; import type { IKibanaResponse, Logger } from '@kbn/core/server'; - -import type { MonitoringEntitySourceResponse } from '../../../../../../common/api/entity_analytics/privilege_monitoring/monitoring_entity_source/monitoring_entity_source.gen'; import { API_VERSIONS, APP_ID } from '../../../../../../common/constants'; import type { EntityAnalyticsRoutesDeps } from '../../../types'; -import { MonitoringEntitySourceDescriptor } from '../../../../../../common/api/entity_analytics/privilege_monitoring/monitoring_entity_source/monitoring_entity_source.gen'; +import type { + GetEntitySourceResponse, + UpdateEntitySourceResponse, +} from '../../../../../../common/api/entity_analytics/privilege_monitoring/monitoring_entity_source/monitoring_entity_source.gen'; +import { + CreateEntitySourceRequestBody, + UpdateEntitySourceRequestBody, + type CreateEntitySourceResponse, +} from '../../../../../../common/api/entity_analytics/privilege_monitoring/monitoring_entity_source/monitoring_entity_source.gen'; export const monitoringEntitySourceRoute = ( router: EntityAnalyticsRoutesDeps['router'], @@ -36,15 +42,11 @@ export const monitoringEntitySourceRoute = ( version: API_VERSIONS.public.v1, validate: { request: { - body: MonitoringEntitySourceDescriptor, + body: CreateEntitySourceRequestBody, }, }, }, - async ( - context, - request, - response - ): Promise> => { + async (context, request, response): Promise> => { const siemResponse = buildSiemResponse(response); try { @@ -63,6 +65,7 @@ export const monitoringEntitySourceRoute = ( } } ); + router.versioned .get({ access: 'public', @@ -78,11 +81,7 @@ export const monitoringEntitySourceRoute = ( version: API_VERSIONS.public.v1, validate: {}, }, - async ( - context, - request, - response - ): Promise> => { + async (context, request, response): Promise> => { const siemResponse = buildSiemResponse(response); try { @@ -100,4 +99,43 @@ export const monitoringEntitySourceRoute = ( } } ); + + router.versioned + .put({ + access: 'public', + path: '/api/entity_analytics/monitoring/entity_source', + security: { + authz: { + requiredPrivileges: ['securitySolution', `${APP_ID}-entity-analytics`], + }, + }, + }) + .addVersion( + { + version: API_VERSIONS.public.v1, + validate: { + request: { + body: UpdateEntitySourceRequestBody, + }, + }, + }, + async (context, request, response): Promise> => { + const siemResponse = buildSiemResponse(response); + + try { + const secSol = await context.securitySolution; + const client = secSol.getMonitoringEntitySourceDataClient(); + const body = await client.update(request.body); + + return response.ok({ body }); + } catch (e) { + const error = transformError(e); + logger.error(`Error creating monitoring entity source sync config: ${error.message}`); + return siemResponse.error({ + statusCode: error.statusCode, + body: error.message, + }); + } + } + ); }; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/privilege_monitoring/saved_objects/monitoring_entity_source.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/privilege_monitoring/saved_objects/monitoring_entity_source.ts index 12d1e83a601bc..6a6b8aa974730 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/privilege_monitoring/saved_objects/monitoring_entity_source.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/privilege_monitoring/saved_objects/monitoring_entity_source.ts @@ -5,6 +5,11 @@ * 2.0. */ import type { SavedObjectsClientContract } from '@kbn/core/server'; +import type { + CreateMonitoringEntitySource, + ListEntitySourcesRequestQuery, + MonitoringEntitySource, +} from '../../../../../common/api/entity_analytics/privilege_monitoring/monitoring_entity_source/monitoring_entity_source.gen'; import { monitoringEntitySourceTypeName } from './monitoring_entity_source_type'; export interface MonitoringEntitySourceDependencies { @@ -12,25 +17,10 @@ export interface MonitoringEntitySourceDependencies { namespace: string; } -export interface MonitoringEntitySourceDescriptor { - type: string; - name: string; - managed?: boolean; - indexPattern?: string; - enabled?: boolean; - error?: string; - integrationName?: string; - matchers?: Array<{ - fields: string[]; - values: string[]; - }>; - filter?: Record; -} - export class MonitoringEntitySourceDescriptorClient { constructor(private readonly dependencies: MonitoringEntitySourceDependencies) {} - getDynamicSavedObjectId(attributes: MonitoringEntitySourceDescriptor) { + getDynamicSavedObjectId(attributes: CreateMonitoringEntitySource) { const { type, indexPattern, integrationName } = this.assertValidIdFields(attributes); const sourceName = indexPattern || integrationName; return `entity-analytics-monitoring-entity-source-${this.dependencies.namespace}-${type}${ @@ -38,13 +28,14 @@ export class MonitoringEntitySourceDescriptorClient { }`; } - async create(attributes: MonitoringEntitySourceDescriptor) { + async create(attributes: CreateMonitoringEntitySource) { const savedObjectId = this.getDynamicSavedObjectId(attributes); + await this.assertNameUniqueness({ ...attributes, id: savedObjectId }); try { // If exists, update it. const { attributes: updated } = - await this.dependencies.soClient.update( + await this.dependencies.soClient.update( monitoringEntitySourceTypeName, savedObjectId, attributes, @@ -56,38 +47,45 @@ export class MonitoringEntitySourceDescriptorClient { // Does not exist, create it. const { attributes: created } = - await this.dependencies.soClient.create( + await this.dependencies.soClient.create( monitoringEntitySourceTypeName, - attributes, + { ...attributes, managed: attributes.managed ?? false }, // Ensure managed is set to true on creation { id: savedObjectId } ); - return created; + return { ...created, id: savedObjectId }; } } - async update(monitoringEntitySource: Partial) { - const id = this.getDynamicSavedObjectId( - monitoringEntitySource as MonitoringEntitySourceDescriptor + async update(monitoringEntitySource: Partial & { id: string }) { + await this.assertNameUniqueness(monitoringEntitySource); + + const { attributes } = await this.dependencies.soClient.update( + monitoringEntitySourceTypeName, + monitoringEntitySource.id, + monitoringEntitySource, + { refresh: 'wait_for' } ); - const { attributes } = - await this.dependencies.soClient.update( - monitoringEntitySourceTypeName, - id, - monitoringEntitySource, - { refresh: 'wait_for' } - ); + return attributes; } - async find() { + async find(query?: ListEntitySourcesRequestQuery) { const scopedSoClient = this.dependencies.soClient.asScopedToNamespace( this.dependencies.namespace ); - return scopedSoClient.find({ + + return scopedSoClient.find({ type: monitoringEntitySourceTypeName, + filter: this.getQueryFilters(query), }); } + private getQueryFilters = (query?: ListEntitySourcesRequestQuery) => { + return Object.entries(query ?? {}) + .map(([key, value]) => `${monitoringEntitySourceTypeName}.attributes.${key}: ${value}`) + .join(' and '); + }; + /** * Need to update to understand the id based on the * type and indexPattern or integrationName. @@ -96,7 +94,7 @@ export class MonitoringEntitySourceDescriptorClient { * or use a dynamic ID based on the type and indexPattern/integrationName. */ async get() { - const { attributes } = await this.dependencies.soClient.get( + const { attributes } = await this.dependencies.soClient.get( monitoringEntitySourceTypeName, 'temp-id' // TODO: https://github.com/elastic/security-team/issues/12851 ); @@ -114,26 +112,43 @@ export class MonitoringEntitySourceDescriptorClient { await this.dependencies.soClient.delete(monitoringEntitySourceTypeName, 'temp-id'); // TODO: https://github.com/elastic/security-team/issues/12851 } - public async findByIndex(): Promise { + public async findByIndex(): Promise { const result = await this.find(); return result.saved_objects .filter((so) => so.attributes.type === 'index') - .map((so) => so.attributes); + .map((so) => ({ ...so.attributes, id: so.id })); } - public async findAll(): Promise { - const result = await this.find(); + public async findAll(query: ListEntitySourcesRequestQuery): Promise { + const result = await this.find(query); return result.saved_objects .filter((so) => so.attributes.type !== 'csv') // from the spec we are not using CSV on monitoring - .map((so) => so.attributes); + .map((so) => ({ ...so.attributes, id: so.id })); } - public assertValidIdFields( - source: Partial - ): MonitoringEntitySourceDescriptor { + private assertValidIdFields(source: Partial): MonitoringEntitySource { if (!source.type || (!source.indexPattern && !source.integrationName)) { throw new Error('Missing required fields for ID generation'); } - return source as MonitoringEntitySourceDescriptor; + return source as MonitoringEntitySource; + } + + private async assertNameUniqueness(attributes: Partial): Promise { + if (attributes.name) { + const { saved_objects: savedObjects } = await this.find({ + name: attributes.name, + }); + + // Exclude the current entity source if updating + const filteredSavedObjects = attributes.id + ? savedObjects.filter((so) => so.id !== attributes.id) + : savedObjects; + + if (filteredSavedObjects.length > 0) { + throw new Error( + `A monitoring entity source with the name "${attributes.name}" already exists.` + ); + } + } } } diff --git a/x-pack/test/api_integration/services/security_solution_api.gen.ts b/x-pack/test/api_integration/services/security_solution_api.gen.ts index a4a714561d876..24f7016475f50 100644 --- a/x-pack/test/api_integration/services/security_solution_api.gen.ts +++ b/x-pack/test/api_integration/services/security_solution_api.gen.ts @@ -27,6 +27,7 @@ import { ConfigureRiskEngineSavedObjectRequestBodyInput } from '@kbn/security-so import { CopyTimelineRequestBodyInput } from '@kbn/security-solution-plugin/common/api/timeline/copy_timeline/copy_timeline_route.gen'; import { CreateAlertsMigrationRequestBodyInput } from '@kbn/security-solution-plugin/common/api/detection_engine/signals_migration/create_signals_migration/create_signals_migration.gen'; import { CreateAssetCriticalityRecordRequestBodyInput } from '@kbn/security-solution-plugin/common/api/entity_analytics/asset_criticality/create_asset_criticality.gen'; +import { CreateEntitySourceRequestBodyInput } from '@kbn/security-solution-plugin/common/api/entity_analytics/privilege_monitoring/monitoring_entity_source/monitoring_entity_source.gen'; import { CreatePrivilegesImportIndexRequestBodyInput } from '@kbn/security-solution-plugin/common/api/entity_analytics/monitoring/create_index.gen'; import { CreatePrivMonUserRequestBodyInput } from '@kbn/security-solution-plugin/common/api/entity_analytics/privilege_monitoring/users/create.gen'; import { CreateRuleRequestBodyInput } from '@kbn/security-solution-plugin/common/api/detection_engine/rule_management/crud/create_rule/create_rule_route.gen'; @@ -45,6 +46,7 @@ import { DeleteEntityEngineRequestQueryInput, DeleteEntityEngineRequestParamsInput, } from '@kbn/security-solution-plugin/common/api/entity_analytics/entity_store/engine/delete.gen'; +import { DeleteEntitySourceRequestParamsInput } from '@kbn/security-solution-plugin/common/api/entity_analytics/privilege_monitoring/monitoring_entity_source/monitoring_entity_source.gen'; import { DeleteNoteRequestBodyInput } from '@kbn/security-solution-plugin/common/api/timeline/delete_note/delete_note_route.gen'; import { DeletePrivMonUserRequestParamsInput } from '@kbn/security-solution-plugin/common/api/entity_analytics/privilege_monitoring/users/delete.gen'; import { DeleteRuleRequestQueryInput } from '@kbn/security-solution-plugin/common/api/detection_engine/rule_management/crud/delete_rule/delete_rule_route.gen'; @@ -83,6 +85,7 @@ import { GetEndpointSuggestionsRequestBodyInput, } from '@kbn/security-solution-plugin/common/api/endpoint/suggestions/get_suggestions.gen'; import { GetEntityEngineRequestParamsInput } from '@kbn/security-solution-plugin/common/api/entity_analytics/entity_store/engine/get.gen'; +import { GetEntitySourceRequestParamsInput } from '@kbn/security-solution-plugin/common/api/entity_analytics/privilege_monitoring/monitoring_entity_source/monitoring_entity_source.gen'; import { GetEntityStoreStatusRequestQueryInput } from '@kbn/security-solution-plugin/common/api/entity_analytics/entity_store/status.gen'; import { GetNotesRequestQueryInput } from '@kbn/security-solution-plugin/common/api/timeline/get_notes/get_notes_route.gen'; import { GetPolicyResponseRequestQueryInput } from '@kbn/security-solution-plugin/common/api/endpoint/policy/policy_response.gen'; @@ -124,6 +127,7 @@ import { } from '@kbn/security-solution-plugin/common/siem_migrations/model/api/rules/rule_migration.gen'; import { InstallPrepackedTimelinesRequestBodyInput } from '@kbn/security-solution-plugin/common/api/timeline/install_prepackaged_timelines/install_prepackaged_timelines_route.gen'; import { ListEntitiesRequestQueryInput } from '@kbn/security-solution-plugin/common/api/entity_analytics/entity_store/entities/list_entities.gen'; +import { ListEntitySourcesRequestQueryInput } from '@kbn/security-solution-plugin/common/api/entity_analytics/privilege_monitoring/monitoring_entity_source/monitoring_entity_source.gen'; import { ListPrivMonUsersRequestQueryInput } from '@kbn/security-solution-plugin/common/api/entity_analytics/privilege_monitoring/users/list.gen'; import { PatchRuleRequestBodyInput } from '@kbn/security-solution-plugin/common/api/detection_engine/rule_management/crud/patch_rule/patch_rule_route.gen'; import { PatchTimelineRequestBodyInput } from '@kbn/security-solution-plugin/common/api/timeline/patch_timelines/patch_timeline_route.gen'; @@ -157,6 +161,10 @@ import { StopEntityEngineRequestParamsInput } from '@kbn/security-solution-plugi import { StopRuleMigrationRequestParamsInput } from '@kbn/security-solution-plugin/common/siem_migrations/model/api/rules/rule_migration.gen'; import { SuggestUserProfilesRequestQueryInput } from '@kbn/security-solution-plugin/common/api/detection_engine/users/suggest_user_profiles_route.gen'; import { TriggerRiskScoreCalculationRequestBodyInput } from '@kbn/security-solution-plugin/common/api/entity_analytics/risk_engine/entity_calculation_route.gen'; +import { + UpdateEntitySourceRequestParamsInput, + UpdateEntitySourceRequestBodyInput, +} from '@kbn/security-solution-plugin/common/api/entity_analytics/privilege_monitoring/monitoring_entity_source/monitoring_entity_source.gen'; import { UpdatePrivMonUserRequestParamsInput, UpdatePrivMonUserRequestBodyInput, @@ -333,6 +341,14 @@ If a record already exists for the specified entity, that record is overwritten .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana') .send(props.body as object); }, + createEntitySource(props: CreateEntitySourceProps, kibanaSpace: string = 'default') { + return supertest + .post(routeWithNamespace('/api/entity_analytics/monitoring/entity_source', kibanaSpace)) + .set('kbn-xsrf', 'true') + .set(ELASTIC_HTTP_VERSION_HEADER, '1') + .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana') + .send(props.body as object); + }, createPrivilegesImportIndex( props: CreatePrivilegesImportIndexProps, kibanaSpace: string = 'default' @@ -511,6 +527,18 @@ For detailed information on Kibana actions and alerting, and additional API call .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana') .query(props.query); }, + deleteEntitySource(props: DeleteEntitySourceProps, kibanaSpace: string = 'default') { + return supertest + .delete( + routeWithNamespace( + replaceParams('/api/entity_analytics/monitoring/entity_source/{id}', props.params), + kibanaSpace + ) + ) + .set('kbn-xsrf', 'true') + .set(ELASTIC_HTTP_VERSION_HEADER, '1') + .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana'); + }, /** * Delete a note from a Timeline using the note ID. */ @@ -951,6 +979,18 @@ finalize it. .set(ELASTIC_HTTP_VERSION_HEADER, '2023-10-31') .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana'); }, + getEntitySource(props: GetEntitySourceProps, kibanaSpace: string = 'default') { + return supertest + .get( + routeWithNamespace( + replaceParams('/api/entity_analytics/monitoring/entity_source/{id}', props.params), + kibanaSpace + ) + ) + .set('kbn-xsrf', 'true') + .set(ELASTIC_HTTP_VERSION_HEADER, '1') + .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana'); + }, getEntityStoreStatus(props: GetEntityStoreStatusProps, kibanaSpace: string = 'default') { return supertest .get(routeWithNamespace('/api/entity_store/status', kibanaSpace)) @@ -1402,6 +1442,14 @@ providing you with the most current and effective threat detection capabilities. .set(ELASTIC_HTTP_VERSION_HEADER, '2023-10-31') .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana'); }, + listEntitySources(props: ListEntitySourcesProps, kibanaSpace: string = 'default') { + return supertest + .get(routeWithNamespace('/api/entity_analytics/monitoring/entity_source/list', kibanaSpace)) + .set('kbn-xsrf', 'true') + .set(ELASTIC_HTTP_VERSION_HEADER, '1') + .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana') + .query(props.query); + }, listPrivMonUsers(props: ListPrivMonUsersProps, kibanaSpace: string = 'default') { return supertest .get(routeWithNamespace('/api/entity_analytics/monitoring/users/list', kibanaSpace)) @@ -1791,6 +1839,19 @@ 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); }, + updateEntitySource(props: UpdateEntitySourceProps, kibanaSpace: string = 'default') { + return supertest + .put( + routeWithNamespace( + replaceParams('/api/entity_analytics/monitoring/entity_source/{id}', props.params), + kibanaSpace + ) + ) + .set('kbn-xsrf', 'true') + .set(ELASTIC_HTTP_VERSION_HEADER, '1') + .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana') + .send(props.body as object); + }, updatePrivMonUser(props: UpdatePrivMonUserProps, kibanaSpace: string = 'default') { return supertest .put( @@ -1920,6 +1981,9 @@ export interface CreateAlertsMigrationProps { export interface CreateAssetCriticalityRecordProps { body: CreateAssetCriticalityRecordRequestBodyInput; } +export interface CreateEntitySourceProps { + body: CreateEntitySourceRequestBodyInput; +} export interface CreatePrivilegesImportIndexProps { body: CreatePrivilegesImportIndexRequestBodyInput; } @@ -1950,6 +2014,9 @@ export interface DeleteEntityEngineProps { query: DeleteEntityEngineRequestQueryInput; params: DeleteEntityEngineRequestParamsInput; } +export interface DeleteEntitySourceProps { + params: DeleteEntitySourceRequestParamsInput; +} export interface DeleteNoteProps { body: DeleteNoteRequestBodyInput; } @@ -2040,6 +2107,9 @@ export interface GetEndpointSuggestionsProps { export interface GetEntityEngineProps { params: GetEntityEngineRequestParamsInput; } +export interface GetEntitySourceProps { + params: GetEntitySourceRequestParamsInput; +} export interface GetEntityStoreStatusProps { query: GetEntityStoreStatusRequestQueryInput; } @@ -2115,6 +2185,9 @@ export interface InstallPrepackedTimelinesProps { export interface ListEntitiesProps { query: ListEntitiesRequestQueryInput; } +export interface ListEntitySourcesProps { + query: ListEntitySourcesRequestQueryInput; +} export interface ListPrivMonUsersProps { query: ListPrivMonUsersRequestQueryInput; } @@ -2190,6 +2263,10 @@ export interface SuggestUserProfilesProps { export interface TriggerRiskScoreCalculationProps { body: TriggerRiskScoreCalculationRequestBodyInput; } +export interface UpdateEntitySourceProps { + params: UpdateEntitySourceRequestParamsInput; + body: UpdateEntitySourceRequestBodyInput; +} export interface UpdatePrivMonUserProps { params: UpdatePrivMonUserRequestParamsInput; body: UpdatePrivMonUserRequestBodyInput;