diff --git a/docs/extend/plugin-list.md b/docs/extend/plugin-list.md index 0f2d12064813d..142e90f9fad06 100644 --- a/docs/extend/plugin-list.md +++ b/docs/extend/plugin-list.md @@ -154,7 +154,7 @@ mapped_pages: | [encryptedSavedObjects](https://github.com/elastic/kibana/blob/main/x-pack/platform/plugins/shared/encrypted_saved_objects/README.md) | The purpose of this plugin is to provide a way to encrypt/decrypt attributes on the custom Saved Objects that works with security and spaces filtering. | | [enterpriseSearch](https://github.com/elastic/kibana/blob/main/x-pack/solutions/search/plugins/enterprise_search/README.md) | This plugin provides Kibana user interfaces for managing the Enterprise Search solution and its products, App Search and Workplace Search. | | [entityManager](https://github.com/elastic/kibana/blob/main/x-pack/platform/plugins/shared/entity_manager/README.md) | This plugin provides access to observed entity data, such as information about hosts, pods, containers, services, and more. | -| [entityStore](https://github.com/elastic/kibana/blob/main/x-pack/solutions/security/plugins/entity_store/README.md) | Central place for Entities management and logs extraction | +| [entityStore](https://github.com/elastic/kibana/blob/main/x-pack/solutions/security/plugins/entity_store/README.md) | Central place for Entities management and logs extraction. | | [eventLog](https://github.com/elastic/kibana/blob/main/x-pack/platform/plugins/shared/event_log/README.md) | The event log plugin provides a persistent history of alerting and action activities. | | [exploratoryView](https://github.com/elastic/kibana/blob/main/x-pack/solutions/observability/plugins/exploratory_view/README.md) | A shared component for visualizing observability data types via lens embeddable. For further details. | | [features](https://github.com/elastic/kibana/blob/main/x-pack/platform/plugins/shared/features/README.md) | The features plugin enhance Kibana with a per-feature privilege system. | diff --git a/x-pack/solutions/security/plugins/entity_store/README.md b/x-pack/solutions/security/plugins/entity_store/README.md index 98788e8d7c585..0497ae10c9d6f 100755 --- a/x-pack/solutions/security/plugins/entity_store/README.md +++ b/x-pack/solutions/security/plugins/entity_store/README.md @@ -1,3 +1,62 @@ # Entity Store -Central place for Entities management and logs extraction +Central place for Entities management and logs extraction. + +## Entity Maintainers Framework + +The Entity Store plugin exposes an **Entity Maintainers Framework** so that other plugins can register recurring tasks that run in the context of the entity store. Registration is part of the plugin setup contract: consumers call `registerEntityMaintainer` during their plugin’s `setup` phase and supply a configuration object. + +### Setup contract and registration config + +From the setup contract: + +```ts +interface EntityStoreSetupContract { + registerEntityMaintainer: RegisterEntityMaintainer; +} +``` + +`RegisterEntityMaintainer` accepts a `RegisterEntityMaintainerConfig`: + +```ts +interface RegisterEntityMaintainerConfig { + id: string; + description?: string; + interval: string; + initialState: EntityMaintainerState; + run: EntityMaintainerTaskMethod; + setup?: EntityMaintainerTaskMethod; +} +``` + +- **id** - Unique identifier for the maintainer (used for task type and scheduling). +- **interval** - Cron-like interval at which the task runs (e.g. `5m`, `1h`). +- **initialState** - Initial state object for the maintainer, used on the first run before any `setup` or `run` has executed. +- **run** - Required. Called on every run (including the first). Must return the current state it manages. +- **setup** - Optional. If provided, it runs once before the first `run`. Useful for one-time initialization. + +### Scheduling and namespaces + +The framework schedules all registered maintainers when the Entity Store is installed for a given space. +The framework is **namespace aware**: each Kibana space gets its own task instance per maintainer (e.g. one task per `id` per namespace). Registration is global, scheduling is per namespace at install time. + +### Run and setup behavior + +- **run** is invoked on every execution at the configured interval. It receives a context (see below) and must return the **current state** it manages. That state is persisted and passed back in the context on the next run. +- **setup** is optional. When supplied, it runs a single time before the first **run**. It receives the same context shape and also returns state, that state becomes the initial state for the first **run**. If setup performs heavy work, the first iteration can be noticeably longer than subsequent ones. + +Both methods must return the state object they manage so the framework can store it and expose it in the context for the next iteration. + +### Callback context + +Both `run` and `setup` receive a single context argument with: + +- **status** - Object containing: + - **metadata** - Maintained by the framework: `namespace`, `runs` (execution count), `lastSuccessTimestamp`, `lastErrorTimestamp`. + - **state** - The state returned by the previous `run` (or by `setup` on the first run, or `initialState` before any execution). +- **abortController** - For cooperative cancellation if needed. +- **logger** - Scoped logger for the task. +- **fakeRequest** - Request-scoped utilities for the task execution environment. +- **esClient** - An Elasticsearch client scoped to the current context, using the permissions of the user who triggered the Entity Store plugin installation process. + +Consumers implement their maintenance logic in `run` (and optionally in `setup`) using this context and return the updated state so the framework can keep it for the next run. diff --git a/x-pack/solutions/security/plugins/entity_store/server/domain/asset_manager.ts b/x-pack/solutions/security/plugins/entity_store/server/domain/asset_manager.ts index 6861442d5a4bb..6218b4f89c1af 100644 --- a/x-pack/solutions/security/plugins/entity_store/server/domain/asset_manager.ts +++ b/x-pack/solutions/security/plugins/entity_store/server/domain/asset_manager.ts @@ -17,6 +17,7 @@ import type { ManagedEntityDefinition, } from '../../common/domain/definitions/entity_schema'; import { scheduleExtractEntityTask, stopExtractEntityTask } from '../tasks/extract_entity_task'; +import { scheduleEntityMaintainerTasks } from '../tasks/entity_maintainer'; import { installElasticsearchAssets, uninstallElasticsearchAssets } from './assets/install_assets'; import { EngineDescriptorTypeName, @@ -81,16 +82,26 @@ export class AssetManager { this.security = deps.security; } - public async initEntity( + public async init( request: KibanaRequest, - type: EntityType, + entityTypes: EntityType[], logExtractionParams?: LogExtractionBodyParams - ): Promise { - const installed = await this.install(type, logExtractionParams); - if (installed) { - await this.start(request, type); + ) { + try { + await Promise.all( + entityTypes.map((type) => this.initEntity(request, type, logExtractionParams)) + ); + + await scheduleEntityMaintainerTasks({ + logger: this.logger, + taskManager: this.taskManager, + namespace: this.namespace, + request, + }); + } catch (error) { + this.logger.error('Error during entity store init:', error); + throw error; } - return installed; } public async start(request: KibanaRequest, type: EntityType) { @@ -133,39 +144,7 @@ export class AssetManager { } } - public async install( - type: EntityType, - logExtractionParams?: LogExtractionBodyParams - ): Promise { - try { - const { engines } = await this.getStatus(); - if (engines.some((e) => e.type === type)) { - return false; - } - - this.logger.get(type).debug(`Installing assets for entity type: ${type}`); - const definition = getEntityDefinition(type, this.namespace); - const initialState: Partial = logExtractionParams ?? {}; - await Promise.all([ - this.engineDescriptorClient.init(type, initialState), - installElasticsearchAssets({ - esClient: this.esClient, - logger: this.logger, - definition, - namespace: this.namespace, - }), - ]); - await this.engineDescriptorClient.update(type, { status: ENGINE_STATUS.STARTED }); - this.logger.debug(`Installed definition: ${type}`); - - return true; - } catch (error) { - this.logger.error(`Error installing assets for entity type ${type}`, { error }); - throw error; - } - } - - public async uninstall(type: EntityType): Promise { + public async uninstall(type: EntityType) { try { const { engines } = await this.getStatus(); if (!engines.some((e) => e.type === type)) { @@ -211,6 +190,19 @@ export class AssetManager { } } + private async initEntity( + request: KibanaRequest, + type: EntityType, + logExtractionParams?: LogExtractionBodyParams + ): Promise { + const installed = await this.install(type, logExtractionParams); + if (installed) { + await this.start(request, type); + } + + return installed; + } + public async getPrivileges( request: KibanaRequest, additionalIndexPatterns: string[] = [] @@ -243,6 +235,38 @@ export class AssetManager { }); } + public async install( + type: EntityType, + logExtractionParams?: LogExtractionBodyParams + ): Promise { + try { + const { engines } = await this.getStatus(); + if (engines.some((e) => e.type === type)) { + return false; + } + + this.logger.get(type).debug(`Installing assets for entity type: ${type}`); + const definition = getEntityDefinition(type, this.namespace); + const initialState: Partial = logExtractionParams ?? {}; + await Promise.all([ + this.engineDescriptorClient.init(type, initialState), + installElasticsearchAssets({ + esClient: this.esClient, + logger: this.logger, + definition, + namespace: this.namespace, + }), + ]); + await this.engineDescriptorClient.update(type, { status: ENGINE_STATUS.STARTED }); + this.logger.debug(`Installed definition: ${type}`); + + return true; + } catch (error) { + this.logger.error(`Error installing assets for entity type ${type}`, { error }); + throw error; + } + } + private async getEngineWithComponents( engine: EngineDescriptor ): Promise { diff --git a/x-pack/solutions/security/plugins/entity_store/server/domain/definitions/saved_objects/constants.ts b/x-pack/solutions/security/plugins/entity_store/server/domain/definitions/saved_objects/engine_descriptor/constants.ts similarity index 89% rename from x-pack/solutions/security/plugins/entity_store/server/domain/definitions/saved_objects/constants.ts rename to x-pack/solutions/security/plugins/entity_store/server/domain/definitions/saved_objects/engine_descriptor/constants.ts index cae2186edeccd..47590ec812f1f 100644 --- a/x-pack/solutions/security/plugins/entity_store/server/domain/definitions/saved_objects/constants.ts +++ b/x-pack/solutions/security/plugins/entity_store/server/domain/definitions/saved_objects/engine_descriptor/constants.ts @@ -6,9 +6,9 @@ */ import { z } from '@kbn/zod'; -import { TasksConfig } from '../../../tasks/config'; -import { EntityStoreTaskType } from '../../../tasks/constants'; -import { EntityType } from '../../../../common/domain/definitions/entity_schema'; +import { TasksConfig } from '../../../../tasks/config'; +import { EntityStoreTaskType } from '../../../../tasks/constants'; +import { EntityType } from '../../../../../common/domain/definitions/entity_schema'; export type EngineStatus = z.infer; export const EngineStatus = z.enum(['installing', 'started', 'stopped', 'updating', 'error']); @@ -37,7 +37,7 @@ export const LogExtractionState = z.object({ frequency: z .string() .regex(/[smdh]$/) - .default(TasksConfig[EntityStoreTaskType.Values.extractEntity].interval), + .default(TasksConfig[EntityStoreTaskType.Values.extractEntity].interval || '30s'), paginationTimestamp: z.string().optional(), paginationId: z.string().optional(), lastExecutionTimestamp: z.string().optional(), diff --git a/x-pack/solutions/security/plugins/entity_store/server/domain/definitions/saved_objects/engine_descriptor.ts b/x-pack/solutions/security/plugins/entity_store/server/domain/definitions/saved_objects/engine_descriptor/index.ts similarity index 94% rename from x-pack/solutions/security/plugins/entity_store/server/domain/definitions/saved_objects/engine_descriptor.ts rename to x-pack/solutions/security/plugins/entity_store/server/domain/definitions/saved_objects/engine_descriptor/index.ts index 466b471bbafe0..5f4c132c74387 100644 --- a/x-pack/solutions/security/plugins/entity_store/server/domain/definitions/saved_objects/engine_descriptor.ts +++ b/x-pack/solutions/security/plugins/entity_store/server/domain/definitions/saved_objects/engine_descriptor/index.ts @@ -10,11 +10,11 @@ import type { SavedObjectsFindResponse, } from '@kbn/core-saved-objects-api-server'; import { SavedObjectsErrorHelpers, type Logger } from '@kbn/core/server'; -import type { EntityType } from '../../../../common/domain/definitions/entity_schema'; +import type { EntityType } from '../../../../../common/domain/definitions/entity_schema'; import type { EngineDescriptor } from './constants'; import { LogExtractionState, VersionState } from './constants'; -import { EngineDescriptorTypeName } from './engine_descriptor_type'; -import { ENGINE_STATUS } from '../../constants'; +import { EngineDescriptorTypeName } from './types'; +import { ENGINE_STATUS } from '../../../constants'; interface UpdateOptions { mergeAttributes?: boolean; diff --git a/x-pack/solutions/security/plugins/entity_store/server/domain/definitions/saved_objects/engine_descriptor_type.ts b/x-pack/solutions/security/plugins/entity_store/server/domain/definitions/saved_objects/engine_descriptor/types.ts similarity index 100% rename from x-pack/solutions/security/plugins/entity_store/server/domain/definitions/saved_objects/engine_descriptor_type.ts rename to x-pack/solutions/security/plugins/entity_store/server/domain/definitions/saved_objects/engine_descriptor/types.ts diff --git a/x-pack/solutions/security/plugins/entity_store/server/domain/definitions/saved_objects/index.ts b/x-pack/solutions/security/plugins/entity_store/server/domain/definitions/saved_objects/index.ts index b6612bf0fcb08..917065d32be34 100644 --- a/x-pack/solutions/security/plugins/entity_store/server/domain/definitions/saved_objects/index.ts +++ b/x-pack/solutions/security/plugins/entity_store/server/domain/definitions/saved_objects/index.ts @@ -5,6 +5,6 @@ * 2.0. */ -export * from './engine_descriptor_type'; +export * from './engine_descriptor/constants'; +export * from './engine_descriptor/types'; export * from './engine_descriptor'; -export * from './constants'; diff --git a/x-pack/solutions/security/plugins/entity_store/server/plugin.ts b/x-pack/solutions/security/plugins/entity_store/server/plugin.ts index 3210c5acf5b4b..305dd5d2feb98 100644 --- a/x-pack/solutions/security/plugins/entity_store/server/plugin.ts +++ b/x-pack/solutions/security/plugins/entity_store/server/plugin.ts @@ -12,20 +12,22 @@ import type { EntityStoreRequestHandlerContext, EntityStoreSetupPlugins, EntityStoreStartPlugins, - PluginStartContract, - PluginSetupContract, + EntityStoreStartContract, + EntityStoreSetupContract, } from './types'; import { createRequestHandlerContext } from './request_context_factory'; import { PLUGIN_ID } from '../common'; import { registerTasks } from './tasks/register_tasks'; import { registerUiSettings } from './infra/feature_flags/register'; import { EngineDescriptorType } from './domain/definitions/saved_objects'; +import { registerEntityMaintainerTask } from './tasks/entity_maintainer'; +import type { RegisterEntityMaintainerConfig } from './tasks/entity_maintainer/types'; export class EntityStorePlugin implements Plugin< - PluginSetupContract, - PluginStartContract, + EntityStoreSetupContract, + EntityStoreStartContract, EntityStoreSetupPlugins, EntityStoreStartPlugins > @@ -38,7 +40,10 @@ export class EntityStorePlugin this.isServerless = initializerContext.env.packageInfo.buildFlavor === 'serverless'; } - public setup(core: EntityStoreCoreSetup, plugins: EntityStoreSetupPlugins) { + public setup( + core: EntityStoreCoreSetup, + plugins: EntityStoreSetupPlugins + ): EntityStoreSetupContract { plugins.taskManager.registerCanEncryptedSavedObjects(plugins.encryptedSavedObjects.canEncrypt); const router = core.http.createRouter(); @@ -61,11 +66,21 @@ export class EntityStorePlugin this.logger.debug('Registering ui settings'); registerUiSettings(core.uiSettings); - this.logger.debug('Registering saved objects type'); + this.logger.debug('Registering saved objects types'); core.savedObjects.registerType(EngineDescriptorType); + + return { + registerEntityMaintainer: (config: RegisterEntityMaintainerConfig) => + registerEntityMaintainerTask({ + taskManager: plugins.taskManager, + logger: this.logger, + config, + core, + }), + }; } - public start(core: CoreStart, plugins: EntityStoreStartPlugins) { + public start(core: CoreStart, plugins: EntityStoreStartPlugins): EntityStoreStartContract { this.logger.info('Initializing plugin'); plugins.taskManager.registerEncryptedSavedObjectsClient( diff --git a/x-pack/solutions/security/plugins/entity_store/server/request_context_factory.ts b/x-pack/solutions/security/plugins/entity_store/server/request_context_factory.ts index f48f2fb3e6975..1bb3bba4324bb 100644 --- a/x-pack/solutions/security/plugins/entity_store/server/request_context_factory.ts +++ b/x-pack/solutions/security/plugins/entity_store/server/request_context_factory.ts @@ -36,7 +36,6 @@ export async function createRequestHandlerContext({ const core = await context.core; const [, startPlugins] = await coreSetup.getStartServices(); const taskManagerStart = startPlugins.taskManager; - const namespace = startPlugins.spaces.spacesService.getSpaceId(request); const dataViewsService = await startPlugins.dataViews.dataViewsServiceFactory( 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 35cc4b1c48146..843ebd1e72a09 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 @@ -48,7 +48,6 @@ export function registerInstall(router: EntityStorePluginRouter) { }, }); } - const { engines } = await assetManager.getStatus(); const installedTypes = new Set(engines.map((e) => e.type)); const toInstall = entityTypes.filter((type) => !installedTypes.has(type)); @@ -57,7 +56,7 @@ export function registerInstall(router: EntityStorePluginRouter) { return res.ok({ body: { ok: true } }); } - await Promise.all(toInstall.map((type) => assetManager.initEntity(req, type, params))); + await assetManager.init(req, toInstall, params); return res.created({ body: { ok: true } }); }) diff --git a/x-pack/solutions/security/plugins/entity_store/server/tasks/config.ts b/x-pack/solutions/security/plugins/entity_store/server/tasks/config.ts index 5ac39042763be..3294b812f33a4 100644 --- a/x-pack/solutions/security/plugins/entity_store/server/tasks/config.ts +++ b/x-pack/solutions/security/plugins/entity_store/server/tasks/config.ts @@ -8,7 +8,8 @@ import type { IntervalSchedule, TaskRegisterDefinition } from '@kbn/task-manager-plugin/server'; import { EntityStoreTaskType } from './constants'; -type TaskScheduleConfig = Omit & IntervalSchedule; +type TaskScheduleConfig = Omit & + Partial; export interface EntityStoreTaskConfig extends TaskScheduleConfig { type: string; @@ -21,4 +22,8 @@ export const TasksConfig: Record = { timeout: '25s', interval: '30s', }, + [EntityStoreTaskType.Values.entityMaintainer]: { + title: 'Entity Store - Entity Maintainer Task', + type: 'entity_store:v2:entity_maintainer_task', + }, }; diff --git a/x-pack/solutions/security/plugins/entity_store/server/tasks/constants.ts b/x-pack/solutions/security/plugins/entity_store/server/tasks/constants.ts index 508e0d05204d0..8b80ffaef2c33 100644 --- a/x-pack/solutions/security/plugins/entity_store/server/tasks/constants.ts +++ b/x-pack/solutions/security/plugins/entity_store/server/tasks/constants.ts @@ -8,4 +8,4 @@ import { z } from '@kbn/zod'; export type EntityStoreTaskType = z.infer; -export const EntityStoreTaskType = z.enum(['extractEntity']); +export const EntityStoreTaskType = z.enum(['extractEntity', 'entityMaintainer']); diff --git a/x-pack/solutions/security/plugins/entity_store/server/tasks/entity_maintainer/entity_maintainers.test.ts b/x-pack/solutions/security/plugins/entity_store/server/tasks/entity_maintainer/entity_maintainers.test.ts new file mode 100644 index 0000000000000..9cc5206c3ad0e --- /dev/null +++ b/x-pack/solutions/security/plugins/entity_store/server/tasks/entity_maintainer/entity_maintainers.test.ts @@ -0,0 +1,430 @@ +/* + * 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 { loggerMock } from '@kbn/logging-mocks'; +import type { KibanaRequest } from '@kbn/core/server'; +import { scheduleEntityMaintainerTasks, registerEntityMaintainerTask } from '.'; +import type { RegisterEntityMaintainerConfig } from './types'; +import { entityMaintainersRegistry } from './entity_maintainers_registry'; + +const mockEnsureScheduled = jest.fn(); +const mockRegisterTaskDefinitions = jest.fn(); +const mockCreateInternalRepository = jest.fn(); +const mockGetStartServices = jest.fn(); + +jest.mock('./entity_maintainers_registry', () => ({ + entityMaintainersRegistry: { + getAll: jest.fn(), + update: jest.fn(), + }, +})); + +function createMockDeps() { + const logger = loggerMock.create(); + (logger.get as jest.Mock) = jest.fn().mockReturnValue(logger); + const request = { headers: {} } as KibanaRequest; + const taskManagerStart = { + ensureScheduled: mockEnsureScheduled.mockResolvedValue(undefined), + }; + const taskManagerSetup = { + registerTaskDefinitions: mockRegisterTaskDefinitions.mockImplementation((defs) => defs), + }; + const mockEsClient = {}; + const start = { + savedObjects: { + createInternalRepository: mockCreateInternalRepository.mockReturnValue({}), + }, + elasticsearch: { + client: { + asScoped: () => ({ asCurrentUser: mockEsClient }), + }, + }, + }; + const core = { + getStartServices: mockGetStartServices.mockResolvedValue([start]), + }; + return { + logger, + request, + taskManagerStart, + taskManagerSetup, + core, + }; +} + +function createMockConfig( + overrides?: Partial +): RegisterEntityMaintainerConfig { + const defaultRun = jest.fn().mockResolvedValue({ foo: 'bar' }); + const { run = defaultRun, ...rest } = overrides ?? {}; + return { + id: 'test-maintainer', + interval: '5m', + initialState: {}, + run, + description: 'Test maintainer', + ...rest, + }; +} + +describe('entity_maintainer task', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('scheduleEntityMaintainerTasks', () => { + it('should call getAll and ensureScheduled for each task with correct id, taskType, and schedule', async () => { + const { logger, request, taskManagerStart } = createMockDeps(); + jest.mocked(entityMaintainersRegistry.getAll).mockReturnValue([ + { id: 'maintainer-a', interval: '1m' }, + { id: 'maintainer-b', interval: '5m' }, + ]); + + await scheduleEntityMaintainerTasks({ + logger, + taskManager: taskManagerStart as any, + namespace: 'default', + request, + }); + + expect(entityMaintainersRegistry.getAll).toHaveBeenCalledTimes(1); + expect(mockEnsureScheduled).toHaveBeenCalledTimes(2); + expect(mockEnsureScheduled).toHaveBeenNthCalledWith( + 1, + { + id: 'maintainer-a:default', + taskType: 'entity_store:v2:entity_maintainer_task:maintainer-a', + schedule: { interval: '1m' }, + state: { namespace: 'default' }, + params: {}, + }, + { request } + ); + expect(mockEnsureScheduled).toHaveBeenNthCalledWith( + 2, + { + id: 'maintainer-b:default', + taskType: 'entity_store:v2:entity_maintainer_task:maintainer-b', + schedule: { interval: '5m' }, + state: { namespace: 'default' }, + params: {}, + }, + { request } + ); + }); + + it('should propagate and log error when getAll throws', async () => { + const { logger, request, taskManagerStart } = createMockDeps(); + const err = new Error('getAll failed'); + jest.mocked(entityMaintainersRegistry.getAll).mockImplementation(() => { + throw err; + }); + + await expect( + scheduleEntityMaintainerTasks({ + logger, + taskManager: taskManagerStart as any, + namespace: 'default', + request, + }) + ).rejects.toThrow('getAll failed'); + + expect(mockEnsureScheduled).not.toHaveBeenCalled(); + }); + }); + + describe('registerEntityMaintainerTask', () => { + it('should register task definition with expected type and title', async () => { + const { logger, taskManagerSetup, core } = createMockDeps(); + const config = createMockConfig(); + + registerEntityMaintainerTask({ + taskManager: taskManagerSetup as any, + logger, + config, + core: core as any, + }); + await core.getStartServices(); + + expect(mockRegisterTaskDefinitions).toHaveBeenCalledTimes(1); + const [defs] = mockRegisterTaskDefinitions.mock.calls[0]; + const taskType = 'entity_store:v2:entity_maintainer_task:test-maintainer'; + expect(defs[taskType]).toBeDefined(); + expect(defs[taskType].title).toBe('Entity Store - Entity Maintainer Task'); + expect(defs[taskType].description).toBe('Test maintainer'); + }); + + it('should trigger the correct run method upon registration and scheduling', async () => { + const { logger, taskManagerSetup, core } = createMockDeps(); + const run = jest.fn().mockResolvedValue({ key: 'value' }); + const config = createMockConfig({ run }); + + registerEntityMaintainerTask({ + taskManager: taskManagerSetup as any, + logger, + config, + core: core as any, + }); + await core.getStartServices(); + + const [defs] = mockRegisterTaskDefinitions.mock.calls[0]; + const taskType = 'entity_store:v2:entity_maintainer_task:test-maintainer'; + const createTaskRunner = defs[taskType].createTaskRunner; + const runner = createTaskRunner({ + taskInstance: { + id: 'test-maintainer:default', + state: {}, + }, + abortController: new AbortController(), + fakeRequest: { headers: {} } as KibanaRequest, + }); + + await runner.run(); + + expect(run).toHaveBeenCalledTimes(1); + expect(run).toHaveBeenCalledWith( + expect.objectContaining({ + status: expect.objectContaining({ + state: {}, + }), + abortController: expect.any(AbortController), + logger: expect.anything(), + fakeRequest: expect.anything(), + esClient: expect.anything(), + }) + ); + }); + + it('should trigger all run methods when multiple registrations occur with single scheduling', async () => { + const { logger, taskManagerSetup, core } = createMockDeps(); + const runA = jest.fn().mockResolvedValue({ from: 'a' }); + const runB = jest.fn().mockResolvedValue({ from: 'b' }); + + registerEntityMaintainerTask({ + taskManager: taskManagerSetup as any, + logger, + config: createMockConfig({ id: 'maintainer-a', run: runA }), + core: core as any, + }); + await core.getStartServices(); + registerEntityMaintainerTask({ + taskManager: taskManagerSetup as any, + logger, + config: createMockConfig({ id: 'maintainer-b', run: runB }), + core: core as any, + }); + await core.getStartServices(); + + expect(mockRegisterTaskDefinitions).toHaveBeenCalledTimes(2); + const defs1 = mockRegisterTaskDefinitions.mock.calls[0][0]; + const defs2 = mockRegisterTaskDefinitions.mock.calls[1][0]; + const runnerA = defs1['entity_store:v2:entity_maintainer_task:maintainer-a'].createTaskRunner( + { + taskInstance: { id: 'maintainer-a:default', state: {} }, + abortController: new AbortController(), + fakeRequest: { headers: {} } as KibanaRequest, + } + ); + const runnerB = defs2['entity_store:v2:entity_maintainer_task:maintainer-b'].createTaskRunner( + { + taskInstance: { id: 'maintainer-b:default', state: {} }, + abortController: new AbortController(), + fakeRequest: { headers: {} } as KibanaRequest, + } + ); + + await runnerA.run(); + await runnerB.run(); + + expect(runA).toHaveBeenCalledTimes(1); + expect(runB).toHaveBeenCalledTimes(1); + }); + + it('should execute setup method only once', async () => { + const { logger, taskManagerSetup, core } = createMockDeps(); + const setup = jest.fn().mockResolvedValue({ initialized: true }); + const run = jest.fn().mockResolvedValue({ synced: true }); + const config = createMockConfig({ setup, run }); + + registerEntityMaintainerTask({ + taskManager: taskManagerSetup as any, + logger, + config, + core: core as any, + }); + await core.getStartServices(); + + const [defs] = mockRegisterTaskDefinitions.mock.calls[0]; + const taskType = 'entity_store:v2:entity_maintainer_task:test-maintainer'; + const createTaskRunner = defs[taskType].createTaskRunner; + const fakeRequest = { headers: {} } as KibanaRequest; + + const runner1 = createTaskRunner({ + taskInstance: { id: 'test-maintainer:default', state: {} }, + abortController: new AbortController(), + fakeRequest, + }); + await runner1.run(); + expect(setup).toHaveBeenCalledTimes(1); + expect(run).toHaveBeenCalledTimes(1); + + const runner2 = createTaskRunner({ + taskInstance: { + id: 'test-maintainer:default', + state: { + metadata: { + runs: 1, + lastSuccessTimestamp: new Date().toISOString(), + lastErrorTimestamp: null, + }, + state: { synced: true }, + }, + }, + abortController: new AbortController(), + fakeRequest, + }); + await runner2.run(); + expect(setup).toHaveBeenCalledTimes(1); + expect(run).toHaveBeenCalledTimes(2); + }); + + it('should change state across lifecycle as run or setup change it', async () => { + const { logger, taskManagerSetup, core } = createMockDeps(); + const setup = jest.fn().mockResolvedValue({ setupState: 1 }); + const run = jest.fn().mockImplementation(({ status }) => { + const prev = status.state.runState ?? status.state.setupState ?? 0; + return Promise.resolve({ ...status.state, runState: prev + 1 }); + }); + const config = createMockConfig({ setup, run, initialState: { initial: 0 } }); + + registerEntityMaintainerTask({ + taskManager: taskManagerSetup as any, + logger, + config, + core: core as any, + }); + await core.getStartServices(); + + const [defs] = mockRegisterTaskDefinitions.mock.calls[0]; + const taskType = 'entity_store:v2:entity_maintainer_task:test-maintainer'; + const createTaskRunner = defs[taskType].createTaskRunner; + const fakeRequest = { headers: {} } as KibanaRequest; + + const runner1 = createTaskRunner({ + taskInstance: { id: 'test-maintainer:default', state: {} }, + abortController: new AbortController(), + fakeRequest, + }); + const result1 = await runner1.run(); + expect(result1.state.state.setupState).toBe(1); + expect(result1.state.state.runState).toBe(2); + + const runner2 = createTaskRunner({ + taskInstance: { + id: 'test-maintainer:default', + state: result1.state, + }, + abortController: new AbortController(), + fakeRequest, + }); + const result2 = await runner2.run(); + expect(result2.state.state.runState).toBe(3); + }); + + it('should populate lastErrorTimestamp when run throws', async () => { + const { logger, taskManagerSetup, core } = createMockDeps(); + const run = jest.fn().mockRejectedValue(new Error('run failed')); + const config = createMockConfig({ run }); + + registerEntityMaintainerTask({ + taskManager: taskManagerSetup as any, + logger, + config, + core: core as any, + }); + await core.getStartServices(); + + const [defs] = mockRegisterTaskDefinitions.mock.calls[0]; + const taskType = 'entity_store:v2:entity_maintainer_task:test-maintainer'; + const createTaskRunner = defs[taskType].createTaskRunner; + const runner = createTaskRunner({ + taskInstance: { id: 'test-maintainer:default', state: {} }, + abortController: new AbortController(), + fakeRequest: { headers: {} } as KibanaRequest, + }); + + const result = await runner.run(); + + expect(result.state.metadata.lastErrorTimestamp).toBeDefined(); + expect(typeof result.state.metadata.lastErrorTimestamp).toBe('string'); + expect(result.state.metadata.runs).toBe(1); + }); + + it('should set status.metadata lastSuccessTimestamp and runs correctly', async () => { + const { logger, taskManagerSetup, core } = createMockDeps(); + const run = jest.fn().mockResolvedValue({ done: true }); + const config = createMockConfig({ run }); + + registerEntityMaintainerTask({ + taskManager: taskManagerSetup as any, + logger, + config, + core: core as any, + }); + await core.getStartServices(); + + const [defs] = mockRegisterTaskDefinitions.mock.calls[0]; + const taskType = 'entity_store:v2:entity_maintainer_task:test-maintainer'; + const createTaskRunner = defs[taskType].createTaskRunner; + const runner = createTaskRunner({ + taskInstance: { id: 'test-maintainer:default', state: {} }, + abortController: new AbortController(), + fakeRequest: { headers: {} } as KibanaRequest, + }); + + const result = await runner.run(); + + expect(result.state.metadata.runs).toBe(1); + expect(result.state.metadata.lastSuccessTimestamp).toBeDefined(); + expect(typeof result.state.metadata.lastSuccessTimestamp).toBe('string'); + expect(result.state.metadata.lastErrorTimestamp).toBeNull(); + }); + + it('should return current state without calling run when fakeRequest is missing', async () => { + const { logger, taskManagerSetup, core } = createMockDeps(); + const run = jest.fn(); + const config = createMockConfig({ run }); + + registerEntityMaintainerTask({ + taskManager: taskManagerSetup as any, + logger, + config, + core: core as any, + }); + await core.getStartServices(); + + const [defs] = mockRegisterTaskDefinitions.mock.calls[0]; + const taskType = 'entity_store:v2:entity_maintainer_task:test-maintainer'; + const createTaskRunner = defs[taskType].createTaskRunner; + const currentState = { metadata: { runs: 2 }, state: { x: 1 } }; + const runner = createTaskRunner({ + taskInstance: { + id: 'test-maintainer:default', + state: currentState, + }, + abortController: new AbortController(), + fakeRequest: undefined, + }); + + const result = await runner.run(); + + expect(run).not.toHaveBeenCalled(); + expect(result.state.metadata.runs).toBe(currentState.metadata.runs); + expect(result.state.state).toEqual(currentState.state); + }); + }); +}); diff --git a/x-pack/solutions/security/plugins/entity_store/server/tasks/entity_maintainer/entity_maintainers_registry.test.ts b/x-pack/solutions/security/plugins/entity_store/server/tasks/entity_maintainer/entity_maintainers_registry.test.ts new file mode 100644 index 0000000000000..b6c8d5dc6892b --- /dev/null +++ b/x-pack/solutions/security/plugins/entity_store/server/tasks/entity_maintainer/entity_maintainers_registry.test.ts @@ -0,0 +1,44 @@ +/* + * 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 { EntityMaintainersRegistry } from './entity_maintainers_registry'; + +describe('EntityMaintainersRegistry', () => { + let registry: EntityMaintainersRegistry; + + beforeEach(() => { + registry = new EntityMaintainersRegistry(); + }); + + describe('getAll', () => { + it('should return empty array when no entries have been added', () => { + expect(registry.getAll()).toEqual([]); + }); + }); + + describe('update', () => { + it('should add an entry and getAll returns it', () => { + registry.update({ id: 'maintainer-a', interval: '5m' }); + expect(registry.getAll()).toEqual([{ id: 'maintainer-a', interval: '5m' }]); + }); + + it('should add multiple entries and getAll returns all in map order', () => { + registry.update({ id: 'maintainer-a', interval: '1m' }); + registry.update({ id: 'maintainer-b', interval: '5m' }); + expect(registry.getAll()).toEqual([ + { id: 'maintainer-a', interval: '1m' }, + { id: 'maintainer-b', interval: '5m' }, + ]); + }); + + it('should overwrite entry when update is called with same id', () => { + registry.update({ id: 'maintainer-a', interval: '1m' }); + registry.update({ id: 'maintainer-a', interval: '10m' }); + expect(registry.getAll()).toEqual([{ id: 'maintainer-a', interval: '10m' }]); + }); + }); +}); diff --git a/x-pack/solutions/security/plugins/entity_store/server/tasks/entity_maintainer/entity_maintainers_registry.ts b/x-pack/solutions/security/plugins/entity_store/server/tasks/entity_maintainer/entity_maintainers_registry.ts new file mode 100644 index 0000000000000..0523e3e326777 --- /dev/null +++ b/x-pack/solutions/security/plugins/entity_store/server/tasks/entity_maintainer/entity_maintainers_registry.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { EntityMaintainerConfig, EntityMaintainerTaskEntry } from './types'; + +export class EntityMaintainersRegistry { + private readonly tasks = new Map(); + + update({ id, interval }: EntityMaintainerTaskEntry): void { + this.tasks.set(id, { interval }); + } + + getAll(): EntityMaintainerTaskEntry[] { + return Array.from(this.tasks.entries()).map(([id, { interval }]) => ({ + id, + interval, + })); + } +} + +export const entityMaintainersRegistry = new EntityMaintainersRegistry(); diff --git a/x-pack/solutions/security/plugins/entity_store/server/tasks/entity_maintainer/index.ts b/x-pack/solutions/security/plugins/entity_store/server/tasks/entity_maintainer/index.ts new file mode 100644 index 0000000000000..d04ac955fdfc7 --- /dev/null +++ b/x-pack/solutions/security/plugins/entity_store/server/tasks/entity_maintainer/index.ts @@ -0,0 +1,176 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { TaskManagerSetupContract } from '@kbn/task-manager-plugin/server'; +import type { Logger } from '@kbn/logging'; +import type { TaskManagerStartContract } from '@kbn/task-manager-plugin/server'; +import type { ElasticsearchClient, KibanaRequest } from '@kbn/core/server'; +import type { + EntityMaintainerStatus, + EntityMaintainerTaskMethod, + RegisterEntityMaintainerConfig, +} from './types'; +import { TasksConfig } from '../config'; +import { EntityStoreTaskType } from '../constants'; +import type { EntityStoreCoreSetup } from '../../types'; +import { entityMaintainersRegistry } from './entity_maintainers_registry'; + +function getTaskType(id: string): string { + return `${TasksConfig[EntityStoreTaskType.Values.entityMaintainer].type}:${id}`; +} + +function getTaskId(id: string, namespace: string): string { + return `${id}:${namespace}`; +} + +export async function scheduleEntityMaintainerTasks({ + logger, + taskManager, + namespace, + request, +}: { + logger: Logger; + taskManager: TaskManagerStartContract; + namespace: string; + request: KibanaRequest; +}): Promise { + try { + logger.debug(`Scheduling entity maintainer tasks`); + const tasks = entityMaintainersRegistry.getAll(); + for (const { id, interval } of tasks) { + await taskManager.ensureScheduled( + { + id: getTaskId(id, namespace), + taskType: getTaskType(id), + schedule: { interval }, + state: { namespace }, + params: {}, + }, + { request } + ); + } + } catch (err) { + logger.error(`Failed to schedule entity maintainer tasks: ${err?.message}`); + throw err; + } +} + +export function registerEntityMaintainerTask({ + taskManager, + logger, + config, + core, +}: { + taskManager: TaskManagerSetupContract; + logger: Logger; + config: RegisterEntityMaintainerConfig; + core: EntityStoreCoreSetup; +}): void { + logger.debug(`Registering entity maintainer task: ${config.id}`); + const { title } = TasksConfig[EntityStoreTaskType.Values.entityMaintainer]; + const { run, interval, initialState, description, id, setup } = config; + const type = getTaskType(id); + + entityMaintainersRegistry.update({ id, interval }); + + void core + .getStartServices() + .then(([start]) => { + taskManager.registerTaskDefinitions({ + [type]: { + title, + description, + createTaskRunner: ({ taskInstance, abortController, fakeRequest }) => ({ + run: async () => { + const currentStatus = taskInstance.state; + + if (!fakeRequest) { + logger.error(`No fake request found, skipping run`); + + return { + state: currentStatus, + }; + } + + const maintainerStatus: EntityMaintainerStatus = { + metadata: { + runs: currentStatus?.metadata?.runs || 0, + lastSuccessTimestamp: currentStatus?.metadata?.lastSuccessTimestamp || null, + lastErrorTimestamp: currentStatus?.metadata?.lastErrorTimestamp || null, + namespace: currentStatus?.namespace || currentStatus?.metadata?.namespace, + }, + state: currentStatus?.metadata?.runs ? currentStatus.state : initialState, + }; + + return await runEntityMaintainerTask({ + currentStatus: maintainerStatus, + fakeRequest, + logger: logger.get(taskInstance.id), + setup, + run, + abortController, + esClient: start.elasticsearch.client.asScoped(fakeRequest).asCurrentUser, + }); + }, + }), + }, + }); + }) + .catch((err) => { + logger.error(`Failed to register entity maintainer task: ${err?.message}`); + }); +} + +async function runEntityMaintainerTask({ + currentStatus, + fakeRequest, + logger, + setup, + run, + abortController, + esClient, +}: { + currentStatus: EntityMaintainerStatus; + fakeRequest: KibanaRequest; + logger: Logger; + setup?: EntityMaintainerTaskMethod; + run: EntityMaintainerTaskMethod; + abortController: AbortController; + esClient: ElasticsearchClient; +}): Promise<{ state: EntityMaintainerStatus }> { + try { + const isFirstRun = currentStatus.metadata.runs === 0; + if (isFirstRun && setup) { + logger.debug(`First run, executing setup`); + currentStatus.state = await setup({ + status: { ...currentStatus }, + abortController, + logger, + fakeRequest, + esClient, + }); + } + logger.debug(`Executing run`); + currentStatus.state = await run({ + status: { ...currentStatus }, + abortController, + logger, + fakeRequest, + esClient, + }); + currentStatus.metadata.lastSuccessTimestamp = new Date().toISOString(); + } catch (err) { + currentStatus.metadata.lastErrorTimestamp = new Date().toISOString(); + logger.debug(`Run failed - ${err?.message}`); + } finally { + currentStatus.metadata.runs++; + } + + return { + state: currentStatus, + }; +} diff --git a/x-pack/solutions/security/plugins/entity_store/server/tasks/entity_maintainer/types.ts b/x-pack/solutions/security/plugins/entity_store/server/tasks/entity_maintainer/types.ts new file mode 100644 index 0000000000000..1344fd2016714 --- /dev/null +++ b/x-pack/solutions/security/plugins/entity_store/server/tasks/entity_maintainer/types.ts @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ElasticsearchClient, KibanaRequest } from '@kbn/core/server'; +import type { Logger } from '@kbn/logging'; + +export interface EntityMaintainerConfig { + interval: string; +} + +export interface EntityMaintainerTaskEntry { + id: string; + interval: string; +} + +export interface EntityMaintainerStatusMetadata { + namespace: string; + runs: number; + lastSuccessTimestamp: string | null; + lastErrorTimestamp: string | null; +} + +type JsonPrimitive = string | number | boolean | null; +type JsonValue = JsonPrimitive | EntityMaintainerState | JsonValue[]; +export interface EntityMaintainerState { + [key: string]: JsonValue; +} + +export interface EntityMaintainerStatus extends Record { + metadata: EntityMaintainerStatusMetadata; + state: EntityMaintainerState; +} + +interface EntityMaintainerTaskMethodContext { + status: EntityMaintainerStatus; + abortController: AbortController; + logger: Logger; + fakeRequest: KibanaRequest; + esClient: ElasticsearchClient; +} + +export type EntityMaintainerTaskMethod = ( + context: EntityMaintainerTaskMethodContext +) => Promise; + +export interface RegisterEntityMaintainerConfig { + id: string; + description?: string; + interval: string; + initialState: EntityMaintainerState; + run: EntityMaintainerTaskMethod; + setup?: EntityMaintainerTaskMethod; +} diff --git a/x-pack/solutions/security/plugins/entity_store/server/types.ts b/x-pack/solutions/security/plugins/entity_store/server/types.ts index f6fef06493639..6b8507b52ef29 100644 --- a/x-pack/solutions/security/plugins/entity_store/server/types.ts +++ b/x-pack/solutions/security/plugins/entity_store/server/types.ts @@ -26,6 +26,7 @@ import type { CoreSetup } from '@kbn/core/server'; import type { AssetManager } from './domain/asset_manager'; import type { FeatureFlags } from './infra/feature_flags'; import type { LogsExtractionClient } from './domain/logs_extraction_client'; +import type { RegisterEntityMaintainerConfig } from './tasks/entity_maintainer/types'; export interface EntityStoreSetupPlugins { taskManager: TaskManagerSetupContract; @@ -56,7 +57,12 @@ export type EntityStoreRequestHandlerContext = CustomRequestHandlerContext<{ export type EntityStorePluginRouter = IRouter; -export type PluginStartContract = void; -export type PluginSetupContract = void; +export type RegisterEntityMaintainer = (config: RegisterEntityMaintainerConfig) => void; -export type EntityStoreCoreSetup = CoreSetup; +export type EntityStoreStartContract = void; + +export interface EntityStoreSetupContract { + registerEntityMaintainer: RegisterEntityMaintainer; +} + +export type EntityStoreCoreSetup = CoreSetup;