diff --git a/x-pack/solutions/security/plugins/entity_store/public/hooks/useInstallEntityStoreV2.test.tsx b/x-pack/solutions/security/plugins/entity_store/public/hooks/useInstallEntityStoreV2.test.tsx index 9db669033e1e1..ed9ec39f1bf9f 100644 --- a/x-pack/solutions/security/plugins/entity_store/public/hooks/useInstallEntityStoreV2.test.tsx +++ b/x-pack/solutions/security/plugins/entity_store/public/hooks/useInstallEntityStoreV2.test.tsx @@ -53,7 +53,9 @@ describe('useInstallEntityStoreV2', () => { const mockServices = createMockServices(); mockServices.uiSettings.get.mockReturnValue(true); mockServices.spaces.getActiveSpace.mockResolvedValue({ id: 'custom-space' }); - mockServices.http.get.mockResolvedValueOnce({ status: EntityStoreStatus.enum.not_installed }); + mockServices.http.get + .mockResolvedValueOnce({ status: EntityStoreStatus.enum.not_installed }) + .mockResolvedValueOnce({ status: EntityStoreStatus.enum.not_installed }); renderHook(() => useInstallEntityStoreV2(asServices(mockServices))); @@ -63,16 +65,20 @@ describe('useInstallEntityStoreV2', () => { }); }); - expect(mockServices.http.get).toHaveBeenCalledTimes(1); + expect(mockServices.http.get).toHaveBeenNthCalledWith(1, { + path: ENTITY_STORE_ROUTES.public.STATUS, + query: { include_components: false }, + }); + expect(mockServices.http.get).toHaveBeenCalledTimes(2); expect(mockServices.http.post).not.toHaveBeenCalled(); }); - it('should proceed when not in default space and v1 is installed', async () => { + it('should install when not in default space, v1 is installed, and v2 is not installed', async () => { const mockServices = createMockServices(); mockServices.uiSettings.get.mockReturnValue(true); mockServices.spaces.getActiveSpace.mockResolvedValue({ id: 'custom-space' }); mockServices.http.get - .mockResolvedValueOnce({ status: EntityStoreStatus.enum.running }) + .mockResolvedValueOnce({ status: EntityStoreStatus.enum.not_installed }) .mockResolvedValueOnce({ status: EntityStoreStatus.enum.running }); mockServices.http.post.mockResolvedValueOnce({}); @@ -83,12 +89,37 @@ describe('useInstallEntityStoreV2', () => { }); expect(mockServices.http.get).toHaveBeenNthCalledWith(1, { - path: '/api/entity_store/status', - }); - expect(mockServices.http.get).toHaveBeenNthCalledWith(2, { path: ENTITY_STORE_ROUTES.public.STATUS, query: { include_components: false }, }); + expect(mockServices.http.get).toHaveBeenNthCalledWith(2, { + path: '/api/entity_store/status', + }); + expect(mockServices.http.post).toHaveBeenCalledWith({ + path: ENTITY_STORE_ROUTES.public.INSTALL, + body: JSON.stringify({}), + }); + }); + + it('should init entity maintainers when not in default space, v1 is not installed, and v2 is running', async () => { + const mockServices = createMockServices(); + mockServices.uiSettings.get.mockReturnValue(true); + mockServices.spaces.getActiveSpace.mockResolvedValue({ id: 'custom-space' }); + mockServices.http.get.mockResolvedValueOnce({ status: EntityStoreStatus.enum.running }); + mockServices.http.post.mockResolvedValue({}); + + renderHook(() => useInstallEntityStoreV2(asServices(mockServices))); + + await waitFor(() => { + expect(mockServices.http.post).toHaveBeenCalledTimes(1); + }); + + expect(mockServices.http.get).toHaveBeenCalledTimes(1); + expect(mockServices.http.post).toHaveBeenCalledWith({ + path: ENTITY_STORE_ROUTES.internal.ENTITY_MAINTAINERS_INIT, + body: JSON.stringify({}), + query: { apiVersion: '2' }, + }); }); it("should not install when entity store status is 'running'", async () => { @@ -119,7 +150,7 @@ describe('useInstallEntityStoreV2', () => { }); }); - it('when entity store is not installed and space is default, installs entity store then inits entity maintainers', async () => { + it('when entity store is not installed and space is default, installs entity store (install API inits maintainers)', async () => { const mockServices = createMockServices(); mockServices.uiSettings.get.mockReturnValue(true); mockServices.spaces.getActiveSpace.mockResolvedValue({ id: 'default' }); @@ -129,18 +160,18 @@ describe('useInstallEntityStoreV2', () => { renderHook(() => useInstallEntityStoreV2(asServices(mockServices))); await waitFor(() => { - expect(mockServices.http.post).toHaveBeenCalledTimes(2); + expect(mockServices.http.post).toHaveBeenCalledTimes(1); }); expect(mockServices.http.get).toHaveBeenCalledWith({ path: ENTITY_STORE_ROUTES.public.STATUS, query: { include_components: false }, }); - expect(mockServices.http.post).toHaveBeenNthCalledWith(1, { + expect(mockServices.http.post).toHaveBeenCalledWith({ path: ENTITY_STORE_ROUTES.public.INSTALL, body: JSON.stringify({}), }); - expect(mockServices.http.post).toHaveBeenNthCalledWith(2, { + expect(mockServices.http.post).not.toHaveBeenCalledWith({ path: ENTITY_STORE_ROUTES.internal.ENTITY_MAINTAINERS_INIT, body: JSON.stringify({}), query: { apiVersion: '2' }, diff --git a/x-pack/solutions/security/plugins/entity_store/public/hooks/useInstallEntityStoreV2.tsx b/x-pack/solutions/security/plugins/entity_store/public/hooks/useInstallEntityStoreV2.tsx index 60ff729c3253e..c1a71b94124a2 100644 --- a/x-pack/solutions/security/plugins/entity_store/public/hooks/useInstallEntityStoreV2.tsx +++ b/x-pack/solutions/security/plugins/entity_store/public/hooks/useInstallEntityStoreV2.tsx @@ -60,21 +60,24 @@ export const useInstallEntityStoreV2 = (services: Services) => { if (!isEntityStoreV2Enabled) return; const space = await services.spaces.getActiveSpace(); - // Install v2 and remove v1 in default namespace AND every namespace where v1 is currently installed - if (space.id !== 'default' && !(await isEntityStoreV1Installed(services.http))) return; - const statusResponse = await services.http.get<{ status: EntityStoreStatus }>( getStatusRequest ); const isEntityStoreV2Installed = isEntityStoreInstalled(statusResponse.status); + // In non-default spaces, only auto-install v2 where v1 existed. If v2 is already there, + // skip the v1 check and still run (e.g. init entity maintainers for this space). + if (space.id !== 'default' && !isEntityStoreV2Installed) { + if (!(await isEntityStoreV1Installed(services.http))) { + return; + } + } // Entity store already installed → init entity maintainers only. if (isEntityStoreV2Installed) { await services.http.post(initEntityMaintainersRequest); return; } - // Entity store not installed → install entity store, then init entity maintainers. + // Entity store not installed → install entity store (init entity maintainers is already done by the install API). await services.http.post(installAllEntitiesRequest); - await services.http.post(initEntityMaintainersRequest); } catch (e) { services.logger.error('Failed to initialize Entity Store V2'); services.logger.error(e); diff --git a/x-pack/solutions/security/plugins/entity_store/server/routes/apis/install/index.ts b/x-pack/solutions/security/plugins/entity_store/server/routes/apis/install/index.ts index 1cb59098a4b2d..d942372958e84 100644 --- a/x-pack/solutions/security/plugins/entity_store/server/routes/apis/install/index.ts +++ b/x-pack/solutions/security/plugins/entity_store/server/routes/apis/install/index.ts @@ -45,7 +45,11 @@ export function registerInstall(router: EntityStorePluginRouter) { }, wrapMiddlewares(async (ctx, req, res): Promise => { const entityStoreCtx = await ctx.entityStore; - const { logger, assetManagerClient: assetManager } = entityStoreCtx; + const { + logger, + assetManagerClient: assetManager, + entityMaintainersClient, + } = entityStoreCtx; const { entityTypes, logExtraction, historySnapshot } = req.body; logger.debug('Install api called'); @@ -70,6 +74,7 @@ export function registerInstall(router: EntityStorePluginRouter) { } await assetManager.init(req, toInstall, logExtraction, historySnapshot); + await entityMaintainersClient.init(req); return res.created({ body: { ok: true } }); }) diff --git a/x-pack/solutions/security/plugins/entity_store/test/scout/api/tests/install_update.spec.ts b/x-pack/solutions/security/plugins/entity_store/test/scout/api/tests/install_update.spec.ts index 5d39a042f24ce..ee6ca66671874 100644 --- a/x-pack/solutions/security/plugins/entity_store/test/scout/api/tests/install_update.spec.ts +++ b/x-pack/solutions/security/plugins/entity_store/test/scout/api/tests/install_update.spec.ts @@ -7,11 +7,17 @@ import { apiTest } from '@kbn/scout-security'; import { expect } from '@kbn/scout-security/api'; -import { PUBLIC_HEADERS, ENTITY_STORE_ROUTES, ENTITY_STORE_TAGS } from '../fixtures/constants'; -import { FF_ENABLE_ENTITY_STORE_V2 } from '../../../../common'; +import { + PUBLIC_HEADERS, + INTERNAL_HEADERS, + ENTITY_STORE_ROUTES, + ENTITY_STORE_TAGS, +} from '../fixtures/constants'; +import { FF_ENABLE_ENTITY_STORE_V2, type GetEntityMaintainersResponse } from '../../../../common'; apiTest.describe('Entity Store install / update API tests', { tag: ENTITY_STORE_TAGS }, () => { let defaultHeaders: Record; + let internalHeaders: Record; apiTest.beforeAll(async ({ samlAuth }) => { const credentials = await samlAuth.asInteractiveUser('admin'); @@ -19,6 +25,10 @@ apiTest.describe('Entity Store install / update API tests', { tag: ENTITY_STORE_ ...credentials.cookieHeader, ...PUBLIC_HEADERS, }; + internalHeaders = { + ...credentials.cookieHeader, + ...INTERNAL_HEADERS, + }; }); apiTest( @@ -35,6 +45,18 @@ apiTest.describe('Entity Store install / update API tests', { tag: ENTITY_STORE_ }); expect(install.statusCode).toBe(201); + const maintainersResponse = await apiClient.get( + ENTITY_STORE_ROUTES.internal.ENTITY_MAINTAINERS_GET, + { + headers: internalHeaders, + responseType: 'json', + } + ); + expect(maintainersResponse.statusCode).toBe(200); + const { maintainers } = maintainersResponse.body as GetEntityMaintainersResponse; + expect(maintainers.length).toBeGreaterThan(0); + expect(maintainers.every((m) => m.taskStatus === 'started')).toBe(true); + const uninstall = await apiClient.post(ENTITY_STORE_ROUTES.public.UNINSTALL, { headers: defaultHeaders, responseType: 'json', diff --git a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/entity_analytics/entity_resolution/trial_license_complete_tier/resolution_csv_upload.ts b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/entity_analytics/entity_resolution/trial_license_complete_tier/resolution_csv_upload.ts index 32a6c41c68864..4d872ecece987 100644 --- a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/entity_analytics/entity_resolution/trial_license_complete_tier/resolution_csv_upload.ts +++ b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/entity_analytics/entity_resolution/trial_license_complete_tier/resolution_csv_upload.ts @@ -12,6 +12,10 @@ import { ENTITY_STORE_ROUTES, API_VERSIONS } from '@kbn/entity-store/common'; import { ENTITY_RESOLUTION_CSV_UPLOAD_URL } from '@kbn/security-solution-plugin/common/entity_analytics/entity_store/constants'; import type { FtrProviderContext } from '../../../../ftr_provider_context'; import { EntityStoreUtils } from '../../utils'; +import { entityMaintainerRouteHelpersFactory } from '../../utils/entity_maintainers'; + +/** Same value as `MAINTAINER_ID` in entity_store `server/maintainers/automated_resolution`. */ +const AUTOMATED_RESOLUTION_MAINTAINER_ID = 'automated-resolution'; const TEST_PREFIX = 'csv-test:'; @@ -116,11 +120,14 @@ export default ({ getService }: FtrProviderContext) => { describe('@ess @serverless @skipInServerlessMKI Entity Resolution CSV Upload', () => { before(async () => { - // Use enableEntityStoreV2 (without maintainer init) to prevent the - // automated resolution maintainer from racing with CSV upload tests. + // Use enableEntityStoreV2 and explicitly stop + // the automated-resolution maintainer so it cannot race with CSV upload tests. // The maintainer would link entities sharing the same user.email, // interfering with the test's own resolution assertions. await entityStoreUtils.enableEntityStoreV2(); + await entityMaintainerRouteHelpersFactory(supertest).stopMaintainer( + AUTOMATED_RESOLUTION_MAINTAINER_ID + ); await cleanEntities(); await seedEntities(); await waitForEntities(); diff --git a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/entity_analytics/risk_score_maintainer/trial_license_complete_tier/preview_api.ts b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/entity_analytics/risk_score_maintainer/trial_license_complete_tier/preview_api.ts index 5fadc1d3a5c40..b8e9beb1aa183 100644 --- a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/entity_analytics/risk_score_maintainer/trial_license_complete_tier/preview_api.ts +++ b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/entity_analytics/risk_score_maintainer/trial_license_complete_tier/preview_api.ts @@ -57,6 +57,9 @@ export default ({ getService }: FtrProviderContext): void => { startMaintainer: async () => { throw new Error('Preview API tests do not use maintainer route startMaintainer'); }, + stopMaintainer: async () => { + throw new Error('Preview API tests do not use maintainer route stopMaintainer'); + }, }, }); const setEntityStoreV2Setting = async (enabled: boolean) => { diff --git a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/entity_analytics/utils/risk_score_maintainer.ts b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/entity_analytics/utils/risk_score_maintainer.ts index 6f1fa42ab2b7f..4138afcffccac 100644 --- a/x-pack/solutions/security/test/security_solution_api_integration/test_suites/entity_analytics/utils/risk_score_maintainer.ts +++ b/x-pack/solutions/security/test/security_solution_api_integration/test_suites/entity_analytics/utils/risk_score_maintainer.ts @@ -37,7 +37,7 @@ interface RetryServiceLike { type MaintainerRoutesLike = Pick< ReturnType, - 'getMaintainers' | 'runMaintainer' | 'runMaintainerSync' | 'startMaintainer' + 'getMaintainers' | 'runMaintainer' | 'runMaintainerSync' | 'startMaintainer' | 'stopMaintainer' >; interface EntityStoreUtilsLike { @@ -364,6 +364,8 @@ export const riskScoreMaintainerScenarioFactory = ({ dataViewPattern, maintainerAutoStart: false, }); + await routes.stopMaintainer('risk-score'); + if (runMode === 'sync') { await routes.runMaintainerSync('risk-score'); return;